···136136 };
137137}
138138139139+/** Pull a space credential off the request — `X-Space-Credential: <jwt>`
140140+ * header. Returns the raw token or null. */
141141+export function extractSpaceCredential(request: Request): string | null {
142142+ const header = request.headers.get("X-Space-Credential");
143143+ return header ? header.trim() : null;
144144+}
145145+139146/** Pull a read-grant invite token off the request — query string `?inviteToken=`
140147 * or `Authorization: Bearer atmo-invite:<token>`. Returns the raw token (not
141148 * hashed) or null. Routes hash + look up via the adapter. */
+253
packages/contrail/src/core/spaces/credentials.ts
···11+/** Space-credential primitives: ES256 (P-256) JWTs minted by the authority,
22+ * verified by the record host (or any third party that can resolve the
33+ * authority's DID document).
44+ *
55+ * Format is a compact JWS:
66+ * header = { alg: "ES256", typ: "JWT", kid: "<authorityDid>#<keyId>" }
77+ * payload = { iss, sub, space, scope, iat, exp }
88+ *
99+ * - `iss` is the authority DID (the signer; for phase 3 this is the local
1010+ * authority's serviceDid; phase 4 adds a binding-resolution layer that
1111+ * lets the issuer be a *different* DID from the space owner).
1212+ * - `sub` is the caller DID — the credential bearer.
1313+ * - `space` is the full `ats://<owner>/<type>/<key>` URI.
1414+ * - `scope` is "rw" or "read".
1515+ *
1616+ * We don't use a JWT library — Web Crypto's subtle covers everything (P-256
1717+ * generate, sign, verify, JWK import/export) and saves a runtime dep. */
1818+1919+const ALG = "ES256";
2020+const TYP = "JWT";
2121+const DEFAULT_KEY_ID = "atproto_space_authority";
2222+2323+export type CredentialScope = "rw" | "read";
2424+2525+export interface CredentialClaims {
2626+ iss: string;
2727+ sub: string;
2828+ space: string;
2929+ scope: CredentialScope;
3030+ iat: number; // seconds since epoch
3131+ exp: number; // seconds since epoch
3232+}
3333+3434+export interface CredentialKeyMaterial {
3535+ /** Private key in JWK form. P-256 / ES256. */
3636+ privateKey: JsonWebKey;
3737+ /** Public key in JWK form. Must match privateKey. */
3838+ publicKey: JsonWebKey;
3939+ /** DID-doc verification method id. The full JWT `kid` becomes
4040+ * `<authorityDid>#<keyId>`. Defaults to "atproto_space_authority". */
4141+ keyId?: string;
4242+}
4343+4444+/** Generate a fresh P-256 keypair as JWKs. Useful for local dev / tests; in
4545+ * production the operator generates once and stores out-of-band. */
4646+export async function generateAuthoritySigningKey(): Promise<CredentialKeyMaterial> {
4747+ const pair = (await crypto.subtle.generateKey(
4848+ { name: "ECDSA", namedCurve: "P-256" },
4949+ true,
5050+ ["sign", "verify"]
5151+ )) as CryptoKeyPair;
5252+ const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey;
5353+ const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey;
5454+ return { privateKey, publicKey };
5555+}
5656+5757+const enc = new TextEncoder();
5858+const dec = new TextDecoder();
5959+6060+function base64urlEncode(bytes: Uint8Array): string {
6161+ let s = btoa(String.fromCharCode(...bytes));
6262+ return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6363+}
6464+6565+function base64urlDecode(s: string): Uint8Array {
6666+ const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=");
6767+ const bin = atob(padded);
6868+ const out = new Uint8Array(bin.length);
6969+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
7070+ return out;
7171+}
7272+7373+function jsonEncode(value: unknown): string {
7474+ return base64urlEncode(enc.encode(JSON.stringify(value)));
7575+}
7676+7777+function jsonDecode<T>(seg: string): T {
7878+ return JSON.parse(dec.decode(base64urlDecode(seg))) as T;
7979+}
8080+8181+async function importPrivate(jwk: JsonWebKey): Promise<CryptoKey> {
8282+ return crypto.subtle.importKey(
8383+ "jwk",
8484+ jwk,
8585+ { name: "ECDSA", namedCurve: "P-256" },
8686+ false,
8787+ ["sign"]
8888+ );
8989+}
9090+9191+async function importPublic(jwk: JsonWebKey): Promise<CryptoKey> {
9292+ return crypto.subtle.importKey(
9393+ "jwk",
9494+ jwk,
9595+ { name: "ECDSA", namedCurve: "P-256" },
9696+ false,
9797+ ["verify"]
9898+ );
9999+}
100100+101101+/** Sign a credential payload with the authority's private key.
102102+ * `iat` and `exp` are filled in by the caller (so tests can mint expired
103103+ * tokens deterministically). */
104104+export async function signCredential(
105105+ payload: CredentialClaims,
106106+ key: CredentialKeyMaterial
107107+): Promise<string> {
108108+ const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`;
109109+ const header = { alg: ALG, typ: TYP, kid };
110110+ const head = jsonEncode(header);
111111+ const body = jsonEncode(payload);
112112+ const signingInput = `${head}.${body}`;
113113+ const privateKey = await importPrivate(key.privateKey);
114114+ const sig = await crypto.subtle.sign(
115115+ { name: "ECDSA", hash: "SHA-256" },
116116+ privateKey,
117117+ enc.encode(signingInput)
118118+ );
119119+ return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`;
120120+}
121121+122122+/** Issue a credential using the current wall-clock for iat/exp. */
123123+export async function issueCredential(
124124+ args: Omit<CredentialClaims, "iat" | "exp"> & { ttlMs: number },
125125+ key: CredentialKeyMaterial
126126+): Promise<{ credential: string; expiresAt: number }> {
127127+ const now = Math.floor(Date.now() / 1000);
128128+ const expSec = now + Math.floor(args.ttlMs / 1000);
129129+ const claims: CredentialClaims = {
130130+ iss: args.iss,
131131+ sub: args.sub,
132132+ space: args.space,
133133+ scope: args.scope,
134134+ iat: now,
135135+ exp: expSec,
136136+ };
137137+ const credential = await signCredential(claims, key);
138138+ return { credential, expiresAt: expSec * 1000 };
139139+}
140140+141141+export type VerifyOk = { ok: true; claims: CredentialClaims };
142142+export type VerifyErr = {
143143+ ok: false;
144144+ reason:
145145+ | "malformed"
146146+ | "bad-alg"
147147+ | "bad-signature"
148148+ | "expired"
149149+ | "not-yet-valid"
150150+ | "wrong-space"
151151+ | "wrong-scope"
152152+ | "unknown-issuer";
153153+};
154154+155155+export interface VerifyOptions {
156156+ /** Optional: when set, rejects credentials whose `space` claim differs.
157157+ * Omit when verifying in middleware where the target space isn't known
158158+ * yet — handlers can do the match themselves against the verified
159159+ * claims. */
160160+ expectedSpace?: string;
161161+ /** Optional: required scope (e.g. "rw" rejects read-only credentials on writes). */
162162+ requiredScope?: CredentialScope;
163163+ /** Resolve a verification key for `iss`. If null, verification fails with
164164+ * unknown-issuer. */
165165+ resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>;
166166+ /** Time provider for tests. Returns ms since epoch. */
167167+ now?: () => number;
168168+}
169169+170170+export async function verifyCredential(
171171+ jwt: string,
172172+ opts: VerifyOptions
173173+): Promise<VerifyOk | VerifyErr> {
174174+ const parts = jwt.split(".");
175175+ if (parts.length !== 3) return { ok: false, reason: "malformed" };
176176+ const [headSeg, bodySeg, sigSeg] = parts as [string, string, string];
177177+178178+ let header: { alg?: string; typ?: string; kid?: string };
179179+ let claims: CredentialClaims;
180180+ try {
181181+ header = jsonDecode(headSeg);
182182+ claims = jsonDecode(bodySeg);
183183+ } catch {
184184+ return { ok: false, reason: "malformed" };
185185+ }
186186+ if (header.alg !== ALG) return { ok: false, reason: "bad-alg" };
187187+ if (opts.expectedSpace !== undefined && claims.space !== opts.expectedSpace) {
188188+ return { ok: false, reason: "wrong-space" };
189189+ }
190190+ if (opts.requiredScope === "rw" && claims.scope !== "rw") {
191191+ return { ok: false, reason: "wrong-scope" };
192192+ }
193193+194194+ const nowMs = (opts.now ?? Date.now)();
195195+ const nowSec = Math.floor(nowMs / 1000);
196196+ if (claims.exp <= nowSec) return { ok: false, reason: "expired" };
197197+ if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" };
198198+199199+ const jwk = await opts.resolveKey(claims.iss, header.kid);
200200+ if (!jwk) return { ok: false, reason: "unknown-issuer" };
201201+202202+ const publicKey = await importPublic(jwk);
203203+ const sigBytes = base64urlDecode(sigSeg);
204204+ const signingInput = `${headSeg}.${bodySeg}`;
205205+ const valid = await crypto.subtle.verify(
206206+ { name: "ECDSA", hash: "SHA-256" },
207207+ publicKey,
208208+ sigBytes,
209209+ enc.encode(signingInput)
210210+ );
211211+ if (!valid) return { ok: false, reason: "bad-signature" };
212212+ return { ok: true, claims };
213213+}
214214+215215+/** Header reader for handlers that want to peek at `iss` before resolving the
216216+ * key (e.g. to short-circuit DID-doc fetches for the local authority). */
217217+export function decodeUnverifiedClaims(jwt: string): CredentialClaims | null {
218218+ const parts = jwt.split(".");
219219+ if (parts.length !== 3) return null;
220220+ try {
221221+ return jsonDecode<CredentialClaims>(parts[1]!);
222222+ } catch {
223223+ return null;
224224+ }
225225+}
226226+227227+/** Verifier interface consumed by the record host. The record host doesn't
228228+ * care HOW credentials get verified — it only cares whether a given JWT is
229229+ * valid. Phase 3 ships an in-process verifier that knows the local
230230+ * authority's public key; phase 4 adds a binding-resolving verifier that
231231+ * consults PDS records / DID docs. */
232232+export interface CredentialVerifier {
233233+ /** Verify a credential's signature, expiry, and `not-before` window. Does
234234+ * NOT enforce a space match — handlers do that against the request URI. */
235235+ verify(jwt: string): Promise<VerifyOk | VerifyErr>;
236236+}
237237+238238+/** In-process verifier for the simple deployment: the authority and record
239239+ * host run in one process and the record host has direct access to the
240240+ * authority's public key. Rejects any credential whose `iss` isn't the
241241+ * configured authority. Phase 4 generalizes to multi-authority. */
242242+export function createInProcessVerifier(args: {
243243+ authorityDid: string;
244244+ publicKey: JsonWebKey;
245245+}): CredentialVerifier {
246246+ return {
247247+ verify(jwt) {
248248+ return verifyCredential(jwt, {
249249+ resolveKey: async (iss) => (iss === args.authorityDid ? args.publicKey : null),
250250+ });
251251+ },
252252+ };
253253+}
+311-66
packages/contrail/src/core/spaces/router.ts
···88 checkInviteReadGrant,
99 createServiceAuthMiddleware,
1010 extractInviteToken,
1111+ extractSpaceCredential,
1112} from "./auth";
1213import { nextTid } from "./tid";
1314import { hashInviteToken } from "../invite/token";
1415import { buildSpaceUri } from "./uri";
1516import {
1617 DEFAULT_BLOB_MAX_SIZE,
1818+ DEFAULT_CREDENTIAL_TTL_MS,
1719 type AuthorityConfig,
1820 type RecordHostConfig,
1921 type RecordHost,
···2325} from "./types";
2426import { blobKey } from "./blob-adapter";
2527import { collectBlobCids } from "./blob-refs";
2828+import {
2929+ createInProcessVerifier,
3030+ decodeUnverifiedClaims,
3131+ issueCredential,
3232+ verifyCredential,
3333+ type CredentialClaims,
3434+ type CredentialScope,
3535+ type CredentialVerifier,
3636+} from "./credentials";
2637import { create as createCid, toString as cidToString } from "@atcute/cid";
27382839/** Optional hook to extend `<ns>.spaceExt.whoami` with extra fields when a
···7283 registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, options.whoamiExtension);
73847485 if (spacesConfig.recordHost) {
7575- registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth);
8686+ // In-process verifier: when authority and record host are colocated,
8787+ // the host has direct access to the authority's public key. Phase 4
8888+ // adds a binding-resolving verifier for split deployments.
8989+ const verifier = authorityConfig.signing
9090+ ? createInProcessVerifier({
9191+ authorityDid: authorityConfig.serviceDid,
9292+ publicKey: authorityConfig.signing.publicKey,
9393+ })
9494+ : undefined;
9595+ registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth, verifier);
7696 }
7797}
7898···159179 return c.json({ space: publicSpaceView(space, false) });
160180 }
161181182182+ if (authz.via === "credential") {
183183+ // Credential proves membership; derive isOwner from sub vs ownerDid.
184184+ const isOwner = authz.claims.sub === space.ownerDid;
185185+ return c.json({ space: publicSpaceView(space, isOwner) });
186186+ }
187187+162188 const sa = authz.sa;
163189 const isOwner = sa.issuer === space.ownerDid;
164190 const member = isOwner ? null : await authority.getMember(uri, sa.issuer);
···283309 const member = await authority.getMember(spaceUri, sa.issuer);
284310 return c.json({ isOwner: false, isMember: !!member });
285311 });
312312+313313+ // ---- Credential endpoints ----
314314+315315+ /** Mint a space credential for a member of `spaceUri`. Caller is identified
316316+ * by the JWT issuer; the credential's `sub` is set to that DID. */
317317+ app.post(`/xrpc/${SPACE}.getCredential`, auth, async (c) => {
318318+ if (!authorityConfig.signing) {
319319+ return c.json(
320320+ { error: "NotImplemented", message: "authority is not configured to sign credentials" },
321321+ 501
322322+ );
323323+ }
324324+ const sa = getAuth(c);
325325+ const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null;
326326+ if (!body?.spaceUri) {
327327+ return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400);
328328+ }
329329+ const space = await authority.getSpace(body.spaceUri);
330330+ if (!space) return c.json({ error: "NotFound" }, 404);
331331+332332+ const isOwner = space.ownerDid === sa.issuer;
333333+ const member = isOwner ? null : await authority.getMember(body.spaceUri, sa.issuer);
334334+ if (!isOwner && !member) {
335335+ return c.json({ error: "Forbidden", reason: "not-member" }, 403);
336336+ }
337337+338338+ // App policy is checked at credential-issuance time. Existing credentials
339339+ // remain valid until expiry — that's the spec contract (revocation
340340+ // bounded by TTL, not synchronous).
341341+ if (space.appPolicy) {
342342+ const allowed = checkClientId(space.appPolicy, sa.clientId);
343343+ if (!allowed) return c.json({ error: "Forbidden", reason: "app-not-allowed" }, 403);
344344+ }
345345+346346+ const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS;
347347+ const { credential, expiresAt } = await issueCredential(
348348+ {
349349+ iss: authorityConfig.serviceDid,
350350+ sub: sa.issuer,
351351+ space: body.spaceUri,
352352+ scope: "rw",
353353+ ttlMs: ttl,
354354+ },
355355+ authorityConfig.signing
356356+ );
357357+ return c.json({ credential, expiresAt });
358358+ });
359359+360360+ /** Refresh an unexpired credential. Used by long-running clients to extend
361361+ * their access without going back through the JWT mint dance. The current
362362+ * credential must verify; the bearer must still be a member. */
363363+ app.post(`/xrpc/${SPACE}.refreshCredential`, async (c) => {
364364+ if (!authorityConfig.signing) {
365365+ return c.json(
366366+ { error: "NotImplemented", message: "authority is not configured to sign credentials" },
367367+ 501
368368+ );
369369+ }
370370+ const body = (await c.req.json().catch(() => null)) as { credential?: string } | null;
371371+ if (!body?.credential) {
372372+ return c.json({ error: "InvalidRequest", message: "credential required" }, 400);
373373+ }
374374+ const signing = authorityConfig.signing;
375375+ const claims = await verifyAndAuthorizeRefresh(body.credential, authorityConfig);
376376+ if ("error" in claims) return c.json(claims, claims.status);
377377+378378+ const space = await authority.getSpace(claims.space);
379379+ if (!space) return c.json({ error: "NotFound" }, 404);
380380+ const isOwner = space.ownerDid === claims.sub;
381381+ const member = isOwner ? null : await authority.getMember(claims.space, claims.sub);
382382+ if (!isOwner && !member) {
383383+ return c.json({ error: "Forbidden", reason: "not-member" }, 403);
384384+ }
385385+386386+ const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS;
387387+ const { credential, expiresAt } = await issueCredential(
388388+ {
389389+ iss: authorityConfig.serviceDid,
390390+ sub: claims.sub,
391391+ space: claims.space,
392392+ scope: claims.scope,
393393+ ttlMs: ttl,
394394+ },
395395+ signing
396396+ );
397397+ return c.json({ credential, expiresAt });
398398+ });
286399}
287400288288-/** Register the **record host** XRPC surface — record + blob CRUD. Today
289289- * consults the authority for membership / app-policy checks at write time;
290290- * phase 3 adds a credential verifier that replaces those calls in
291291- * split-deployment configurations. */
401401+/** Verify a credential presented at refreshCredential. Returns the claims, or
402402+ * an error envelope ready to relay. Different from the record-host verifier
403403+ * in two ways: (a) we don't have the expectedSpace yet — we read it from the
404404+ * credential itself; (b) we don't enforce a scope. */
405405+async function verifyAndAuthorizeRefresh(
406406+ credential: string,
407407+ authorityConfig: AuthorityConfig
408408+): Promise<CredentialClaims | { error: string; reason?: string; message?: string; status: 400 | 401 }> {
409409+ const peek = decodeUnverifiedClaims(credential);
410410+ if (!peek) return { error: "InvalidRequest", reason: "malformed", status: 400 };
411411+ if (peek.iss !== authorityConfig.serviceDid) {
412412+ return { error: "Forbidden", reason: "wrong-issuer", status: 401 };
413413+ }
414414+ if (!authorityConfig.signing) {
415415+ return { error: "InvalidState", status: 401 };
416416+ }
417417+ const signing = authorityConfig.signing;
418418+ const result = await verifyCredential(credential, {
419419+ expectedSpace: peek.space,
420420+ resolveKey: async (iss) => (iss === authorityConfig.serviceDid ? signing.publicKey : null),
421421+ });
422422+ if (!result.ok) {
423423+ return { error: "InvalidCredential", reason: result.reason, status: 401 };
424424+ }
425425+ return result.claims;
426426+}
427427+428428+/** App-policy check using just `clientId`. Mirrors `acl.ts:checkAppPolicy`
429429+ * but inlined here so the credential-issuance path doesn't need to construct
430430+ * a full AclInput. */
431431+function checkClientId(
432432+ appPolicy: NonNullable<SpaceRow["appPolicy"]>,
433433+ clientId: string | undefined
434434+): boolean {
435435+ const listed = clientId ? appPolicy.apps.includes(clientId) : false;
436436+ if (appPolicy.mode === "allow") return !listed;
437437+ return listed;
438438+}
439439+440440+/** Register the **record host** XRPC surface — record + blob CRUD.
441441+ *
442442+ * Auth precedence on every route:
443443+ * 1. `X-Space-Credential` header (if a verifier is wired and the credential
444444+ * is valid) — caller DID = credential `sub`, no clientId.
445445+ * 2. Read-route invite token (`?inviteToken=` or `Bearer atmo-invite:...`).
446446+ * 3. Service-auth JWT (existing behavior) — caller DID = JWT issuer.
447447+ *
448448+ * When a credential is presented, the record host trusts it: no member
449449+ * check, no app-policy check (those happen at issuance time on the
450450+ * authority side). Service-auth requests still consult the authority — that
451451+ * bridge is what phase 5 cuts when the host/authority split goes runtime. */
292452export function registerRecordHostRoutes(
293453 app: Hono,
294454 recordHost: RecordHost,
295455 authority: SpaceAuthority,
296456 recordHostConfig: RecordHostConfig,
297457 config: ContrailConfig,
298298- auth: MiddlewareHandler
458458+ auth: MiddlewareHandler,
459459+ /** Optional credential verifier. When present, the record host accepts
460460+ * `X-Space-Credential` as an alternative to a service-auth JWT. */
461461+ credentialVerifier?: CredentialVerifier
299462): void {
300463 const SPACE = `${config.namespace}.space`;
301464302302- /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is
303303- * present so anonymous bearer reads don't 401 before the route handler can
304304- * validate the token. */
465465+ /** Auth wrapper: tries credential first, then delegates to JWT auth. */
466466+ const authWithCredential: MiddlewareHandler = async (c, next) => {
467467+ const credToken = extractSpaceCredential(c.req.raw);
468468+ if (credToken) {
469469+ if (!credentialVerifier) {
470470+ return c.json(
471471+ { error: "AuthRequired", reason: "credential-verifier-not-configured" },
472472+ 401
473473+ );
474474+ }
475475+ const result = await credentialVerifier.verify(credToken);
476476+ if (!result.ok) {
477477+ return c.json({ error: "AuthRequired", reason: result.reason }, 401);
478478+ }
479479+ c.set("spaceCredential", result.claims);
480480+ await next();
481481+ return;
482482+ }
483483+ return auth(c, next);
484484+ };
485485+486486+ /** Read-route auth: like {@link authWithCredential} but also short-circuits
487487+ * on a read-grant invite token. Token presence skips both credential and
488488+ * JWT middlewares; the route handler validates the token via authorizeRead. */
305489 const readAuth: MiddlewareHandler = async (c, next) => {
306490 if (extractInviteToken(c.req.raw)) {
307491 await next();
308492 return;
309493 }
310310- return auth(c, next);
494494+ return authWithCredential(c, next);
311495 };
312496313497 app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => {
···336520 return c.json({ error: "Forbidden", reason: result.reason }, 403);
337521 }
338522 }
523523+ // Credential and token paths are pre-authorized — credential's signature
524524+ // proves the authority granted access; token validation already happened.
339525340526 const list = await recordHost.listRecords(spaceUri, collection, {
341527 byUser: c.req.query("byUser") ?? undefined,
···379565 });
380566381567 // Write endpoints
382382- app.post(`/xrpc/${SPACE}.putRecord`, auth, async (c) => {
383383- const sa = getAuth(c);
568568+ app.post(`/xrpc/${SPACE}.putRecord`, authWithCredential, async (c) => {
384569 const body = (await c.req.json().catch(() => null)) as
385570 | { spaceUri?: string; collection?: string; rkey?: string; record?: Record<string, unknown> }
386571 | null;
···390575 const space = await authority.getSpace(body.spaceUri);
391576 if (!space) return c.json({ error: "NotFound" }, 404);
392577393393- const member = await authority.getMember(body.spaceUri, sa.issuer);
394394- const result = checkAccess({
395395- op: "write",
396396- space,
397397- callerDid: sa.issuer,
398398- member,
399399- clientId: sa.clientId,
400400- });
401401- if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
578578+ const caller = resolveCaller(c, body.spaceUri, "rw");
579579+ if (caller instanceof Response) return caller;
580580+581581+ if (!caller.viaCredential) {
582582+ const member = await authority.getMember(body.spaceUri, caller.callerDid);
583583+ const result = checkAccess({
584584+ op: "write",
585585+ space,
586586+ callerDid: caller.callerDid,
587587+ member,
588588+ clientId: caller.clientId,
589589+ });
590590+ if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
591591+ }
402592403593 // Validate that every blob referenced by this record has already been
404594 // uploaded into this space. This mirrors how PDSes require uploadBlob
···426616 await recordHost.putRecord({
427617 spaceUri: body.spaceUri,
428618 collection: body.collection,
429429- authorDid: sa.issuer,
619619+ authorDid: caller.callerDid,
430620 rkey,
431621 cid: null,
432622 record: body.record,
433623 createdAt: now,
434624 });
435435- return c.json({ rkey, authorDid: sa.issuer, createdAt: now });
625625+ return c.json({ rkey, authorDid: caller.callerDid, createdAt: now });
436626 });
437627438438- app.post(`/xrpc/${SPACE}.deleteRecord`, auth, async (c) => {
439439- const sa = getAuth(c);
628628+ app.post(`/xrpc/${SPACE}.deleteRecord`, authWithCredential, async (c) => {
440629 const body = (await c.req.json().catch(() => null)) as
441630 | { spaceUri?: string; collection?: string; rkey?: string }
442631 | null;
···446635 const space = await authority.getSpace(body.spaceUri);
447636 if (!space) return c.json({ error: "NotFound" }, 404);
448637449449- const member = await authority.getMember(body.spaceUri, sa.issuer);
450450- const result = checkAccess({
451451- op: "delete",
452452- space,
453453- callerDid: sa.issuer,
454454- member,
455455- clientId: sa.clientId,
456456- targetAuthorDid: sa.issuer,
457457- });
458458- if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
638638+ const caller = resolveCaller(c, body.spaceUri, "rw");
639639+ if (caller instanceof Response) return caller;
640640+641641+ if (!caller.viaCredential) {
642642+ const member = await authority.getMember(body.spaceUri, caller.callerDid);
643643+ const result = checkAccess({
644644+ op: "delete",
645645+ space,
646646+ callerDid: caller.callerDid,
647647+ member,
648648+ clientId: caller.clientId,
649649+ targetAuthorDid: caller.callerDid,
650650+ });
651651+ if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403);
652652+ }
653653+ // Credential path: scope=rw is checked in resolveCaller. Delete remains
654654+ // author-scoped — the credential's `sub` is the caller, and we only
655655+ // delete records authored by that DID.
459656460460- await recordHost.deleteRecord(body.spaceUri, body.collection, sa.issuer, body.rkey);
657657+ await recordHost.deleteRecord(body.spaceUri, body.collection, caller.callerDid, body.rkey);
461658 return c.json({ ok: true });
462659 });
463660···468665 const maxSize = blobsCfg.maxSize ?? DEFAULT_BLOB_MAX_SIZE;
469666 const accept = blobsCfg.accept;
470667471471- app.post(`/xrpc/${SPACE}.uploadBlob`, auth, async (c) => {
472472- const sa = getAuth(c);
668668+ app.post(`/xrpc/${SPACE}.uploadBlob`, authWithCredential, async (c) => {
473669 const spaceUri = c.req.query("spaceUri");
474670 if (!spaceUri) {
475671 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400);
···477673 const space = await authority.getSpace(spaceUri);
478674 if (!space) return c.json({ error: "NotFound" }, 404);
479675480480- const member = await authority.getMember(spaceUri, sa.issuer);
481481- const aclResult = checkAccess({
482482- op: "write",
483483- space,
484484- callerDid: sa.issuer,
485485- member,
486486- clientId: sa.clientId,
487487- });
488488- if (!aclResult.allow) {
489489- return c.json({ error: "Forbidden", reason: aclResult.reason }, 403);
676676+ const caller = resolveCaller(c, spaceUri, "rw");
677677+ if (caller instanceof Response) return caller;
678678+679679+ if (!caller.viaCredential) {
680680+ const member = await authority.getMember(spaceUri, caller.callerDid);
681681+ const aclResult = checkAccess({
682682+ op: "write",
683683+ space,
684684+ callerDid: caller.callerDid,
685685+ member,
686686+ clientId: caller.clientId,
687687+ });
688688+ if (!aclResult.allow) {
689689+ return c.json({ error: "Forbidden", reason: aclResult.reason }, 403);
690690+ }
490691 }
491692492693 const mimeType = c.req.header("content-type") ?? "application/octet-stream";
···524725 cid: cidString,
525726 mimeType,
526727 size: bytes.byteLength,
527527- authorDid: sa.issuer,
728728+ authorDid: caller.callerDid,
528729 createdAt: Date.now(),
529730 });
530731···579780 });
580781 });
581782582582- app.get(`/xrpc/${SPACE}.listBlobs`, auth, async (c) => {
583583- const sa = getAuth(c);
783783+ app.get(`/xrpc/${SPACE}.listBlobs`, authWithCredential, async (c) => {
584784 const spaceUri = c.req.query("spaceUri");
585785 if (!spaceUri) {
586786 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400);
···588788 const space = await authority.getSpace(spaceUri);
589789 if (!space) return c.json({ error: "NotFound" }, 404);
590790591591- const member = await authority.getMember(spaceUri, sa.issuer);
592592- const aclResult = checkAccess({
593593- op: "read",
594594- space,
595595- callerDid: sa.issuer,
596596- member,
597597- clientId: sa.clientId,
598598- });
599599- if (!aclResult.allow) {
600600- return c.json({ error: "Forbidden", reason: aclResult.reason }, 403);
791791+ const caller = resolveCaller(c, spaceUri, "read");
792792+ if (caller instanceof Response) return caller;
793793+794794+ if (!caller.viaCredential) {
795795+ const member = await authority.getMember(spaceUri, caller.callerDid);
796796+ const aclResult = checkAccess({
797797+ op: "read",
798798+ space,
799799+ callerDid: caller.callerDid,
800800+ member,
801801+ clientId: caller.clientId,
802802+ });
803803+ if (!aclResult.allow) {
804804+ return c.json({ error: "Forbidden", reason: aclResult.reason }, 403);
805805+ }
601806 }
602807603808 const result = await recordHost.listBlobMeta(spaceUri, {
···610815 }
611816}
612817613613-/** Authorize a read request: either a valid service-auth JWT (which also
614614- * identifies the caller for member checks downstream) or a valid read-grant
615615- * invite token bearer. */
818818+/** Authorize a read request — three valid paths: a verified space credential
819819+ * (set by the credential middleware), a read-grant invite token, or a
820820+ * service-auth JWT.
821821+ *
822822+ * Credential and token paths skip the membership check downstream — the
823823+ * credential or token IS the proof. The JWT path requires a member check
824824+ * in the route handler. */
616825async function authorizeRead(
617826 c: Context,
618827 authority: SpaceAuthority,
619828 spaceUri: string
620620-): Promise<{ via: "token" } | { via: "jwt"; sa: ServiceAuth } | Response> {
829829+): Promise<
830830+ | { via: "credential"; claims: CredentialClaims }
831831+ | { via: "token" }
832832+ | { via: "jwt"; sa: ServiceAuth }
833833+ | Response
834834+> {
835835+ const cred = c.get("spaceCredential") as CredentialClaims | undefined;
836836+ if (cred) {
837837+ if (cred.space !== spaceUri) {
838838+ return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403);
839839+ }
840840+ return { via: "credential", claims: cred };
841841+ }
621842 const rawToken = extractInviteToken(c.req.raw);
622843 if (rawToken) {
623844 const ok = await checkInviteReadGrant(authority, rawToken, spaceUri, hashInviteToken);
···627848 const sa = c.get("serviceAuth") as ServiceAuth | undefined;
628849 if (sa) return { via: "jwt", sa };
629850 return c.json(
630630- { error: "AuthRequired", message: "JWT or read-grant invite token required" },
851851+ { error: "AuthRequired", message: "JWT, credential, or read-grant invite token required" },
631852 401
632853 );
854854+}
855855+856856+/** Unified caller resolution for write/manage paths on the record host.
857857+ * Either a verified credential (set by middleware) or a service-auth JWT.
858858+ * When a credential is present, also enforces space-match and the requested
859859+ * scope. Returns either a caller envelope or a Response to relay. */
860860+function resolveCaller(
861861+ c: Context,
862862+ requestSpace: string,
863863+ requiredScope: CredentialScope
864864+): { callerDid: string; clientId: string | undefined; viaCredential: boolean } | Response {
865865+ const cred = c.get("spaceCredential") as CredentialClaims | undefined;
866866+ if (cred) {
867867+ if (cred.space !== requestSpace) {
868868+ return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403);
869869+ }
870870+ if (requiredScope === "rw" && cred.scope !== "rw") {
871871+ return c.json({ error: "Forbidden", reason: "credential-wrong-scope" }, 403);
872872+ }
873873+ return { callerDid: cred.sub, clientId: undefined, viaCredential: true };
874874+ }
875875+ const sa = c.get("serviceAuth") as ServiceAuth | undefined;
876876+ if (!sa) return c.json({ error: "AuthRequired", reason: "no-auth" }, 401);
877877+ return { callerDid: sa.issuer, clientId: sa.clientId, viaCredential: false };
633878}
634879635880function getAuth(c: Parameters<MiddlewareHandler>[0]): ServiceAuth {
+16-4
packages/contrail/src/core/spaces/types.ts
···11import type { Database } from "../types";
22import type { DidDocumentResolver } from "@atcute/identity-resolver";
33import type { BlobAdapter } from "./blob-adapter";
44+import type { CredentialKeyMaterial } from "./credentials";
4556export type AppPolicyMode = "allow" | "deny";
67···2526export const DEFAULT_BLOB_MAX_SIZE = 2 * 1024 * 1024;
2627export const DEFAULT_BLOB_GC_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000;
27282929+/** Default credential lifetime. The rough spec calls for 2–4h; we pick the
3030+ * lower bound so revocation (kicked-from-space) is observable within 2h. */
3131+export const DEFAULT_CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000;
3232+2833/** Configuration for the **space authority** role: holds the member list,
2929- * signs credentials (later phases), and gates space-management operations.
3030- * In a fully-split deployment, the authority can run in a different process
3131- * (or even a different operator) than the record host. */
3434+ * signs credentials, and gates space-management operations. In a fully-split
3535+ * deployment, the authority can run in a different process (or even a
3636+ * different operator) than the record host. */
3237export interface AuthorityConfig {
3338 /** NSID that identifies the kind of space this authority hosts,
3439 * e.g. "tools.atmo.event.space". */
3540 type: string;
3636- /** Service DID that service-auth tokens must target (aud claim). */
4141+ /** Service DID that service-auth tokens must target (aud claim) AND that
4242+ * signs credentials it issues (`iss` claim on emitted JWTs). */
3743 serviceDid: string;
3844 /** Default app policy applied to new spaces. */
3945 defaultAppPolicy?: AppPolicy;
4046 /** DID document resolver for service-auth JWT verification.
4147 * Defaults to a composite PLC + did:web resolver if omitted. */
4248 resolver?: DidDocumentResolver;
4949+ /** Signing key material for issuing space credentials. When omitted,
5050+ * `<ns>.space.getCredential` returns 501 NotImplemented and the record
5151+ * host's credential-verifying middleware can't be wired up. */
5252+ signing?: CredentialKeyMaterial;
5353+ /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */
5454+ credentialTtlMs?: number;
4355}
44564557/** Configuration for the **record host** role: stores per-space records and
+19
packages/contrail/src/index.ts
···6868export type { BlobAdapter, BlobUploadMeta, R2BucketLike } from "./core/spaces/blob-adapter";
6969export type { SpacesBlobsConfig, BlobMetaRow } from "./core/spaces/types";
70707171+// Space credentials
7272+export {
7373+ generateAuthoritySigningKey,
7474+ signCredential,
7575+ issueCredential,
7676+ verifyCredential,
7777+ decodeUnverifiedClaims,
7878+ createInProcessVerifier,
7979+} from "./core/spaces/credentials";
8080+export type {
8181+ CredentialClaims,
8282+ CredentialKeyMaterial,
8383+ CredentialScope,
8484+ CredentialVerifier,
8585+ VerifyOk,
8686+ VerifyErr,
8787+ VerifyOptions,
8888+} from "./core/spaces/credentials";
8989+7190// Realtime
7291export type {
7392 PubSub,