[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 7d?

Florian (May 3, 2026, 7:24 PM +0200) 30ce51aa 00eb9898

+298 -5
+33 -5
packages/contrail-appview/src/core/router/collection.ts
··· 599 599 600 600 // Union path: when the caller is authenticated, fold in records from 601 601 // spaces they're a member of. Anonymous callers just get public results. 602 + // 603 + // The caller authenticates with service-auth (`Authorization: Bearer 604 + // <jwt>`). Their member-of space list comes from one of: 605 + // 1. `X-Membership-Manifest` header — signed list issued by some 606 + // authority asserting `sub` is in these spaces. The manifest's 607 + // `sub` MUST match the JWT's issuer (DID) — the manifest is not 608 + // a bearer token. Preferred for multi-authority deployments. 609 + // 2. Local listSpaces — appview asks its own authority adapter 610 + // `listSpaces({ memberDid: jwt.issuer })`. Works when the appview 611 + // operator IS the authority. 602 612 let spaceUris: string[] | undefined; 603 613 const hasAuthHeader = !!c.req.header("Authorization"); 614 + const manifestHeader = c.req.header("X-Membership-Manifest"); 604 615 if (spacesCtx) { 605 616 const nsid = new URL(c.req.url).pathname.match(/\/xrpc\/([^?]+)/)?.[1] as Nsid | null; 606 617 const auth = await verifyServiceAuthRequest(spacesCtx.verifier, c.req.raw, nsid); 607 618 if (auth) { 608 - const { spaces } = await spacesCtx.adapter.listSpaces({ 609 - memberDid: auth.issuer, 610 - limit: 200, 611 - }); 612 - spaceUris = spaces.map((s) => s.uri); 619 + if (manifestHeader && spacesCtx.manifestVerifier) { 620 + const verified = await spacesCtx.manifestVerifier.verify(manifestHeader); 621 + if (!verified.ok) { 622 + return c.json( 623 + { error: "AuthRequired", reason: verified.reason, message: "invalid membership manifest" }, 624 + 401 625 + ); 626 + } 627 + if (verified.claims.sub !== auth.issuer) { 628 + return c.json( 629 + { error: "Forbidden", reason: "manifest-sub-mismatch", message: "manifest sub does not match caller" }, 630 + 403 631 + ); 632 + } 633 + spaceUris = verified.claims.spaces; 634 + } else { 635 + const { spaces } = await spacesCtx.adapter.listSpaces({ 636 + memberDid: auth.issuer, 637 + limit: 200, 638 + }); 639 + spaceUris = spaces.map((s) => s.uri); 640 + } 613 641 } else if (hasAuthHeader) { 614 642 // Had an auth header but it was invalid — reject rather than 615 643 // silently downgrading to public results.
+16
packages/contrail-appview/src/core/router/index.ts
··· 12 12 import { HostedAdapter } from "../spaces/adapter"; 13 13 import type { StorageAdapter } from "../spaces/types"; 14 14 import type { ServiceJwtVerifier } from "@atcute/xrpc-server/auth"; 15 + import { createManifestVerifier } from "@atmo-dev/contrail-base"; 16 + import type { ManifestVerifier } from "@atmo-dev/contrail-base"; 15 17 import type { CommunityIntegration } from "../community-integration"; 16 18 import { registerRealtimeRoutes } from "../realtime/router"; 17 19 import type { RealtimeRoutesOptions } from "../realtime/router"; ··· 29 31 export interface SpacesContext { 30 32 adapter: StorageAdapter; 31 33 verifier: ServiceJwtVerifier; 34 + /** Verifies inbound `X-Membership-Manifest` headers. Built automatically 35 + * when an authority is configured locally with signing keys; deployments 36 + * that aggregate manifests from multiple authorities should construct one 37 + * via {@link createManifestVerifier} with a custom key resolver and pass 38 + * it through `options.spacesCtx`. */ 39 + manifestVerifier?: ManifestVerifier; 32 40 } 33 41 34 42 export interface CreateAppOptions { ··· 131 139 ? { 132 140 adapter: options.spaces?.adapter ?? new HostedAdapter(spacesDb, config), 133 141 verifier: buildVerifier(config.spaces.authority), 142 + manifestVerifier: config.spaces.authority.signing 143 + ? createManifestVerifier({ 144 + resolveKey: async (iss) => 145 + iss === config.spaces!.authority!.serviceDid 146 + ? config.spaces!.authority!.signing!.publicKey 147 + : null, 148 + }) 149 + : undefined, 134 150 } 135 151 : null; 136 152
+37
packages/contrail-base/src/spaces/manifest.ts
··· 138 138 return { ok: true, claims }; 139 139 } 140 140 141 + /** Stateful manifest verifier with a TTL'd in-memory cache keyed by JWT. 142 + * Returns the same shape as {@link verifyMembershipManifest}; cache hits skip 143 + * the crypto round-trip but still respect `exp`. Reuse one verifier per 144 + * process — never construct per-request. */ 145 + export interface ManifestVerifier { 146 + verify(jwt: string): Promise<ManifestVerifyOk | ManifestVerifyErr>; 147 + } 148 + 149 + /** Build a manifest verifier with caching. The cache is unbounded for now — 150 + * manifests are short-lived (default 2h) and clients typically refresh them, 151 + * so cardinality is bounded by active-user count. */ 152 + export function createManifestVerifier(opts: VerifyManifestOptions): ManifestVerifier { 153 + const cache = new Map<string, ManifestVerifyOk | ManifestVerifyErr>(); 154 + const now = opts.now ?? Date.now; 155 + return { 156 + async verify(jwt) { 157 + const cached = cache.get(jwt); 158 + if (cached) { 159 + if (cached.ok) { 160 + if (cached.claims.exp * 1000 > now()) return cached; 161 + cache.delete(jwt); 162 + } else { 163 + // Negative cache only for permanent failures (signature, alg); 164 + // expired / not-yet-valid will roll over with the clock. 165 + if (cached.reason === "bad-signature" || cached.reason === "bad-alg" || cached.reason === "malformed") { 166 + return cached; 167 + } 168 + cache.delete(jwt); 169 + } 170 + } 171 + const result = await verifyMembershipManifest(jwt, opts); 172 + cache.set(jwt, result); 173 + return result; 174 + }, 175 + }; 176 + } 177 + 141 178 /** Peek at claims without verifying — useful for routing decisions. */ 142 179 export function decodeUnverifiedManifest(jwt: string): MembershipManifestClaims | null { 143 180 const parts = jwt.split(".");
+210
packages/contrail/tests/spaces-manifest-appview.test.ts
··· 1 + /** Appview-side manifest consumption: cross-space listRecords union path 2 + * honoring `X-Membership-Manifest`. */ 3 + 4 + import { describe, it, expect, beforeAll } from "vitest"; 5 + import { Hono } from "hono"; 6 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 7 + import { initSchema } from "../src/core/db/schema"; 8 + import { createApp } from "../src/core/router"; 9 + import { resolveConfig } from "../src/core/types"; 10 + import type { ContrailConfig } from "../src/core/types"; 11 + import { 12 + generateAuthoritySigningKey, 13 + issueMembershipManifest, 14 + markInProcess, 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 + const SPACE_TYPE = "tools.atmo.event.space"; 24 + 25 + let SIGNING: CredentialKeyMaterial; 26 + 27 + beforeAll(async () => { 28 + SIGNING = await generateAuthoritySigningKey(); 29 + }); 30 + 31 + function makeConfig(): ContrailConfig { 32 + return { 33 + namespace: "test.man2", 34 + collections: { 35 + message: { collection: "app.event.message" }, 36 + }, 37 + spaces: { 38 + authority: { 39 + type: SPACE_TYPE, 40 + serviceDid: SERVICE_DID, 41 + signing: SIGNING, 42 + }, 43 + recordHost: {}, 44 + }, 45 + }; 46 + } 47 + 48 + async function makeApp(): Promise<Hono> { 49 + const db = createSqliteDatabase(":memory:"); 50 + const cfg = makeConfig(); 51 + const resolved = resolveConfig(cfg); 52 + await initSchema(db, resolved); 53 + return createApp(db, resolved); 54 + } 55 + 56 + /** Make a Request marked with an in-process principal so the union path's 57 + * `verifyServiceAuthRequest` returns the right caller without minting a real 58 + * JWT or wiring up a key resolver. */ 59 + function inProc(url: string, did: string, headers: Record<string, string> = {}): Request { 60 + const req = new Request(url, { headers }); 61 + return markInProcess(req, did); 62 + } 63 + 64 + async function createSpace(app: Hono, owner: string, key: string): Promise<string> { 65 + const res = await app.fetch( 66 + markInProcess( 67 + new Request("http://localhost/xrpc/test.man2.space.createSpace", { 68 + method: "POST", 69 + headers: { "Content-Type": "application/json" }, 70 + body: JSON.stringify({ key }), 71 + }), 72 + owner 73 + ) 74 + ); 75 + expect(res.status).toBe(200); 76 + const body = (await res.json()) as any; 77 + return body.space.uri; 78 + } 79 + 80 + async function plant( 81 + app: Hono, 82 + did: string, 83 + spaceUri: string, 84 + text: string 85 + ): Promise<void> { 86 + const res = await app.fetch( 87 + markInProcess( 88 + new Request("http://localhost/xrpc/test.man2.space.putRecord", { 89 + method: "POST", 90 + headers: { "Content-Type": "application/json" }, 91 + body: JSON.stringify({ 92 + spaceUri, 93 + collection: "app.event.message", 94 + record: { $type: "app.event.message", text }, 95 + }), 96 + }), 97 + did 98 + ) 99 + ); 100 + expect(res.status).toBe(200); 101 + } 102 + 103 + async function mintManifest(sub: string, spaces: string[]): Promise<string> { 104 + const { manifest } = await issueMembershipManifest( 105 + { iss: SERVICE_DID, sub, spaces, ttlMs: 60_000 }, 106 + SIGNING 107 + ); 108 + return manifest; 109 + } 110 + 111 + describe("appview union listRecords — manifest-driven", () => { 112 + it("uses manifest space list when valid + sub matches caller", async () => { 113 + const app = await makeApp(); 114 + // Alice owns one space, Bob owns another. 115 + const aliceSpace = await createSpace(app, ALICE, "alice-only"); 116 + const bobSpace = await createSpace(app, BOB, "bob-only"); 117 + await plant(app, ALICE, aliceSpace, "from-alice"); 118 + await plant(app, BOB, bobSpace, "from-bob"); 119 + 120 + // Alice presents a manifest covering ONLY bobSpace (doesn't matter that 121 + // she's not actually a member — the manifest is the source of truth here). 122 + // The authority would never sign such a manifest in practice, but the 123 + // appview's contract is: trust the verified manifest's claims. 124 + const manifest = await mintManifest(ALICE, [bobSpace]); 125 + 126 + const res = await app.fetch( 127 + inProc( 128 + "http://localhost/xrpc/test.man2.message.listRecords", 129 + ALICE, 130 + { "X-Membership-Manifest": manifest } 131 + ) 132 + ); 133 + expect(res.status).toBe(200); 134 + const body = (await res.json()) as any; 135 + const texts = body.records.map((r: any) => r.value.text); 136 + expect(texts).toContain("from-bob"); 137 + expect(texts).not.toContain("from-alice"); 138 + }); 139 + 140 + it("rejects manifest whose sub does not match the caller", async () => { 141 + const app = await makeApp(); 142 + const aliceSpace = await createSpace(app, ALICE, "alice-only"); 143 + await plant(app, ALICE, aliceSpace, "from-alice"); 144 + 145 + // Bob's manifest, presented by Alice → should 403. 146 + const bobManifest = await mintManifest(BOB, [aliceSpace]); 147 + 148 + const res = await app.fetch( 149 + inProc( 150 + "http://localhost/xrpc/test.man2.message.listRecords", 151 + ALICE, 152 + { "X-Membership-Manifest": bobManifest } 153 + ) 154 + ); 155 + expect(res.status).toBe(403); 156 + expect((await res.json() as any).reason).toBe("manifest-sub-mismatch"); 157 + }); 158 + 159 + it("rejects an unsigned/forged manifest", async () => { 160 + const app = await makeApp(); 161 + const otherKey = await generateAuthoritySigningKey(); 162 + const { manifest } = await issueMembershipManifest( 163 + { iss: SERVICE_DID, sub: ALICE, spaces: [], ttlMs: 60_000 }, 164 + otherKey 165 + ); 166 + const res = await app.fetch( 167 + inProc( 168 + "http://localhost/xrpc/test.man2.message.listRecords", 169 + ALICE, 170 + { "X-Membership-Manifest": manifest } 171 + ) 172 + ); 173 + expect(res.status).toBe(401); 174 + expect((await res.json() as any).reason).toBe("bad-signature"); 175 + }); 176 + 177 + it("falls back to local listSpaces when no manifest is present", async () => { 178 + const app = await makeApp(); 179 + const aliceSpace = await createSpace(app, ALICE, "alice-only"); 180 + const bobSpace = await createSpace(app, BOB, "bob-only"); 181 + await plant(app, ALICE, aliceSpace, "from-alice"); 182 + await plant(app, BOB, bobSpace, "from-bob"); 183 + 184 + // Alice queries with no manifest → local listSpaces returns aliceSpace only. 185 + const res = await app.fetch( 186 + inProc("http://localhost/xrpc/test.man2.message.listRecords", ALICE) 187 + ); 188 + expect(res.status).toBe(200); 189 + const body = (await res.json()) as any; 190 + const texts = body.records.map((r: any) => r.value.text); 191 + expect(texts).toContain("from-alice"); 192 + expect(texts).not.toContain("from-bob"); 193 + }); 194 + 195 + it("anonymous (no auth, no manifest) gets public results — no 401", async () => { 196 + const app = await makeApp(); 197 + const aliceSpace = await createSpace(app, ALICE, "alice-only"); 198 + await plant(app, ALICE, aliceSpace, "from-alice"); 199 + 200 + const res = await app.fetch( 201 + new Request("http://localhost/xrpc/test.man2.message.listRecords") 202 + ); 203 + // No auth header, no manifest → drops through to anonymous public path. 204 + expect(res.status).toBe(200); 205 + const body = (await res.json()) as any; 206 + // Private space records aren't visible publicly. 207 + const texts = (body.records ?? []).map((r: any) => r.value?.text); 208 + expect(texts).not.toContain("from-alice"); 209 + }); 210 + });
+2
packages/contrail/vitest.config.ts
··· 4 4 const baseSrc = path.resolve(__dirname, "../contrail-base/src"); 5 5 const authoritySrc = path.resolve(__dirname, "../contrail-authority/src"); 6 6 const recordHostSrc = path.resolve(__dirname, "../contrail-record-host/src"); 7 + const appviewSrc = path.resolve(__dirname, "../contrail-appview/src"); 7 8 8 9 export default defineConfig({ 9 10 test: { ··· 20 21 "@atmo-dev/contrail-base": path.join(baseSrc, "index.ts"), 21 22 "@atmo-dev/contrail-authority": path.join(authoritySrc, "index.ts"), 22 23 "@atmo-dev/contrail-record-host": path.join(recordHostSrc, "index.ts"), 24 + "@atmo-dev/contrail-appview": path.join(appviewSrc, "index.ts"), 23 25 }, 24 26 }, 25 27 });