[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

phase tres

Florian (Apr 30, 2026, 5:31 PM +0200) 4a47874c 44194b24

+1084 -70
+7
packages/contrail/src/core/spaces/auth.ts
··· 136 136 }; 137 137 } 138 138 139 + /** Pull a space credential off the request — `X-Space-Credential: <jwt>` 140 + * header. Returns the raw token or null. */ 141 + export function extractSpaceCredential(request: Request): string | null { 142 + const header = request.headers.get("X-Space-Credential"); 143 + return header ? header.trim() : null; 144 + } 145 + 139 146 /** Pull a read-grant invite token off the request — query string `?inviteToken=` 140 147 * or `Authorization: Bearer atmo-invite:<token>`. Returns the raw token (not 141 148 * hashed) or null. Routes hash + look up via the adapter. */
+253
packages/contrail/src/core/spaces/credentials.ts
··· 1 + /** Space-credential primitives: ES256 (P-256) JWTs minted by the authority, 2 + * verified by the record host (or any third party that can resolve the 3 + * authority's DID document). 4 + * 5 + * Format is a compact JWS: 6 + * header = { alg: "ES256", typ: "JWT", kid: "<authorityDid>#<keyId>" } 7 + * payload = { iss, sub, space, scope, iat, exp } 8 + * 9 + * - `iss` is the authority DID (the signer; for phase 3 this is the local 10 + * authority's serviceDid; phase 4 adds a binding-resolution layer that 11 + * lets the issuer be a *different* DID from the space owner). 12 + * - `sub` is the caller DID — the credential bearer. 13 + * - `space` is the full `ats://<owner>/<type>/<key>` URI. 14 + * - `scope` is "rw" or "read". 15 + * 16 + * We don't use a JWT library — Web Crypto's subtle covers everything (P-256 17 + * generate, sign, verify, JWK import/export) and saves a runtime dep. */ 18 + 19 + const ALG = "ES256"; 20 + const TYP = "JWT"; 21 + const DEFAULT_KEY_ID = "atproto_space_authority"; 22 + 23 + export type CredentialScope = "rw" | "read"; 24 + 25 + export interface CredentialClaims { 26 + iss: string; 27 + sub: string; 28 + space: string; 29 + scope: CredentialScope; 30 + iat: number; // seconds since epoch 31 + exp: number; // seconds since epoch 32 + } 33 + 34 + export interface CredentialKeyMaterial { 35 + /** Private key in JWK form. P-256 / ES256. */ 36 + privateKey: JsonWebKey; 37 + /** Public key in JWK form. Must match privateKey. */ 38 + publicKey: JsonWebKey; 39 + /** DID-doc verification method id. The full JWT `kid` becomes 40 + * `<authorityDid>#<keyId>`. Defaults to "atproto_space_authority". */ 41 + keyId?: string; 42 + } 43 + 44 + /** Generate a fresh P-256 keypair as JWKs. Useful for local dev / tests; in 45 + * production the operator generates once and stores out-of-band. */ 46 + export async function generateAuthoritySigningKey(): Promise<CredentialKeyMaterial> { 47 + const pair = (await crypto.subtle.generateKey( 48 + { name: "ECDSA", namedCurve: "P-256" }, 49 + true, 50 + ["sign", "verify"] 51 + )) as CryptoKeyPair; 52 + const privateKey = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; 53 + const publicKey = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey; 54 + return { privateKey, publicKey }; 55 + } 56 + 57 + const enc = new TextEncoder(); 58 + const dec = new TextDecoder(); 59 + 60 + function base64urlEncode(bytes: Uint8Array): string { 61 + let s = btoa(String.fromCharCode(...bytes)); 62 + return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 63 + } 64 + 65 + function base64urlDecode(s: string): Uint8Array { 66 + const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); 67 + const bin = atob(padded); 68 + const out = new Uint8Array(bin.length); 69 + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 70 + return out; 71 + } 72 + 73 + function jsonEncode(value: unknown): string { 74 + return base64urlEncode(enc.encode(JSON.stringify(value))); 75 + } 76 + 77 + function jsonDecode<T>(seg: string): T { 78 + return JSON.parse(dec.decode(base64urlDecode(seg))) as T; 79 + } 80 + 81 + async function importPrivate(jwk: JsonWebKey): Promise<CryptoKey> { 82 + return crypto.subtle.importKey( 83 + "jwk", 84 + jwk, 85 + { name: "ECDSA", namedCurve: "P-256" }, 86 + false, 87 + ["sign"] 88 + ); 89 + } 90 + 91 + async function importPublic(jwk: JsonWebKey): Promise<CryptoKey> { 92 + return crypto.subtle.importKey( 93 + "jwk", 94 + jwk, 95 + { name: "ECDSA", namedCurve: "P-256" }, 96 + false, 97 + ["verify"] 98 + ); 99 + } 100 + 101 + /** Sign a credential payload with the authority's private key. 102 + * `iat` and `exp` are filled in by the caller (so tests can mint expired 103 + * tokens deterministically). */ 104 + export async function signCredential( 105 + payload: CredentialClaims, 106 + key: CredentialKeyMaterial 107 + ): Promise<string> { 108 + const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; 109 + const header = { alg: ALG, typ: TYP, kid }; 110 + const head = jsonEncode(header); 111 + const body = jsonEncode(payload); 112 + const signingInput = `${head}.${body}`; 113 + const privateKey = await importPrivate(key.privateKey); 114 + const sig = await crypto.subtle.sign( 115 + { name: "ECDSA", hash: "SHA-256" }, 116 + privateKey, 117 + enc.encode(signingInput) 118 + ); 119 + return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; 120 + } 121 + 122 + /** Issue a credential using the current wall-clock for iat/exp. */ 123 + export async function issueCredential( 124 + args: Omit<CredentialClaims, "iat" | "exp"> & { ttlMs: number }, 125 + key: CredentialKeyMaterial 126 + ): Promise<{ credential: string; expiresAt: number }> { 127 + const now = Math.floor(Date.now() / 1000); 128 + const expSec = now + Math.floor(args.ttlMs / 1000); 129 + const claims: CredentialClaims = { 130 + iss: args.iss, 131 + sub: args.sub, 132 + space: args.space, 133 + scope: args.scope, 134 + iat: now, 135 + exp: expSec, 136 + }; 137 + const credential = await signCredential(claims, key); 138 + return { credential, expiresAt: expSec * 1000 }; 139 + } 140 + 141 + export type VerifyOk = { ok: true; claims: CredentialClaims }; 142 + export type VerifyErr = { 143 + ok: false; 144 + reason: 145 + | "malformed" 146 + | "bad-alg" 147 + | "bad-signature" 148 + | "expired" 149 + | "not-yet-valid" 150 + | "wrong-space" 151 + | "wrong-scope" 152 + | "unknown-issuer"; 153 + }; 154 + 155 + export interface VerifyOptions { 156 + /** Optional: when set, rejects credentials whose `space` claim differs. 157 + * Omit when verifying in middleware where the target space isn't known 158 + * yet — handlers can do the match themselves against the verified 159 + * claims. */ 160 + expectedSpace?: string; 161 + /** Optional: required scope (e.g. "rw" rejects read-only credentials on writes). */ 162 + requiredScope?: CredentialScope; 163 + /** Resolve a verification key for `iss`. If null, verification fails with 164 + * unknown-issuer. */ 165 + resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>; 166 + /** Time provider for tests. Returns ms since epoch. */ 167 + now?: () => number; 168 + } 169 + 170 + export async function verifyCredential( 171 + jwt: string, 172 + opts: VerifyOptions 173 + ): Promise<VerifyOk | VerifyErr> { 174 + const parts = jwt.split("."); 175 + if (parts.length !== 3) return { ok: false, reason: "malformed" }; 176 + const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; 177 + 178 + let header: { alg?: string; typ?: string; kid?: string }; 179 + let claims: CredentialClaims; 180 + try { 181 + header = jsonDecode(headSeg); 182 + claims = jsonDecode(bodySeg); 183 + } catch { 184 + return { ok: false, reason: "malformed" }; 185 + } 186 + if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; 187 + if (opts.expectedSpace !== undefined && claims.space !== opts.expectedSpace) { 188 + return { ok: false, reason: "wrong-space" }; 189 + } 190 + if (opts.requiredScope === "rw" && claims.scope !== "rw") { 191 + return { ok: false, reason: "wrong-scope" }; 192 + } 193 + 194 + const nowMs = (opts.now ?? Date.now)(); 195 + const nowSec = Math.floor(nowMs / 1000); 196 + if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; 197 + if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; 198 + 199 + const jwk = await opts.resolveKey(claims.iss, header.kid); 200 + if (!jwk) return { ok: false, reason: "unknown-issuer" }; 201 + 202 + const publicKey = await importPublic(jwk); 203 + const sigBytes = base64urlDecode(sigSeg); 204 + const signingInput = `${headSeg}.${bodySeg}`; 205 + const valid = await crypto.subtle.verify( 206 + { name: "ECDSA", hash: "SHA-256" }, 207 + publicKey, 208 + sigBytes, 209 + enc.encode(signingInput) 210 + ); 211 + if (!valid) return { ok: false, reason: "bad-signature" }; 212 + return { ok: true, claims }; 213 + } 214 + 215 + /** Header reader for handlers that want to peek at `iss` before resolving the 216 + * key (e.g. to short-circuit DID-doc fetches for the local authority). */ 217 + export function decodeUnverifiedClaims(jwt: string): CredentialClaims | null { 218 + const parts = jwt.split("."); 219 + if (parts.length !== 3) return null; 220 + try { 221 + return jsonDecode<CredentialClaims>(parts[1]!); 222 + } catch { 223 + return null; 224 + } 225 + } 226 + 227 + /** Verifier interface consumed by the record host. The record host doesn't 228 + * care HOW credentials get verified — it only cares whether a given JWT is 229 + * valid. Phase 3 ships an in-process verifier that knows the local 230 + * authority's public key; phase 4 adds a binding-resolving verifier that 231 + * consults PDS records / DID docs. */ 232 + export interface CredentialVerifier { 233 + /** Verify a credential's signature, expiry, and `not-before` window. Does 234 + * NOT enforce a space match — handlers do that against the request URI. */ 235 + verify(jwt: string): Promise<VerifyOk | VerifyErr>; 236 + } 237 + 238 + /** In-process verifier for the simple deployment: the authority and record 239 + * host run in one process and the record host has direct access to the 240 + * authority's public key. Rejects any credential whose `iss` isn't the 241 + * configured authority. Phase 4 generalizes to multi-authority. */ 242 + export function createInProcessVerifier(args: { 243 + authorityDid: string; 244 + publicKey: JsonWebKey; 245 + }): CredentialVerifier { 246 + return { 247 + verify(jwt) { 248 + return verifyCredential(jwt, { 249 + resolveKey: async (iss) => (iss === args.authorityDid ? args.publicKey : null), 250 + }); 251 + }, 252 + }; 253 + }
+311 -66
packages/contrail/src/core/spaces/router.ts
··· 8 8 checkInviteReadGrant, 9 9 createServiceAuthMiddleware, 10 10 extractInviteToken, 11 + extractSpaceCredential, 11 12 } from "./auth"; 12 13 import { nextTid } from "./tid"; 13 14 import { hashInviteToken } from "../invite/token"; 14 15 import { buildSpaceUri } from "./uri"; 15 16 import { 16 17 DEFAULT_BLOB_MAX_SIZE, 18 + DEFAULT_CREDENTIAL_TTL_MS, 17 19 type AuthorityConfig, 18 20 type RecordHostConfig, 19 21 type RecordHost, ··· 23 25 } from "./types"; 24 26 import { blobKey } from "./blob-adapter"; 25 27 import { collectBlobCids } from "./blob-refs"; 28 + import { 29 + createInProcessVerifier, 30 + decodeUnverifiedClaims, 31 + issueCredential, 32 + verifyCredential, 33 + type CredentialClaims, 34 + type CredentialScope, 35 + type CredentialVerifier, 36 + } from "./credentials"; 26 37 import { create as createCid, toString as cidToString } from "@atcute/cid"; 27 38 28 39 /** Optional hook to extend `<ns>.spaceExt.whoami` with extra fields when a ··· 72 83 registerAuthorityRoutes(app, adapter, authorityConfig, config, auth, options.whoamiExtension); 73 84 74 85 if (spacesConfig.recordHost) { 75 - registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth); 86 + // In-process verifier: when authority and record host are colocated, 87 + // the host has direct access to the authority's public key. Phase 4 88 + // adds a binding-resolving verifier for split deployments. 89 + const verifier = authorityConfig.signing 90 + ? createInProcessVerifier({ 91 + authorityDid: authorityConfig.serviceDid, 92 + publicKey: authorityConfig.signing.publicKey, 93 + }) 94 + : undefined; 95 + registerRecordHostRoutes(app, adapter, adapter, spacesConfig.recordHost, config, auth, verifier); 76 96 } 77 97 } 78 98 ··· 159 179 return c.json({ space: publicSpaceView(space, false) }); 160 180 } 161 181 182 + if (authz.via === "credential") { 183 + // Credential proves membership; derive isOwner from sub vs ownerDid. 184 + const isOwner = authz.claims.sub === space.ownerDid; 185 + return c.json({ space: publicSpaceView(space, isOwner) }); 186 + } 187 + 162 188 const sa = authz.sa; 163 189 const isOwner = sa.issuer === space.ownerDid; 164 190 const member = isOwner ? null : await authority.getMember(uri, sa.issuer); ··· 283 309 const member = await authority.getMember(spaceUri, sa.issuer); 284 310 return c.json({ isOwner: false, isMember: !!member }); 285 311 }); 312 + 313 + // ---- Credential endpoints ---- 314 + 315 + /** Mint a space credential for a member of `spaceUri`. Caller is identified 316 + * by the JWT issuer; the credential's `sub` is set to that DID. */ 317 + app.post(`/xrpc/${SPACE}.getCredential`, auth, async (c) => { 318 + if (!authorityConfig.signing) { 319 + return c.json( 320 + { error: "NotImplemented", message: "authority is not configured to sign credentials" }, 321 + 501 322 + ); 323 + } 324 + const sa = getAuth(c); 325 + const body = (await c.req.json().catch(() => null)) as { spaceUri?: string } | null; 326 + if (!body?.spaceUri) { 327 + return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); 328 + } 329 + const space = await authority.getSpace(body.spaceUri); 330 + if (!space) return c.json({ error: "NotFound" }, 404); 331 + 332 + const isOwner = space.ownerDid === sa.issuer; 333 + const member = isOwner ? null : await authority.getMember(body.spaceUri, sa.issuer); 334 + if (!isOwner && !member) { 335 + return c.json({ error: "Forbidden", reason: "not-member" }, 403); 336 + } 337 + 338 + // App policy is checked at credential-issuance time. Existing credentials 339 + // remain valid until expiry — that's the spec contract (revocation 340 + // bounded by TTL, not synchronous). 341 + if (space.appPolicy) { 342 + const allowed = checkClientId(space.appPolicy, sa.clientId); 343 + if (!allowed) return c.json({ error: "Forbidden", reason: "app-not-allowed" }, 403); 344 + } 345 + 346 + const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS; 347 + const { credential, expiresAt } = await issueCredential( 348 + { 349 + iss: authorityConfig.serviceDid, 350 + sub: sa.issuer, 351 + space: body.spaceUri, 352 + scope: "rw", 353 + ttlMs: ttl, 354 + }, 355 + authorityConfig.signing 356 + ); 357 + return c.json({ credential, expiresAt }); 358 + }); 359 + 360 + /** Refresh an unexpired credential. Used by long-running clients to extend 361 + * their access without going back through the JWT mint dance. The current 362 + * credential must verify; the bearer must still be a member. */ 363 + app.post(`/xrpc/${SPACE}.refreshCredential`, async (c) => { 364 + if (!authorityConfig.signing) { 365 + return c.json( 366 + { error: "NotImplemented", message: "authority is not configured to sign credentials" }, 367 + 501 368 + ); 369 + } 370 + const body = (await c.req.json().catch(() => null)) as { credential?: string } | null; 371 + if (!body?.credential) { 372 + return c.json({ error: "InvalidRequest", message: "credential required" }, 400); 373 + } 374 + const signing = authorityConfig.signing; 375 + const claims = await verifyAndAuthorizeRefresh(body.credential, authorityConfig); 376 + if ("error" in claims) return c.json(claims, claims.status); 377 + 378 + const space = await authority.getSpace(claims.space); 379 + if (!space) return c.json({ error: "NotFound" }, 404); 380 + const isOwner = space.ownerDid === claims.sub; 381 + const member = isOwner ? null : await authority.getMember(claims.space, claims.sub); 382 + if (!isOwner && !member) { 383 + return c.json({ error: "Forbidden", reason: "not-member" }, 403); 384 + } 385 + 386 + const ttl = authorityConfig.credentialTtlMs ?? DEFAULT_CREDENTIAL_TTL_MS; 387 + const { credential, expiresAt } = await issueCredential( 388 + { 389 + iss: authorityConfig.serviceDid, 390 + sub: claims.sub, 391 + space: claims.space, 392 + scope: claims.scope, 393 + ttlMs: ttl, 394 + }, 395 + signing 396 + ); 397 + return c.json({ credential, expiresAt }); 398 + }); 286 399 } 287 400 288 - /** Register the **record host** XRPC surface — record + blob CRUD. Today 289 - * consults the authority for membership / app-policy checks at write time; 290 - * phase 3 adds a credential verifier that replaces those calls in 291 - * split-deployment configurations. */ 401 + /** Verify a credential presented at refreshCredential. Returns the claims, or 402 + * an error envelope ready to relay. Different from the record-host verifier 403 + * in two ways: (a) we don't have the expectedSpace yet — we read it from the 404 + * credential itself; (b) we don't enforce a scope. */ 405 + async function verifyAndAuthorizeRefresh( 406 + credential: string, 407 + authorityConfig: AuthorityConfig 408 + ): Promise<CredentialClaims | { error: string; reason?: string; message?: string; status: 400 | 401 }> { 409 + const peek = decodeUnverifiedClaims(credential); 410 + if (!peek) return { error: "InvalidRequest", reason: "malformed", status: 400 }; 411 + if (peek.iss !== authorityConfig.serviceDid) { 412 + return { error: "Forbidden", reason: "wrong-issuer", status: 401 }; 413 + } 414 + if (!authorityConfig.signing) { 415 + return { error: "InvalidState", status: 401 }; 416 + } 417 + const signing = authorityConfig.signing; 418 + const result = await verifyCredential(credential, { 419 + expectedSpace: peek.space, 420 + resolveKey: async (iss) => (iss === authorityConfig.serviceDid ? signing.publicKey : null), 421 + }); 422 + if (!result.ok) { 423 + return { error: "InvalidCredential", reason: result.reason, status: 401 }; 424 + } 425 + return result.claims; 426 + } 427 + 428 + /** App-policy check using just `clientId`. Mirrors `acl.ts:checkAppPolicy` 429 + * but inlined here so the credential-issuance path doesn't need to construct 430 + * a full AclInput. */ 431 + function checkClientId( 432 + appPolicy: NonNullable<SpaceRow["appPolicy"]>, 433 + clientId: string | undefined 434 + ): boolean { 435 + const listed = clientId ? appPolicy.apps.includes(clientId) : false; 436 + if (appPolicy.mode === "allow") return !listed; 437 + return listed; 438 + } 439 + 440 + /** Register the **record host** XRPC surface — record + blob CRUD. 441 + * 442 + * Auth precedence on every route: 443 + * 1. `X-Space-Credential` header (if a verifier is wired and the credential 444 + * is valid) — caller DID = credential `sub`, no clientId. 445 + * 2. Read-route invite token (`?inviteToken=` or `Bearer atmo-invite:...`). 446 + * 3. Service-auth JWT (existing behavior) — caller DID = JWT issuer. 447 + * 448 + * When a credential is presented, the record host trusts it: no member 449 + * check, no app-policy check (those happen at issuance time on the 450 + * authority side). Service-auth requests still consult the authority — that 451 + * bridge is what phase 5 cuts when the host/authority split goes runtime. */ 292 452 export function registerRecordHostRoutes( 293 453 app: Hono, 294 454 recordHost: RecordHost, 295 455 authority: SpaceAuthority, 296 456 recordHostConfig: RecordHostConfig, 297 457 config: ContrailConfig, 298 - auth: MiddlewareHandler 458 + auth: MiddlewareHandler, 459 + /** Optional credential verifier. When present, the record host accepts 460 + * `X-Space-Credential` as an alternative to a service-auth JWT. */ 461 + credentialVerifier?: CredentialVerifier 299 462 ): void { 300 463 const SPACE = `${config.namespace}.space`; 301 464 302 - /** Read-route auth: skip the JWT middleware when an `?inviteToken=` is 303 - * present so anonymous bearer reads don't 401 before the route handler can 304 - * validate the token. */ 465 + /** Auth wrapper: tries credential first, then delegates to JWT auth. */ 466 + const authWithCredential: MiddlewareHandler = async (c, next) => { 467 + const credToken = extractSpaceCredential(c.req.raw); 468 + if (credToken) { 469 + if (!credentialVerifier) { 470 + return c.json( 471 + { error: "AuthRequired", reason: "credential-verifier-not-configured" }, 472 + 401 473 + ); 474 + } 475 + const result = await credentialVerifier.verify(credToken); 476 + if (!result.ok) { 477 + return c.json({ error: "AuthRequired", reason: result.reason }, 401); 478 + } 479 + c.set("spaceCredential", result.claims); 480 + await next(); 481 + return; 482 + } 483 + return auth(c, next); 484 + }; 485 + 486 + /** Read-route auth: like {@link authWithCredential} but also short-circuits 487 + * on a read-grant invite token. Token presence skips both credential and 488 + * JWT middlewares; the route handler validates the token via authorizeRead. */ 305 489 const readAuth: MiddlewareHandler = async (c, next) => { 306 490 if (extractInviteToken(c.req.raw)) { 307 491 await next(); 308 492 return; 309 493 } 310 - return auth(c, next); 494 + return authWithCredential(c, next); 311 495 }; 312 496 313 497 app.get(`/xrpc/${SPACE}.listRecords`, readAuth, async (c) => { ··· 336 520 return c.json({ error: "Forbidden", reason: result.reason }, 403); 337 521 } 338 522 } 523 + // Credential and token paths are pre-authorized — credential's signature 524 + // proves the authority granted access; token validation already happened. 339 525 340 526 const list = await recordHost.listRecords(spaceUri, collection, { 341 527 byUser: c.req.query("byUser") ?? undefined, ··· 379 565 }); 380 566 381 567 // Write endpoints 382 - app.post(`/xrpc/${SPACE}.putRecord`, auth, async (c) => { 383 - const sa = getAuth(c); 568 + app.post(`/xrpc/${SPACE}.putRecord`, authWithCredential, async (c) => { 384 569 const body = (await c.req.json().catch(() => null)) as 385 570 | { spaceUri?: string; collection?: string; rkey?: string; record?: Record<string, unknown> } 386 571 | null; ··· 390 575 const space = await authority.getSpace(body.spaceUri); 391 576 if (!space) return c.json({ error: "NotFound" }, 404); 392 577 393 - const member = await authority.getMember(body.spaceUri, sa.issuer); 394 - const result = checkAccess({ 395 - op: "write", 396 - space, 397 - callerDid: sa.issuer, 398 - member, 399 - clientId: sa.clientId, 400 - }); 401 - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 578 + const caller = resolveCaller(c, body.spaceUri, "rw"); 579 + if (caller instanceof Response) return caller; 580 + 581 + if (!caller.viaCredential) { 582 + const member = await authority.getMember(body.spaceUri, caller.callerDid); 583 + const result = checkAccess({ 584 + op: "write", 585 + space, 586 + callerDid: caller.callerDid, 587 + member, 588 + clientId: caller.clientId, 589 + }); 590 + if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 591 + } 402 592 403 593 // Validate that every blob referenced by this record has already been 404 594 // uploaded into this space. This mirrors how PDSes require uploadBlob ··· 426 616 await recordHost.putRecord({ 427 617 spaceUri: body.spaceUri, 428 618 collection: body.collection, 429 - authorDid: sa.issuer, 619 + authorDid: caller.callerDid, 430 620 rkey, 431 621 cid: null, 432 622 record: body.record, 433 623 createdAt: now, 434 624 }); 435 - return c.json({ rkey, authorDid: sa.issuer, createdAt: now }); 625 + return c.json({ rkey, authorDid: caller.callerDid, createdAt: now }); 436 626 }); 437 627 438 - app.post(`/xrpc/${SPACE}.deleteRecord`, auth, async (c) => { 439 - const sa = getAuth(c); 628 + app.post(`/xrpc/${SPACE}.deleteRecord`, authWithCredential, async (c) => { 440 629 const body = (await c.req.json().catch(() => null)) as 441 630 | { spaceUri?: string; collection?: string; rkey?: string } 442 631 | null; ··· 446 635 const space = await authority.getSpace(body.spaceUri); 447 636 if (!space) return c.json({ error: "NotFound" }, 404); 448 637 449 - const member = await authority.getMember(body.spaceUri, sa.issuer); 450 - const result = checkAccess({ 451 - op: "delete", 452 - space, 453 - callerDid: sa.issuer, 454 - member, 455 - clientId: sa.clientId, 456 - targetAuthorDid: sa.issuer, 457 - }); 458 - if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 638 + const caller = resolveCaller(c, body.spaceUri, "rw"); 639 + if (caller instanceof Response) return caller; 640 + 641 + if (!caller.viaCredential) { 642 + const member = await authority.getMember(body.spaceUri, caller.callerDid); 643 + const result = checkAccess({ 644 + op: "delete", 645 + space, 646 + callerDid: caller.callerDid, 647 + member, 648 + clientId: caller.clientId, 649 + targetAuthorDid: caller.callerDid, 650 + }); 651 + if (!result.allow) return c.json({ error: "Forbidden", reason: result.reason }, 403); 652 + } 653 + // Credential path: scope=rw is checked in resolveCaller. Delete remains 654 + // author-scoped — the credential's `sub` is the caller, and we only 655 + // delete records authored by that DID. 459 656 460 - await recordHost.deleteRecord(body.spaceUri, body.collection, sa.issuer, body.rkey); 657 + await recordHost.deleteRecord(body.spaceUri, body.collection, caller.callerDid, body.rkey); 461 658 return c.json({ ok: true }); 462 659 }); 463 660 ··· 468 665 const maxSize = blobsCfg.maxSize ?? DEFAULT_BLOB_MAX_SIZE; 469 666 const accept = blobsCfg.accept; 470 667 471 - app.post(`/xrpc/${SPACE}.uploadBlob`, auth, async (c) => { 472 - const sa = getAuth(c); 668 + app.post(`/xrpc/${SPACE}.uploadBlob`, authWithCredential, async (c) => { 473 669 const spaceUri = c.req.query("spaceUri"); 474 670 if (!spaceUri) { 475 671 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); ··· 477 673 const space = await authority.getSpace(spaceUri); 478 674 if (!space) return c.json({ error: "NotFound" }, 404); 479 675 480 - const member = await authority.getMember(spaceUri, sa.issuer); 481 - const aclResult = checkAccess({ 482 - op: "write", 483 - space, 484 - callerDid: sa.issuer, 485 - member, 486 - clientId: sa.clientId, 487 - }); 488 - if (!aclResult.allow) { 489 - return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); 676 + const caller = resolveCaller(c, spaceUri, "rw"); 677 + if (caller instanceof Response) return caller; 678 + 679 + if (!caller.viaCredential) { 680 + const member = await authority.getMember(spaceUri, caller.callerDid); 681 + const aclResult = checkAccess({ 682 + op: "write", 683 + space, 684 + callerDid: caller.callerDid, 685 + member, 686 + clientId: caller.clientId, 687 + }); 688 + if (!aclResult.allow) { 689 + return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); 690 + } 490 691 } 491 692 492 693 const mimeType = c.req.header("content-type") ?? "application/octet-stream"; ··· 524 725 cid: cidString, 525 726 mimeType, 526 727 size: bytes.byteLength, 527 - authorDid: sa.issuer, 728 + authorDid: caller.callerDid, 528 729 createdAt: Date.now(), 529 730 }); 530 731 ··· 579 780 }); 580 781 }); 581 782 582 - app.get(`/xrpc/${SPACE}.listBlobs`, auth, async (c) => { 583 - const sa = getAuth(c); 783 + app.get(`/xrpc/${SPACE}.listBlobs`, authWithCredential, async (c) => { 584 784 const spaceUri = c.req.query("spaceUri"); 585 785 if (!spaceUri) { 586 786 return c.json({ error: "InvalidRequest", message: "spaceUri required" }, 400); ··· 588 788 const space = await authority.getSpace(spaceUri); 589 789 if (!space) return c.json({ error: "NotFound" }, 404); 590 790 591 - const member = await authority.getMember(spaceUri, sa.issuer); 592 - const aclResult = checkAccess({ 593 - op: "read", 594 - space, 595 - callerDid: sa.issuer, 596 - member, 597 - clientId: sa.clientId, 598 - }); 599 - if (!aclResult.allow) { 600 - return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); 791 + const caller = resolveCaller(c, spaceUri, "read"); 792 + if (caller instanceof Response) return caller; 793 + 794 + if (!caller.viaCredential) { 795 + const member = await authority.getMember(spaceUri, caller.callerDid); 796 + const aclResult = checkAccess({ 797 + op: "read", 798 + space, 799 + callerDid: caller.callerDid, 800 + member, 801 + clientId: caller.clientId, 802 + }); 803 + if (!aclResult.allow) { 804 + return c.json({ error: "Forbidden", reason: aclResult.reason }, 403); 805 + } 601 806 } 602 807 603 808 const result = await recordHost.listBlobMeta(spaceUri, { ··· 610 815 } 611 816 } 612 817 613 - /** Authorize a read request: either a valid service-auth JWT (which also 614 - * identifies the caller for member checks downstream) or a valid read-grant 615 - * invite token bearer. */ 818 + /** Authorize a read request — three valid paths: a verified space credential 819 + * (set by the credential middleware), a read-grant invite token, or a 820 + * service-auth JWT. 821 + * 822 + * Credential and token paths skip the membership check downstream — the 823 + * credential or token IS the proof. The JWT path requires a member check 824 + * in the route handler. */ 616 825 async function authorizeRead( 617 826 c: Context, 618 827 authority: SpaceAuthority, 619 828 spaceUri: string 620 - ): Promise<{ via: "token" } | { via: "jwt"; sa: ServiceAuth } | Response> { 829 + ): Promise< 830 + | { via: "credential"; claims: CredentialClaims } 831 + | { via: "token" } 832 + | { via: "jwt"; sa: ServiceAuth } 833 + | Response 834 + > { 835 + const cred = c.get("spaceCredential") as CredentialClaims | undefined; 836 + if (cred) { 837 + if (cred.space !== spaceUri) { 838 + return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403); 839 + } 840 + return { via: "credential", claims: cred }; 841 + } 621 842 const rawToken = extractInviteToken(c.req.raw); 622 843 if (rawToken) { 623 844 const ok = await checkInviteReadGrant(authority, rawToken, spaceUri, hashInviteToken); ··· 627 848 const sa = c.get("serviceAuth") as ServiceAuth | undefined; 628 849 if (sa) return { via: "jwt", sa }; 629 850 return c.json( 630 - { error: "AuthRequired", message: "JWT or read-grant invite token required" }, 851 + { error: "AuthRequired", message: "JWT, credential, or read-grant invite token required" }, 631 852 401 632 853 ); 854 + } 855 + 856 + /** Unified caller resolution for write/manage paths on the record host. 857 + * Either a verified credential (set by middleware) or a service-auth JWT. 858 + * When a credential is present, also enforces space-match and the requested 859 + * scope. Returns either a caller envelope or a Response to relay. */ 860 + function resolveCaller( 861 + c: Context, 862 + requestSpace: string, 863 + requiredScope: CredentialScope 864 + ): { callerDid: string; clientId: string | undefined; viaCredential: boolean } | Response { 865 + const cred = c.get("spaceCredential") as CredentialClaims | undefined; 866 + if (cred) { 867 + if (cred.space !== requestSpace) { 868 + return c.json({ error: "Forbidden", reason: "credential-wrong-space" }, 403); 869 + } 870 + if (requiredScope === "rw" && cred.scope !== "rw") { 871 + return c.json({ error: "Forbidden", reason: "credential-wrong-scope" }, 403); 872 + } 873 + return { callerDid: cred.sub, clientId: undefined, viaCredential: true }; 874 + } 875 + const sa = c.get("serviceAuth") as ServiceAuth | undefined; 876 + if (!sa) return c.json({ error: "AuthRequired", reason: "no-auth" }, 401); 877 + return { callerDid: sa.issuer, clientId: sa.clientId, viaCredential: false }; 633 878 } 634 879 635 880 function getAuth(c: Parameters<MiddlewareHandler>[0]): ServiceAuth {
+16 -4
packages/contrail/src/core/spaces/types.ts
··· 1 1 import type { Database } from "../types"; 2 2 import type { DidDocumentResolver } from "@atcute/identity-resolver"; 3 3 import type { BlobAdapter } from "./blob-adapter"; 4 + import type { CredentialKeyMaterial } from "./credentials"; 4 5 5 6 export type AppPolicyMode = "allow" | "deny"; 6 7 ··· 25 26 export const DEFAULT_BLOB_MAX_SIZE = 2 * 1024 * 1024; 26 27 export const DEFAULT_BLOB_GC_ORPHAN_AFTER_MS = 24 * 60 * 60 * 1000; 27 28 29 + /** Default credential lifetime. The rough spec calls for 2–4h; we pick the 30 + * lower bound so revocation (kicked-from-space) is observable within 2h. */ 31 + export const DEFAULT_CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; 32 + 28 33 /** Configuration for the **space authority** role: holds the member list, 29 - * signs credentials (later phases), and gates space-management operations. 30 - * In a fully-split deployment, the authority can run in a different process 31 - * (or even a different operator) than the record host. */ 34 + * signs credentials, and gates space-management operations. In a fully-split 35 + * deployment, the authority can run in a different process (or even a 36 + * different operator) than the record host. */ 32 37 export interface AuthorityConfig { 33 38 /** NSID that identifies the kind of space this authority hosts, 34 39 * e.g. "tools.atmo.event.space". */ 35 40 type: string; 36 - /** Service DID that service-auth tokens must target (aud claim). */ 41 + /** Service DID that service-auth tokens must target (aud claim) AND that 42 + * signs credentials it issues (`iss` claim on emitted JWTs). */ 37 43 serviceDid: string; 38 44 /** Default app policy applied to new spaces. */ 39 45 defaultAppPolicy?: AppPolicy; 40 46 /** DID document resolver for service-auth JWT verification. 41 47 * Defaults to a composite PLC + did:web resolver if omitted. */ 42 48 resolver?: DidDocumentResolver; 49 + /** Signing key material for issuing space credentials. When omitted, 50 + * `<ns>.space.getCredential` returns 501 NotImplemented and the record 51 + * host's credential-verifying middleware can't be wired up. */ 52 + signing?: CredentialKeyMaterial; 53 + /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */ 54 + credentialTtlMs?: number; 43 55 } 44 56 45 57 /** Configuration for the **record host** role: stores per-space records and
+19
packages/contrail/src/index.ts
··· 68 68 export type { BlobAdapter, BlobUploadMeta, R2BucketLike } from "./core/spaces/blob-adapter"; 69 69 export type { SpacesBlobsConfig, BlobMetaRow } from "./core/spaces/types"; 70 70 71 + // Space credentials 72 + export { 73 + generateAuthoritySigningKey, 74 + signCredential, 75 + issueCredential, 76 + verifyCredential, 77 + decodeUnverifiedClaims, 78 + createInProcessVerifier, 79 + } from "./core/spaces/credentials"; 80 + export type { 81 + CredentialClaims, 82 + CredentialKeyMaterial, 83 + CredentialScope, 84 + CredentialVerifier, 85 + VerifyOk, 86 + VerifyErr, 87 + VerifyOptions, 88 + } from "./core/spaces/credentials"; 89 + 71 90 // Realtime 72 91 export type { 73 92 PubSub,
+398
packages/contrail/tests/spaces-credentials.test.ts
··· 1 + import { describe, it, expect, beforeAll } from "vitest"; 2 + import { Hono } from "hono"; 3 + import type { MiddlewareHandler } from "hono"; 4 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 5 + import { initSchema } from "../src/core/db/schema"; 6 + import { createApp } from "../src/core/router"; 7 + import { resolveConfig } from "../src/core/types"; 8 + import type { ContrailConfig } from "../src/core/types"; 9 + import { 10 + generateAuthoritySigningKey, 11 + signCredential, 12 + verifyCredential, 13 + issueCredential, 14 + } from "../src/core/spaces/credentials"; 15 + import type { CredentialKeyMaterial } from "../src/core/spaces/credentials"; 16 + 17 + const ALICE = "did:plc:alice"; 18 + const BOB = "did:plc:bob"; 19 + const CHARLIE = "did:plc:charlie"; 20 + 21 + const SERVICE_DID = "did:web:test.example#svc"; 22 + 23 + let SIGNING: CredentialKeyMaterial; 24 + 25 + beforeAll(async () => { 26 + SIGNING = await generateAuthoritySigningKey(); 27 + }); 28 + 29 + function makeConfig(): ContrailConfig { 30 + return { 31 + namespace: "test.cred", 32 + collections: { 33 + message: { collection: "app.event.message" }, 34 + }, 35 + spaces: { 36 + authority: { 37 + type: "tools.atmo.event.space", 38 + serviceDid: SERVICE_DID, 39 + signing: SIGNING, 40 + credentialTtlMs: 60_000, 41 + }, 42 + recordHost: {}, 43 + }, 44 + }; 45 + } 46 + 47 + function fakeAuth(): MiddlewareHandler { 48 + return async (c, next) => { 49 + const did = c.req.header("X-Test-Did"); 50 + if (!did) return c.json({ error: "AuthRequired" }, 401); 51 + c.set("serviceAuth", { 52 + issuer: did, 53 + audience: SERVICE_DID, 54 + lxm: undefined, 55 + clientId: c.req.header("X-Test-App") ?? undefined, 56 + }); 57 + await next(); 58 + }; 59 + } 60 + 61 + async function makeApp(): Promise<Hono> { 62 + const db = createSqliteDatabase(":memory:"); 63 + const cfg = makeConfig(); 64 + const resolved = resolveConfig(cfg); 65 + await initSchema(db, resolved); 66 + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); 67 + } 68 + 69 + function call( 70 + app: Hono, 71 + method: string, 72 + path: string, 73 + did: string | null, 74 + body?: any, 75 + extraHeaders?: Record<string, string> 76 + ) { 77 + const headers: Record<string, string> = { ...(extraHeaders ?? {}) }; 78 + if (did) headers["X-Test-Did"] = did; 79 + if (body !== undefined) headers["Content-Type"] = "application/json"; 80 + return app.fetch( 81 + new Request(`http://localhost${path}`, { 82 + method, 83 + headers, 84 + body: body !== undefined ? JSON.stringify(body) : undefined, 85 + }) 86 + ); 87 + } 88 + 89 + async function createSpace(app: Hono, owner: string): Promise<string> { 90 + const res = await call(app, "POST", "/xrpc/test.cred.space.createSpace", owner, {}); 91 + expect(res.status).toBe(200); 92 + return ((await res.json()) as any).space.uri; 93 + } 94 + 95 + async function getCredential(app: Hono, did: string, spaceUri: string): Promise<string> { 96 + const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", did, { spaceUri }); 97 + expect(res.status).toBe(200); 98 + return ((await res.json()) as any).credential; 99 + } 100 + 101 + describe("space credentials — sign/verify primitives", () => { 102 + it("round-trips via verifyCredential", async () => { 103 + const { credential } = await issueCredential( 104 + { 105 + iss: SERVICE_DID, 106 + sub: ALICE, 107 + space: "ats://did:plc:alice/test/main", 108 + scope: "rw", 109 + ttlMs: 60_000, 110 + }, 111 + SIGNING 112 + ); 113 + const result = await verifyCredential(credential, { 114 + expectedSpace: "ats://did:plc:alice/test/main", 115 + resolveKey: async () => SIGNING.publicKey, 116 + }); 117 + expect(result.ok).toBe(true); 118 + if (result.ok) { 119 + expect(result.claims.iss).toBe(SERVICE_DID); 120 + expect(result.claims.sub).toBe(ALICE); 121 + expect(result.claims.scope).toBe("rw"); 122 + } 123 + }); 124 + 125 + it("rejects expired credentials", async () => { 126 + const past = Math.floor(Date.now() / 1000) - 10; 127 + const credential = await signCredential( 128 + { 129 + iss: SERVICE_DID, 130 + sub: ALICE, 131 + space: "ats://x/y/z", 132 + scope: "rw", 133 + iat: past - 60, 134 + exp: past, 135 + }, 136 + SIGNING 137 + ); 138 + const result = await verifyCredential(credential, { 139 + expectedSpace: "ats://x/y/z", 140 + resolveKey: async () => SIGNING.publicKey, 141 + }); 142 + expect(result.ok).toBe(false); 143 + if (!result.ok) expect(result.reason).toBe("expired"); 144 + }); 145 + 146 + it("rejects wrong-space credentials", async () => { 147 + const { credential } = await issueCredential( 148 + { iss: SERVICE_DID, sub: ALICE, space: "ats://a/b/c", scope: "rw", ttlMs: 60_000 }, 149 + SIGNING 150 + ); 151 + const result = await verifyCredential(credential, { 152 + expectedSpace: "ats://different/space/here", 153 + resolveKey: async () => SIGNING.publicKey, 154 + }); 155 + expect(result.ok).toBe(false); 156 + if (!result.ok) expect(result.reason).toBe("wrong-space"); 157 + }); 158 + 159 + it("rejects credentials signed by a different key", async () => { 160 + const { credential } = await issueCredential( 161 + { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "rw", ttlMs: 60_000 }, 162 + SIGNING 163 + ); 164 + const otherKey = await generateAuthoritySigningKey(); 165 + const result = await verifyCredential(credential, { 166 + expectedSpace: "ats://x/y/z", 167 + resolveKey: async () => otherKey.publicKey, 168 + }); 169 + expect(result.ok).toBe(false); 170 + if (!result.ok) expect(result.reason).toBe("bad-signature"); 171 + }); 172 + 173 + it("rejects unknown issuer", async () => { 174 + const { credential } = await issueCredential( 175 + { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "rw", ttlMs: 60_000 }, 176 + SIGNING 177 + ); 178 + const result = await verifyCredential(credential, { 179 + expectedSpace: "ats://x/y/z", 180 + resolveKey: async () => null, 181 + }); 182 + expect(result.ok).toBe(false); 183 + if (!result.ok) expect(result.reason).toBe("unknown-issuer"); 184 + }); 185 + 186 + it("scope=rw rejects read-only credentials when verifier requires rw", async () => { 187 + const { credential } = await issueCredential( 188 + { iss: SERVICE_DID, sub: ALICE, space: "ats://x/y/z", scope: "read", ttlMs: 60_000 }, 189 + SIGNING 190 + ); 191 + const result = await verifyCredential(credential, { 192 + expectedSpace: "ats://x/y/z", 193 + requiredScope: "rw", 194 + resolveKey: async () => SIGNING.publicKey, 195 + }); 196 + expect(result.ok).toBe(false); 197 + if (!result.ok) expect(result.reason).toBe("wrong-scope"); 198 + }); 199 + }); 200 + 201 + describe("space credentials — getCredential / refreshCredential endpoints", () => { 202 + it("issues a credential for a space owner", async () => { 203 + const app = await makeApp(); 204 + const uri = await createSpace(app, ALICE); 205 + const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", ALICE, { 206 + spaceUri: uri, 207 + }); 208 + expect(res.status).toBe(200); 209 + const body = (await res.json()) as any; 210 + expect(body.credential).toBeTypeOf("string"); 211 + expect(body.expiresAt).toBeTypeOf("number"); 212 + expect(body.expiresAt).toBeGreaterThan(Date.now()); 213 + }); 214 + 215 + it("issues a credential for a member who isn't the owner", async () => { 216 + const app = await makeApp(); 217 + const uri = await createSpace(app, ALICE); 218 + await call(app, "POST", "/xrpc/test.cred.space.addMember", ALICE, { 219 + spaceUri: uri, 220 + did: BOB, 221 + }); 222 + const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", BOB, { 223 + spaceUri: uri, 224 + }); 225 + expect(res.status).toBe(200); 226 + }); 227 + 228 + it("denies non-members", async () => { 229 + const app = await makeApp(); 230 + const uri = await createSpace(app, ALICE); 231 + const res = await call(app, "POST", "/xrpc/test.cred.space.getCredential", CHARLIE, { 232 + spaceUri: uri, 233 + }); 234 + expect(res.status).toBe(403); 235 + expect((await res.json()).reason).toBe("not-member"); 236 + }); 237 + 238 + it("refreshes an unexpired credential", async () => { 239 + const app = await makeApp(); 240 + const uri = await createSpace(app, ALICE); 241 + const cred = await getCredential(app, ALICE, uri); 242 + 243 + const res = await call(app, "POST", "/xrpc/test.cred.space.refreshCredential", null, { 244 + credential: cred, 245 + }); 246 + expect(res.status).toBe(200); 247 + const body = (await res.json()) as any; 248 + expect(body.credential).toBeTypeOf("string"); 249 + expect(body.credential).not.toBe(cred); // fresh iat/exp → different signature 250 + }); 251 + 252 + it("refresh rejects when the holder is no longer a member", async () => { 253 + const app = await makeApp(); 254 + const uri = await createSpace(app, ALICE); 255 + await call(app, "POST", "/xrpc/test.cred.space.addMember", ALICE, { 256 + spaceUri: uri, 257 + did: BOB, 258 + }); 259 + const cred = await getCredential(app, BOB, uri); 260 + // Owner kicks Bob out. 261 + const removeRes = await call(app, "POST", "/xrpc/test.cred.space.removeMember", ALICE, { 262 + spaceUri: uri, 263 + did: BOB, 264 + }); 265 + expect(removeRes.status).toBe(200); 266 + 267 + const res = await call(app, "POST", "/xrpc/test.cred.space.refreshCredential", null, { 268 + credential: cred, 269 + }); 270 + expect(res.status).toBe(403); 271 + }); 272 + }); 273 + 274 + describe("space credentials — record host accepts X-Space-Credential", () => { 275 + it("putRecord works with a credential and no service-auth JWT", async () => { 276 + const app = await makeApp(); 277 + const uri = await createSpace(app, ALICE); 278 + const cred = await getCredential(app, ALICE, uri); 279 + 280 + const res = await call( 281 + app, 282 + "POST", 283 + "/xrpc/test.cred.space.putRecord", 284 + null, // no X-Test-Did header 285 + { 286 + spaceUri: uri, 287 + collection: "app.event.message", 288 + record: { $type: "app.event.message", text: "hi from credential" }, 289 + }, 290 + { "X-Space-Credential": cred } 291 + ); 292 + expect(res.status).toBe(200); 293 + const body = (await res.json()) as any; 294 + expect(body.authorDid).toBe(ALICE); 295 + expect(body.rkey).toBeTypeOf("string"); 296 + }); 297 + 298 + it("listRecords works with a credential", async () => { 299 + const app = await makeApp(); 300 + const uri = await createSpace(app, ALICE); 301 + const cred = await getCredential(app, ALICE, uri); 302 + // Plant a record via the JWT path so listRecords has something to return. 303 + await call( 304 + app, 305 + "POST", 306 + "/xrpc/test.cred.space.putRecord", 307 + ALICE, 308 + { 309 + spaceUri: uri, 310 + collection: "app.event.message", 311 + record: { $type: "app.event.message", text: "one" }, 312 + } 313 + ); 314 + 315 + const res = await call( 316 + app, 317 + "GET", 318 + `/xrpc/test.cred.space.listRecords?spaceUri=${encodeURIComponent(uri)}&collection=app.event.message`, 319 + null, 320 + undefined, 321 + { "X-Space-Credential": cred } 322 + ); 323 + expect(res.status).toBe(200); 324 + expect(((await res.json()) as any).records.length).toBe(1); 325 + }); 326 + 327 + it("rejects credential issued for a different space", async () => { 328 + const app = await makeApp(); 329 + const uriA = await createSpace(app, ALICE); 330 + // Alice can create a second one; key auto-generated, owner is implicit member. 331 + const uriB = await createSpace(app, ALICE); 332 + const credForA = await getCredential(app, ALICE, uriA); 333 + 334 + const res = await call( 335 + app, 336 + "POST", 337 + "/xrpc/test.cred.space.putRecord", 338 + null, 339 + { 340 + spaceUri: uriB, 341 + collection: "app.event.message", 342 + record: { $type: "app.event.message", text: "wrong space" }, 343 + }, 344 + { "X-Space-Credential": credForA } 345 + ); 346 + expect(res.status).toBe(403); 347 + expect((await res.json()).reason).toBe("credential-wrong-space"); 348 + }); 349 + 350 + it("rejects malformed credentials", async () => { 351 + const app = await makeApp(); 352 + const uri = await createSpace(app, ALICE); 353 + const res = await call( 354 + app, 355 + "POST", 356 + "/xrpc/test.cred.space.putRecord", 357 + null, 358 + { 359 + spaceUri: uri, 360 + collection: "app.event.message", 361 + record: { $type: "app.event.message", text: "x" }, 362 + }, 363 + { "X-Space-Credential": "not-a-jwt" } 364 + ); 365 + expect(res.status).toBe(401); 366 + expect((await res.json()).reason).toBe("malformed"); 367 + }); 368 + 369 + it("rejects credentials forged with an unknown issuer", async () => { 370 + const app = await makeApp(); 371 + const uri = await createSpace(app, ALICE); 372 + const otherKey = await generateAuthoritySigningKey(); 373 + const { credential } = await issueCredential( 374 + { 375 + iss: "did:web:attacker.example", 376 + sub: ALICE, 377 + space: uri, 378 + scope: "rw", 379 + ttlMs: 60_000, 380 + }, 381 + otherKey 382 + ); 383 + const res = await call( 384 + app, 385 + "POST", 386 + "/xrpc/test.cred.space.putRecord", 387 + null, 388 + { 389 + spaceUri: uri, 390 + collection: "app.event.message", 391 + record: { $type: "app.event.message", text: "x" }, 392 + }, 393 + { "X-Space-Credential": credential } 394 + ); 395 + expect(res.status).toBe(401); 396 + expect((await res.json()).reason).toBe("unknown-issuer"); 397 + }); 398 + });
+42
packages/lexicons/lexicon-templates/spaces/getCredential.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.space.getCredential", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Mint a short-lived space credential for a member of `spaceUri`. The caller is identified by their service-auth JWT; the credential's `sub` is set to that DID, scoped 'rw'. Use the returned credential as the `X-Space-Credential` header on subsequent requests instead of minting a fresh service-auth JWT each time. Refresh via `refreshCredential` before expiry.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["spaceUri"], 13 + "properties": { 14 + "spaceUri": { "type": "string" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["credential", "expiresAt"], 23 + "properties": { 24 + "credential": { 25 + "type": "string", 26 + "description": "Compact JWS (ES256) signed by the authority's key. Header `kid` references the verification method on the authority's DID document." 27 + }, 28 + "expiresAt": { 29 + "type": "integer", 30 + "description": "Expiry as ms since epoch." 31 + } 32 + } 33 + } 34 + }, 35 + "errors": [ 36 + { "name": "NotFound" }, 37 + { "name": "Forbidden" }, 38 + { "name": "NotImplemented", "description": "Authority is not configured to sign credentials." } 39 + ] 40 + } 41 + } 42 + }
+38
packages/lexicons/lexicon-templates/spaces/refreshCredential.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.space.refreshCredential", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Refresh an unexpired space credential. The current credential must verify and the bearer must still be a member. Returns a fresh credential (same scope, same sub, same space) with a new expiry. No service-auth JWT required — the credential itself is the authentication.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["credential"], 13 + "properties": { 14 + "credential": { "type": "string" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["credential", "expiresAt"], 23 + "properties": { 24 + "credential": { "type": "string" }, 25 + "expiresAt": { "type": "integer" } 26 + } 27 + } 28 + }, 29 + "errors": [ 30 + { "name": "InvalidRequest" }, 31 + { "name": "InvalidCredential" }, 32 + { "name": "NotFound" }, 33 + { "name": "Forbidden" }, 34 + { "name": "NotImplemented" } 35 + ] 36 + } 37 + } 38 + }