[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 7c

Florian (May 3, 2026, 2:50 AM +0200) 00eb9898 e7149dcd

+531
+53
packages/contrail-authority/src/routes.ts
··· 21 21 checkInviteReadGrant, 22 22 decodeUnverifiedClaims, 23 23 DEFAULT_CREDENTIAL_TTL_MS, 24 + DEFAULT_MANIFEST_TTL_MS, 24 25 extractInviteToken, 25 26 hashInviteToken, 26 27 issueCredential, 28 + issueMembershipManifest, 27 29 nextTid, 28 30 verifyCredential, 29 31 } from "@atmo-dev/contrail-base"; ··· 323 325 signing 324 326 ); 325 327 return c.json({ credential, expiresAt }); 328 + }); 329 + 330 + // ---- Membership manifest ---- 331 + // 332 + // Issues a signed list of every space the caller is a member of (or owns) 333 + // according to this authority. Appviews carry it on inbound requests so 334 + // unioned listRecords queries can be filtered without syncing the full 335 + // member list. Same key material as credentials, different payload. 336 + 337 + app.post(`/xrpc/${SPACE}.getMembershipManifest`, auth, async (c) => { 338 + if (!authorityConfig.signing) { 339 + return c.json( 340 + { error: "NotImplemented", message: "authority is not configured to sign manifests" }, 341 + 501 342 + ); 343 + } 344 + const sa = getAuth(c); 345 + const cap = authorityConfig.manifestMaxSpaces ?? 500; 346 + 347 + // Page through listSpaces — owner-or-member union — up to the cap. 348 + const seen = new Set<string>(); 349 + const drain = async (scope: "owner" | "member"): Promise<void> => { 350 + let cursor: string | undefined; 351 + while (seen.size < cap) { 352 + const result = await authority.listSpaces({ 353 + ...(scope === "owner" ? { ownerDid: sa.issuer } : { memberDid: sa.issuer }), 354 + cursor, 355 + limit: Math.min(200, cap - seen.size), 356 + }); 357 + for (const s of result.spaces) { 358 + if (s.deletedAt == null) seen.add(s.uri); 359 + if (seen.size >= cap) break; 360 + } 361 + if (!result.cursor || seen.size >= cap) break; 362 + cursor = result.cursor; 363 + } 364 + }; 365 + await drain("owner"); 366 + if (seen.size < cap) await drain("member"); 367 + 368 + const ttl = authorityConfig.manifestTtlMs ?? DEFAULT_MANIFEST_TTL_MS; 369 + const { manifest, expiresAt } = await issueMembershipManifest( 370 + { 371 + iss: authorityConfig.serviceDid, 372 + sub: sa.issuer, 373 + spaces: [...seen], 374 + ttlMs: ttl, 375 + }, 376 + authorityConfig.signing 377 + ); 378 + return c.json({ manifest, expiresAt, truncated: seen.size >= cap }); 326 379 }); 327 380 } 328 381
+3
packages/contrail-base/src/index.ts
··· 40 40 // Credentials 41 41 export * from "./spaces/credentials"; 42 42 43 + // Membership manifests 44 + export * from "./spaces/manifest"; 45 + 43 46 // Binding + key resolution 44 47 export * from "./spaces/binding"; 45 48
+260
packages/contrail/tests/spaces-manifest.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 + signMembershipManifest, 12 + verifyMembershipManifest, 13 + decodeUnverifiedManifest, 14 + issueMembershipManifest, 15 + } from "@atmo-dev/contrail-base"; 16 + import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; 17 + 18 + const ALICE = "did:plc:alice"; 19 + const BOB = "did:plc:bob"; 20 + const CHARLIE = "did:plc:charlie"; 21 + 22 + const SERVICE_DID = "did:web:test.example#svc"; 23 + 24 + let SIGNING: CredentialKeyMaterial; 25 + 26 + beforeAll(async () => { 27 + SIGNING = await generateAuthoritySigningKey(); 28 + }); 29 + 30 + function makeConfig(overrides?: Partial<ContrailConfig["spaces"] extends { authority?: infer A } ? A : never>): ContrailConfig { 31 + return { 32 + namespace: "test.man", 33 + collections: { 34 + message: { collection: "app.event.message" }, 35 + }, 36 + spaces: { 37 + authority: { 38 + type: "tools.atmo.event.space", 39 + serviceDid: SERVICE_DID, 40 + signing: SIGNING, 41 + manifestTtlMs: 60_000, 42 + ...(overrides ?? {}), 43 + }, 44 + recordHost: {}, 45 + }, 46 + }; 47 + } 48 + 49 + function fakeAuth(): MiddlewareHandler { 50 + return async (c, next) => { 51 + const did = c.req.header("X-Test-Did"); 52 + if (!did) return c.json({ error: "AuthRequired" }, 401); 53 + c.set("serviceAuth", { 54 + issuer: did, 55 + audience: SERVICE_DID, 56 + lxm: undefined, 57 + clientId: c.req.header("X-Test-App") ?? undefined, 58 + }); 59 + await next(); 60 + }; 61 + } 62 + 63 + async function makeApp(cfg: ContrailConfig = makeConfig()): Promise<Hono> { 64 + const db = createSqliteDatabase(":memory:"); 65 + const resolved = resolveConfig(cfg); 66 + await initSchema(db, resolved); 67 + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); 68 + } 69 + 70 + function call(app: Hono, method: string, path: string, did: string | null, body?: any) { 71 + const headers: Record<string, string> = {}; 72 + if (did) headers["X-Test-Did"] = did; 73 + if (body !== undefined) headers["Content-Type"] = "application/json"; 74 + return app.fetch( 75 + new Request(`http://localhost${path}`, { 76 + method, 77 + headers, 78 + body: body !== undefined ? JSON.stringify(body) : undefined, 79 + }) 80 + ); 81 + } 82 + 83 + async function createSpace(app: Hono, owner: string): Promise<string> { 84 + const res = await call(app, "POST", "/xrpc/test.man.space.createSpace", owner, {}); 85 + expect(res.status).toBe(200); 86 + return ((await res.json()) as any).space.uri; 87 + } 88 + 89 + describe("membership manifest — sign/verify primitives", () => { 90 + it("round-trips via verifyMembershipManifest", async () => { 91 + const { manifest } = await issueMembershipManifest( 92 + { 93 + iss: SERVICE_DID, 94 + sub: ALICE, 95 + spaces: ["ats://a/x/1", "ats://a/x/2"], 96 + ttlMs: 60_000, 97 + }, 98 + SIGNING 99 + ); 100 + const result = await verifyMembershipManifest(manifest, { 101 + resolveKey: async (iss) => (iss === SERVICE_DID ? SIGNING.publicKey : null), 102 + }); 103 + expect(result.ok).toBe(true); 104 + if (result.ok) { 105 + expect(result.claims.iss).toBe(SERVICE_DID); 106 + expect(result.claims.sub).toBe(ALICE); 107 + expect(result.claims.spaces).toEqual(["ats://a/x/1", "ats://a/x/2"]); 108 + } 109 + }); 110 + 111 + it("rejects expired manifests", async () => { 112 + const past = Math.floor(Date.now() / 1000) - 10; 113 + const manifest = await signMembershipManifest( 114 + { 115 + iss: SERVICE_DID, 116 + sub: ALICE, 117 + spaces: ["ats://a/x/1"], 118 + iat: past - 60, 119 + exp: past, 120 + }, 121 + SIGNING 122 + ); 123 + const result = await verifyMembershipManifest(manifest, { 124 + resolveKey: async () => SIGNING.publicKey, 125 + }); 126 + expect(result.ok).toBe(false); 127 + if (!result.ok) expect(result.reason).toBe("expired"); 128 + }); 129 + 130 + it("rejects bad signatures", async () => { 131 + const { manifest } = await issueMembershipManifest( 132 + { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, 133 + SIGNING 134 + ); 135 + const otherKey = await generateAuthoritySigningKey(); 136 + const result = await verifyMembershipManifest(manifest, { 137 + resolveKey: async () => otherKey.publicKey, 138 + }); 139 + expect(result.ok).toBe(false); 140 + if (!result.ok) expect(result.reason).toBe("bad-signature"); 141 + }); 142 + 143 + it("rejects unknown issuer", async () => { 144 + const { manifest } = await issueMembershipManifest( 145 + { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, 146 + SIGNING 147 + ); 148 + const result = await verifyMembershipManifest(manifest, { 149 + resolveKey: async () => null, 150 + }); 151 + expect(result.ok).toBe(false); 152 + if (!result.ok) expect(result.reason).toBe("unknown-issuer"); 153 + }); 154 + 155 + it("decodeUnverifiedManifest peeks without verifying", async () => { 156 + const { manifest } = await issueMembershipManifest( 157 + { iss: SERVICE_DID, sub: ALICE, spaces: ["ats://a/x/1"], ttlMs: 60_000 }, 158 + SIGNING 159 + ); 160 + const claims = decodeUnverifiedManifest(manifest); 161 + expect(claims).not.toBeNull(); 162 + expect(claims!.sub).toBe(ALICE); 163 + expect(claims!.spaces).toEqual(["ats://a/x/1"]); 164 + }); 165 + }); 166 + 167 + describe("membership manifest — getMembershipManifest endpoint", () => { 168 + it("returns a manifest covering owned + joined spaces", async () => { 169 + const app = await makeApp(); 170 + // Alice owns 2; Bob joins one of them. 171 + const uriA1 = await createSpace(app, ALICE); 172 + const uriA2 = await createSpace(app, ALICE); 173 + await call(app, "POST", "/xrpc/test.man.space.addMember", ALICE, { 174 + spaceUri: uriA1, 175 + did: BOB, 176 + }); 177 + // Bob also owns one. 178 + const uriB1 = await createSpace(app, BOB); 179 + 180 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", BOB); 181 + expect(res.status).toBe(200); 182 + const body = (await res.json()) as any; 183 + expect(body.manifest).toBeTypeOf("string"); 184 + expect(body.expiresAt).toBeTypeOf("number"); 185 + expect(body.truncated).toBe(false); 186 + 187 + // Verify signature + payload. 188 + const result = await verifyMembershipManifest(body.manifest, { 189 + resolveKey: async (iss) => (iss === SERVICE_DID ? SIGNING.publicKey : null), 190 + }); 191 + expect(result.ok).toBe(true); 192 + if (result.ok) { 193 + expect(result.claims.iss).toBe(SERVICE_DID); 194 + expect(result.claims.sub).toBe(BOB); 195 + expect(result.claims.spaces.sort()).toEqual([uriA1, uriB1].sort()); 196 + } 197 + 198 + // Alice's membership shouldn't be visible to Bob's manifest. 199 + if (result.ok) expect(result.claims.spaces).not.toContain(uriA2); 200 + }); 201 + 202 + it("returns an empty spaces array for a user with no memberships", async () => { 203 + const app = await makeApp(); 204 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", CHARLIE); 205 + expect(res.status).toBe(200); 206 + const body = (await res.json()) as any; 207 + const result = await verifyMembershipManifest(body.manifest, { 208 + resolveKey: async () => SIGNING.publicKey, 209 + }); 210 + expect(result.ok).toBe(true); 211 + if (result.ok) expect(result.claims.spaces).toEqual([]); 212 + }); 213 + 214 + it("dedupes — owner who is also a member appears once", async () => { 215 + const app = await makeApp(); 216 + const uri = await createSpace(app, ALICE); 217 + // createSpace internally addMembers the owner; both scopes will include it. 218 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); 219 + expect(res.status).toBe(200); 220 + const body = (await res.json()) as any; 221 + const result = await verifyMembershipManifest(body.manifest, { 222 + resolveKey: async () => SIGNING.publicKey, 223 + }); 224 + expect(result.ok).toBe(true); 225 + if (result.ok) expect(result.claims.spaces).toEqual([uri]); 226 + }); 227 + 228 + it("sets truncated:true when over manifestMaxSpaces", async () => { 229 + const cfg = makeConfig(); 230 + cfg.spaces!.authority!.manifestMaxSpaces = 2; 231 + const app = await makeApp(cfg); 232 + await createSpace(app, ALICE); 233 + await createSpace(app, ALICE); 234 + await createSpace(app, ALICE); 235 + 236 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); 237 + expect(res.status).toBe(200); 238 + const body = (await res.json()) as any; 239 + expect(body.truncated).toBe(true); 240 + const result = await verifyMembershipManifest(body.manifest, { 241 + resolveKey: async () => SIGNING.publicKey, 242 + }); 243 + expect(result.ok).toBe(true); 244 + if (result.ok) expect(result.claims.spaces.length).toBe(2); 245 + }); 246 + 247 + it("requires auth", async () => { 248 + const app = await makeApp(); 249 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", null); 250 + expect(res.status).toBe(401); 251 + }); 252 + 253 + it("returns 501 when authority is not configured to sign", async () => { 254 + const cfg = makeConfig(); 255 + delete cfg.spaces!.authority!.signing; 256 + const app = await makeApp(cfg); 257 + const res = await call(app, "POST", "/xrpc/test.man.space.getMembershipManifest", ALICE); 258 + expect(res.status).toBe(501); 259 + }); 260 + });
+164
packages/contrail-base/src/spaces/manifest.ts
··· 1 + /** Membership manifest: a short-lived signed list of spaces a caller is a 2 + * member of, issued by an authority. Lets appviews filter unioned queries 3 + * without syncing the full member list — each user carries their own 4 + * bounded slice as they hit the appview. 5 + * 6 + * Same signing infrastructure as space credentials (ES256 JWT), different 7 + * payload + endpoint. */ 8 + 9 + import { 10 + signCredential, 11 + verifyCredential, 12 + decodeUnverifiedClaims, 13 + type CredentialKeyMaterial, 14 + } from "./credentials"; 15 + 16 + const ALG = "ES256"; 17 + const TYP = "JWT"; 18 + const DEFAULT_KEY_ID = "atproto_space_authority"; 19 + 20 + /** Default manifest TTL — same 2h as credentials. */ 21 + export const DEFAULT_MANIFEST_TTL_MS = 2 * 60 * 60 * 1000; 22 + 23 + export interface MembershipManifestClaims { 24 + /** Authority DID that issued (and signed) the manifest. */ 25 + iss: string; 26 + /** User DID this manifest is for. */ 27 + sub: string; 28 + /** Space URIs the user is a member of (according to this authority). */ 29 + spaces: string[]; 30 + /** Seconds since epoch. */ 31 + iat: number; 32 + exp: number; 33 + } 34 + 35 + /** Sign a manifest using the authority's signing key. */ 36 + export async function signMembershipManifest( 37 + payload: MembershipManifestClaims, 38 + key: CredentialKeyMaterial 39 + ): Promise<string> { 40 + // signCredential internally builds the JWT given a CredentialClaims-like 41 + // shape. The manifest payload has different fields (`spaces` instead of 42 + // `space`/`scope`) so we hand-roll the JWT here using the same utilities. 43 + const enc = new TextEncoder(); 44 + const kid = `${payload.iss}#${key.keyId ?? DEFAULT_KEY_ID}`; 45 + const header = { alg: ALG, typ: TYP, kid }; 46 + const head = base64urlEncode(enc.encode(JSON.stringify(header))); 47 + const body = base64urlEncode(enc.encode(JSON.stringify(payload))); 48 + const signingInput = `${head}.${body}`; 49 + const privateKey = await crypto.subtle.importKey( 50 + "jwk", 51 + key.privateKey, 52 + { name: "ECDSA", namedCurve: "P-256" }, 53 + false, 54 + ["sign"] 55 + ); 56 + const sig = await crypto.subtle.sign( 57 + { name: "ECDSA", hash: "SHA-256" }, 58 + privateKey, 59 + enc.encode(signingInput) 60 + ); 61 + return `${signingInput}.${base64urlEncode(new Uint8Array(sig))}`; 62 + } 63 + 64 + /** Issue a manifest with current iat/exp. */ 65 + export async function issueMembershipManifest( 66 + args: Omit<MembershipManifestClaims, "iat" | "exp"> & { ttlMs: number }, 67 + key: CredentialKeyMaterial 68 + ): Promise<{ manifest: string; expiresAt: number }> { 69 + const now = Math.floor(Date.now() / 1000); 70 + const expSec = now + Math.floor(args.ttlMs / 1000); 71 + const payload: MembershipManifestClaims = { 72 + iss: args.iss, 73 + sub: args.sub, 74 + spaces: args.spaces, 75 + iat: now, 76 + exp: expSec, 77 + }; 78 + const manifest = await signMembershipManifest(payload, key); 79 + return { manifest, expiresAt: expSec * 1000 }; 80 + } 81 + 82 + export type ManifestVerifyOk = { ok: true; claims: MembershipManifestClaims }; 83 + export type ManifestVerifyErr = { 84 + ok: false; 85 + reason: "malformed" | "bad-alg" | "bad-signature" | "expired" | "not-yet-valid" | "unknown-issuer"; 86 + }; 87 + 88 + export interface VerifyManifestOptions { 89 + /** Resolve the issuer's verification key. */ 90 + resolveKey: (iss: string, kid: string | undefined) => Promise<JsonWebKey | null>; 91 + /** Time provider for tests. */ 92 + now?: () => number; 93 + } 94 + 95 + export async function verifyMembershipManifest( 96 + jwt: string, 97 + opts: VerifyManifestOptions 98 + ): Promise<ManifestVerifyOk | ManifestVerifyErr> { 99 + const parts = jwt.split("."); 100 + if (parts.length !== 3) return { ok: false, reason: "malformed" }; 101 + const [headSeg, bodySeg, sigSeg] = parts as [string, string, string]; 102 + 103 + let header: { alg?: string; kid?: string }; 104 + let claims: MembershipManifestClaims; 105 + try { 106 + header = JSON.parse(new TextDecoder().decode(base64urlDecode(headSeg))); 107 + claims = JSON.parse(new TextDecoder().decode(base64urlDecode(bodySeg))); 108 + } catch { 109 + return { ok: false, reason: "malformed" }; 110 + } 111 + if (header.alg !== ALG) return { ok: false, reason: "bad-alg" }; 112 + if (!Array.isArray(claims.spaces)) return { ok: false, reason: "malformed" }; 113 + 114 + const nowMs = (opts.now ?? Date.now)(); 115 + const nowSec = Math.floor(nowMs / 1000); 116 + if (claims.exp <= nowSec) return { ok: false, reason: "expired" }; 117 + if (claims.iat > nowSec + 60) return { ok: false, reason: "not-yet-valid" }; 118 + 119 + const jwk = await opts.resolveKey(claims.iss, header.kid); 120 + if (!jwk) return { ok: false, reason: "unknown-issuer" }; 121 + 122 + const publicKey = await crypto.subtle.importKey( 123 + "jwk", 124 + jwk, 125 + { name: "ECDSA", namedCurve: "P-256" }, 126 + false, 127 + ["verify"] 128 + ); 129 + const sigBytes = base64urlDecode(sigSeg); 130 + const enc = new TextEncoder(); 131 + const valid = await crypto.subtle.verify( 132 + { name: "ECDSA", hash: "SHA-256" }, 133 + publicKey, 134 + sigBytes as BufferSource, 135 + enc.encode(`${headSeg}.${bodySeg}`) 136 + ); 137 + if (!valid) return { ok: false, reason: "bad-signature" }; 138 + return { ok: true, claims }; 139 + } 140 + 141 + /** Peek at claims without verifying — useful for routing decisions. */ 142 + export function decodeUnverifiedManifest(jwt: string): MembershipManifestClaims | null { 143 + const parts = jwt.split("."); 144 + if (parts.length !== 3) return null; 145 + try { 146 + return JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]!))) as MembershipManifestClaims; 147 + } catch { 148 + return null; 149 + } 150 + } 151 + 152 + // Local base64url helpers — mirror the credentials module so we don't expose 153 + // these as public utilities. 154 + function base64urlEncode(bytes: Uint8Array): string { 155 + const s = btoa(String.fromCharCode(...bytes)); 156 + return s.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 157 + } 158 + function base64urlDecode(s: string): Uint8Array { 159 + const padded = s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="); 160 + const bin = atob(padded); 161 + const out = new Uint8Array(bin.length); 162 + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 163 + return out; 164 + }
+10
packages/contrail-base/src/spaces/types.ts
··· 52 52 signing?: CredentialKeyMaterial; 53 53 /** Credential lifetime in ms. Defaults to {@link DEFAULT_CREDENTIAL_TTL_MS}. */ 54 54 credentialTtlMs?: number; 55 + /** Membership-manifest lifetime in ms. Manifests carry a user's full 56 + * member-of list and let appviews filter unioned queries without syncing 57 + * the authority's full member tables. Same key material as credentials. 58 + * Defaults to {@link DEFAULT_MANIFEST_TTL_MS}. */ 59 + manifestTtlMs?: number; 60 + /** Maximum number of spaces returned in a manifest. The endpoint paginates 61 + * through `listSpaces` up to this cap; users with more spaces get a 62 + * truncated manifest (the remainder won't be unioned in queries). Defaults 63 + * to 500. */ 64 + manifestMaxSpaces?: number; 55 65 } 56 66 57 67 /** Configuration for the **record host** role: stores per-space records and
+41
packages/lexicons/lexicon-templates/spaces/getMembershipManifest.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.space.getMembershipManifest", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Mint a signed list of every space the caller is a member of (or owns) according to this authority. Appviews can carry the manifest on inbound requests so unioned listRecords queries are filtered against the caller's bounded slice without the appview having to sync the authority's full member list. Same key material as `getCredential`; different payload (an array of space URIs rather than a single space + scope). Truncated to `manifestMaxSpaces` (default 500) — the response sets `truncated: true` when the cap was hit.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": {} 13 + } 14 + }, 15 + "output": { 16 + "encoding": "application/json", 17 + "schema": { 18 + "type": "object", 19 + "required": ["manifest", "expiresAt", "truncated"], 20 + "properties": { 21 + "manifest": { 22 + "type": "string", 23 + "description": "Compact JWS (ES256) signed by the authority's key. Payload claims: `iss` (authority DID), `sub` (caller DID), `spaces` (array of space URIs), `iat`, `exp`." 24 + }, 25 + "expiresAt": { 26 + "type": "integer", 27 + "description": "Expiry as ms since epoch." 28 + }, 29 + "truncated": { 30 + "type": "boolean", 31 + "description": "True if the caller is in more spaces than fit in one manifest. The remainder won't be unioned in queries until the next refresh." 32 + } 33 + } 34 + } 35 + }, 36 + "errors": [ 37 + { "name": "NotImplemented", "description": "Authority is not configured to sign manifests." } 38 + ] 39 + } 40 + } 41 + }