Skip to content

Architecture & threat model

Artifact Publisher hosts HTML that an agent wrote for someone who did not run that agent. The architecture treats generated markup as untrusted so the recipient can open a deliverable without receiving a path into the publisher’s control surface.

An artifact is untrusted input. An agent can be prompt-injected, mistaken, or simply generate script that does more than intended. The design does not try to classify those cases before serving a document. Instead, it relies on browser isolation to constrain the artifact.

If artifact HTML were trusted, one origin and a sanitiser might be enough. Because it is not, separation is structural: the browser refuses cross-origin access rather than the application deciding to grant it.

Artifact Publisher has two distinct browser origins. A recipient sees one review experience; the browser sees two security contexts.

Surface Hostname Serves
Site artifactpublisher.com Marketing pages and these docs
Control & review app.artifactpublisher.com Review links (/artifact/<slug>), comments, MCP (/mcp)
Artifact artifacts.artifactpublisher.com Raw artifact HTML, only inside the sandboxed iframe

Links created before the move keep working: currico.ch, app.currico.ch, and artifacts.currico.ch still serve the same artifacts with the same access rules, and the older /a/<slug> path is accepted on every hostname. Each hostname family keeps its own control/artifact pair, so the separation below holds for old links too. New links always use app.artifactpublisher.com/artifact/<slug>.

Serves reviewer chrome, the page-level comment interface, and the authenticated MCP endpoint. It embeds the artifact in a sandboxed iframe and enforces its own restrictive Content Security Policy:

  • default-src 'none', object-src 'none', base-uri 'none'
  • frame-src narrowed to the artifact origin, so the shell cannot be tricked into framing anything else
  • frame-ancestors 'none' and X-Frame-Options: DENY, because this origin holds the only anonymous write path

Serves the agent’s HTML and nothing else. No API, no comment endpoint, no reviewer session, and no Set-Cookie. Its policy blocks every way out of the document:

  • sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox — re-applied even when the URL is opened directly, so the document is always in an opaque origin (still no allow-same-origin; popups allow user-activated target=_blank links)
  • connect-src 'none' — no fetch, XHR, WebSocket, or beacon
  • form-action 'none' — no form submissions
  • default-src 'none' with only data: images and fonts — no remote subresources
  • frame-ancestors limited to the control origin

The artifact is not a neighbour of the reviewer interface. Its script has no readable parent document, no control-origin storage, and no artifact-origin session cookie to reach for.

Reviewers are anonymous holders of an unlisted link. There is no viewer account in v1, so POST /artifact/:slug/comments on the control origin is the only unauthenticated mutation in the platform. It is authorised by possession of the link plus an exact same-origin check on the Origin header.

That check rejects three cases deliberately:

  • a cross-origin Origin, which is the ordinary CSRF case;
  • a missing Origin — there is no unauthenticated non-browser write client to accommodate, so accepting the empty case would hand the bypass straight back;
  • the literal null origin, which is exactly what a sandboxed artifact document sends. Without this, a published artifact could comment on itself.

Every other mutation requires a scoped publisher bearer key.

A share link is unlisted: not indexed, not listed on a public surface. It is not identity-based access control. Anyone holding a live link can view the artifact, including someone it was forwarded to.

That makes unlisted links appropriate for a direct handoff to intended recipients, not content that requires per-person authentication or an audit trail of who opened it. When a link travels farther than intended, revoke it.

Updating an artifact appends a new immutable version behind the stable link. Earlier versions are not edited in place, and every comment records the version the reviewer was viewing. Storage keys encode the workspace, artifact, and version, so immutability holds at the object-storage layer rather than by convention.

revoke_artifact stops the artifact from being served, rather than merely hiding a link in an interface. A copied link therefore stops resolving to content after revocation. Revocation is forward-looking: it cannot recall a document someone already viewed, downloaded, or captured.

The table states what the design addresses and what remains. It is a description of the architecture, not a security guarantee.

Threat Mitigation Residual
Hostile artifact script reads the reviewer’s session Separate artifact origin, sandboxed embedding, and no session or cookie on that origin Depends on browser enforcement of origin isolation and sandboxing
Artifact tampers with the comment interface Comments live on the control origin outside the artifact frame The artifact can still display misleading content inside its own frame
Artifact posts a comment as if it were a reviewer The comment endpoint rejects the null origin a sandboxed document sends Depends on the browser attaching Origin correctly
Recipient gains access to the agent, session, machine, or repository Recipients receive a published artifact link, not those systems Anything the publisher includes in the artifact is visible to link holders
Share link is forwarded beyond the intended recipient Links are unlisted and revocable at the serving layer Anyone holding a live link can view it; there is no per-recipient identity
Stale or wrong content remains at the stable link Updates append a new immutable version behind that link Copies saved by a recipient are outside the system
A leaked API key is used to manage artifacts Keys are scoped bearer credentials, stored only as digests, and can be revoked Possession authorises the caller until the key is revoked
A signed-in account reads or revokes another account’s keys Every account query is scoped to the workspace resolved from the verified identity; a foreign key id answers exactly like a missing one An account holder still controls everything inside their own workspace
A foreign page calls the account API from a browser The API allows exactly one trusted origin, never reflects Origin, and uses no cookie credential Depends on the browser enforcing CORS and on the holder not pasting a session token elsewhere
A client tries to raise a platform limit Size ceiling, rate limits, isolation, storage keys, and headers are not configurable; naming one is a rejection An operator with account access can still change platform configuration

Publisher identity, where accounts are enabled

Section titled “Publisher identity, where accounts are enabled”

Some deployments enable self-serve individual accounts. The boundary is deliberately narrow.

  • Clerk is the identity system. It owns GitHub and Google sign-in, browser sessions, recovery, and explicit provider linking. Artifact Publisher verifies a Clerk session token and stores only the opaque account identifier from it — no password, no provider access token, no provider handle, and no email address.
  • Two accounts are never merged because their email strings match. There is no stored email to merge on, and linking is only ever an authenticated action inside Clerk.
  • D1, not Clerk, decides what you may touch. Clerk establishes who the human is; Artifact Publisher resolves the one personal workspace that identity owns and the publisher keys inside it. The workspace is resolved from the verified identity, never from a workspace named in a request.
  • A key is shown once. The plaintext of a publisher key exists only in the response that mints it. Only a hash is stored, so a lost key is replaced, not recovered.
  • The account page never touches the artifact origin. It lives on the marketing origin, calls the control origin with an explicit bearer header rather than a shared cookie, and no account route exists on the artifact origin at all.
  • Recipients stay anonymous. There is no viewer account, and none is planned. Sign-in exists for the person publishing.
  • Reviewer identity. Access is by unlisted link, not per-recipient authentication or access logging.
  • Teams and organisations. Accounts are individual. There are no members, roles, invitations, seats, SSO, or shared workspaces.
  • Artifact content review. Artifacts are contained, not inspected for disclosure or correctness. The publisher remains responsible for their contents.
  • General web hosting. The product serves self-contained HTML artifacts, not arbitrary sites, folders, servers, or external asset pipelines.
  • Outbound integrations. Feedback is retrieved through the tool surface; no webhook or notification capability is claimed.

Artifact Publisher does not claim an independent audit, penetration testing, certification, or attestation against a compliance framework. Evaluate the documented architecture against your own requirements before sharing sensitive material.

Artifacts may need multi-viewer interactive state — checklists, votes, progress, who-brings lists, and similar — without giving sandboxed HTML network access. Artifact Publisher keeps one small JSON document per artifact on the control origin. The artifact stays offline (connect-src 'none'); it only postMessages the parent reviewer page, which talks to the API.

  • Viewers who can open the live review link read and patch via GET/PATCH /artifact/:slug/state (same access principal as viewing the artifact).
  • A serve-time helper injects window.ArtifactRelayState before author scripts (R2 HTML bytes are unchanged).
  • Snapshot shape: { revision, values, resetAt }. Values are arbitrary JSON (including null as a stored value). set(key, value) replaces one top-level key; patch(values) sets several keys atomically; unmentioned keys survive.
  • Publishers use MCP get_artifact_state, set_artifact_state, and reset_artifact_state. There is no anonymous reset route.
  • Limits and conflict model: see docs/decisions/0008-general-synced-state.md (200 keys, size/depth caps, whole-value last-write-wins per key, short parent polling after subscribe — not a WebSocket claim).

Example:

<script>
ArtifactRelayState.subscribe(({ values }) => {
/* render from values */
});
document.querySelector('#tent').onchange = (e) => {
ArtifactRelayState.set('tent', { checked: e.target.checked, assignee: 'Ada' });
};
ArtifactRelayState.set('progress', 0.75);
</script>

Helpers only work when the artifact is opened through its review link (parent bridge present). Opening the raw artifact URL alone has no sync.

Checklist-shaped UIs may keep using window.ArtifactRelayBoard and GET/PATCH /artifact/:slug/board. That surface projects board-shaped values (checked, assignee, updatedAt) from the same shared document and is deprecated in favor of ArtifactRelayState. Publisher MCP get_artifact_board / reset_artifact_board remain as compatibility tools; board reset clears the entire shared document. See docs/decisions/0007-shared-board-state.md and 0008.