Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 14 additions & 25 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:<id>` 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:<id>` 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

Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>` | Optional. Runs in the creating transaction; mint the creator's `write` and `archive` grants on `artifact:<id>` here. |
| `decorate` | `(tenantId, rows) => Promise<void>` | 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<void>` | 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<void>` | 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 |
| ------------------------------------------ | ---------------------------- | --------------------------------------------------------------------- |
Expand Down
27 changes: 27 additions & 0 deletions e2e/reference-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
37 changes: 0 additions & 37 deletions examples/reference-host/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ import {
createArtifactRoutes,
runArtifactMigrations,
type ArtifactDb,
type ArtifactRow,
type ArtifactTx,
type ResolvedPrincipal,
type ContentStore,
type SerializedArtifactBase,
Expand All @@ -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 = {
Expand Down Expand Up @@ -379,7 +343,6 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
contentStore,
requireGrant,
decorate,
onArtifactCreated: grantOwnership,
}),
);
const mounted = app.route("/api", api);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 26 additions & 6 deletions src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,6 +55,25 @@ 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,
})),
);
}

export type CreateArtifactRoutesDeps = {
db: ArtifactDb;
contentStore: ContentStore;
Expand All @@ -79,12 +100,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:<id>` 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:<id>` are already
* minted by then; this is the seam for any other side effect. Defaults to a
* no-op.
*/
onArtifactCreated?: (
tx: ArtifactTx,
Expand Down Expand Up @@ -521,6 +539,7 @@ export function createArtifactRoutes({
: { origin: "manual" },
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
});
await grantCreator(tx, created, scope);
await onArtifactCreated(tx, created, scope);
return created;
});
Expand Down Expand Up @@ -633,6 +652,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);
}
Expand Down
Loading