@@ -24,63 +24,109 @@ Interchange serves its own routes under (`app.route("/api/me", …)`,
2424` app.route("/api/tenants", …) ` ). No ` /v1 ` segment, no vendor prefix.
2525
2626``` ts
27- const api = new Hono <AppEnv >();
28- mountArtifacts (api , { db , contentStore , resolvePrincipal });
27+ const api = new Hono <TenantEnv >();
28+ // Host middleware has already placed `tenant` and `principal` on the context.
29+ mountArtifacts (api , { db , contentStore , requireGrant });
2930app .route (" /api" , api );
3031```
3132
3233which serves ` /api/artifacts ` , ` /api/artifacts/:id ` ,
3334` /api/artifacts/:id/versions ` , ` /api/artifacts/:id/download ` , and
3435` /api/instances/:instanceId/mail-attachments ` . Nesting rather than teaching the
35- core a base path keeps the frozen ` mountX<E extends Env>(app, opts) => Hono<E> `
36- seam untouched.
36+ core a base path keeps the mount free of a configurable base path.
3737
38- Everything else it needs arrives through ` opts ` . Nothing is reached for.
38+ Everything else it needs arrives through ` opts ` or the host's request context.
39+ Nothing is reached for.
3940
4041## The mount seam
4142
42- ` mountArtifacts<E extends Env>(app: Hono<E>, opts): Hono<E> ` is generic over the
43- host's Hono ` Env ` , so it composes with an app that carries its own environment
44- rather than requiring a bare ` Hono ` .
45-
46- Three options have no sensible default — ` db ` , ` contentStore ` ,
47- ` resolvePrincipal ` — and the rest degrade a * feature* , never safety, when
48- omitted. The README's table of what a minimal host passes is the reference; what
49- matters architecturally is that every default ** fails closed** : no ` isAdmin `
50- means nobody is an admin, no ` identity ` means no directory and no cross-tenant
51- reads, no ` decorate ` means no decoration.
52-
53- What the package does ** not** require of a host: no auth middleware, no session
54- library, no UI. What it DOES require: Interchange's control plane —
55- ` public.tenant ` and ` public.principal ` must exist before the migrations run,
56- because the tables carry hard foreign keys into them.
57-
58- ` resolvePrincipal ` 's signature is identical across the Corbits cores, so a host
59- mounting more than one passes the same function to each. The resolved tenant is
60- authoritative — there is no caller-supplied tenant override anywhere in the
61- route surface.
43+ ` mountArtifacts(app: Hono<TenantEnv>, opts): Hono<TenantEnv> ` takes Interchange's
44+ ` TenantEnv ` so it composes with a host app mounted beneath Interchange auth +
45+ tenant middleware. The host places full ` tenant ` and ` principal ` rows on the
46+ context; this package reads them natively and never invents a second principal
47+ resolution path.
48+
49+ Three options have no sensible default — ` db ` , ` contentStore ` , ` requireGrant ` —
50+ and the rest degrade a * feature* , never safety, when omitted. The README's table
51+ of what a minimal host passes is the reference; what matters architecturally is
52+ that optional seams ** fail closed** : no ` decorate ` means no decoration.
53+
54+ What the package does ** not** require of a host: no session library, no UI, no
55+ directory, no owner/admin policy callback. What it DOES require: Interchange's
56+ control plane — ` public.tenant ` and ` public.principal ` must exist before the
57+ migrations run, because the tables carry hard foreign keys into them — and a
58+ host that puts the authenticated principal on ` TenantEnv ` and hands in its
59+ ` RequireGrant ` .
60+
61+ The principal's tenant is authoritative — there is no caller-supplied tenant
62+ override anywhere in the route or tool surface. Tool reads always stay inside
63+ ` scope.tenantId ` .
64+
65+ ** This is a decision, not an oversight.** The prior ` Identity ` port let
66+ ` readArtifact ` / ` readArtifactChunk ` take a ` tenantId ` argument and cross into
67+ it when ` identity.ownerIsMemberOfTenant(scope, tenantId) ` said the caller's
68+ owner belonged there — a membership check this package invented and owned.
69+ That is exactly the kind of policy this PR removes. It is not replaced by a
70+ grant check, and won't be by a later one either: Interchange's ` GrantStore `
71+ resolves a principal's grants ** within one tenant**
72+ (` collectGrants(principalId, tenantId) ` ; ` @intx/db ` 's implementation filters
73+ ` grant ` rows by ` tenant_id ` , and a principal is itself a row scoped to one
74+ tenant). There is no platform primitive for "principal P, home tenant A, holds
75+ a grant readable from tenant B" to check — inventing one here would mean this
76+ package building a second, bespoke cross-tenant authorization concept on top
77+ of the platform's, which is the precise failure mode "authorization is the
78+ host's job" is meant to prevent. If a real product need for cross-tenant
79+ artifact reads shows up, it belongs in Interchange's grant model, not
80+ re-derived per package.
81+
82+ ## Three custom seams
83+
84+ Beyond the host's native context and grants, this package exposes ** three**
85+ extension seams: the substrate (` ContentStore ` ), a display-only decorator
86+ (` decorate ` / provenance), and a grant-provisioning hook (` onArtifactCreated ` ).
87+ Authorization is not a custom seam — it is the host's Interchange ` RequireGrant ` ;
88+ ` onArtifactCreated ` is not authorization either, it is the write side of the
89+ same idea — the host deciding what makes its grant model true, this package
90+ only handing it the row and the scope that made it.
6291
6392## The options
6493
65- ` ContentStore ` and ` Identity ` are types declared in ` ports.ts ` ; the rest are
66- plain ` mountArtifacts ` options. ` resolvePrincipal ` takes the host's request
67- context as ` unknown ` .
94+ ` ContentStore ` is declared in ` ports.ts ` ; the rest are plain ` mountArtifacts `
95+ options. Who the request runs as is read from ` TenantEnv ` , not passed as a
96+ callback .
6897
6998| Option | What it is for | Default |
7099| --- | --- | --- |
71- | ` resolvePrincipal ` | Who the request runs as. Reads the host session; returns ` null ` when signed out . | none — required |
100+ | ` requireGrant ` | Host-owned Interchange grant middleware factory. Single-artifact mutations (revise, archive/unarchive, …) run ` requireGrant(idResource("artifact", "id"), <action>) ` . | none — required |
72101| ` contentStore ` | Where an artifact's file bytes live (` ContentStore ` ). | none — required |
73- | ` isAdmin ` | Whether a principal is a tenant admin. Only archive/unarchive consults it. | nobody is an admin |
74- | ` identity ` | Owner display names, the agent→human ownership resolution, creator-kind principal sets, and cross-tenant membership (` Identity ` ). | ` anonymousIdentity ` |
75- | ` decorate ` | A ** display-only** decorator over serialized rows. | no-op |
102+ | ` decorate ` | A ** display-only** decorator over serialized rows (provenance labels, host joins). | no-op |
103+ | ` onArtifactCreated ` | Host hook run inside the same transaction as artifact creation — where a host mints grants for the row it just made. | no-op |
76104
77105` decorate ` 's display-only status is a contract, not a convention: it may add
78106fields to rows on their way out and must never affect * what* is returned or
79107* who* may see it. Joining a host's workflow tables inside this package would
80108couple it to a schema it must not know, so the host supplies the decorator.
81-
82- ` Identity.ownerIsMemberOfTenant ` gates cross-tenant reads and must fail closed;
83- the shipped ` anonymousIdentity ` does.
109+ Clients that need an owner display name resolve ` ownerPrincipalId ` themselves;
110+ this package never ships directory names on the wire.
111+
112+ ### Grant provisioning (` onArtifactCreated ` )
113+
114+ Checking a grant (` requireGrant ` ) and minting one (` onArtifactCreated ` ) are the
115+ same host responsibility looked at from both ends: this package neither
116+ invents authorization policy nor decides who a newly created row belongs to
117+ for grant purposes — it hands the host the row, inside the transaction that
118+ made it durable, and the host decides.
119+
120+ ` examples/reference-host ` provisions a real ` creator ` -origin grant on create —
121+ ` write ` and ` archive ` on ` artifact:<id> ` for the creating principal, inserted
122+ into Interchange's own ` grant ` table via ` @intx/db ` 's schema, in the same
123+ transaction as the artifact row. Its ` buildApp ` 's default ` requireGrant ` is the
124+ platform's real ` createRequireGrant ` over that same table (via
125+ ` createGrantStore ` ), not a stub — a principal with no matching row is refused,
126+ exactly as in production. See ` grantOwnership ` in
127+ ` examples/reference-host/src/index.ts ` and the "ownership-derived grants"
128+ scenarios in its acceptance suite for the end-to-end proof: the creator
129+ succeeds, a co-tenant with no grant does not.
84130
85131### ContentStore
86132
@@ -107,15 +153,15 @@ which store is installed.
107153
108154| File | Role |
109155| --- | --- |
110- | ` mount.ts ` | HTTP surface: parsing, validation, status codes. |
156+ | ` mount.ts ` | HTTP surface: parsing, validation, status codes; reads ` TenantEnv ` principal; wires host ` requireGrant ` . |
111157| ` artifacts.ts ` | The core domain — create, revise, list, get, archive, serialize. |
112158| ` uploads.ts ` | ` createFileArtifact ` , the MIME policies, and the size caps. |
113159| ` download.ts ` | One download path over the three storage conventions. |
114160| ` content-store.ts ` | The two shipped ` ContentStore ` implementations. |
115- | ` tools.ts ` | Agent-facing tool definitions and windowed artifact reads. |
161+ | ` tools.ts ` | Agent-facing tool definitions and windowed artifact reads (caller tenant only) . |
116162| ` web-site.ts ` | The ` web-site ` kind's content encoding and validation. |
117163| ` mail-attachments.ts ` | Artifact↔message associations. |
118- | ` ports.ts ` | The ` ContentStore ` and ` Identity ` types, and the fail-closed ` anonymousIdentity ` default . |
164+ | ` ports.ts ` | The ` ContentStore ` type and the shared ` ResolvedPrincipal ` shape . |
119165| ` schema.ts ` / ` migrations.ts ` | The four tables, and the DDL that creates them. |
120166
121167## Data model
@@ -140,10 +186,10 @@ single-column constraints applied by a ledgered migration — free at write time
140186the control plane independently; it does ** not** enforce that ` principal_id ` (or
141187` owner_principal_id ` ) belongs to the same tenant as ` tenant_id ` . A multi-table
142188trigger or composite FK into ` public.principal ` would couple every write to a
143- control-plane lookup and is deliberately out of scope. The host's
144- ` resolvePrincipal ` is the authority: it returns the ` (tenantId, principalId) `
145- pair every route and tool write stamps , so a correctly mounted host never
146- plants a cross-tenant principal. Operators cleaning legacy rows before the
189+ control-plane lookup and is deliberately out of scope. The host's middleware and
190+ context are the authority: routes stamp the ` (tenantId, principalId) ` pair from
191+ the Interchange ` principal ` already on ` TenantEnv ` , so a correctly mounted host
192+ never plants a cross-tenant principal. Operators cleaning legacy rows before the
147193` tenant_id NOT NULL ` migration must assign a valid tenant or delete orphans —
148194the migration fails with an explicit message if null ` tenant_id ` rows remain.
149195
@@ -234,9 +280,9 @@ upload **gate** (`createFileArtifact` takes `policy` as a required argument and
234280refuses anything outside it before the ` ContentStore ` is touched); and the
235281download path with its ` nosniff ` /` attachment ` behaviour.
236282
237- Supplied by the host: the Hono app and the database handle; who the caller is;
238- whether they are an admin; the directory, if there is one; provenance
239- decoration; and a ` ContentStore ` .
283+ Supplied by the host: the ` Hono<TenantEnv> ` app and the database handle; the
284+ authenticated ` tenant ` / ` principal ` on the request context; the host's
285+ ` RequireGrant ` ; display-only provenance decoration; and a ` ContentStore ` .
240286
241287## Known limits
242288
0 commit comments