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
136 changes: 91 additions & 45 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,63 +24,109 @@ 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<AppEnv>();
mountArtifacts(api, { db, contentStore, resolvePrincipal });
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);
```

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 frozen `mountX<E extends Env>(app, opts) => Hono<E>`
seam untouched.
core a base path keeps the mount free of a configurable base path.

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

## The mount seam

`mountArtifacts<E extends Env>(app: Hono<E>, opts): Hono<E>` is generic over the
host's Hono `Env`, so it composes with an app that carries its own environment
rather than requiring a bare `Hono`.

Three options have no sensible default — `db`, `contentStore`,
`resolvePrincipal` — and the rest degrade a *feature*, never safety, when
omitted. The README's table of what a minimal host passes is the reference; what
matters architecturally is that every default **fails closed**: no `isAdmin`
means nobody is an admin, no `identity` means no directory and no cross-tenant
reads, no `decorate` means no decoration.

What the package does **not** require of a host: no auth middleware, no session
library, no UI. What it DOES require: Interchange's control plane —
`public.tenant` and `public.principal` must exist before the migrations run,
because the tables carry hard foreign keys into them.

`resolvePrincipal`'s signature is identical across the Corbits cores, so a host
mounting more than one passes the same function to each. The resolved tenant is
authoritative — there is no caller-supplied tenant override anywhere in the
route surface.
`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.

Three options have no sensible default — `db`, `contentStore`, `requireGrant` —
and the rest degrade a *feature*, never safety, when omitted. The README's table
of what a minimal host passes is the reference; what matters architecturally is
that optional seams **fail closed**: no `decorate` means no decoration.

What the package does **not** require of a host: no session library, no UI, no
directory, no owner/admin policy callback. What it DOES require: Interchange's
control plane — `public.tenant` and `public.principal` must exist before the
migrations run, because the tables carry hard foreign keys into them — and a
host that puts the authenticated principal on `TenantEnv` and hands in its
`RequireGrant`.

The principal's tenant is authoritative — there is no caller-supplied tenant
override anywhere in the route or tool surface. Tool reads always stay inside
`scope.tenantId`.

**This is a decision, not an oversight.** The prior `Identity` port let
`readArtifact` / `readArtifactChunk` take a `tenantId` argument and cross into
it when `identity.ownerIsMemberOfTenant(scope, tenantId)` said the caller's
owner belonged there — a membership check this package invented and owned.
That is exactly the kind of policy this PR removes. It is not replaced by a
grant check, and won't be by a later one either: Interchange's `GrantStore`
resolves a principal's grants **within one tenant**
(`collectGrants(principalId, tenantId)`; `@intx/db`'s implementation filters
`grant` rows by `tenant_id`, and a principal is itself a row scoped to one
tenant). There is no platform primitive for "principal P, home tenant A, holds
a grant readable from tenant B" to check — inventing one here would mean this
package building a second, bespoke cross-tenant authorization concept on top
of the platform's, which is the precise failure mode "authorization is the
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

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.

## The options

`ContentStore` and `Identity` are types declared in `ports.ts`; the rest are
plain `mountArtifacts` options. `resolvePrincipal` takes the host's request
context as `unknown`.
`ContentStore` is declared in `ports.ts`; the rest are plain `mountArtifacts`
options. Who the request runs as is read from `TenantEnv`, not passed as a
callback.

| Option | What it is for | Default |
| --- | --- | --- |
| `resolvePrincipal` | Who the request runs as. Reads the host session; returns `null` when signed out. | none — required |
| `requireGrant` | Host-owned Interchange grant middleware factory. Single-artifact mutations (revise, archive/unarchive, …) run `requireGrant(idResource("artifact", "id"), <action>)`. | none — required |
| `contentStore` | Where an artifact's file bytes live (`ContentStore`). | none — required |
| `isAdmin` | Whether a principal is a tenant admin. Only archive/unarchive consults it. | nobody is an admin |
| `identity` | Owner display names, the agent→human ownership resolution, creator-kind principal sets, and cross-tenant membership (`Identity`). | `anonymousIdentity` |
| `decorate` | A **display-only** decorator over serialized rows. | no-op |
| `decorate` | A **display-only** decorator over serialized rows (provenance labels, host joins). | no-op |
| `onArtifactCreated` | Host hook run inside the same transaction as artifact creation — where a host mints grants for the row it just made. | no-op |

`decorate`'s display-only status is a contract, not a convention: it may add
fields to rows on their way out and must never affect *what* is returned or
*who* may see it. Joining a host's workflow tables inside this package would
couple it to a schema it must not know, so the host supplies the decorator.

`Identity.ownerIsMemberOfTenant` gates cross-tenant reads and must fail closed;
the shipped `anonymousIdentity` does.
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.

`examples/reference-host` 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.

### ContentStore

Expand All @@ -107,15 +153,15 @@ which store is installed.

| File | Role |
| --- | --- |
| `mount.ts` | HTTP surface: parsing, validation, status codes. |
| `mount.ts` | HTTP surface: parsing, validation, status codes; reads `TenantEnv` principal; wires host `requireGrant`. |
| `artifacts.ts` | The core domain — create, revise, list, get, archive, serialize. |
| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. |
| `download.ts` | One download path over the three storage conventions. |
| `content-store.ts` | The two shipped `ContentStore` implementations. |
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads. |
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads (caller tenant only). |
| `web-site.ts` | The `web-site` kind's content encoding and validation. |
| `mail-attachments.ts` | Artifact↔message associations. |
| `ports.ts` | The `ContentStore` and `Identity` types, and the fail-closed `anonymousIdentity` default. |
| `ports.ts` | The `ContentStore` type and the shared `ResolvedPrincipal` shape. |
| `schema.ts` / `migrations.ts` | The four tables, and the DDL that creates them. |

