diff --git a/README.md b/README.md index f30fffe..ac8dd19 100644 --- a/README.md +++ b/README.md @@ -1,496 +1,152 @@ # @corbits/artifacts -Artifacts, versions and file uploads as a mountable module for any Interchange host. -Backend only — this package ships no UI. +Artifacts, versions, and file uploads as a mountable module for any Interchange host. Backend only — this package ships no UI. `mountArtifacts` and `mountWorkflowArtifacts` add routes to a Hono app you already have; the host owns the app, the database pool, the session, and the grant store. -`mountArtifacts(app, opts)` adds routes to a Hono app you already have. It never -creates the app, opens a pool, or reaches for a session — the host owns all three and -hands them in. +## Runtime support -See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model, the mount options, and the -design rationale behind them. +Node >= 24 consumes built `dist/`. Bun loads TypeScript source via the `bun` export condition. Peer stack: `hono`, `hono-openapi`, `drizzle-orm`, `postgres`, `arktype`, `@intx/types`, `@intx/hub-api`, `@intx/agent` (minimum `@intx/*` **0.3.0**). -## Requirements +## Quickstart -| | | -| --- | --- | -| Runtime | Node 24+ and Bun 1.2+ | -| Postgres | 13+ (`gen_random_uuid()`) | -| Minimum `@intx/*` | **0.3.0** | - -Peer dependencies: `hono`, `hono-openapi`, `drizzle-orm`, `postgres`, `arktype`, -`@intx/types`, `@intx/hub-api`. They are peers rather than pinned deps because each is -shared runtime state — a Hono app, a drizzle handle, Interchange's `TenantEnv` / -`RequireGrant`, a module-global registry — and a second copy in the tree does not -error, it silently misbehaves. - -Your **host** additionally needs `@intx/db`, `@intx/hub-sessions` and -`@intx/hub-common` for `createApp`. This module imports none of them, so they are not -peers here and npm will not warn you they are missing. +```bash +npm add @corbits/artifacts +pnpm add @corbits/artifacts +yarn add @corbits/artifacts +bun add @corbits/artifacts +``` -## Install +There are three surfaces a host wires up, in the order a hub typically mounts them. -Not published to npm yet. Until a registry publish, `npm install @corbits/artifacts` -404s. Git is the install path. +### 1. Hub-side, tenant-scoped: `mountArtifacts` -```bash -bun add github:corbitsdev/corbits-artifacts \ - hono hono-openapi drizzle-orm postgres arktype \ - @intx/types@^0.3.0 @intx/hub-api@^0.3.0 -``` +Mounted under the hub's tenant prefix, alongside a host's other session-authenticated routes. It reads `principal`/`tenant` off the Hono context (placed there by the host's own auth + tenant middleware) and authorizes mutations through the host's `requireGrant` — built from Interchange's `createRequireGrant` over the host's own `GrantStore` and `ConditionRegistry`. -## Mount +| `opts` | Type | What the host provides | +| --- | --- | --- | +| `db` | `ArtifactDb` | Artifacts are stored there. `createArtifactDb` opens a handle for a host with none; a hub that already has one passes it through. | +| `contentStore` | `ContentStore` | Blob storage for file bytes. `InlineContentStore` (exported by this package) fits a minimal host; bring your own store for object storage. | +| `requireGrant` | `RequireGrant` | The host's grant middleware factory. This package implements no ownership or membership policy of its own — every mutating single-artifact route is gated through it. | +| `countSegments` | `ArtifactCountSegments` (optional) | Named predicates over `ArtifactListRow` for `GET /artifacts/counts` (e.g. bucket by `kind`). The taxonomy is entirely host-owned; omitted, the route still answers with the tenant-wide `all` total. | +| `onArtifactCreated`, `decorate`, `uploadPolicy` | — (optional) | See [ARCHITECTURE.md](./ARCHITECTURE.md) for the grant-provisioning hook, the display-only row decorator, and the upload policy. | ```ts -import { Hono } from "hono"; -import { createRequireGrant, type TenantEnv } from "@intx/hub-api"; +import type { Hono } from "hono"; +import type { TenantEnv } from "@intx/hub-api"; +import { createRequireGrant } from "@intx/hub-api"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { InlineContentStore, mountArtifacts, - runArtifactMigrations, + type ArtifactDb, + type ArtifactCountSegments, } from "@corbits/artifacts"; -// Boot-time, once. Idempotent — safe on every boot of every replica. -await runArtifactMigrations(hub.db); - -// Host middleware places Interchange `tenant` and `principal` on the context -// before these routes run. Mount takes Hono and reads them natively. -const api = new Hono(); -const requireGrant = createRequireGrant({ grantStore, conditionRegistry }); -mountArtifacts(api, { - db: hub.db, - contentStore: InlineContentStore, - requireGrant, -}); -app.route("/api", api); -``` - -Routes are registered root-relative and served under `/api` — the prefix Interchange -serves its own routes under. No `/v1`, no vendor prefix. - -`examples/reference-host` in this repository is a complete `@intx/hub-api` host with -this module mounted and the acceptance suite pointed at it. Start there if you need the -whole `createApp` wiring, including host middleware that sets `tenant`/`principal` and -a host-owned `RequireGrant`. - -## Run-scoped mount (workflow runs) - -`mountArtifacts` reads `tenant`/`principal` off a `TenantEnv` context — it has no -bearer-token auth surface, and never will: mixing a browser-session convention and a -sidecar-token convention into one mount would make each harder to reason about. A -workflow run has no browser session, so it authenticates a different way (a sidecar -bearer token + a run address header). `mountWorkflowArtifacts` is the parallel mount for -that caller: - -```ts -import { Hono } from "hono"; -import { InlineContentStore, mountWorkflowArtifacts } from "@corbits/artifacts"; - -const workflowApi = new Hono(); -mountWorkflowArtifacts(workflowApi, { - db: hub.db, - contentStore: InlineContentStore, - // Host-owned: verify the sidecar's bearer token and run address however the - // host issues them, and return the run's tenant/principal/run id, or null. - resolveRunScope: async (bearerToken, runAddress) => - hub.resolveWorkflowRun(bearerToken, runAddress), -}); -app.route("/workflow-artifacts", workflowApi); -``` - -Routes: `POST /artifacts` (create), `GET /artifacts` (list, `kind`/`limit`), -`GET /artifacts/recent`, `GET /artifacts/find?title=` (exact-title lookup), -`POST /artifacts/link-file`, `PATCH /artifacts/:id` (revise), `GET /artifacts/:id` -(read-back — 404s a skill-draft or another tenant's row, same as `mountArtifacts`' -detail route), `GET /artifacts/:id/read` and `GET /artifacts/:id/chunk` (the budgeted -reads the artifact tools use), and `POST /artifacts/binary` (base64 `contentBase64` -body, for a render step that needs to persist bytes rather than text). Together they -cover every entry in `ARTIFACT_TOOL_DEFINITIONS`. Every route is authenticated; there -is no unauthenticated case here the way collection reads have one on the -tenant-session mount, since a workflow run always presents credentials. - -`POST /artifacts`, `POST /artifacts/binary`, and `PATCH /artifacts/:id` each accept an -optional `metadata` field with the same semantics `mountArtifacts` validates: omitted on -a revise carries the prior version's metadata forward, an explicit `null` clears it, and -any other value must be a JSON object or the request is `400`. `source` (`{ origin: -"workflow", runId }`) and `generatedBy` are always server-stamped from the resolved run -scope — a body field of either name is never read, so a workflow run cannot mint -provenance for itself. - -### Agent tokens - -A deployed agent has no sidecar token of its own. Pass `agentToken` and a bearer the -hub minted for that agent authenticates instead — only the authentication changes: the -run is still named by `x-workflow-run-address`, and a token whose tenant is not the -resolved run's tenant is refused with the same bare 401. - -```ts -mountWorkflowArtifacts(workflowApi, { - db: hub.db, - contentStore: InlineContentStore, - resolveRunScope: async (bearerToken, runAddress) => - hub.resolveWorkflowRun(bearerToken, runAddress), - agentToken: { - verify: (ctx) => hub.verifyAgentToken(ctx), - resolveRun: (runAddress) => hub.resolveRunByAddress(runAddress), +export function mountArtifactRoutes( + app: Hono, + deps: { + db: ArtifactDb; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; + countSegments?: ArtifactCountSegments; }, -}); -``` - -### Sidecar bundle - -`@corbits/artifacts/sidecar-bundle` is the `defineTool` factory an agent definition -pins, so no agent owns artifact client code. It declares `requires: ["capabilities", -"address"]`, resolves the `hub` credential handle from the runtime capabilities, and -calls the routes above through that mediated fetch with the run address header. The -credential is the agent's own hub token; the bundle never sees the secret and never -names a host — the handle's origin pin resolves its relative paths. - -`artifact_create` and `artifact_write` both take an optional `metadata` object — -application metadata stored with the version, e.g. which project and stage this belongs -to — which the bundle forwards to the route unchanged, omitting the field entirely when -the model does not supply one. - -**Rate limiting is host-side.** `mountWorkflowArtifacts` mints no per-run quota — a host -that wants one wraps `resolveRunScope` (returning `null` to reject) or puts its own -middleware in front of the mounted app. `maxContentChars` (default 64,000) and -`maxBinaryBytes` (default `MAX_UPLOAD_BYTES`) are the two size ceilings this package -does own, since they protect the same storage `mountArtifacts` protects. - -## The options - -Three options have no sensible default; the rest fail closed and degrade a *feature*, -never safety. - -| Option | Required | Default and what omitting it costs | -| --- | --- | --- | -| `db` | **yes** | The drizzle handle the host already has | -| `contentStore` | **yes** | `InlineContentStore` for a minimal host | -| `requireGrant` | **yes** | The host's Interchange grant middleware factory. Archive/unarchive and other single-artifact mutations authorize through `requireGrant(idResource("artifact", "id"), …)`. This package implements no owner, agent-owner, membership, or admin policy. | -| `decorate` | no | No-op — rows carry no decoration. Display-only by contract, so it can never change what is returned or who sees it. Clients resolve display names from `ownerPrincipalId` when they need them. | -| `onArtifactCreated` | no | No-op. Runs inside the same transaction as artifact creation, once per row — the seam a host uses to provision grants (e.g. a `creator`-origin grant on `artifact:` for `write`/`archive`) for the row it just made. See `examples/reference-host`'s `grantOwnership` for a worked example against a real grant store. | -| `uploadPolicy` | no | `ARTIFACT_UPLOAD_POLICY` — the standard document/image/spreadsheet allowlist. | -| `countSegments` | no | `{}` — `GET /api/artifacts/counts` answers just `{ all }`. See [Counts](#counts). | - -Who the request runs as is **not** an option: the host's auth/tenant middleware puts -`tenant` and `principal` on the `TenantEnv` context, and this package reads them. No -principal on the context is the signed-out case (empty collection reads, `403` -everywhere else). - -### Compatibility - -`MountArtifactsOpts` follows semver: breaking changes to option names or shapes only -happen in major versions. - -## Routes - -| Surface | Behavior | -| --- | --- | -| `GET /api/artifacts` | Tenant-scoped list; query/kind/owner/date filters, keyset cursor, archived toggle. **Discovery only:** each item omits `content` (fetch bodies via detail, download, or tools) | -| `POST /api/artifacts` | Human import — link a URL or paste text | -| `POST /api/artifacts/upload` | multipart import. An optional `generatedBy` form field is stored as `source.generatedBy`, a free-form display label nothing here reads back | -| `GET /api/artifacts/:id` | Deep link (archived artifacts still load) | -| `GET`/`POST /api/artifacts/:id/versions` | Version history (paginated, no content bodies) and revision. `POST` accepts an optional `expectedVersion` precondition — see below | -| `GET /api/artifacts/:id/versions/:version` | One version, including content | -| `POST /api/artifacts/:id/(un)archive` | Idempotent soft-hide | -| `GET /api/artifacts/:id/download` | One path over three storage conventions | -| `GET /api/artifacts/counts` | Per-segment counts over the tenant. See [Counts](#counts) | -| `GET /api/artifacts/:id/preview` | Sandboxed `text/html`-only preview. See [Preview](#preview) | -| `…/api/instances/:id/mail-attachments` | Artifact↔message associations | - -Every route carries `describeRoute`, so it appears in the host's `/openapi.json`. - -**List contract (minor client break):** `GET /api/artifacts` (and `listArtifacts` / -`serializeArtifactListItem`) no longer include full `content` on each item. Clients that -previously rendered list rows from the list payload must load bodies via -`GET /api/artifacts/:id`, download, or the read tools. Search still matches title and -content server-side; only the response projection changes. - -**Version history pagination:** `GET /api/artifacts/:id/versions` returns -`{ versions, nextCursor }` with the same default/max limit clamps as list. Cursor is the -last version number returned (newest-first). Version rows omit content; use -`GET /api/artifacts/:id/versions/:version` (or `getArtifactVersion`) for a pinned body. -That route reuses the detail route's read authorization and response shape — a -non-integer or sub-1 `:version` is `400`, and an unknown version collapses into the -same `404 Artifact not found` every other single-artifact failure mode does. -`GET /api/artifacts/:id/download?version=N` applies the same pin to a downloadable -artifact's bytes, for the conventions where content really is per-version (an inline -data: URL, or downloadable text like `csv-export`); omitting `version` downloads the -current one. A blob-backed upload has no per-version bytes — the `ContentStore` -reference lives in the artifact row's own `source`, never in `artifact_version` — so -`?version=N` for one is `400 {"error":"Uploaded file content is not versioned"}` -unless `N` names the artifact's current version, rather than silently answering with -today's blob under an older version's name. - -**Version metadata and lineage:** each version optionally carries `metadata` (any JSON -object, opaque to the package — stored and returned as-is) and `parentVersionIds` (an -explicit array of ids the writer supplies; never inferred from version order). -`createArtifact`, `writeArtifactVersion`, and `findOrVersionArtifact` all accept both as -optional arguments; `getArtifactVersion` and `listArtifactVersions` return both on every -version. Omitting `metadata` on a revision carries the previous version's value forward, -the same way an omitted `title`/`content` does; `parentVersionIds` is never carried -forward — a version with no explicit parents simply has none. `artifact.metadata` mirrors -the current version's value, same as `title`/`content`/`version` already do. - -**Content digest:** every version carries `contentSha256` — sha256 (hex) over the UTF-8 -bytes of `content` for text and URL artifacts, or over the uploaded bytes for a -blob-backed file artifact's version 1 (its `content` column is a store-specific -pointer, not the bytes themselves). Returned on the detail route, a pinned version -read, version history, and the revise response; `artifact.contentSha256` mirrors the -current version's value the same way `metadata` does. A metadata/title-only revise -(content omitted, so it carries forward) carries the digest forward unchanged too. Rows -written before this field existed serialize `contentSha256: null` — there is no -backfill. - -**Optimistic revise (`expectedVersion`):** `POST /api/artifacts/:id/versions` accepts an -optional `expectedVersion` (a positive integer), checked under the same `FOR UPDATE` lock -that guards the write. A mismatch answers `409 {"error":"Version conflict","currentVersion":N}` -and writes nothing; omitting it is today's unconditional-write behavior. Use it to bind a -revise to the exact version a caller last read — a human review approving specific -content, for example — rather than silently overwriting whatever changed underneath it. - -**Write size limits:** create and revise reject titles longer than 512 characters and -content larger than 15 MiB UTF-8 (`ArtifactSizeError` / HTTP 400). JSON mutators also -refuse a declared `Content-Length` over that same 15 MiB ceiling with HTTP 413 before -buffering the body; missing `Content-Length` still streams into the parser. **Hosts -should set a global request body limit upstream** of this mount (Hono middleware, Bun -server, reverse proxy) — the package check is a best-effort edge guard, not a substitute -for a host-level cap. Upload byte caps remain on the multipart path. - -**Auth before body:** mutating JSON routes (`POST /api/artifacts`, -`POST /api/artifacts/:id/versions`, `POST …/mail-attachments`) read the principal from -context before parsing the body, so an unauthenticated caller gets 403 without learning -whether the JSON was well-formed. Upload already auth'd first. Single-artifact write -routes run existence/tenant/skill-draft resolution (the same check `loadScoped` does) -before the host's `requireGrant`, and only run `requireGrant` once that has confirmed a -real, visible row — so `requireGrant` is answering "is this permitted," never "does this -exist," and a caller who cannot see the row gets `404` however a real grant evaluator -would answer for it. - -### Two response contracts - -**No principal on the context** — the cross-core rule every `@corbits/*` package -follows, so a host mounting several hands its client one policy rather than three: - -| Route class | Response | In this core | -| --- | --- | --- | -| List / count / aggregate reads | **empty `200`** | `GET /api/artifacts` → `{"artifacts":[],"nextCursor":null}`; `GET …/mail-attachments` → `{"refs":[]}` | -| Streams, detail reads, mutations | **`403`** | every other route | - -A collection read answers the truth and names no resource. Everything else names a -specific resource, and whether it exists is not an unresolvable caller's to learn. This -core exposes no stream today; the class is listed so a future one is classified by the -rule rather than by guesswork. - -**Once a principal is on the context**, four causes collapse into one `404 -{"error":"Artifact not found"}` on all six single-artifact routes: the id was never -minted, the id is not shaped like an id, the row is a `skill-draft`, or the row belongs -to another tenant. A cross-tenant `403` would be an existence oracle — any account -holder could walk ids and learn which name a real artifact somewhere in the deployment. - -Archive/unarchive still answer `403` when the host's `requireGrant` denies the -`archive` action on a row the caller can see: an authorization decision about a row -known to exist, not a disclosure. - -## Uploads: three allowlists, and who owns each - -Three surfaces mint file artifacts, each with its own explicit allowlist. Collapsing -them into one global list would silently widen the narrow ones. - -| Policy | Surface | Owner | -| --- | --- | --- | -| `ARTIFACT_UPLOAD_POLICY` | Gallery import — `POST /api/artifacts/upload` | this package | -| `SPREADSHEET_UPLOAD_POLICY` | Spreadsheet ingest | the host | -| `PARSED_DOCUMENT_POLICY` | Chat/mail attachment divert | the host | - -`ARTIFACT_UPLOAD_POLICY` also accepts packaged archives — `application/gzip` / -`application/x-gzip` (`.tar.gz`, `.tgz`, `.gz`) and `application/x-tar` (`.tar`) — for -consumers storing packaged builds. An archive mints kind `file`, is never -inline-previewable (the download path only ever inlines a `application/pdf`; everything -else, archives included, is served as an `attachment`), and is still subject to -`MAX_UPLOAD_BYTES`, the same per-file ceiling as any other upload. - -What this package owns is the **gate**: `createFileArtifact` — the one function every -file artifact goes through — takes `policy` as a required argument and refuses anything -outside it with `UnsupportedUploadTypeError` before the `ContentStore` is touched. A -host route cannot mint a file artifact without naming the surface it is minting it for. - -```ts -// Parse BEFORE calling, so a parse failure leaves no orphan artifact. -const parsed = await parseAttachment(file); -try { - await db.transaction((tx) => - createFileArtifact(tx, contentStore, { - scope, - ownerPrincipalId: scope.principalId, - filename: file.name, - mimeType: file.type, - bytes, - policy: PARSED_DOCUMENT_POLICY, +): void { + mountArtifacts(app, { + db: deps.db, + contentStore: InlineContentStore, + requireGrant: createRequireGrant({ + grantStore: deps.grantStore, + conditionRegistry: deps.conditionRegistry, }), - ); -} catch (err) { - if (err instanceof UnsupportedUploadTypeError) return c.json({ error: err.message }, 415); - throw err; + ...(deps.countSegments !== undefined ? { countSegments: deps.countSegments } : {}), + }); } ``` -**File parsing is the host's, deliberately.** This package ships no PDF, spreadsheet or -document text extractor and will not grow one: an extractor is a heavyweight fast-moving -native dependency, and what the extracted text is *for* is the host's product. Of "parse -a PDF and serve it inline", the inline half is ours and is covered by the acceptance -suite. The contract owed to a parsing host is **parse before you store** — then a -failure leaves no orphan artifact and no orphan bytes, and a success writes bytes, row -and version 1 in one transaction. +### 2. Hub-side, run-scoped: `mountWorkflowArtifacts` -## Counts +A parallel mount for a workflow run, which has no browser session — only a bearer token and an `x-workflow-run-address` header. Mount it at its own path (`/api/workflow-artifacts` by convention; see the sidecar bundle below) rather than under the tenant prefix. `resolveRunScope` is the host's existing sidecar-token → run lookup; `agentToken` is a second, optional auth path so a deployed agent can present the bearer the hub minted for its own definition instead of the sidecar's token. -`GET /api/artifacts/counts` answers `{ all, ...perSegmentCounts }` — a real walk over -every page of the tenant's (non-archived) artifacts, bucketed by whichever predicates -the host passes as `countSegments`. This package owns none of the segment taxonomy -(what makes an artifact a "sheet" or a "routine" is entirely product-specific); it only -walks the rows once and tallies: +| `opts` | Type | What the host provides | +| --- | --- | --- | +| `db`, `contentStore` | `ArtifactDb`, `ContentStore` | Same as above. | +| `resolveRunScope` | `WorkflowRunResolver` | `(bearerToken, runAddress) => ResolvedWorkflowRunScope \| null`. Returning `null` answers 401 — this package makes no assumption about how a host issues or verifies its sidecar tokens. | +| `agentToken` | `AgentTokenAuth` (optional) | `{ verify(ctx), resolveRun(runAddress) }`. `verify` returns `undefined` for "not an agent token" (the sidecar path is tried instead) and refuses a bearer whose tenant doesn't match the resolved run's. | +| `uploadPolicy`, `maxBinaryBytes`, `maxContentChars` | — (optional) | Per-run ceilings; see [ARCHITECTURE.md](./ARCHITECTURE.md). | ```ts -mountArtifacts(app, { - db, - contentStore, - requireGrant, - countSegments: { - document: (row) => row.kind === "document", - sheet: (row) => row.kind === "sheet", - routine: (row) => row.kind === "routine", +import type { Hono } from "hono"; +import { + InlineContentStore, + mountWorkflowArtifacts, + type ArtifactDb, + type WorkflowArtifactEnv, + type WorkflowRunResolver, + type AgentTokenAuth, +} from "@corbits/artifacts"; + +export function mountWorkflowArtifactRoutes( + app: Hono, + deps: { + db: ArtifactDb; + resolveRunScope: WorkflowRunResolver; + agentToken?: AgentTokenAuth; }, -}); -// GET /api/artifacts/counts -> { all: 42, document: 30, sheet: 10, routine: 2 } +): void { + mountWorkflowArtifacts(app, { + db: deps.db, + contentStore: InlineContentStore, + resolveRunScope: deps.resolveRunScope, + ...(deps.agentToken !== undefined ? { agentToken: deps.agentToken } : {}), + }); +} ``` -Omit `countSegments` and the route still answers with just the tenant-wide `all`. The -walk caps at 200 pages (`MAX_COUNT_PAGES`, exported from the package) and throws -`ArtifactCountsIncompleteError` — mapped to HTTP `503` — rather than ever returning a -partial count as if it were the whole tenant. `countArtifactsBySegments` (also exported) -is the underlying function, usable directly outside the mounted route. +A host with no `ArtifactDb` yet opens one with `createArtifactDb(DATABASE_URL)` and applies this package's migrations at boot with `runArtifactMigrations(db)`, before either mount runs. -## Preview +### 3. Agent/tool side: `@corbits/artifacts/sidecar-bundle` -`GET /api/artifacts/:id/preview` serves an artifact's body as a **sandboxed HTML -document** — for a self-contained page an artifact stores (a rendered report, a chart -export) that a host wants to render visually without giving it any reach into the host's -own origin. Only an artifact whose resolved content type is exactly `text/html` is -previewable; anything else answers `415`. The response carries: +A deployed agent doesn't write its own artifact client — it imports the tool factory this package ships and adds it to its tool list. The factory resolves a `hub` credential from the runtime capabilities the host injects and calls the run-scoped routes above through it; it holds no database handle and no secret of its own. +```ts +import { defineAgent, type InferencePreference } from "@intx/agent"; +import { artifacts } from "@corbits/artifacts/sidecar-bundle"; + +export function buildAssistant(sources: readonly InferencePreference[]) { + return defineAgent({ + id: "assistant", + systemPrompt: "You help the team read and write shared artifacts.", + capabilities: [], + inference: { sources }, + tools: [artifacts], + }); +} ``` -Content-Security-Policy: sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'unsafe-inline' -X-Content-Type-Options: nosniff -``` - -`sandbox allow-scripts` puts the document in an opaque unique origin — scripts may run, -but there is no cookie/storage access, no same-origin fetch, no top-level navigation, no -popups. `default-src 'none'` blocks any further network reach; `style-src`/`img-src -data:`/`script-src 'unsafe-inline'` are exactly what a single-file page needs and no -more. `X-Frame-Options` is deliberately never set, so the host's own canvas/iframe can -still embed it. `resolveArtifactPreview` and `artifactPreviewHeaders` are exported -directly for a host that wants to serve the same preview from its own route. - -## ContentStore - -Where file bytes live is a port. Two impls ship and both pass the same suite: -`InlineContentStore` (bytea side-table, referenced by `source.upload.id`) and -`DataUrlContentStore` (bytes inline in `content` as a data: URL). An asset-substrate -backend is a third impl, not a rewrite. - -The single download path resolves the conventions in precedence order — out-of-band -blob, then inline data URL, then downloadable text (`csv-export`). Bytes are served as -`attachment` with `X-Content-Type-Options: nosniff`, except a PDF requested `?inline=1`. -## Schema and migrations +The host binds the agent's `hub` credential handle when it deploys the definition; wiring that binding is a deploy-time concern outside this package. -Four tables — `artifact`, `artifact_version`, `upload`, `mail_attachment_ref` — in the -package-owned `artifacts` Postgres schema. `tenant_id` is required (`NOT NULL`) and, -with the principal columns, is a hard foreign key into Interchange's -`public.tenant` / `public.principal`, so the host's own migrations must run first. -Version columns CHECK ≥ 1; size columns CHECK ≥ 0. Whether a principal belongs to -the stamped tenant is **host-owned** via the middleware that places `tenant` and -`principal` on the request context — the package does not install multi-table triggers -for that alignment (see ARCHITECTURE.md). +## How it works -`runArtifactMigrations(db)` is idempotent, advisory-locked, creates and owns the -`artifacts` Postgres schema, and keeps its own ledger -(`artifacts.migrations`). Call it unconditionally on every boot of every -replica: concurrent cold starts serialize on a transaction-scoped advisory lock, and a -re-run prints nothing. +`mountArtifacts` registers tenant-session routes (list, import, upload, versions, download, archive) and authorizes through the host's `requireGrant`. `mountWorkflowArtifacts` is the parallel mount for run-scoped callers: a bearer plus run address, no browser session. `@corbits/artifacts/sidecar-bundle` is the agent-side tool factory that calls the run-scoped routes over a mediated, credential-scoped fetch. All three persist rows in Postgres and blobs through a pluggable `ContentStore` (`InlineContentStore` for a minimal host). App creation, pooling, and auth stay with the host. -If the ledger is empty but package tables already exist (restored dump, dropped -ledger), the runner fails closed with `MigrationAdoptError`. Operators who have -confirmed the live schema may pass `{ adopt: true }` to record checksums without -re-running DDL. Adopt validates tables, column types, `artifact.tenant_id NOT NULL`, -and the named version/size CHECK constraints — not a columns-only glance. +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the data model and mount options. -Event timestamps are `timestamptz` so list keyset cursors and date filters stay -stable under a non-UTC session `TimeZone`. A ledgered retype migration converts -legacy zoneless columns with `USING col AT TIME ZONE 'UTC'` (existing walls were -always documented as UTC). Do not edit shipped migrations to roll back — ship a -new reverse cast if you must. - -`(tenant_id, title, kind)` is **not** a uniqueness constraint — only -`(artifact_id, version)` is. Use `findOrVersionArtifact(db, args)` for "find -by title, create if absent, add a version if present": it takes a -transaction-scoped advisory lock so concurrent callers for the same -`(tenantId, kind, title)` converge on one artifact instead of racing into -two. See ARCHITECTURE.md's "Find-or-version" note for the collision -semantics and why this is a package primitive rather than a schema -constraint. - -## Working on it +## Development ```sh +git clone https://github.com/corbitsdev/corbits-artifacts.git +cd corbits-artifacts bun install docker run -d --name corbits-artifact-pg -p 5457:5432 \ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=artifact_core postgres:16 - -# The package suite TRUNCATEs tables and some migration tests DROP SCHEMA. -# Both refuse unless you opt in and the database name is allowlisted. export ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1 -bun run test # dependency check, then unit + integration +bun run typecheck +bun run test # pretest dependency check, then unit + integration bun run build # dist/ (JS + .d.ts) -bun run test:acceptance # builds, then the acceptance scenarios +bun run test:acceptance # builds, then examples/reference-host ``` -The reference host is not a workspace: run `bun install --cwd examples/reference-host` once before `test:acceptance`. - -`test:acceptance` builds first because the reference host consumes the built `dist` the -way a real consumer would — running it against stale output is how a green acceptance -run stops meaning anything. - -Tests and the example expect -`postgres://postgres:postgres@localhost:5457/artifact_core`; override with -`ARTIFACT_DATABASE_URL`. Destructive package tests additionally require -`ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1` and an allowlisted database name -(`artifact_core`, or any name ending in `_test`). - -| Requirement | Value | -| --- | --- | -| Opt-in env | `ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1` (exactly `"1"`) | -| Database name allowlist | `artifact_core` (the documented local docker default), **or** any name ending in `_test` (e.g. `artifacts_test`) | - -Point `ARTIFACT_DATABASE_URL` at an allowlisted ephemeral database. A missing opt-in -or a production-looking name throws before any TRUNCATE/DROP runs. The gate is pure -URL/env parsing, so its unit tests do not need a live Postgres. - -| | | -| --- | --- | -| `src/` | The published package. Owns `artifact`, `artifact_version`, `upload` and `mail_attachment_ref`. | -| `examples/reference-host` | Mounts it on a real `@intx/hub-api` app against a live Postgres and asserts the acceptance scenarios end to end. | - -Strict TypeScript, arktype at boundaries, drizzle for data access. The package owns -the `artifacts` Postgres schema — all four tables and the migration ledger live there, -so sibling cores and host tables never collide. No `@workbench/*` imports anywhere — -`scripts/check-deps.ts` fails the build on any import of that unpublished scope (it runs -in `pretest` and CI). - -The tarball ships `src/` alongside `dist/`, so the emitted `.js.map` and `.d.ts.map` -resolve: go-to-definition and stack traces land on real TypeScript. +Tests expect `postgres://postgres:postgres@localhost:5457/artifact_core` (override with `ARTIFACT_DATABASE_URL`). Destructive tests require `ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1` and an allowlisted database name (`artifact_core`, or any name ending in `_test`). See [CONTRIBUTING.md](./CONTRIBUTING.md). ## License