1- # corbits- artifacts
1+ # @ corbits/ artifacts
22
3- Home of ** [ ` @corbits/artifacts ` ] ( ./packages/artifacts ) ** — artifacts, versions
4- and file uploads, mountable onto any Hono host on a shared or a separate database, with
5- a pluggable ` ContentStore ` for where the bytes live. Backend only; this package ships
6- no UI.
3+ Artifacts, versions and file uploads as a mountable module for any Interchange host.
4+ Backend only — this package ships no UI.
75
8- See the [ package README] ( ./packages/artifacts/README.md ) for install, the mount
9- snippet and the mount options, and [ ARCHITECTURE.md] ( ./ARCHITECTURE.md ) for the data model.
6+ ` mountArtifacts(app, opts) ` adds routes to a Hono app you already have. It never
7+ creates the app, opens a pool, or reaches for a session — the host owns all three and
8+ hands them in.
109
11- ## Layout
10+ See [ ARCHITECTURE.md] ( ./ARCHITECTURE.md ) for the data model, the mount options, and the
11+ design rationale behind them.
12+
13+ ## Requirements
1214
1315| | |
1416| --- | --- |
15- | ` packages/artifacts ` | The published package. Owns ` artifact ` , ` artifact_version ` , ` upload ` and ` mail_attachment_ref ` . |
16- | ` examples/reference-host ` | Mounts it on a real ` @intx/hub-api ` app against a live Postgres and asserts the acceptance scenarios end to end. |
17+ | Runtime | Node 22+ or Bun 1.1+ |
18+ | Postgres | 13+ (` gen_random_uuid() ` ) |
19+ | Minimum ` @intx/* ` | ** 0.2.2** |
20+
21+ Peer dependencies: ` hono ` , ` hono-openapi ` , ` drizzle-orm ` , ` postgres ` , ` arktype ` ,
22+ ` @intx/types ` . They are peers rather than pinned deps because each is shared runtime
23+ state — a Hono app, a drizzle handle, a module-global registry — and a second copy in
24+ the tree does not error, it silently misbehaves.
25+
26+ Your ** host** additionally needs ` @intx/hub-api ` , ` @intx/db ` , ` @intx/hub-sessions ` and
27+ ` @intx/hub-common ` for ` createApp ` . This module imports none of them, so they are not
28+ peers here and npm will not warn you they are missing.
29+
30+ ## Install
31+
32+ ``` bash
33+ # From git (Bun)
34+ bun add github:corbitsdev/corbits-artifacts
35+
36+ # Or with peers explicitly
37+ bun add github:corbitsdev/corbits-artifacts \
38+ hono hono-openapi drizzle-orm postgres arktype @intx/types@^0.2.2
39+ ```
40+
41+ ``` bash
42+ # npm / pack
43+ npm install @corbits/artifacts \
44+ hono hono-openapi drizzle-orm postgres arktype @intx/types@^0.2.2
45+ ```
46+
47+ > ** Not on npm yet.** Until the first release, consume it from git or an ` npm pack `
48+ > tarball. The ` @intx/* ` packages * are* published, at ` 0.2.2 ` . This repository root * is*
49+ > the package, so git installs resolve cleanly.
50+
51+ ## Mount
52+
53+ ``` ts
54+ import { Hono } from " hono" ;
55+ import type { AppEnv } from " @intx/hub-api" ;
56+ import {
57+ InlineContentStore ,
58+ mountArtifacts ,
59+ runArtifactMigrations ,
60+ type ResolvedPrincipal ,
61+ } from " @corbits/artifacts" ;
62+
63+ // Boot-time, once. Idempotent — safe on every boot of every replica.
64+ await runArtifactMigrations (hub .db );
65+
66+ const api = new Hono <AppEnv >();
67+ mountArtifacts (api , {
68+ db: hub .db ,
69+ contentStore: InlineContentStore ,
70+ resolvePrincipal(ctx ): ResolvedPrincipal | null {
71+ const user = (ctx as Context <AppEnv >).get (" user" );
72+ if (! user ) return null ;
73+ return { tenantId: user .tenantId , principalId: user .id };
74+ },
75+ });
76+ app .route (" /api" , api );
77+ ```
78+
79+ Routes are registered root-relative and served under ` /api ` — the prefix Interchange
80+ serves its own routes under. No ` /v1 ` , no vendor prefix.
81+
82+ ` examples/reference-host ` in this repository is a complete ` @intx/hub-api ` host with
83+ this module mounted and the acceptance suite pointed at it. Start there if you need the
84+ whole ` createApp ` wiring.
85+
86+ ## The options
87+
88+ Three options have no sensible default; the rest fail closed and degrade a * feature* ,
89+ never safety.
90+
91+ | Option | Required | Default and what omitting it costs |
92+ | --- | --- | --- |
93+ | ` db ` | ** yes** | The drizzle handle the host already has |
94+ | ` contentStore ` | ** yes** | ` InlineContentStore ` for a minimal host |
95+ | ` resolvePrincipal ` | ** yes** | ` (ctx: unknown) => { tenantId, principalId } \| null ` . Identical to ` @corbits/mailbox-core ` 's, so a host mounting both passes one function to both. The resolved tenant is authoritative — no caller-supplied override. |
96+ | ` isAdmin ` | no | Nobody is an admin — only the owner (and the member behind a producing agent) can archive. Nothing becomes more permissive. |
97+ | ` identity ` | no | ` anonymousIdentity ` — ` ownerName ` is ` null ` , ` ?creatorKind= ` matches nothing, cross-tenant reads refused. |
98+ | ` decorate ` | no | No-op — rows carry no decoration. Display-only by contract, so it can never change what is returned or who sees it. |
99+ | ` uploadPolicy ` | no | ` ARTIFACT_UPLOAD_POLICY ` — the standard document/image/spreadsheet allowlist. |
100+
101+ ### Compatibility
102+
103+ ` MountArtifactsOpts ` follows semver: breaking changes to option names or shapes only
104+ happen in major versions.
105+
106+ ## Routes
107+
108+ | Surface | Behavior |
109+ | --- | --- |
110+ | ` GET /api/artifacts ` | Tenant-scoped list; query/kind/owner/creatorKind/date filters, keyset cursor, archived toggle. ** Discovery only:** each item omits ` content ` (fetch bodies via detail, download, or tools) |
111+ | ` POST /api/artifacts ` | Human import — link a URL or paste text |
112+ | ` 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 |
113+ | ` GET /api/artifacts/:id ` | Deep link (archived artifacts still load) |
114+ | ` GET ` /` POST /api/artifacts/:id/versions ` | Version history (paginated, no content bodies) and revision |
115+ | ` POST /api/artifacts/:id/(un)archive ` | Idempotent soft-hide |
116+ | ` GET /api/artifacts/:id/download ` | One path over three storage conventions |
117+ | ` …/api/instances/:id/mail-attachments ` | Artifact↔message associations |
118+
119+ Every route carries ` describeRoute ` , so it appears in the host's ` /openapi.json ` .
120+
121+ ** List contract (minor client break):** ` GET /api/artifacts ` (and ` listArtifacts ` /
122+ ` serializeArtifactListItem ` ) no longer include full ` content ` on each item. Clients that
123+ previously rendered list rows from the list payload must load bodies via
124+ ` GET /api/artifacts/:id ` , download, or the read tools. Search still matches title and
125+ content server-side; only the response projection changes.
126+
127+ ** Version history pagination:** ` GET /api/artifacts/:id/versions ` returns
128+ ` { versions, nextCursor } ` with the same default/max limit clamps as list. Cursor is the
129+ last version number returned (newest-first). Version rows omit content; use
130+ ` GET /api/artifacts/:id?version=N ` (or ` getArtifactVersion ` ) for a pinned body.
131+
132+ ** Write size limits:** create and revise reject titles longer than 512 characters and
133+ content larger than 15 MiB UTF-8 (` ArtifactSizeError ` / HTTP 400). JSON mutators also
134+ refuse a declared ` Content-Length ` over that same 15 MiB ceiling with HTTP 413 before
135+ buffering the body; missing ` Content-Length ` still streams into the parser. ** Hosts
136+ should set a global request body limit upstream** of this mount (Hono middleware, Bun
137+ server, reverse proxy) — the package check is a best-effort edge guard, not a substitute
138+ for a host-level cap. Upload byte caps remain on the multipart path.
139+
140+ ** Auth before body:** mutating JSON routes (` POST /api/artifacts ` ,
141+ ` POST /api/artifacts/:id/versions ` , ` POST …/mail-attachments ` ) resolve the principal
142+ before parsing the body, so an unauthenticated caller gets 403 without learning whether
143+ the JSON was well-formed. Upload already auth'd first.
144+
145+ ### Two response contracts
146+
147+ ** No resolvable principal** — the cross-core rule every ` @corbits/* ` package follows, so
148+ a host mounting several hands its client one policy rather than three:
149+
150+ | Route class | Response | In this core |
151+ | --- | --- | --- |
152+ | List / count / aggregate reads | ** empty ` 200 ` ** | ` GET /api/artifacts ` → ` {"artifacts":[],"nextCursor":null} ` ; ` GET …/mail-attachments ` → ` {"refs":[]} ` |
153+ | Streams, detail reads, mutations | ** ` 403 ` ** | every other route |
154+
155+ A collection read answers the truth and names no resource. Everything else names a
156+ specific resource, and whether it exists is not an unresolvable caller's to learn. This
157+ core exposes no stream today; the class is listed so a future one is classified by the
158+ rule rather than by guesswork.
159+
160+ ** Once a principal is resolved** , four causes collapse into one `404
161+ {"error":"Artifact not found"}` on all six single-artifact routes: the id was never
162+ minted, the id is not shaped like an id, the row is a ` skill-draft ` , or the row belongs
163+ to another tenant. A cross-tenant ` 403 ` would be an existence oracle — any account
164+ holder could walk ids and learn which name a real artifact somewhere in the deployment.
165+
166+ Archive/unarchive still answer ` 403 ` for a caller who can see the artifact but may not
167+ administer it: an authorization decision about a row known to exist, not a disclosure.
168+
169+ ## Uploads: three allowlists, and who owns each
170+
171+ Three surfaces mint file artifacts, each with its own explicit allowlist. Collapsing
172+ them into one global list would silently widen the narrow ones.
173+
174+ | Policy | Surface | Owner |
175+ | --- | --- | --- |
176+ | ` ARTIFACT_UPLOAD_POLICY ` | Gallery import — ` POST /api/artifacts/upload ` | this package |
177+ | ` SPREADSHEET_UPLOAD_POLICY ` | Spreadsheet ingest | the host |
178+ | ` PARSED_DOCUMENT_POLICY ` | Chat/mail attachment divert | the host |
179+
180+ What this package owns is the ** gate** : ` createFileArtifact ` — the one function every
181+ file artifact goes through — takes ` policy ` as a required argument and refuses anything
182+ outside it with ` UnsupportedUploadTypeError ` before the ` ContentStore ` is touched. A
183+ host route cannot mint a file artifact without naming the surface it is minting it for.
184+
185+ ``` ts
186+ // Parse BEFORE calling, so a parse failure leaves no orphan artifact.
187+ const parsed = await parseAttachment (file );
188+ try {
189+ await db .transaction ((tx ) =>
190+ createFileArtifact (tx , contentStore , {
191+ scope ,
192+ ownerPrincipalId: scope .principalId ,
193+ filename: file .name ,
194+ mimeType: file .type ,
195+ bytes ,
196+ policy: PARSED_DOCUMENT_POLICY ,
197+ }),
198+ );
199+ } catch (err ) {
200+ if (err instanceof UnsupportedUploadTypeError ) return c .json ({ error: err .message }, 415 );
201+ throw err ;
202+ }
203+ ```
204+
205+ ** File parsing is the host's, deliberately.** This package ships no PDF, spreadsheet or
206+ document text extractor and will not grow one: an extractor is a heavyweight fast-moving
207+ native dependency, and what the extracted text is * for* is the host's product. Of "parse
208+ a PDF and serve it inline", the inline half is ours and is covered by the acceptance
209+ suite. The contract owed to a parsing host is ** parse before you store** — then a
210+ failure leaves no orphan artifact and no orphan bytes, and a success writes bytes, row
211+ and version 1 in one transaction.
212+
213+ ## ContentStore
214+
215+ Where file bytes live is a port. Two impls ship and both pass the same suite:
216+ ` InlineContentStore ` (bytea side-table, referenced by ` source.upload.id ` ) and
217+ ` DataUrlContentStore ` (bytes inline in ` content ` as a data: URL). An asset-substrate
218+ backend is a third impl, not a rewrite.
219+
220+ The single download path resolves the conventions in precedence order — out-of-band
221+ blob, then inline data URL, then downloadable text (` csv-export ` ). Bytes are served as
222+ ` attachment ` with ` X-Content-Type-Options: nosniff ` , except a PDF requested ` ?inline=1 ` .
223+
224+ ## Schema and migrations
225+
226+ Four tables — ` artifact ` , ` artifact_version ` , ` upload ` , ` mail_attachment_ref ` — in the
227+ package-owned ` artifacts ` Postgres schema. ` tenant_id ` is required (` NOT NULL ` ) and,
228+ with the principal columns, is a hard foreign key into Interchange's
229+ ` public.tenant ` / ` public.principal ` , so the host's own migrations must run first.
230+ Version columns CHECK ≥ 1; size columns CHECK ≥ 0. Whether a principal belongs to
231+ the stamped tenant is ** host-owned** via ` resolvePrincipal ` — the package does not
232+ install multi-table triggers for that alignment (see ARCHITECTURE.md).
233+
234+ ` runArtifactMigrations(db) ` is idempotent, advisory-locked, creates and owns the
235+ ` artifacts ` Postgres schema, and keeps its own ledger
236+ (` artifacts.migrations ` ). Call it unconditionally on every boot of every
237+ replica: concurrent cold starts serialize on a transaction-scoped advisory lock, and a
238+ re-run prints nothing.
239+
240+ If the ledger is empty but package tables already exist (restored dump, dropped
241+ ledger), the runner fails closed with ` MigrationAdoptError ` . Operators who have
242+ confirmed the live schema may pass ` { adopt: true } ` to record checksums without
243+ re-running DDL. Adopt validates tables, column types, ` artifact.tenant_id NOT NULL ` ,
244+ and the named version/size CHECK constraints — not a columns-only glance.
245+
246+ Event timestamps are ` timestamptz ` so list keyset cursors and date filters stay
247+ stable under a non-UTC session ` TimeZone ` . A ledgered retype migration converts
248+ legacy zoneless columns with ` USING col AT TIME ZONE 'UTC' ` (existing walls were
249+ always documented as UTC). Do not edit shipped migrations to roll back — ship a
250+ new reverse cast if you must.
17251
18252## Working on it
19253
@@ -26,10 +260,9 @@ docker run -d --name corbits-artifact-pg -p 5457:5432 \
26260# Both refuse unless you opt in and the database name is allowlisted.
27261export ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1
28262
29- bun run test:package # dependency check, then unit + integration
263+ bun run test # dependency check, then unit + integration
30264bun run build # dist/ (JS + .d.ts)
31265bun run test:acceptance # builds, then the acceptance scenarios
32- bun run test # both suites
33266```
34267
35268` test:acceptance ` builds first because the reference host consumes the built ` dist ` the
@@ -40,16 +273,30 @@ Tests and the example expect
40273` postgres://postgres:postgres@localhost:5457/artifact_core ` ; override with
41274` ARTIFACT_DATABASE_URL ` . Destructive package tests additionally require
42275` ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1 ` and an allowlisted database name
43- (` artifact_core ` , or any name ending in ` _test ` ). See the package README Development
44- section for the full gate contract.
276+ (` artifact_core ` , or any name ending in ` _test ` ).
45277
46- ## Conventions
278+ | Requirement | Value |
279+ | --- | --- |
280+ | Opt-in env | ` ALLOW_DESTRUCTIVE_ARTIFACT_TESTS=1 ` (exactly ` "1" ` ) |
281+ | Database name allowlist | ` artifact_core ` (the documented local docker default), ** or** any name ending in ` _test ` (e.g. ` artifacts_test ` ) |
282+
283+ Point ` ARTIFACT_DATABASE_URL ` at an allowlisted ephemeral database. A missing opt-in
284+ or a production-looking name throws before any TRUNCATE/DROP runs. The gate is pure
285+ URL/env parsing, so its unit tests do not need a live Postgres.
286+
287+ | | |
288+ | --- | --- |
289+ | ` src/ ` | The published package. Owns ` artifact ` , ` artifact_version ` , ` upload ` and ` mail_attachment_ref ` . |
290+ | ` examples/reference-host ` | Mounts it on a real ` @intx/hub-api ` app against a live Postgres and asserts the acceptance scenarios end to end. |
47291
48292Strict TypeScript, arktype at boundaries, drizzle for data access. The package owns
49- the ` artifacts ` Postgres schema — all four tables and the migration
50- ledger live there, so sibling cores and host tables never collide.
51- No ` @workbench/* ` imports anywhere — ` scripts/check-deps.ts ` fails the build on any
52- import of that unpublished scope (it runs in ` pretest ` and CI).
293+ the ` artifacts ` Postgres schema — all four tables and the migration ledger live there,
294+ so sibling cores and host tables never collide. No ` @workbench/* ` imports anywhere —
295+ ` scripts/check-deps.ts ` fails the build on any import of that unpublished scope (it runs
296+ in ` pretest ` and CI).
297+
298+ The tarball ships ` src/ ` alongside ` dist/ ` , so the emitted ` .js.map ` and ` .d.ts.map `
299+ resolve: go-to-definition and stack traces land on real TypeScript.
53300
54301## License
55302
0 commit comments