## Data model
Expand All @@ -140,10 +186,10 @@ single-column constraints applied by a ledgered migration — free at write time
the control plane independently; it does **not** enforce that `principal_id` (or
`owner_principal_id`) belongs to the same tenant as `tenant_id`. A multi-table
trigger or composite FK into `public.principal` would couple every write to a
control-plane lookup and is deliberately out of scope. The host's
`resolvePrincipal` is the authority: it returns the `(tenantId, principalId)`
pair every route and tool write stamps, so a correctly mounted host never
plants a cross-tenant principal. Operators cleaning legacy rows before the
control-plane lookup and is deliberately out of scope. The host's middleware and
context are the authority: routes stamp the `(tenantId, principalId)` pair from
the Interchange `principal` already on `TenantEnv`, so a correctly mounted host
never plants a cross-tenant principal. Operators cleaning legacy rows before the
`tenant_id NOT NULL` migration must assign a valid tenant or delete orphans —
the migration fails with an explicit message if null `tenant_id` rows remain.

Expand Down Expand Up @@ -234,9 +280,9 @@ upload **gate** (`createFileArtifact` takes `policy` as a required argument and
refuses anything outside it before the `ContentStore` is touched); and the
download path with its `nosniff`/`attachment` behaviour.

Supplied by the host: the Hono app and the database handle; who the caller is;
whether they are an admin; the directory, if there is one; provenance
decoration; and a `ContentStore`.
Supplied by the host: the `Hono<TenantEnv>` app and the database handle; the
authenticated `tenant`/`principal` on the request context; the host's
`RequireGrant`; display-only provenance decoration; and a `ContentStore`.

## Known limits

Expand Down
57 changes: 49 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,47 @@ 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
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.
`examples/reference-host` now wires a real one (`grantOwnership`) against
Interchange's own `grant` table, and its default `requireGrant` is the
platform's real `createRequireGrant` over that table rather than a
default-allow stub — see ARCHITECTURE.md's "Grant provisioning" section.
- Single-artifact write routes (`POST .../versions`, `POST .../archive`,
`POST .../unarchive`) now resolve existence/tenant/skill-draft (the same
check `loadScoped` does) BEFORE running `requireGrant`, not after. A real,
resource-specific grant evaluator has no existence check of its own — it
denies a ghost id or another tenant's artifact with the same `403` it would
give for a real row the caller lacks permission on, which a default-allow
stub can never surface. This restores the documented "a caller who cannot
see the artifact gets 404" guarantee for write routes running a real grant
check, matching what already held for reads.

### Breaking

- `mountArtifacts` takes `Hono<TenantEnv>`, reads the host-provided tenant and
principal context natively, and requires 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`.
Artifact lists no longer accept `creatorKind`.
- **Cross-tenant tool reads are removed, intentionally, not just undocumented.**
`readArtifact` / `readArtifactChunk` no longer take a `tenantId` override;
tool reads are always confined to `scope.tenantId`. The prior override read
through `Identity.ownerIsMemberOfTenant`, a membership policy this package
invented and owned — exactly what this PR removes. It is not replaced by a
grant check because there is no platform primitive to replace it with:
Interchange's `GrantStore` resolves a principal's grants within one tenant
(a principal is itself a row scoped to one tenant), so "grant readable
across tenants" does not exist to check. Reintroducing cross-tenant reads
here would mean this package inventing a second, bespoke cross-tenant
authorization concept on top of the platform's — the failure mode this PR
exists to remove. If a real need for it surfaces, it belongs in
Interchange's grant model, not a per-package workaround.

### 0.1.0 — first release

Expand All @@ -24,10 +65,10 @@ new; the list below is what the surface consists of rather than what changed.

- `mountArtifacts(app, opts)` — mounts artifacts, versions and uploads on a
host's existing Hono app: tenant-scoped list with keyset paging and
query/kind/owner/creator-kind/date filters, human import of a link or pasted
text, multipart upload, deep-link detail, version history and revision,
idempotent soft archive and unarchive, a single download path, and
artifact↔message attachment refs. Every route carries OpenAPI metadata.
query/kind/owner/date filters, human import of a link or pasted text,
multipart upload, deep-link detail, version history and revision, idempotent
soft archive and unarchive, a single download path, and artifact↔message
attachment refs. Every route carries OpenAPI metadata.
- `runArtifactMigrations(db)` — idempotent, advisory-locked, checksum-guarded,
with its own ledger table (`artifacts.migrations`) and silent re-runs. Safe to
call on every boot of every replica. All tables live in the package-owned
Expand All @@ -38,10 +79,10 @@ new; the list below is what the surface consists of rather than what changed.
- `ContentStore` port with two shipped implementations, `InlineContentStore`
(bytea side-table) and `DataUrlContentStore` (inline `data:` URL), both
passing the same suite.
- Host options: `resolvePrincipal`, plus `isAdmin`, `identity` and
`decorate`, each with a fail-closed default.
- Agent-facing tool definitions with windowed artifact reads, and the `web_site`
artifact kind.
- Host options: required `db`, `contentStore`, and `requireGrant`, plus optional
display-only `decorate` and `uploadPolicy` behavior.
- Agent-facing tool definitions with tenant-confined windowed artifact reads,
and the `web_site` artifact kind.
- Requires `@intx/*` 0.2.2 or newer, Node 22+ or Bun 1.1+, and Postgres 13+.
(`@intx/*` 0.1.2 does not install — its deps pin the unpublished
`@intx/*@0.0.0` — and ships raw TypeScript.)
Expand Down
Loading
Loading