diff --git a/packages/contrail/src/core/spaces/binding.ts b/packages/contrail/src/core/spaces/binding.ts new file mode 100644 index 0000000..d349d55 --- /dev/null +++ b/packages/contrail/src/core/spaces/binding.ts @@ -0,0 +1,256 @@ +/** Binding resolution: given a space URI, which DID is authorized to sign + * credentials for it, and where do we find that DID's verification key? + * + * Two layers of pluggable resolvers compose into a credential verifier: + * + * BindingResolver — `ats:////` → authority DID + * KeyResolver — (DID, kid) → JsonWebKey + * + * The BindingResolver is what makes user-owned-DID-with-PDS-record work: + * given a space URI, we resolve the owner's PDS, fetch the declaration + * record, and read its `authority` field. For provisioned (no-PDS) DIDs we + * fall back to the owner DID's `#atproto_space_authority` service entry. + * And finally for the trivial case (HappyView-style "owner self-issues"), + * we return the owner DID itself. + * + * See conversation history (phase 4 design) for the rationale on why these + * three sources, in this order. */ + +import type { DidDocumentResolver } from "@atcute/identity-resolver"; +import type { Did } from "@atcute/lexicons"; +import { parseSpaceUri } from "./uri"; + +export interface BindingResolver { + /** Resolve the DID authorized to sign credentials for this space. Returns + * null if no binding could be found via this resolver — the composite + * walks down its list looking for a non-null. */ + resolveAuthority(spaceUri: string): Promise; +} + +export interface KeyResolver { + /** Resolve `did`'s verification key for credential signing. `kid` is the + * full header `kid` value (e.g. "did:web:x.com#atproto_space_authority"), + * used to disambiguate when a DID doc lists multiple methods. */ + resolveKey(did: string, kid: string | undefined): Promise; +} + +// --------------------------------------------------------------------------- +// Binding resolvers +// --------------------------------------------------------------------------- + +/** Always returns the configured authority DID. Used in-process when the + * authority and record host run in one deployment — no need to walk DID + * docs or PDSes; we know what we are. */ +export function createLocalBindingResolver(args: { + authorityDid: string; +}): BindingResolver { + const { authorityDid } = args; + return { + async resolveAuthority() { + return authorityDid; + }, + }; +} + +/** 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 + * resulting credential actually verifies depends on whether the owner's DID + * doc publishes a usable signing key. */ +export function createOwnerSelfBindingResolver(): BindingResolver { + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + return parts ? parts.ownerDid : null; + }, + }; +} + +/** Walks the resolver list in order, returns the first non-null. Use this + * to compose [pdsRecord, didDocService, ownerSelf] etc. */ +export function createCompositeBindingResolver( + resolvers: BindingResolver[] +): BindingResolver { + return { + async resolveAuthority(spaceUri) { + for (const r of resolvers) { + const did = await r.resolveAuthority(spaceUri); + if (did) return did; + } + return null; + }, + }; +} + +/** Reads a space-declaration record from the owner's PDS at + * `at:////` and returns its `authority` field if present. + * + * This is the user-owned-DID path: the user writes a record to their PDS + * authorizing some service as the space's authority, no DID-doc edits + * required. */ +export function createPdsBindingResolver(args: { + /** DID resolver, used to look up the owner's PDS endpoint. */ + resolver: DidDocumentResolver; + /** Fetch impl. Defaults to `globalThis.fetch`. */ + fetch?: typeof fetch; + /** Per-request timeout in ms. Defaults to 5000. */ + timeoutMs?: number; +}): BindingResolver { + const fetchImpl = args.fetch ?? globalThis.fetch; + const timeoutMs = args.timeoutMs ?? 5000; + + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + if (!parts) return null; + const pds = await pdsEndpointFor(args.resolver, parts.ownerDid); + if (!pds) return null; + + const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); + url.searchParams.set("repo", parts.ownerDid); + url.searchParams.set("collection", parts.type); + url.searchParams.set("rkey", parts.key); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + let res: Response; + try { + res = await fetchImpl(url.toString(), { signal: ctrl.signal }); + } catch { + return null; + } finally { + clearTimeout(timer); + } + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as + | { value?: { authority?: unknown } } + | null; + const authority = body?.value?.authority; + return typeof authority === "string" && authority.startsWith("did:") ? authority : null; + }, + }; +} + +/** Reads `service[id="#atproto_space_authority"].serviceEndpoint` from the + * owner's DID doc. This is the no-PDS path — useful for provisioned space + * DIDs that exist as DID docs only. + * + * Note the service endpoint here is a *DID*, not a URL. The DID names the + * authority; the key resolver's job is to then fetch its verification key. + * For DID docs that declare a URL endpoint, we treat the URL as a + * did:web hint — caller can normalize. */ +export function createDidDocBindingResolver(args: { + resolver: DidDocumentResolver; + /** Service id to look up. Defaults to "#atproto_space_authority". */ + serviceId?: string; +}): BindingResolver { + const serviceId = args.serviceId ?? "#atproto_space_authority"; + return { + async resolveAuthority(spaceUri) { + const parts = parseSpaceUri(spaceUri); + if (!parts) return null; + let doc; + try { + doc = await args.resolver.resolve(parts.ownerDid as Did); + } catch { + return null; + } + const entry = doc.service?.find((s: { id?: string }) => s.id === serviceId); + if (!entry) return null; + const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; + if (typeof endpoint !== "string") return null; + // Endpoint may be a DID (preferred) or a URL hint. Only DIDs are + // verifiable downstream; URLs require the caller to map URL → DID. + return endpoint.startsWith("did:") ? endpoint : null; + }, + }; +} + +// --------------------------------------------------------------------------- +// Key resolvers +// --------------------------------------------------------------------------- + +/** Knows the local authority's public key directly. Returns null for any + * other DID — composite with a DID-doc resolver if you also accept + * external authorities. */ +export function createLocalKeyResolver(args: { + authorityDid: string; + publicKey: JsonWebKey; +}): KeyResolver { + return { + async resolveKey(did) { + return did === args.authorityDid ? args.publicKey : null; + }, + }; +} + +/** Resolves a DID, finds the verification method matching `kid`, returns + * its `publicKeyJwk`. */ +export function createDidDocKeyResolver(args: { + resolver: DidDocumentResolver; +}): KeyResolver { + return { + async resolveKey(did, kid) { + let doc; + try { + doc = await args.resolver.resolve(did as Did); + } catch { + return null; + } + const methods = (doc as { verificationMethod?: VerificationMethod[] }).verificationMethod; + if (!methods) return null; + // kid is "#" — we match against the method.id which DID + // docs spell as "#" too. + const method = kid + ? methods.find((m) => m.id === kid) + : methods[0]; + if (!method?.publicKeyJwk) return null; + return method.publicKeyJwk as JsonWebKey; + }, + }; +} + +/** Walks resolvers in order; returns the first non-null. */ +export function createCompositeKeyResolver( + resolvers: KeyResolver[] +): KeyResolver { + return { + async resolveKey(did, kid) { + for (const r of resolvers) { + const k = await r.resolveKey(did, kid); + if (k) return k; + } + return null; + }, + }; +} + +interface VerificationMethod { + id: string; + type?: string; + controller?: string; + publicKeyJwk?: unknown; + publicKeyMultibase?: string; +} + +// --------------------------------------------------------------------------- +// Internal: PDS endpoint lookup +// --------------------------------------------------------------------------- + +async function pdsEndpointFor( + resolver: DidDocumentResolver, + did: string +): Promise { + let doc; + try { + doc = await resolver.resolve(did as Did); + } catch { + return null; + } + const entry = doc.service?.find( + (s: { id?: string }) => s.id === "#atproto_pds" + ); + if (!entry) return null; + const endpoint = (entry as { serviceEndpoint?: unknown }).serviceEndpoint; + return typeof endpoint === "string" ? endpoint : null; +} diff --git a/packages/contrail/src/core/spaces/credentials.ts b/packages/contrail/src/core/spaces/credentials.ts index 8c4a843..d5c3029 100644 --- a/packages/contrail/src/core/spaces/credentials.ts +++ b/packages/contrail/src/core/spaces/credentials.ts @@ -238,7 +238,8 @@ export interface CredentialVerifier { /** In-process verifier for the simple deployment: the authority and record * host run in one process and the record host has direct access to the * authority's public key. Rejects any credential whose `iss` isn't the - * configured authority. Phase 4 generalizes to multi-authority. */ + * configured authority. Phase 4 has a more general + * {@link createBindingCredentialVerifier} that does proper binding lookup. */ export function createInProcessVerifier(args: { authorityDid: string; publicKey: JsonWebKey; @@ -251,3 +252,33 @@ export function createInProcessVerifier(args: { }, }; } + +/** Verifier composed of a {@link BindingResolver} (which DID is authorized + * to issue for this space?) and a {@link KeyResolver} (what's that DID's + * public key?). This is the production-shape verifier — phase 4's main + * contribution. + * + * Verification flow: + * 1. Decode the JWT's claims (no signature check yet). + * 2. Ask the binding resolver: who's authorized for `claims.space`? + * 3. Confirm `claims.iss === authorizedDid`. + * 4. Ask the key resolver for that DID's verification key. + * 5. Verify signature + expiry + scope match. + */ +export function createBindingCredentialVerifier(args: { + bindings: import("./binding").BindingResolver; + keys: import("./binding").KeyResolver; +}): CredentialVerifier { + return { + async verify(jwt) { + const peek = decodeUnverifiedClaims(jwt); + if (!peek) return { ok: false, reason: "malformed" }; + const authorizedDid = await args.bindings.resolveAuthority(peek.space); + if (!authorizedDid) return { ok: false, reason: "unknown-issuer" }; + if (peek.iss !== authorizedDid) return { ok: false, reason: "unknown-issuer" }; + return verifyCredential(jwt, { + resolveKey: (iss, kid) => args.keys.resolveKey(iss, kid), + }); + }, + }; +} diff --git a/packages/contrail/src/core/spaces/router.ts b/packages/contrail/src/core/spaces/router.ts index fa9aeb5..c0c9e92 100644 --- a/packages/contrail/src/core/spaces/router.ts +++ b/packages/contrail/src/core/spaces/router.ts @@ -26,7 +26,7 @@ import { import { blobKey } from "./blob-adapter"; import { collectBlobCids } from "./blob-refs"; import { - createInProcessVerifier, + createBindingCredentialVerifier, decodeUnverifiedClaims, issueCredential, verifyCredential, @@ -34,6 +34,10 @@ import { type CredentialScope, type CredentialVerifier, } from "./credentials"; +import { + createLocalBindingResolver, + createLocalKeyResolver, +} from "./binding"; import { create as createCid, toString as cidToString } from "@atcute/cid"; /** Optional hook to extend `.spaceExt.whoami` with extra fields when a @@ -57,6 +61,12 @@ export interface SpacesRoutesOptions { adapter?: StorageAdapter; /** Optional whoami extension; see {@link WhoamiExtension}. */ whoamiExtension?: WhoamiExtension; + /** Optional credential verifier for the record host. When omitted, a + * default in-process binding verifier is built from the authority's + * signing config (Local binding + Local key resolvers). Override to + * accept credentials from external authorities — wire in PDS-record / + * DID-doc binding resolvers and a DID-doc key resolver. */ + credentialVerifier?: CredentialVerifier; } /** Umbrella registration: wires both the authority and the record-host @@ -83,15 +93,24 @@ export function registerSpacesRoutes( registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, options.whoamiExtension); if (spacesConfig.recordHost) { - // In-process verifier: when authority and record host are colocated, - // the host has direct access to the authority's public key. Phase 4 - // adds a binding-resolving verifier for split deployments. - const verifier = authorityConfig.signing - ? createInProcessVerifier({ - authorityDid: authorityConfig.serviceDid, - publicKey: authorityConfig.signing.publicKey, - }) - : undefined; + // 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. + const verifier = + options.credentialVerifier ?? + (authorityConfig.signing + ? createBindingCredentialVerifier({ + bindings: createLocalBindingResolver({ + authorityDid: authorityConfig.serviceDid, + }), + keys: createLocalKeyResolver({ + authorityDid: authorityConfig.serviceDid, + publicKey: authorityConfig.signing.publicKey, + }), + }) + : undefined); registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth, verifier); } } diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index 54e22bf..5e59dac 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -76,6 +76,7 @@ export { verifyCredential, decodeUnverifiedClaims, createInProcessVerifier, + createBindingCredentialVerifier, } from "./core/spaces/credentials"; export type { CredentialClaims, @@ -87,6 +88,19 @@ export type { VerifyOptions, } from "./core/spaces/credentials"; +// Binding + key resolution +export { + createLocalBindingResolver, + createOwnerSelfBindingResolver, + createCompositeBindingResolver, + createPdsBindingResolver, + createDidDocBindingResolver, + createLocalKeyResolver, + createDidDocKeyResolver, + createCompositeKeyResolver, +} from "./core/spaces/binding"; +export type { BindingResolver, KeyResolver } from "./core/spaces/binding"; + // Realtime export type { PubSub, diff --git a/packages/contrail/tests/spaces-binding.test.ts b/packages/contrail/tests/spaces-binding.test.ts new file mode 100644 index 0000000..427b6f2 --- /dev/null +++ b/packages/contrail/tests/spaces-binding.test.ts @@ -0,0 +1,435 @@ +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 { + createLocalBindingResolver, + createOwnerSelfBindingResolver, + createCompositeBindingResolver, + createPdsBindingResolver, + createDidDocBindingResolver, + createLocalKeyResolver, + createDidDocKeyResolver, + createCompositeKeyResolver, +} from "../src/core/spaces/binding"; +import { + generateAuthoritySigningKey, + issueCredential, + createBindingCredentialVerifier, +} from "../src/core/spaces/credentials"; +import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; + +const ALICE = "did:plc:alice"; +const SERVICE_DID = "did:web:test.example#svc"; + +let SIGNING: CredentialKeyMaterial; + +beforeAll(async () => { + SIGNING = await generateAuthoritySigningKey(); +}); + +const SPACE_URI = "ats://did:plc:alice/com.example.event.space/main"; + +describe("BindingResolver — basic resolvers", () => { + it("Local always returns the configured DID", async () => { + const r = createLocalBindingResolver({ authorityDid: SERVICE_DID }); + expect(await r.resolveAuthority(SPACE_URI)).toBe(SERVICE_DID); + expect(await r.resolveAuthority("ats://did:plc:bob/x/y")).toBe(SERVICE_DID); + }); + + it("OwnerSelf parses the owner from the URI", async () => { + const r = createOwnerSelfBindingResolver(); + expect(await r.resolveAuthority(SPACE_URI)).toBe(ALICE); + expect(await r.resolveAuthority("not a space uri")).toBeNull(); + }); + + it("Composite returns the first non-null result", async () => { + const r = createCompositeBindingResolver([ + { resolveAuthority: async () => null }, + { resolveAuthority: async () => "did:web:second" }, + { resolveAuthority: async () => "did:web:third" }, + ]); + expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:second"); + }); + + it("Composite returns null if every resolver returns null", async () => { + const r = createCompositeBindingResolver([ + { resolveAuthority: async () => null }, + { resolveAuthority: async () => null }, + ]); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); +}); + +describe("BindingResolver — PDS record", () => { + function mockResolver(opts: { + pdsEndpoint?: string; + fail?: boolean; + }): any { + return { + resolve: async (did: string) => { + if (opts.fail) throw new Error("nope"); + return { + id: did, + service: opts.pdsEndpoint + ? [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: opts.pdsEndpoint, + }, + ] + : [], + }; + }, + }; + } + + function mockFetch(map: Map): typeof fetch { + return (async (url: string) => { + const u = String(url); + const found = [...map.entries()].find(([k]) => u.startsWith(k)); + if (!found) return new Response("not found", { status: 404 }); + return new Response(JSON.stringify(found[1]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + } + + it("reads `authority` from a declaration record", async () => { + const fetch = mockFetch( + new Map([ + [ + "https://pds.test/xrpc/com.atproto.repo.getRecord", + { + uri: "at://did:plc:alice/com.example.event.space/main", + value: { + $type: "com.example.event.space", + authority: "did:web:custom-authority.example", + recordHost: "did:web:host.example", + createdAt: "2026-04-30T00:00:00Z", + }, + }, + ], + ]) + ); + const r = createPdsBindingResolver({ + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), + fetch, + }); + expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:custom-authority.example"); + }); + + it("returns null when the PDS record is missing", async () => { + const fetch = mockFetch(new Map()); // 404 for everything + const r = createPdsBindingResolver({ + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), + fetch, + }); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); + + it("returns null when the record has no authority field", async () => { + const fetch = mockFetch( + new Map([ + [ + "https://pds.test/xrpc/com.atproto.repo.getRecord", + { value: { $type: "x", createdAt: "..." } }, + ], + ]) + ); + const r = createPdsBindingResolver({ + resolver: mockResolver({ pdsEndpoint: "https://pds.test" }), + fetch, + }); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); + + it("returns null when the owner DID doc has no PDS endpoint", async () => { + const fetch = mockFetch(new Map()); + const r = createPdsBindingResolver({ + resolver: mockResolver({}), + fetch, + }); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); +}); + +describe("BindingResolver — DID-doc service entry", () => { + function mockResolver(serviceDid: string | null): any { + return { + resolve: async (did: string) => ({ + id: did, + service: serviceDid + ? [ + { + id: "#atproto_space_authority", + type: "AtprotoSpaceAuthority", + serviceEndpoint: serviceDid, + }, + ] + : [], + }), + }; + } + + it("reads the #atproto_space_authority service entry", async () => { + const r = createDidDocBindingResolver({ + resolver: mockResolver("did:web:authority.example"), + }); + expect(await r.resolveAuthority(SPACE_URI)).toBe("did:web:authority.example"); + }); + + it("returns null when the service entry is absent", async () => { + const r = createDidDocBindingResolver({ resolver: mockResolver(null) }); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); + + it("rejects URL-shaped service endpoints (must be a DID)", async () => { + const r = createDidDocBindingResolver({ + resolver: mockResolver("https://authority.example.com"), + }); + expect(await r.resolveAuthority(SPACE_URI)).toBeNull(); + }); +}); + +describe("KeyResolver", () => { + it("Local matches by DID", async () => { + const r = createLocalKeyResolver({ + authorityDid: SERVICE_DID, + publicKey: SIGNING.publicKey, + }); + expect(await r.resolveKey(SERVICE_DID, undefined)).toEqual(SIGNING.publicKey); + expect(await r.resolveKey("did:web:other", undefined)).toBeNull(); + }); + + it("DidDoc finds the verification method matching kid", async () => { + const otherKey = await generateAuthoritySigningKey(); + const resolver = { + resolve: async (did: string) => ({ + id: did, + verificationMethod: [ + { + id: `${did}#atproto_space_authority`, + type: "JsonWebKey2020", + controller: did, + publicKeyJwk: SIGNING.publicKey, + }, + { + id: `${did}#another-key`, + type: "JsonWebKey2020", + controller: did, + publicKeyJwk: otherKey.publicKey, + }, + ], + }), + }; + const r = createDidDocKeyResolver({ resolver: resolver as any }); + const key = await r.resolveKey( + "did:web:authority.example", + "did:web:authority.example#atproto_space_authority" + ); + expect(key).toEqual(SIGNING.publicKey); + }); + + it("DidDoc returns the second key when kid points there", async () => { + const otherKey = await generateAuthoritySigningKey(); + const resolver = { + resolve: async (did: string) => ({ + id: did, + verificationMethod: [ + { + id: `${did}#atproto_space_authority`, + type: "JsonWebKey2020", + controller: did, + publicKeyJwk: SIGNING.publicKey, + }, + { + id: `${did}#another-key`, + type: "JsonWebKey2020", + controller: did, + publicKeyJwk: otherKey.publicKey, + }, + ], + }), + }; + const r = createDidDocKeyResolver({ resolver: resolver as any }); + const key = await r.resolveKey( + "did:web:authority.example", + "did:web:authority.example#another-key" + ); + expect(key).toEqual(otherKey.publicKey); + }); + + it("Composite walks resolvers in order", async () => { + const fallback = await generateAuthoritySigningKey(); + const r = createCompositeKeyResolver([ + { resolveKey: async () => null }, + { resolveKey: async () => fallback.publicKey }, + ]); + expect(await r.resolveKey("did:any", undefined)).toEqual(fallback.publicKey); + }); +}); + +describe("createBindingCredentialVerifier — composes binding + key resolvers", () => { + it("verifies a credential whose iss matches the binding-resolved authority", async () => { + const verifier = createBindingCredentialVerifier({ + bindings: createLocalBindingResolver({ authorityDid: SERVICE_DID }), + keys: createLocalKeyResolver({ + authorityDid: SERVICE_DID, + publicKey: SIGNING.publicKey, + }), + }); + const { credential } = await issueCredential( + { iss: SERVICE_DID, sub: ALICE, space: SPACE_URI, scope: "rw", ttlMs: 60_000 }, + SIGNING + ); + const result = await verifier.verify(credential); + expect(result.ok).toBe(true); + }); + + it("rejects when credential iss disagrees with the binding", async () => { + const verifier = createBindingCredentialVerifier({ + bindings: createLocalBindingResolver({ authorityDid: SERVICE_DID }), + keys: createLocalKeyResolver({ + authorityDid: SERVICE_DID, + publicKey: SIGNING.publicKey, + }), + }); + const { credential } = await issueCredential( + { + iss: "did:web:imposter.example", + sub: ALICE, + space: SPACE_URI, + scope: "rw", + ttlMs: 60_000, + }, + SIGNING + ); + const result = await verifier.verify(credential); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unknown-issuer"); + }); + + it("rejects when no resolver knows the authority", async () => { + const verifier = createBindingCredentialVerifier({ + bindings: { resolveAuthority: async () => null }, + keys: { resolveKey: async () => null }, + }); + const { credential } = await issueCredential( + { iss: SERVICE_DID, sub: ALICE, space: SPACE_URI, scope: "rw", ttlMs: 60_000 }, + SIGNING + ); + const result = await verifier.verify(credential); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unknown-issuer"); + }); +}); + +describe("end-to-end — record host accepts credential from external authority", () => { + function makeConfig(): ContrailConfig { + return { + namespace: "test.binding", + collections: { message: { collection: "app.event.message" } }, + spaces: { + authority: { + type: "tools.atmo.event.space", + serviceDid: SERVICE_DID, + // No `signing` — this deployment is record-host only, accepting + // credentials issued by an external authority. + }, + recordHost: {}, + }, + }; + } + + 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(); + }; + } + + it("verifies a credential issued by a separate authority via injected verifier", async () => { + // Set up: the record-host's verifier knows about an external authority + // (did:web:external-authority.example) and where to find its public key. + // No PDS / DID-doc fetches — the verifier is configured directly. + const externalAuthority = "did:web:external-authority.example"; + const externalKey = SIGNING; // simulate operator-provided key material + + const verifier = createBindingCredentialVerifier({ + bindings: createLocalBindingResolver({ authorityDid: externalAuthority }), + keys: createLocalKeyResolver({ + authorityDid: externalAuthority, + publicKey: externalKey.publicKey, + }), + }); + + const db = createSqliteDatabase(":memory:"); + const cfg = makeConfig(); + const resolved = resolveConfig(cfg); + await initSchema(db, resolved); + const app = createApp(db, resolved, { + spaces: { + authMiddleware: fakeAuth(), + credentialVerifier: verifier, + }, + }); + + // Create a space directly in the local authority's tables. (In a real + // split deployment, the authority would do this; the record host would + // just enroll. Phase 5 introduces enrollment — for now, we use the + // local authority routes as a stand-in.) + const create = await app.fetch( + new Request(`http://localhost/xrpc/test.binding.space.createSpace`, { + method: "POST", + headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, + body: "{}", + }) + ); + expect(create.status).toBe(200); + const uri = ((await create.json()) as any).space.uri; + + // External authority signs a credential. + const { credential } = await issueCredential( + { + iss: externalAuthority, + sub: ALICE, + space: uri, + scope: "rw", + ttlMs: 60_000, + }, + externalKey + ); + + // Record host accepts it on putRecord with no service-auth JWT. + const put = await app.fetch( + new Request(`http://localhost/xrpc/test.binding.space.putRecord`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Space-Credential": credential, + }, + body: JSON.stringify({ + spaceUri: uri, + collection: "app.event.message", + record: { $type: "app.event.message", text: "hello" }, + }), + }) + ); + expect(put.status).toBe(200); + expect(((await put.json()) as any).authorDid).toBe(ALICE); + }); +}); diff --git a/packages/lexicons/lexicon-templates/spaces/declaration.json b/packages/lexicons/lexicon-templates/spaces/declaration.json new file mode 100644 index 0000000..0a5572b --- /dev/null +++ b/packages/lexicons/lexicon-templates/spaces/declaration.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "tools.atmo.space.declaration", + "description": "PDS-record-as-discovery: when present at `at:////` (i.e. paired with the space URI by NSID + rkey), this record declares which DID is authorized to issue credentials for the space and which DID hosts its records. The space owner's PDS write is the cryptographic proof of authorization — the binding resolver fetches this record to verify a credential's `iss` against the user's wishes, no DID-doc edits required. App-defined space-type lexicons MAY embed these fields directly instead of writing a separate record under this NSID.", + "defs": { + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": ["authority", "createdAt"], + "properties": { + "authority": { + "type": "string", + "format": "did", + "description": "DID authorized to sign credentials for this space (`iss` on emitted JWTs). May equal the space owner DID for the self-issuing case." + }, + "recordHost": { + "type": "string", + "format": "did", + "description": "DID of the record host where records for this space live. Clients use this to know where to send writes; verifiers ignore it." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +}