Skip to content

Commit 99a65c3

Browse files
committed
Flatten repo so @corbits/artifacts is installable from git
Move the single package from packages/artifacts to the repository root so `bun add github:corbitsdev/corbits-artifacts` resolves cleanly. Keep examples as a workspace consumer; Bun installs from TypeScript sources via the bun export condition, Node from built dist. Closes CL-4792
1 parent db5a723 commit 99a65c3

41 files changed

Lines changed: 387 additions & 925 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ jobs:
3939
# No unpublished @workbench/* scope may leak in — it does not exist on
4040
# npm, so a leak makes the package uninstallable outside the monorepo.
4141
- name: check-deps
42-
run: bun run --cwd packages/artifacts scripts/check-deps.ts
42+
run: bun run scripts/check-deps.ts
4343

4444
# typecheck builds first: examples import @corbits/artifacts through the
4545
# published dist types, same path a consumer resolves.
4646
- name: typecheck
4747
run: bun run typecheck
4848

4949
- name: unit + integration tests
50-
run: bun run --cwd packages/artifacts test:coverage
50+
run: bun run test:coverage
5151

5252
# dist/ is already emitted by typecheck; re-run for a clean package build
5353
# so acceptance and the node consumer smoke test do not depend on typecheck
@@ -64,8 +64,8 @@ jobs:
6464
- name: node consumer smoke test
6565
run: |
6666
set -euo pipefail
67-
TARBALL="$(cd packages/artifacts && npm pack --silent)"
68-
TARBALL="$PWD/packages/artifacts/$TARBALL"
67+
TARBALL="$(npm pack --silent)"
68+
TARBALL="$PWD/$TARBALL"
6969
mkdir -p "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer"
7070
npm init -y >/dev/null && npm pkg set type=module >/dev/null
7171
npm install "$TARBALL"

‎ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
How `@corbits/artifacts` is put together, and where its boundaries are. For
44
install, the mount snippet, the route table and the response contracts, see the
5-
[package README](./packages/artifacts/README.md) — this document is about
5+
[package README](./README.md) — this document is about
66
structure and reasoning, and does not repeat them.
77

88
## The shape of the thing

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ always called out under their own heading.
99

1010
## [Unreleased]
1111

12+
### Changed
13+
14+
- The repository root **is** the `@corbits/artifacts` package. The previous
15+
`packages/artifacts` workspace nesting is gone so
16+
`bun add github:corbitsdev/corbits-artifacts` installs cleanly. Bun consumers
17+
resolve TypeScript sources via the `bun` export condition; Node consumers
18+
continue to use the built `dist/` from `npm pack` / a published release.
19+
1220
### 0.1.0 — first release
1321

1422
Initial public release. Nothing has been published before this, so everything is

‎README.md‎

Lines changed: 266 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,253 @@
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.
27261
export 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
30264
bun run build # dist/ (JS + .d.ts)
31265
bun 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

48292
Strict 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

Comments
 (0)