From 65ed736a6c6dc380ff11aa17afcdd5b484f54e0e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 10:13:28 -0700 Subject: [PATCH 1/3] feat(mount): mint creator write and archive grants on create Insert creator-origin grants on artifact: in the creating transaction for both POST /artifacts and POST /artifacts/upload. onArtifactCreated still runs for other side effects. --- AGENTS.md | 39 ++++++++++------------------ README.md | 18 ++++++------- e2e/reference-host.test.ts | 27 +++++++++++++++++++ examples/reference-host/src/index.ts | 37 -------------------------- src/mount.ts | 35 ++++++++++++++++++++----- 5 files changed, 79 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a96626b..8e0ca73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,15 +103,12 @@ host's job" is meant to prevent. If a real product need for cross-tenant artifact reads shows up, it belongs in Interchange's grant model, not re-derived per package. -### Three custom seams +### Two custom seams -Beyond the host's native context and grants, this package exposes **three** -extension seams: the substrate (`ContentStore`), a display-only decorator -(`decorate` / provenance), and a grant-provisioning hook (`onArtifactCreated`). -Authorization is not a custom seam — it is the host's Interchange `RequireGrant`; -`onArtifactCreated` is not authorization either, it is the write side of the -same idea — the host deciding what makes its grant model true, this package -only handing it the row and the scope that made it. +Beyond the host's native context and grants, this package exposes **two** +extension seams: the substrate (`ContentStore`) and a display-only decorator +(`decorate` / provenance). Authorization is not a custom seam — it is the +host's Interchange `RequireGrant`. ### Row decoration @@ -122,26 +119,18 @@ couple it to a schema it must not know, so the host supplies the decorator. Clients that need an owner display name resolve `ownerPrincipalId` themselves; this package never ships directory names on the wire. -### Grant provisioning (`onArtifactCreated`) - -Checking a grant (`requireGrant`) and minting one (`onArtifactCreated`) are the -same host responsibility looked at from both ends: this package neither -invents authorization policy nor decides who a newly created row belongs to -for grant purposes — it hands the host the row, inside the transaction that -made it durable, and the host decides. +### Creator grants Creating needs its own grant, `create` on `artifact:*`, checked before the body is read; the reference host seeds it for every principal in its tenant. -`examples/reference-host` then provisions a real `creator`-origin grant on create — -`write` and `archive` on `artifact:` for the creating principal, inserted -into Interchange's own `grant` table via `@intx/db`'s schema, in the same -transaction as the artifact row. Its `buildApp`'s default `requireGrant` is the -platform's real `createRequireGrant` over that same table (via -`createGrantStore`), not a stub — a principal with no matching row is refused, -exactly as in production. See `grantOwnership` in -`examples/reference-host/src/index.ts` and the "ownership-derived grants" -scenarios in its acceptance suite for the end-to-end proof: the creator -succeeds, a co-tenant with no grant does not. +On create, the package mints `creator`-origin `write` and `archive` grants on +`artifact:` for the creating principal, inserted into Interchange's own +`grant` table in the same transaction as the artifact row (once per file on +upload). `onArtifactCreated` remains for a host's other creation side effects. +The reference host's `buildApp` default `requireGrant` is the platform's real +`createRequireGrant` over that table (via `createGrantStore`), so the +"ownership-derived grants" scenarios in its acceptance suite prove the +creator succeeds and a co-tenant with no grant does not. ### ContentStore diff --git a/README.md b/README.md index 29affef..90fef47 100644 --- a/README.md +++ b/README.md @@ -62,15 +62,15 @@ await close(); ### `createArtifactRoutes(deps)` -| `deps` | Type | What the host provides | -| ------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `db` | `ArtifactDb` | The hub's drizzle handle, for example from `createDB`. | -| `contentStore` | `ContentStore` | Where file bytes go. `InlineContentStore` keeps them in Postgres; implement the port for object storage. | -| `requireGrant` | `RequireGrant` | From `@intx/hub-api`'s `createRequireGrant`. | -| `onArtifactCreated` | `(tx, row, scope) => Promise` | Optional. Runs in the creating transaction; mint the creator's `write` and `archive` grants on `artifact:` here. | -| `decorate` | `(tenantId, rows) => Promise` | Optional. Adds display-only fields to serialized rows. It must not change which rows are returned. | -| `uploadPolicy` | `UploadPolicy` | Optional. MIME types `POST /artifacts/upload` accepts. Defaults to `ARTIFACT_UPLOAD_POLICY`. | -| `countSegments` | `ArtifactCountSegments` | Optional. Named predicates for `GET /artifacts/counts`. | +| `deps` | Type | What the host provides | +| ------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `db` | `ArtifactDb` | The hub's drizzle handle, for example from `createDB`. | +| `contentStore` | `ContentStore` | Where file bytes go. `InlineContentStore` keeps them in Postgres; implement the port for object storage. | +| `requireGrant` | `RequireGrant` | From `@intx/hub-api`'s `createRequireGrant`. | +| `onArtifactCreated` | `(tx, row, scope) => Promise` | Optional. Runs in the creating transaction for any other creation side effect. The creator's `write` and `archive` grants are minted for you. | +| `decorate` | `(tenantId, rows) => Promise` | Optional. Adds display-only fields to serialized rows. It must not change which rows are returned. | +| `uploadPolicy` | `UploadPolicy` | Optional. MIME types `POST /artifacts/upload` accepts. Defaults to `ARTIFACT_UPLOAD_POLICY`. | +| `countSegments` | `ArtifactCountSegments` | Optional. Named predicates for `GET /artifacts/counts`. | | Route | Grant | Does | | ------------------------------------------ | ---------------------------- | --------------------------------------------------------------------- | diff --git a/e2e/reference-host.test.ts b/e2e/reference-host.test.ts index eb074c4..3a0b7fb 100644 --- a/e2e/reference-host.test.ts +++ b/e2e/reference-host.test.ts @@ -203,6 +203,33 @@ describe.each<[string, ContentStore]>([ ); expect(inline.headers.get("content-disposition")).toStartWith("inline;"); }); + + test("each upload mints the creator's write and archive grants, which then revise and archive it", async () => { + for (const { id } of uploaded) { + const rows = await host.db.execute<{ + action: string; + origin: string; + }>(sql` + SELECT "action", "origin" FROM "grant" + WHERE "resource" = ${`artifact:${id}`} ORDER BY "action" + `); + expect(rows.map((r) => ({ ...r }))).toEqual([ + { action: "archive", origin: "creator" }, + { action: "write", origin: "creator" }, + ]); + } + + const id = uploaded[0]!.id; + const revise = await app.request( + `/api/artifacts/${id}/versions`, + postJson({ content: "revised" }), + ); + expect(revise.status).toBe(200); + const archive = await app.request(`/api/artifacts/${id}/archive`, { + method: "POST", + }); + expect(archive.status).toBe(200); + }); }); describe("download an older version's content over HTTP (DataUrlContentStore)", () => { diff --git a/examples/reference-host/src/index.ts b/examples/reference-host/src/index.ts index 12f2e89..e61cd76 100644 --- a/examples/reference-host/src/index.ts +++ b/examples/reference-host/src/index.ts @@ -43,8 +43,6 @@ import { createArtifactRoutes, runArtifactMigrations, type ArtifactDb, - type ArtifactRow, - type ArtifactTx, type ResolvedPrincipal, type ContentStore, type SerializedArtifactBase, @@ -66,40 +64,6 @@ async function decorate( } } -/** - * The worked example this host owes the next `@corbits/*-core` package: what - * a "the artifact's owner may write to it" grant actually IS, and who mints - * it. `@corbits/artifacts` provisions nothing itself — this runs through - * `createArtifactRoutes`' `onArtifactCreated` hook, inside the same transaction as - * the row it grants on, so a grant never outlives (or fails to accompany) the - * artifact it names. - * - * `origin: "creator"` is the platform's own vocabulary for exactly this case - * (see `@intx/types/authz`'s `GrantRule.origin`) — the host is not inventing - * a policy layer, it is recording, in the platform's own grant table, the - * one fact this module already decided: `scope.principalId` made this row. - */ -async function grantOwnership( - tx: ArtifactTx, - row: ArtifactRow, - scope: ResolvedPrincipal, -) { - const resource = `artifact:${row.id}`; - await tx.insert(intxSchema.grant).values( - (["write", "archive"] as const).map((action) => ({ - id: generateId("grant"), - tenantId: scope.tenantId, - principalId: scope.principalId, - roleId: null, - resource, - action, - effect: "allow" as const, - origin: "creator" as const, - conditions: null, - })), - ); -} - export type Session = { userId: string } | null; export type ReferenceHost = { @@ -379,7 +343,6 @@ export async function createReferenceHost(): Promise { contentStore, requireGrant, decorate, - onArtifactCreated: grantOwnership, }), ); const mounted = app.route("/api", api); diff --git a/src/mount.ts b/src/mount.ts index 0da35fa..a3f7fe0 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -3,7 +3,9 @@ import { type } from "arktype"; import { Hono, type Context } from "hono"; import type { MiddlewareHandler } from "hono"; import { describeRoute } from "hono-openapi"; +import { grant } from "@intx/db/schema"; import { idResource, type RequireGrant, type TenantEnv } from "@intx/hub-api"; +import { generateId } from "@intx/hub-common"; import type { ArtifactDb, ArtifactTx } from "./db.js"; import { ArtifactNotFoundError, @@ -53,6 +55,28 @@ import { type UploadPolicy, } from "./uploads.js"; +async function grantCreator( + tx: ArtifactTx, + row: ArtifactRow, + scope: ResolvedPrincipal, +) { + await tx + .insert(grant) + .values( + (["write", "archive"] as const).map((action) => ({ + id: generateId("grant"), + tenantId: scope.tenantId, + principalId: scope.principalId, + roleId: null, + resource: `artifact:${row.id}`, + action, + effect: "allow" as const, + origin: "creator" as const, + })), + ) + .onConflictDoNothing(); +} + export type CreateArtifactRoutesDeps = { db: ArtifactDb; contentStore: ContentStore; @@ -79,12 +103,9 @@ export type CreateArtifactRoutesDeps = { /** * Host hook run INSIDE the same transaction as artifact creation, once per * row (`POST /artifacts` once, `POST /artifacts/upload` once per file). - * This is the seam a host uses to provision whatever grants make its - * authorization model true — for example, a `creator`-origin grant on - * `artifact:` for `write` and `archive` so the caller who just made the - * row can revise and archive it. artifact-core mints no grants itself: - * provisioning, like checking, is the host's job. Defaults to a no-op, so a - * host with no grant model omitting this behaves exactly as before. + * The creator's `write` and `archive` grants on `artifact:` are already + * minted by then; this is the seam for any other side effect. Defaults to a + * no-op. */ onArtifactCreated?: ( tx: ArtifactTx, @@ -521,6 +542,7 @@ export function createArtifactRoutes({ : { origin: "manual" }, ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), }); + await grantCreator(tx, created, scope); await onArtifactCreated(tx, created, scope); return created; }); @@ -633,6 +655,7 @@ export function createArtifactRoutes({ bytes: new Uint8Array(await file.arrayBuffer()), ...(generatedBy !== undefined ? { generatedBy } : {}), }); + await grantCreator(tx, row, scope); await onArtifactCreated(tx, row, scope); created.push(row); } From 263de9485b9736f36554ff8c094ec48ab85fa9c8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 30 Sep 2026 09:16:43 -0700 Subject: [PATCH 2/3] Declare @intx/hub-common as a peer dependency The mount imports generateId from it at runtime, so a consumer must resolve it rather than rely on a hoisted transitive copy. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index b87dbb1..7dc51db 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@intx/agent": "^0.4.0", "@intx/db": "^0.4.0", "@intx/hub-api": "^0.4.0", + "@intx/hub-common": "0.4.0", "@intx/types": "^0.4.0", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", From a45837a361830c6bd7a6f2d99205084ee9f4f36e Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:16:43 -0700 Subject: [PATCH 3/3] style: polish grant-minting diff --- src/mount.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/mount.ts b/src/mount.ts index a3f7fe0..2913565 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -60,21 +60,18 @@ async function grantCreator( row: ArtifactRow, scope: ResolvedPrincipal, ) { - await tx - .insert(grant) - .values( - (["write", "archive"] as const).map((action) => ({ - id: generateId("grant"), - tenantId: scope.tenantId, - principalId: scope.principalId, - roleId: null, - resource: `artifact:${row.id}`, - action, - effect: "allow" as const, - origin: "creator" as const, - })), - ) - .onConflictDoNothing(); + await tx.insert(grant).values( + (["write", "archive"] as const).map((action) => ({ + id: generateId("grant"), + tenantId: scope.tenantId, + principalId: scope.principalId, + roleId: null, + resource: `artifact:${row.id}`, + action, + effect: "allow" as const, + origin: "creator" as const, + })), + ); } export type CreateArtifactRoutesDeps = {