From dc4d2bcaf15a440bc3a254044ad6f0e0760e491c Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:05:29 +0200 Subject: [PATCH] phase V --- .../src/core/realtime/publishing-adapter.ts | 4 + packages/contrail/src/core/spaces/adapter.ts | 59 +++ packages/contrail/src/core/spaces/binding.ts | 18 + packages/contrail/src/core/spaces/router.ts | 163 +++++-- packages/contrail/src/core/spaces/schema.ts | 12 + packages/contrail/src/core/spaces/types.ts | 29 +- packages/contrail/src/index.ts | 10 + .../contrail/tests/spaces-enrollment.test.ts | 402 ++++++++++++++++++ .../lexicon-templates/recordHost/enroll.json | 39 ++ 9 files changed, 704 insertions(+), 32 deletions(-) create mode 100644 packages/contrail/tests/spaces-enrollment.test.ts create mode 100644 packages/lexicons/lexicon-templates/recordHost/enroll.json diff --git a/packages/contrail/src/core/realtime/publishing-adapter.ts b/packages/contrail/src/core/realtime/publishing-adapter.ts index 07cbc00..c5dc61f 100644 --- a/packages/contrail/src/core/realtime/publishing-adapter.ts +++ b/packages/contrail/src/core/realtime/publishing-adapter.ts @@ -52,6 +52,10 @@ export function wrapWithPublishing( revokeInvite: inner.revokeInvite.bind(inner), getInvite: inner.getInvite.bind(inner), redeemInvite: inner.redeemInvite.bind(inner), + enroll: inner.enroll.bind(inner), + getEnrollment: inner.getEnrollment.bind(inner), + listEnrollments: inner.listEnrollments.bind(inner), + removeEnrollment: inner.removeEnrollment.bind(inner), getRecord: inner.getRecord.bind(inner), listRecords: inner.listRecords.bind(inner), listCollections: inner.listCollections.bind(inner), diff --git a/packages/contrail/src/core/spaces/adapter.ts b/packages/contrail/src/core/spaces/adapter.ts index b5f6b3c..f80ba5e 100644 --- a/packages/contrail/src/core/spaces/adapter.ts +++ b/packages/contrail/src/core/spaces/adapter.ts @@ -13,6 +13,7 @@ import type { BlobMetaRow, CollectionCount, CreateInviteInput, + EnrollmentRow, InviteKind, InviteRow, ListBlobsOptions, @@ -77,6 +78,15 @@ function mapBlobMetaRow(row: any): BlobMetaRow { }; } +function mapEnrollmentRow(row: any): EnrollmentRow { + return { + spaceUri: row.space_uri, + authorityDid: row.authority_did, + enrolledAt: toNum(row.enrolled_at), + enrolledBy: row.enrolled_by, + }; +} + function mapInviteRow(row: any): InviteRow { return { tokenHash: row.token_hash, @@ -362,6 +372,55 @@ export class HostedAdapter implements StorageAdapter { return row ? mapInviteRow(row) : null; } + async enroll(input: EnrollmentRow): Promise { + await this.db + .prepare( + `INSERT INTO record_host_enrollments (space_uri, authority_did, enrolled_at, enrolled_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (space_uri) DO UPDATE SET + authority_did = excluded.authority_did, + enrolled_at = excluded.enrolled_at, + enrolled_by = excluded.enrolled_by` + ) + .bind(input.spaceUri, input.authorityDid, input.enrolledAt, input.enrolledBy) + .run(); + } + + async getEnrollment(spaceUri: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM record_host_enrollments WHERE space_uri = ?`) + .bind(spaceUri) + .first(); + return row ? mapEnrollmentRow(row) : null; + } + + async listEnrollments( + options: { authorityDid?: string; limit?: number } = {} + ): Promise { + const limit = Math.min(options.limit ?? 200, 1000); + if (options.authorityDid) { + const { results } = await this.db + .prepare( + `SELECT * FROM record_host_enrollments WHERE authority_did = ? ORDER BY enrolled_at DESC LIMIT ?` + ) + .bind(options.authorityDid, limit) + .all(); + return results.map(mapEnrollmentRow); + } + const { results } = await this.db + .prepare(`SELECT * FROM record_host_enrollments ORDER BY enrolled_at DESC LIMIT ?`) + .bind(limit) + .all(); + return results.map(mapEnrollmentRow); + } + + async removeEnrollment(spaceUri: string): Promise { + await this.db + .prepare(`DELETE FROM record_host_enrollments WHERE space_uri = ?`) + .bind(spaceUri) + .run(); + } + async putRecord(record: StoredRecord): Promise { const table = this.tableFor(record.collection); const uri = buildRecordUri(record.authorDid, record.collection, record.rkey); diff --git a/packages/contrail/src/core/spaces/binding.ts b/packages/contrail/src/core/spaces/binding.ts index d349d55..a81b95f 100644 --- a/packages/contrail/src/core/spaces/binding.ts +++ b/packages/contrail/src/core/spaces/binding.ts @@ -19,6 +19,7 @@ import type { DidDocumentResolver } from "@atcute/identity-resolver"; import type { Did } from "@atcute/lexicons"; import { parseSpaceUri } from "./uri"; +import type { RecordHost } from "./types"; export interface BindingResolver { /** Resolve the DID authorized to sign credentials for this space. Returns @@ -52,6 +53,23 @@ export function createLocalBindingResolver(args: { }; } +/** Reads the record host's local enrollment table. This is the *canonical* + * binding source on a record host: the host owner explicitly consented to a + * given authority for a given space (via the `recordHost.enroll` endpoint + * or auto-enrollment from the authority's createSpace). PDS-record / + * DID-doc resolvers are out-of-band discovery aids; the enrollment is what + * actually gates whether records get stored here. */ +export function createEnrollmentBindingResolver(args: { + recordHost: RecordHost; +}): BindingResolver { + return { + async resolveAuthority(spaceUri) { + const e = await args.recordHost.getEnrollment(spaceUri); + return e?.authorityDid ?? null; + }, + }; +} + /** Returns the space owner DID as the authority. This is the implicit * fallback ("HappyView path") — when no PDS record and no DID-doc service * entry declare an issuer, the owner is taken to be its own. Whether the diff --git a/packages/contrail/src/core/spaces/router.ts b/packages/contrail/src/core/spaces/router.ts index c0c9e92..dadb7c9 100644 --- a/packages/contrail/src/core/spaces/router.ts +++ b/packages/contrail/src/core/spaces/router.ts @@ -12,7 +12,7 @@ import { } from "./auth"; import { nextTid } from "./tid"; import { hashInviteToken } from "../invite/token"; -import { buildSpaceUri } from "./uri"; +import { buildSpaceUri, parseSpaceUri } from "./uri"; import { DEFAULT_BLOB_MAX_SIZE, DEFAULT_CREDENTIAL_TTL_MS, @@ -35,6 +35,8 @@ import { type CredentialVerifier, } from "./credentials"; import { + createCompositeBindingResolver, + createEnrollmentBindingResolver, createLocalBindingResolver, createLocalKeyResolver, } from "./binding"; @@ -90,21 +92,39 @@ export function registerSpacesRoutes( const verifier = ctx?.verifier ?? buildVerifier(authorityConfig); const auth = options.authMiddleware ?? createServiceAuthMiddleware(verifier); - registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, options.whoamiExtension); + // When the record host is colocated, hand it to the authority so + // createSpace can auto-enroll. This is the in-process default — + // single-call createSpace gives you a usable space without requiring an + // explicit `recordHost.enroll` afterward. Split deployments don't get + // this convenience; their createSpace caller (or operator) has to call + // enroll on the remote host explicitly. + const localRecordHost = spacesConfig.recordHost ? adapter : null; + registerAuthorityRoutes( + app, + adapter, + authorityConfig, + config, + auth, + options.whoamiExtension, + localRecordHost + ); if (spacesConfig.recordHost) { - // Build the default in-process verifier when the authority can sign: - // Local binding (always points at the configured authority) + Local - // key resolver (knows the authority's public key directly). Caller - // can override via `options.credentialVerifier` to accept external - // authorities — wire in PDS-record / DID-doc resolvers there. + // Default in-process verifier: enrollment is the canonical binding + // source (matches the host's local consent), with Local-binding as a + // fallback for the case where the authority's createSpace ran but + // auto-enroll was bypassed. Local key resolver knows the local + // authority's public key. Caller overrides via + // `options.credentialVerifier` to accept external authorities — wire + // in DID-doc key resolvers there. const verifier = options.credentialVerifier ?? (authorityConfig.signing ? createBindingCredentialVerifier({ - bindings: createLocalBindingResolver({ - authorityDid: authorityConfig.serviceDid, - }), + bindings: createCompositeBindingResolver([ + createEnrollmentBindingResolver({ recordHost: adapter }), + createLocalBindingResolver({ authorityDid: authorityConfig.serviceDid }), + ]), keys: createLocalKeyResolver({ authorityDid: authorityConfig.serviceDid, publicKey: authorityConfig.signing.publicKey, @@ -116,14 +136,20 @@ export function registerSpacesRoutes( } /** Register the **space authority** XRPC surface — space lifecycle, member - * list, app policy, whoami. Does NOT touch records or blobs. */ + * list, app policy, whoami. Does NOT touch records or blobs. + * + * When a `localRecordHost` is passed, the authority's `createSpace` handler + * also enrolls the new space on that host — convenient for in-process + * deployments where the same operator runs both roles. Split deployments + * pass `null` and arrange enrollment explicitly via `recordHost.enroll`. */ export function registerAuthorityRoutes( app: Hono, authority: SpaceAuthority, authorityConfig: AuthorityConfig, config: ContrailConfig, auth: MiddlewareHandler, - whoamiExtension?: WhoamiExtension + whoamiExtension?: WhoamiExtension, + localRecordHost?: RecordHost | null ): void { /** Space endpoints are emitted per-deployment under the configured namespace; * the deployment owns and publishes its own lexicons. The library ships @@ -243,6 +269,19 @@ export function registerAuthorityRoutes( // Owner is implicit; we still write a row so membership queries are uniform. await authority.addMember(uri, sa.issuer, sa.issuer); + // Auto-enroll the space on the colocated record host (if any). Without + // this, putRecord/listRecords would 404 with "not-enrolled" until the + // caller explicitly hit recordHost.enroll. Idempotent (ON CONFLICT + // UPDATE), so calling enroll again later is harmless. + if (localRecordHost) { + await localRecordHost.enroll({ + spaceUri: uri, + authorityDid: authorityConfig.serviceDid, + enrolledAt: Date.now(), + enrolledBy: sa.issuer, + }); + } + return c.json({ space: publicSpaceView(space, true) }); }); @@ -513,20 +552,79 @@ export function registerRecordHostRoutes( return authWithCredential(c, next); }; + /** Hard gate on every record-host operation: the space must be enrolled. + * Returns the enrollment row, or a Response to relay. */ + async function requireEnrollment( + c: Context, + spaceUri: string + ): Promise<{ authorityDid: string } | Response> { + const enrollment = await recordHost.getEnrollment(spaceUri); + if (!enrollment) { + return c.json( + { error: "NotFound", reason: "not-enrolled", message: "space is not enrolled on this record host" }, + 404 + ); + } + return enrollment; + } + + // ---- Enrollment endpoint ---- + // + // Authorize a space onto this record host. Caller must be either the space + // owner OR the declared authority — the host treats either as a sufficient + // signal of consent. Idempotent: re-enrolling the same space updates the + // authority binding. + const RECORD_HOST = `${config.namespace}.recordHost`; + + app.post(`/xrpc/${RECORD_HOST}.enroll`, auth, async (c) => { + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { spaceUri?: string; authority?: string } + | null; + if (!body?.spaceUri || !body.authority) { + return c.json({ error: "InvalidRequest", message: "spaceUri and authority required" }, 400); + } + const parts = parseSpaceUri(body.spaceUri); + if (!parts) { + return c.json({ error: "InvalidRequest", reason: "malformed-uri" }, 400); + } + const callerIsOwner = sa.issuer === parts.ownerDid; + const callerIsAuthority = sa.issuer === body.authority; + if (!callerIsOwner && !callerIsAuthority) { + return c.json( + { error: "Forbidden", reason: "not-owner-or-authority" }, + 403 + ); + } + await recordHost.enroll({ + spaceUri: body.spaceUri, + authorityDid: body.authority, + enrolledAt: Date.now(), + enrolledBy: sa.issuer, + }); + return c.json({ ok: true }); + }); + app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => { const spaceUri = c.req.query("spaceUri"); const collection = c.req.query("collection"); if (!spaceUri || !collection) { return c.json({ error: "InvalidRequest", message: "spaceUri and collection required" }, 400); } - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, spaceUri); + if (enrollment instanceof Response) return enrollment; const authz = await authorizeRead(c, authority, spaceUri); if (authz instanceof Response) return authz; if (authz.via === "jwt") { + // JWT path consults the authority for member + app-policy checks. + // Split deployments where the host has no live authority will degrade + // here (getSpace returns null) and return 404, which is correct: a + // host that can't reach an authority can't validate JWT membership. const sa = authz.sa; + const space = await authority.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(spaceUri, sa.issuer); const result = checkAccess({ op: "read", @@ -558,14 +656,16 @@ export function registerRecordHostRoutes( if (!spaceUri || !collection || !author || !rkey) { return c.json({ error: "InvalidRequest", message: "spaceUri, collection, author, rkey required" }, 400); } - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, spaceUri); + if (enrollment instanceof Response) return enrollment; const authz = await authorizeRead(c, authority, spaceUri); if (authz instanceof Response) return authz; if (authz.via === "jwt") { const sa = authz.sa; + const space = await authority.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(spaceUri, sa.issuer); const result = checkAccess({ op: "read", @@ -591,13 +691,16 @@ export function registerRecordHostRoutes( if (!body?.spaceUri || !body.collection || !body.record) { return c.json({ error: "InvalidRequest", message: "spaceUri, collection, record required" }, 400); } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, body.spaceUri); + if (enrollment instanceof Response) return enrollment; const caller = resolveCaller(c, body.spaceUri, "rw"); if (caller instanceof Response) return caller; if (!caller.viaCredential) { + // JWT path needs authority access for member + app-policy checks. + const space = await authority.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(body.spaceUri, caller.callerDid); const result = checkAccess({ op: "write", @@ -651,13 +754,15 @@ export function registerRecordHostRoutes( if (!body?.spaceUri || !body.collection || !body.rkey) { return c.json({ error: "InvalidRequest", message: "spaceUri, collection, rkey required" }, 400); } - const space = await authority.getSpace(body.spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, body.spaceUri); + if (enrollment instanceof Response) return enrollment; const caller = resolveCaller(c, body.spaceUri, "rw"); if (caller instanceof Response) return caller; if (!caller.viaCredential) { + const space = await authority.getSpace(body.spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(body.spaceUri, caller.callerDid); const result = checkAccess({ op: "delete", @@ -689,13 +794,15 @@ export function registerRecordHostRoutes( if (!spaceUri) { return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); } - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, spaceUri); + if (enrollment instanceof Response) return enrollment; const caller = resolveCaller(c, spaceUri, "rw"); if (caller instanceof Response) return caller; if (!caller.viaCredential) { + const space = await authority.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(spaceUri, caller.callerDid); const aclResult = checkAccess({ op: "write", @@ -764,14 +871,16 @@ export function registerRecordHostRoutes( if (!spaceUri || !cid) { return c.json({ error: "InvalidRequest", message: "spaceUri and cid required" }, 400); } - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, spaceUri); + if (enrollment instanceof Response) return enrollment; const authz = await authorizeRead(c, authority, spaceUri); if (authz instanceof Response) return authz; if (authz.via === "jwt") { const sa = authz.sa; + const space = await authority.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(spaceUri, sa.issuer); const aclResult = checkAccess({ op: "read", @@ -804,13 +913,15 @@ export function registerRecordHostRoutes( if (!spaceUri) { return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); } - const space = await authority.getSpace(spaceUri); - if (!space) return c.json({ error: "NotFound" }, 404); + const enrollment = await requireEnrollment(c, spaceUri); + if (enrollment instanceof Response) return enrollment; const caller = resolveCaller(c, spaceUri, "read"); if (caller instanceof Response) return caller; if (!caller.viaCredential) { + const space = await authority.getSpace(spaceUri); + if (!space) return c.json({ error: "NotFound" }, 404); const member = await authority.getMember(spaceUri, caller.callerDid); const aclResult = checkAccess({ op: "read", diff --git a/packages/contrail/src/core/spaces/schema.ts b/packages/contrail/src/core/spaces/schema.ts index a08ada6..ae33a7c 100644 --- a/packages/contrail/src/core/spaces/schema.ts +++ b/packages/contrail/src/core/spaces/schema.ts @@ -59,6 +59,18 @@ export function buildSpacesBaseSchema(dialect: SqlDialect): string[] { note TEXT )`, `CREATE INDEX IF NOT EXISTS idx_spaces_invites_space ON spaces_invites(space_uri, created_at DESC)`, + + // Record-host enrollment table — the host's local cache of "spaces I + // accept records for, and which authority signs credentials for each." + // Filled by the recordHost.enroll endpoint, or auto-populated by the + // authority's createSpace when both roles run in the same process. + `CREATE TABLE IF NOT EXISTS record_host_enrollments ( + space_uri TEXT PRIMARY KEY, + authority_did TEXT NOT NULL, + enrolled_at ${dialect.bigintType} NOT NULL, + enrolled_by TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_enrollments_authority ON record_host_enrollments(authority_did)`, ]; } diff --git a/packages/contrail/src/core/spaces/types.ts b/packages/contrail/src/core/spaces/types.ts index 5429c96..6561773 100644 --- a/packages/contrail/src/core/spaces/types.ts +++ b/packages/contrail/src/core/spaces/types.ts @@ -170,6 +170,17 @@ export interface BlobMetaRow { createdAt: number; } +/** Local cache on the record host: this space is accepted here, and `authority_did` + * is the DID authorized to sign credentials for it. Populated via the + * `recordHost.enroll` endpoint or auto-populated by the authority's + * createSpace when both roles run in one process. */ +export interface EnrollmentRow { + spaceUri: string; + authorityDid: string; + enrolledAt: number; + enrolledBy: string; +} + export interface ListBlobsOptions { byUser?: string; cursor?: string; @@ -220,14 +231,20 @@ export interface SpaceAuthority { redeemInvite(tokenHash: string, now: number): Promise; } -/** **Record host** interface — stores records and blobs for a space. Trusts - * the authority's ACL: in later phases, validates incoming credentials - * rather than calling out for a member check. +/** **Record host** interface — stores records and blobs for a space, plus + * the local enrollment table that decides which spaces this host accepts. * - * Read-side note: today the record host needs `getSpace` to know the space - * exists at all (and for app-policy checks before permissioned writes). When - * enrollment lands (phase 5), this becomes a local lookup instead. */ + * Trust model: the host trusts whatever credential the authority signs, so + * long as the authority is the one named in the local enrollment for this + * space. Enrollment is the consent step — the host owner agrees to spend + * storage on a given space, scoped to a specific authority. */ export interface RecordHost { + // Enrollment + enroll(input: EnrollmentRow): Promise; + getEnrollment(spaceUri: string): Promise; + listEnrollments(options?: { authorityDid?: string; limit?: number }): Promise; + removeEnrollment(spaceUri: string): Promise; + // Records putRecord(record: StoredRecord): Promise; getRecord(spaceUri: string, collection: string, authorDid: string, rkey: string): Promise; diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index 5e59dac..bbd8efe 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -91,6 +91,7 @@ export type { // Binding + key resolution export { createLocalBindingResolver, + createEnrollmentBindingResolver, createOwnerSelfBindingResolver, createCompositeBindingResolver, createPdsBindingResolver, @@ -101,6 +102,15 @@ export { } from "./core/spaces/binding"; export type { BindingResolver, KeyResolver } from "./core/spaces/binding"; +// Route registration — exposed for split deployments where authority and +// record host run as separate Hono apps. Consumers wire them onto bare Honos +// individually instead of going through createApp's umbrella. +export { + registerAuthorityRoutes, + registerRecordHostRoutes, +} from "./core/spaces/router"; +export type { EnrollmentRow } from "./core/spaces/types"; + // Realtime export type { PubSub, diff --git a/packages/contrail/tests/spaces-enrollment.test.ts b/packages/contrail/tests/spaces-enrollment.test.ts new file mode 100644 index 0000000..3b7ef5b --- /dev/null +++ b/packages/contrail/tests/spaces-enrollment.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; +import { + registerAuthorityRoutes, + registerRecordHostRoutes, +} from "../src/core/spaces/router"; +import { HostedAdapter } from "../src/core/spaces/adapter"; +import { + buildVerifier, + createServiceAuthMiddleware, +} from "../src/core/spaces/auth"; +import { + generateAuthoritySigningKey, + issueCredential, + createBindingCredentialVerifier, +} from "../src/core/spaces/credentials"; +import { + createEnrollmentBindingResolver, + createLocalKeyResolver, +} from "../src/core/spaces/binding"; +import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; + +const ALICE = "did:plc:alice"; +const BOB = "did:plc:bob"; +const SERVICE_DID = "did:web:test.example#svc"; + +let SIGNING: CredentialKeyMaterial; + +beforeAll(async () => { + SIGNING = await generateAuthoritySigningKey(); +}); + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { + issuer: did, + audience: SERVICE_DID, + lxm: undefined, + clientId: c.req.header("X-Test-App") ?? undefined, + }); + await next(); + }; +} + +function call( + app: Hono, + method: string, + path: string, + did: string | null, + body?: any, + extraHeaders?: Record +) { + const headers: Record = { ...(extraHeaders ?? {}) }; + if (did) headers["X-Test-Did"] = did; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +// --------------------------------------------------------------------------- +// In-process: createSpace auto-enrolls; non-enrolled spaces 404 +// --------------------------------------------------------------------------- + +describe("auto-enrollment via createSpace", () => { + function makeConfig(): ContrailConfig { + return { + namespace: "test.enroll", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { + type: "tools.atmo.event.space", + serviceDid: SERVICE_DID, + signing: SIGNING, + }, + recordHost: {}, + }, + }; + } + + async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const cfg = makeConfig(); + const resolved = resolveConfig(cfg); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + } + + it("createSpace creates an enrollment row alongside the space row", async () => { + const app = await makeApp(); + const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); + expect(create.status).toBe(200); + const uri = ((await create.json()) as any).space.uri; + + // Subsequent putRecord should succeed because the space is enrolled. + const put = await call(app, "POST", "/xrpc/test.enroll.space.putRecord", ALICE, { + spaceUri: uri, + collection: "app.event.message", + record: { $type: "app.event.message", text: "hi" }, + }); + expect(put.status).toBe(200); + }); + + it("explicit enroll via the endpoint is idempotent", async () => { + const app = await makeApp(); + const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); + const uri = ((await create.json()) as any).space.uri; + + // Re-enrolling the same space (e.g. to update the authority binding). + const reenroll = await call(app, "POST", "/xrpc/test.enroll.recordHost.enroll", ALICE, { + spaceUri: uri, + authority: SERVICE_DID, + }); + expect(reenroll.status).toBe(200); + expect(((await reenroll.json()) as any).ok).toBe(true); + }); + + it("non-owner / non-authority callers cannot enroll", async () => { + const app = await makeApp(); + const create = await call(app, "POST", "/xrpc/test.enroll.space.createSpace", ALICE, {}); + const uri = ((await create.json()) as any).space.uri; + + const res = await call(app, "POST", "/xrpc/test.enroll.recordHost.enroll", BOB, { + spaceUri: uri, + authority: SERVICE_DID, + }); + expect(res.status).toBe(403); + expect((await res.json()).reason).toBe("not-owner-or-authority"); + }); +}); + +// --------------------------------------------------------------------------- +// Split deployment: authority on machine A, record host on machine B +// --------------------------------------------------------------------------- + +describe("split deployment — authority and record host on separate apps", () => { + function buildAuthorityApp(db: any): Hono { + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + // Authority-only deployment: no recordHost configured. + spaces: { + authority: { + type: "tools.atmo.event.space", + serviceDid: SERVICE_DID, + signing: SIGNING, + }, + }, + }; + const resolved = resolveConfig(cfg); + const adapter = new HostedAdapter(db, resolved); + const app = new Hono(); + registerAuthorityRoutes( + app, + adapter, + cfg.spaces!.authority!, + resolved, + fakeAuth(), + undefined, + null // no local record host → no auto-enroll + ); + return app; + } + + function buildRecordHostApp(db: any): Hono { + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + spaces: { + // Authority config still required to provide the auth middleware + // and serviceDid context, but signing can be omitted (host doesn't + // sign — it verifies). + authority: { + type: "tools.atmo.event.space", + serviceDid: SERVICE_DID, + }, + recordHost: {}, + }, + }; + const resolved = resolveConfig(cfg); + const adapter = new HostedAdapter(db, resolved); + const verifier = createBindingCredentialVerifier({ + bindings: createEnrollmentBindingResolver({ recordHost: adapter }), + keys: createLocalKeyResolver({ + authorityDid: SERVICE_DID, + publicKey: SIGNING.publicKey, + }), + }); + const app = new Hono(); + registerRecordHostRoutes( + app, + adapter, + adapter, + cfg.spaces!.recordHost!, + resolved, + fakeAuth(), + verifier + ); + return app; + } + + it("end-to-end: create on authority, enroll on host, write+read via credential", async () => { + // Two physically-separate DBs — proves the host doesn't peek into the + // authority's storage to know about spaces. + const authorityDb = createSqliteDatabase(":memory:"); + const hostDb = createSqliteDatabase(":memory:"); + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, + recordHost: {}, + }, + }; + const resolved = resolveConfig(cfg); + await initSchema(authorityDb, resolved); + await initSchema(hostDb, resolved); + + const authorityApp = buildAuthorityApp(authorityDb); + const hostApp = buildRecordHostApp(hostDb); + + // 1. Authority creates a space — auto-enroll did NOT happen because the + // authority has no local record host. + const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); + expect(create.status).toBe(200); + const uri = ((await create.json()) as any).space.uri; + + // 2. Host rejects writes for the not-yet-enrolled space. + // The credential's binding resolver (EnrollmentBindingResolver) + // finds no enrollment → returns null → verifier 401 "unknown-issuer". + // This is a different layer than the requireEnrollment 404 (which + // fires on JWT/non-credential paths), but both block writes. + const cred1 = await call(authorityApp, "POST", "/xrpc/test.split.space.getCredential", ALICE, { + spaceUri: uri, + }); + const credential = ((await cred1.json()) as any).credential; + const earlyPut = await call( + hostApp, + "POST", + "/xrpc/test.split.space.putRecord", + null, + { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "x" } }, + { "X-Space-Credential": credential } + ); + expect(earlyPut.status).toBe(401); + expect((await earlyPut.json()).reason).toBe("unknown-issuer"); + + // 3. Owner enrolls the space on the host. + const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { + spaceUri: uri, + authority: SERVICE_DID, + }); + expect(enroll.status).toBe(200); + + // 4. Now the host accepts writes via credential. + const put = await call( + hostApp, + "POST", + "/xrpc/test.split.space.putRecord", + null, + { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "hello" } }, + { "X-Space-Credential": credential } + ); + expect(put.status).toBe(200); + expect(((await put.json()) as any).authorDid).toBe(ALICE); + + // 5. Read back via credential. + const list = await call( + hostApp, + "GET", + `/xrpc/test.split.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, + null, + undefined, + { "X-Space-Credential": credential } + ); + expect(list.status).toBe(200); + expect(((await list.json()) as any).records.length).toBe(1); + }); + + it("host rejects credentials whose iss doesn't match the enrollment", async () => { + const authorityDb = createSqliteDatabase(":memory:"); + const hostDb = createSqliteDatabase(":memory:"); + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, + recordHost: {}, + }, + }; + const resolved = resolveConfig(cfg); + await initSchema(authorityDb, resolved); + await initSchema(hostDb, resolved); + + const authorityApp = buildAuthorityApp(authorityDb); + const hostApp = buildRecordHostApp(hostDb); + + const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); + const uri = ((await create.json()) as any).space.uri; + await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", ALICE, { + spaceUri: uri, + authority: SERVICE_DID, + }); + + // A credential signed by a *different* DID with the same key, but iss + // doesn't match the enrolled authority. + const { credential } = await issueCredential( + { + iss: "did:web:imposter.example", + sub: ALICE, + space: uri, + scope: "rw", + ttlMs: 60_000, + }, + SIGNING + ); + const res = await call( + hostApp, + "POST", + "/xrpc/test.split.space.putRecord", + null, + { spaceUri: uri, collection: "app.event.message", record: { $type: "app.event.message", text: "x" } }, + { "X-Space-Credential": credential } + ); + expect(res.status).toBe(401); + expect((await res.json()).reason).toBe("unknown-issuer"); + }); + + it("non-enrolled spaces 404 with not-enrolled on JWT-path reads", async () => { + // Service-auth JWTs go through requireEnrollment in the route handler, + // so a non-enrolled space gives the explicit "not-enrolled" reason. + const authorityDb = createSqliteDatabase(":memory:"); + const hostDb = createSqliteDatabase(":memory:"); + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, + recordHost: {}, + }, + }; + const resolved = resolveConfig(cfg); + await initSchema(authorityDb, resolved); + await initSchema(hostDb, resolved); + const authorityApp = buildAuthorityApp(authorityDb); + const hostApp = buildRecordHostApp(hostDb); + const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); + const uri = ((await create.json()) as any).space.uri; + + const list = await call( + hostApp, + "GET", + `/xrpc/test.split.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, + ALICE + ); + expect(list.status).toBe(404); + expect((await list.json()).reason).toBe("not-enrolled"); + }); + + it("the declared authority can enroll a space without the owner's involvement", async () => { + // Scenario: the authority service ('Contrail' in our naming) is a + // separate DID and acts on behalf of an owner. The authority can enroll + // because phase 5 accepts either owner-or-authority. + const authorityDb = createSqliteDatabase(":memory:"); + const hostDb = createSqliteDatabase(":memory:"); + const cfg: ContrailConfig = { + namespace: "test.split", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { type: "tools.atmo.event.space", serviceDid: SERVICE_DID, signing: SIGNING }, + recordHost: {}, + }, + }; + const resolved = resolveConfig(cfg); + await initSchema(authorityDb, resolved); + await initSchema(hostDb, resolved); + + const authorityApp = buildAuthorityApp(authorityDb); + const hostApp = buildRecordHostApp(hostDb); + const create = await call(authorityApp, "POST", "/xrpc/test.split.space.createSpace", ALICE, {}); + const uri = ((await create.json()) as any).space.uri; + + // Authority service identifies itself via the JWT issuer matching its DID. + const enroll = await call(hostApp, "POST", "/xrpc/test.split.recordHost.enroll", SERVICE_DID, { + spaceUri: uri, + authority: SERVICE_DID, + }); + expect(enroll.status).toBe(200); + }); +}); diff --git a/packages/lexicons/lexicon-templates/recordHost/enroll.json b/packages/lexicons/lexicon-templates/recordHost/enroll.json new file mode 100644 index 0000000..2e7bc66 --- /dev/null +++ b/packages/lexicons/lexicon-templates/recordHost/enroll.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "tools.atmo.recordHost.enroll", + "defs": { + "main": { + "type": "procedure", + "description": "Enroll a space on this record host. The host stores `(spaceUri, authority)` in its local enrollment table; subsequent record / blob operations for this space go through, and the credential verifier uses the enrolled authority as the canonical binding source. Caller must be either the space owner OR the declared authority — the host treats either as a sufficient signal of consent. Idempotent: re-enrolling updates the authority binding.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["spaceUri", "authority"], + "properties": { + "spaceUri": { "type": "string" }, + "authority": { + "type": "string", + "format": "did", + "description": "DID authorized to sign credentials for this space." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean" } + } + } + }, + "errors": [ + { "name": "InvalidRequest" }, + { "name": "Forbidden", "description": "Caller is neither the space owner nor the declared authority." } + ] + } + } +} -- 2.51.2