Artifact protocol

The global artifact control plane, authorization scopes, staged byte transfers, and SDK contract.

Building on OctoStaffdocs/build/30-artifact-protocol.mdx
On this page

Artifacts are named byte objects governed by an explicit Bubble authorization target. They are not inherently attached to a thread: the same control plane can store artifacts under a system, thread, or principal scope.

The SDK exposes this through the negotiated artifacts extension, and an enabled server mounts its HTTP surface under /v1/artifacts. There is deliberately no ThreadHandle.artifacts API and no /v1/threads/:threadId/artifacts route. A thread-oriented adapter can still pass threadTarget(threadId) as the access scope.

Clients explicitly register the trusted local implementation when they create a connection, then request its typed key. The SDK checks /v1/info before loading the adapter: a missing server manifest yields unsupported, and a different extension protocol version yields incompatible. Bubble never supplies or downloads executable extension code.

ts
import { createBubbleClient } from '@octostaff/sdk/client';
import { artifactExtensionKey } from '@octostaff/sdk/extensions/artifacts';

const bubble = createBubbleClient({ baseUrl, token });
const artifacts = await bubble.extensions.require(artifactExtensionKey);

When the server does not enable the extension, it neither advertises the manifest nor mounts /v1/artifacts. require fails during negotiation before issuing an artifact operation; optional returns undefined for callers that can operate without artifact support.

Access Scopes and Authorization

Every operation identifies the AuthzTarget governing the artifact:

ts
import { principalTarget, systemTarget, threadTarget } from '@octostaff/sdk';

systemTarget(); // { type: 'system' }
threadTarget('thread-1'); // { type: 'thread', threadId: 'thread-1' }
principalTarget('user-alice'); // { type: 'principal', principalId: 'user-alice' }

The service derives the caller only from the authenticated request. Supplying an access scope selects the policy target; it never selects or overrides the caller.

OperationRequired capability
List, download, import sourceartifact.read
Begin upload, commit, inline uploadartifact.write
Import destinationartifact.write
Delete a committed or pending artifactartifact.manage

Imports carry two independent targets: sourceAccessScope is checked for read access and destinationAccessScope is checked for write access.

HTTP Control Plane

All control-plane requests are authenticated. Signed byte transfer URLs are the only exception; possession of a live transfer URL authorizes that one transfer.

MethodPathRequestResponse
GET/v1/artifactsListArtifactsQueryListArtifactsResponse
POST/v1/artifacts/uploadsBeginArtifactUploadBodyBeginArtifactUploadResponse
POST/v1/artifacts/commitsCommitArtifactBodyArtifactDescriptor
POST/v1/artifacts/inlineUploadArtifactInlineBodyArtifactDescriptor
POST/v1/artifacts/downloadsDownloadArtifactBodyDownloadArtifactResponse
POST/v1/artifacts/importsImportArtifactBodyArtifactDescriptor
DELETE/v1/artifactsDeleteArtifactBody204
PUT/v1/artifacts/transfersRaw bytes plus signed token query204
GET/v1/artifacts/transfersSigned token queryRaw bytes

Listing encodes an authorization target in the query string. targetId is required for thread and principal, and omitted for system:

text
/v1/artifacts?targetType=thread&targetId=thread-1
/v1/artifacts?targetType=principal&targetId=user-alice
/v1/artifacts?targetType=system

Staged Uploads

The SDK stages uploads but never uploads the bytes automatically. The caller performs the returned PUT, then commits the declared content:

ts
import { threadTarget } from '@octostaff/sdk';

const accessScope = threadTarget('thread-1');
const contentHash = `sha256:${sha256Hex(bytes)}`;

const pending = await artifacts.beginUpload({
  accessScope,
  name: 'reports/result.txt',
  contentHash,
  byteSize: bytes.byteLength,
  mimeType: 'text/plain',
});

await fetch(pending.upload.url, {
  method: pending.upload.method,
  headers: pending.upload.headers,
  body: bytes,
});

const artifact = await artifacts.commit({
  accessScope,
  name: 'reports/result.txt',
  contentHash,
});

Commit verifies that uploaded size, MIME type, and SHA-256 digest match the declaration. A transfer is short-lived and may be either a Bubble-signed local URL or an object-store URL. Callers must use the returned method and headers.

uploadInline is the bounded alternative for small payloads. It carries the same metadata plus standard base64 bytes and commits in one authenticated request.

Reading, Importing, and Deleting

The SDK returns transfer instructions for downloads rather than fetching bytes inside the client:

ts
const resolved = await artifacts.download({
  accessScope,
  name: 'reports/result.txt',
});
const bytes = await fetch(resolved.download.url, {
  method: resolved.download.method,
  headers: resolved.download.headers,
}).then((response) => response.arrayBuffer());

Imports create a destination name referencing the same immutable byte object; they do not copy the bytes:

ts
await artifacts.importArtifact({
  sourceAccessScope: threadTarget('thread-1'),
  destinationAccessScope: principalTarget('user-alice'),
  sourceName: 'reports/result.txt',
  name: 'saved/result.txt',
});

list(accessScope) returns committed artifacts in exactly that scope. delete({ accessScope, name }) tombstones a committed name or removes its pending upload. Physical bytes are retained while another imported name still references them.

Safe Public Shapes

ArtifactDescriptor contains an ArtifactRecord and content ArtifactRef. ArtifactTransfer contains only the URL, expiry, method, and required headers. Backing-store keys are server-private: the SDK does not export an artifact object shape containing blobKey, and strict response codecs reject it.

The @octostaff/sdk/extensions/artifacts subpath exports the corresponding runtime codecs, including ArtifactDescriptorSchema, ArtifactTransferSchema, request body schemas, and the begin/download/list response schemas.

idempotencyKey is optional on staged uploads, inline uploads, and imports. Use the same key when retrying one logical operation after an uncertain transport or worker failure.