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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
npm install "$TARBALL"
node -e '
import("@corbits/artifacts").then((m) => {
for (const name of ["mountArtifacts", "runArtifactMigrations"]) {
for (const name of ["createArtifactRoutes", "runArtifactMigrations"]) {
if (typeof m[name] !== "function") throw new Error(`missing export: ${name}`);
}
console.log("node consumer ok");
Expand Down
23 changes: 17 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ always called out under their own heading.

- Run-scoped `POST /artifacts`, `POST /artifacts/binary`, and
`PATCH /artifacts/:id` (`mountWorkflowArtifacts`) accept an optional
`metadata` field, matching `mountArtifacts`' semantics exactly: omitted on
`metadata` field, matching the tenant routes' semantics exactly: omitted on
a revise carries the prior version's metadata forward, an explicit `null`
clears it, and any other value must be a JSON object or the request is
`400`. `artifact_create` and `artifact_write` in `ARTIFACT_TOOL_DEFINITIONS`
Expand Down Expand Up @@ -102,9 +102,10 @@ always called out under their own heading.
`bun add github:corbitsdev/corbits-artifacts` installs cleanly. Bun consumers
resolve TypeScript sources via the `bun` export condition; Node consumers
continue to use the built `dist/` from `npm pack` / a published release.
- `mountArtifacts` takes an optional `onArtifactCreated(tx, row, scope)` hook,
run inside the same transaction as artifact creation (once per row, so once
on `POST /artifacts` and once per file on `POST /artifacts/upload`). This is
- `createArtifactRoutes` takes an optional
`onArtifactCreated(tx, row, scope)` hook, run inside the same transaction
as artifact creation (once per row, so once on `POST /artifacts` and once
per file on `POST /artifacts/upload`). This is
the seam a host uses to provision grants for the row it just made — for
example, a `creator`-origin grant on `artifact:<id>` for `write` and
`archive`. Defaults to a no-op, so existing hosts are unaffected.
Expand All @@ -124,6 +125,16 @@ always called out under their own heading.

### Breaking

- `mountArtifacts(app, opts)` is replaced by `createArtifactRoutes(deps)`,
which returns a `Hono<TenantEnv>` sub-app the host mounts with
`app.route(...)` instead of mutating the host app. `MountArtifactsOpts` is
renamed `CreateArtifactRoutesDeps`; the options are unchanged.
- `POST /artifacts` and `POST /artifacts/upload` now require
`requireGrant("artifact:*", "create")`, as hub-api's `createGrantRoutes`
requires `create` on `grant:*`. A host must grant its principals `create`
on `artifact:*` for them to keep creating artifacts. An unauthenticated
caller of these two routes now gets `{ "error": "Forbidden" }` instead of
`{ "error": "Tenant not accessible" }`.
- `runArtifactMigrations(config, { schema })` takes the same arguments as
Interchange's `runMigrations`: a `DBConfig` and the host schema holding
`tenant` and `principal`. It applies the SQL files shipped under
Expand All @@ -135,8 +146,8 @@ always called out under their own heading.
`mailAttachmentRef`) are no longer exported from the package entry. Hosts
reach artifacts through the routes and functions; `ARTIFACTS_SCHEMA` and the
`*Row` types stay public.
- `mountArtifacts` takes `Hono<TenantEnv>`, reads the host-provided tenant and
principal context natively, and requires the host's Interchange `RequireGrant`
- The tenant routes take `Hono<TenantEnv>`, read the host-provided tenant and
principal context natively, and require the host's Interchange `RequireGrant`
middleware. The `resolvePrincipal`, `isAdmin`, and `identity` options and the
`Identity` / `anonymousIdentity` exports are not part of the package surface.
- Serialized artifact rows expose `ownerPrincipalId` without an `ownerName`.
Expand Down
28 changes: 14 additions & 14 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ built `dist/` — the same artifact a consumer installs. That is why `test:accep
builds first: running it against stale output is how a green acceptance run stops
meaning anything.

If you change the mount seam, a port, or anything about how a host wires this up, the
If you change the route factory, a port, or anything about how a host wires this up, the
reference host is where that change has to be shown working.

## Dependency rule
Expand Down Expand Up @@ -103,27 +103,25 @@ Interchange serves its own routes under (`app.route("/api/me", …)`,
`app.route("/api/tenants", …)`). No `/v1` segment, no vendor prefix.

```ts
const api = new Hono<TenantEnv>();
// Host middleware has already placed `tenant` and `principal` on the context.
mountArtifacts(api, { db, contentStore, requireGrant });
app.route("/api", api);
app.route("/api", createArtifactRoutes({ db, contentStore, requireGrant }));
```

which serves `/api/artifacts`, `/api/artifacts/:id`,
`/api/artifacts/:id/versions`, `/api/artifacts/:id/download`, and
`/api/instances/:instanceId/mail-attachments`. Nesting rather than teaching the
core a base path keeps the mount free of a configurable base path.
`/api/instances/:instanceId/mail-attachments`. Returning a sub-app rather than
taking a base path keeps the factory free of a configurable base path.

Everything else it needs arrives through `opts` or the host's request context.
Everything else it needs arrives through `deps` or the host's request context.
Nothing is reached for.

### The mount seam
### The route factory

`mountArtifacts(app: Hono<TenantEnv>, opts): Hono<TenantEnv>` takes Interchange's
`TenantEnv` so it composes with a host app mounted beneath Interchange auth +
tenant middleware. The host places full `tenant` and `principal` rows on the
context; this package reads them natively and never invents a second principal
resolution path.
`createArtifactRoutes(deps): Hono<TenantEnv>` returns a sub-app typed with
Interchange's `TenantEnv`, built the way hub-api's `createGrantRoutes` is, so it
composes beneath Interchange auth + tenant middleware. The host places full
`tenant` and `principal` rows on the context; this package reads them natively
and never invents a second principal resolution path.

Three options have no sensible default — `db`, `contentStore`, `requireGrant` —
and the rest degrade a *feature*, never safety, when omitted. The README's option
Expand Down Expand Up @@ -185,7 +183,9 @@ 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.

`examples/reference-host` provisions a real `creator`-origin grant on create —
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
Expand Down
47 changes: 15 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,51 +26,34 @@ const { db, close } = createArtifactDb(process.env.DATABASE_URL!);
await close();
```

### 1. Hub-side, tenant-scoped: `mountArtifacts`
### 1. Hub-side, tenant-scoped: `createArtifactRoutes`

Mounted under the hub's tenant prefix, alongside a host's other session-authenticated routes. It reads `principal`/`tenant` off the Hono context (placed there by the host's own auth + tenant middleware) and authorizes mutations through the host's `requireGrant` — built from Interchange's `createRequireGrant` over the host's own `GrantStore` and `ConditionRegistry`.
Returns a `Hono<TenantEnv>` sub-app the host mounts with `app.route`, alongside its other session-authenticated routes. It reads `principal`/`tenant` off the Hono context (placed there by the host's own auth + tenant middleware) and authorizes mutations through the host's `requireGrant` — built from Interchange's `createRequireGrant` over the host's own `GrantStore` and `ConditionRegistry`.

| `opts` | Type | What the host provides |
| `deps` | Type | What the host provides |
| --- | --- | --- |
| `db` | `ArtifactDb` | Artifacts are stored there. `createArtifactDb` opens a handle for a host with none; a hub that already has one passes it through. |
| `contentStore` | `ContentStore` | Blob storage for file bytes. `InlineContentStore` (exported by this package) fits a minimal host; bring your own store for object storage. |
| `requireGrant` | `RequireGrant` | The host's grant middleware factory. This package implements no ownership or membership policy of its own — every mutating single-artifact route is gated through it. |
| `requireGrant` | `RequireGrant` | The host's grant middleware factory. This package implements no ownership or membership policy of its own. Creating an artifact requires `create` on `artifact:*`; revising or archiving one requires `write` or `archive` on `artifact:<id>`. Recording mail-attachment references needs only a principal. |
| `countSegments` | `ArtifactCountSegments` (optional) | Named predicates over `ArtifactListRow` for `GET /artifacts/counts` (e.g. bucket by `kind`). The taxonomy is entirely host-owned; omitted, the route still answers with the tenant-wide `all` total. |
| `onArtifactCreated` | `(tx, row, scope) => Promise<void>` (optional) | Runs inside the transaction that creates each artifact. This is where the host mints grants for the new row, e.g. `write` and `archive` on `artifact:<id>` for its creator. The package mints none itself. |
| `decorate` | `(tenantId, rows) => Promise<void>` (optional) | Adds display-only fields to serialized rows on the way out (provenance labels, host joins). It must never change which rows are returned or who may see them. |
| `uploadPolicy` | `UploadPolicy` (optional) | Which MIME types `POST /artifacts/upload` accepts. Defaults to `ARTIFACT_UPLOAD_POLICY`. |

```ts
import type { Hono } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import { createRequireGrant } from "@intx/hub-api";
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
import {
InlineContentStore,
mountArtifacts,
type ArtifactDb,
type ArtifactCountSegments,
} from "@corbits/artifacts";

export function mountArtifactRoutes(
app: Hono<TenantEnv>,
deps: {
db: ArtifactDb;
grantStore: GrantStore;
conditionRegistry: ConditionRegistry;
countSegments?: ArtifactCountSegments;
},
): void {
mountArtifacts(app, {
db: deps.db,
import { createArtifactRoutes, InlineContentStore } from "@corbits/artifacts";

// `app` is the host's Hono<TenantEnv>; its auth + tenant middleware has
// already placed `tenant` and `principal` on the context.
app.route(
"/api",
createArtifactRoutes({
db,
contentStore: InlineContentStore,
requireGrant: createRequireGrant({
grantStore: deps.grantStore,
conditionRegistry: deps.conditionRegistry,
}),
...(deps.countSegments !== undefined ? { countSegments: deps.countSegments } : {}),
});
}
requireGrant: createRequireGrant({ grantStore, conditionRegistry }),
}),
);
```

### 2. Hub-side, run-scoped: `mountWorkflowArtifacts`
Expand Down
47 changes: 34 additions & 13 deletions examples/reference-host/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from "@intx/hub-sessions";
import {
InlineContentStore,
mountArtifacts,
createArtifactRoutes,
runArtifactMigrations,
type ArtifactDb,
type ArtifactRow,
Expand Down Expand Up @@ -79,7 +79,7 @@ async function decorate(_tenantId: string, rows: readonly SerializedArtifactBase
* 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
* `mountArtifacts`'s `onArtifactCreated` hook, inside the same transaction as
* `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.
*
Expand Down Expand Up @@ -242,6 +242,23 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
(p) => p.kind === "agent" && p.refId === "user-alice",
)!.id;

// The routes check `artifact:*` / `create` before any create, the same way
// hub-api checks `grant:*` / `create` before minting a grant. This demo
// grants it to the principals it seeds here; a real host decides who may.
await db.insert(intxSchema.grant).values(
principals.map((principal) => ({
id: generateId("grant"),
tenantId: tenant.id,
principalId: principal.id,
roleId: null,
resource: "artifact:*",
action: "create",
effect: "allow" as const,
origin: "system" as const,
conditions: null,
})),
);

let currentSession: Session = { userId: "user-alice" };

const getSession = async (_headers: Headers) => {
Expand Down Expand Up @@ -304,12 +321,13 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
});
// Mounted @corbits/* modules serve under `/api`, matching Interchange's
// own convention (`app.route("/api/me", …)`). The core registers its
// routes root-relative (`/artifacts*`, `/instances/:id/mail-attachments`),
// so the host nests them in a sub-app and routes that at `/api`. Served
// paths: `/api/artifacts*` — no `/v1` segment, no vendor prefix.
// routes root-relative (`/artifacts*`, `/instances/:id/mail-attachments`).
// The host wraps them in its own `api` sub-app so the principal middleware
// below stays scoped to `/api`. Served paths: `/api/artifacts*` — no `/v1`
// segment, no vendor prefix.
const api = new Hono<TenantEnv>();
// Place full tenant/principal rows from the hub session user. Signed-out
// (or unknown) callers leave the context empty so mountArtifacts applies
// (or unknown) callers leave the context empty so the artifact routes apply
// the no-principal contract.
api.use("*", async (c, next) => {
const user = c.get("user");
Expand Down Expand Up @@ -351,13 +369,16 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
}
return next();
};
mountArtifacts(api, {
db,
contentStore,
requireGrant,
decorate,
onArtifactCreated: grantOwnership,
});
api.route(
"/",
createArtifactRoutes({
db,
contentStore,
requireGrant,
decorate,
onArtifactCreated: grantOwnership,
}),
);
const mounted = app.route("/api", api);
// `Hono#request` may answer synchronously; normalize to a promise so every
// caller can simply await it.
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @corbits/artifacts — a backend-only, mountable artifact + upload store.
export { mountArtifacts } from "./mount.js";
export type { MountArtifactsOpts } from "./mount.js";
export { createArtifactRoutes } from "./mount.js";
export type { CreateArtifactRoutesDeps } from "./mount.js";

export { mountWorkflowArtifacts } from "./workflow-mount.js";
export type {
Expand Down
Loading
Loading