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

add e2e tests for spaces auth path and privacy invariants

Three new tests in apps/contrail-e2e, each running in-process against a
real devnet PDS + PLC + Postgres with spaces enabled:

- spaces-auth.test.ts: real service-auth JWT minted via
com.atproto.server.getServiceAuth is accepted; wrong aud and wrong
lxm binding are rejected. Exercises the full verifier path (PDS
signs with the user's PLC-published key, Contrail resolves the key
from devnet PLC, @atcute/xrpc-server verifies the signature) — the
existing package-level spaces tests all use a fakeAuth header and
never touch this path.

- spaces-firehose-invisibility.test.ts: records written via
{ns}.space.putRecord do not appear on the ATProto firehose. Uses a
direct-to-PDS createRecord as a positive control so a silently
broken subscriber can't make the negative assertion vacuous.

- spaces-table-isolation.test.ts: a space putRecord lands in
spaces_records_<collection> with the public records_<collection>
table empty for the same caller — proves the store-level separation
that the spaces privacy model depends on.

helpers.ts gains CONTRAIL_SERVICE_DID, createDevnetResolver (PlcDid-
DocumentResolver pointed at devnet PLC), and mintServiceAuthJwt. Adds
@atcute/identity-resolver, @atcute/jetstream, and @atcute/lexicons as
direct devDependencies.

Tom Scanlan (Apr 24, 2026, 7:04 PM EDT) d02c0c68 ca8a7b36

+529
+3
apps/contrail-e2e/package.json
··· 16 16 "devDependencies": { 17 17 "@atcute/atproto": "^3.1.10", 18 18 "@atcute/client": "^4.2.1", 19 + "@atcute/identity-resolver": "^1.2.2", 20 + "@atcute/jetstream": "^1.1.2", 21 + "@atcute/lexicons": "^1.3.0", 19 22 "@types/pg": "^8.20.0", 20 23 "typescript": "^5.9.3", 21 24 "vitest": "^4.1.0"
+52
apps/contrail-e2e/tests/helpers.ts
··· 6 6 * dogfooding ingester the developer might have running in another terminal. 7 7 */ 8 8 import pg from "pg"; 9 + import type { Client } from "@atcute/client"; 10 + import { 11 + CompositeDidDocumentResolver, 12 + PlcDidDocumentResolver, 13 + } from "@atcute/identity-resolver"; 14 + import type { Did as AtDid, Nsid } from "@atcute/lexicons"; 9 15 10 16 export type Did = `did:${string}:${string}`; 11 17 12 18 export const PDS_PORT = Number(process.env.DEVNET_PDS_PORT ?? 4000); 13 19 export const PDS_URL = `http://localhost:${PDS_PORT}`; 20 + export const PLC_PORT = Number(process.env.DEVNET_PLC_PORT ?? 2582); 21 + export const PLC_URL = `http://localhost:${PLC_PORT}`; 14 22 export const HANDLE_DOMAIN = process.env.DEVNET_HANDLE_DOMAIN ?? ".devnet.test"; 15 23 export const PDS_ADMIN_PASSWORD = process.env.DEVNET_PDS_ADMIN_PASSWORD ?? "devnet-admin-password"; 16 24 export const DATABASE_URL = 17 25 process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5433/contrail"; 26 + 27 + /** 28 + * Arbitrary service DID used for the test Contrail deployment. The only 29 + * requirements: (a) the JWTs we mint via getServiceAuth use this as their 30 + * `aud` claim, and (b) Contrail's SpacesConfig.serviceDid matches. The DID 31 + * itself doesn't need to be resolvable — the verifier only resolves issuers 32 + * (users), not the audience. 33 + */ 34 + export const CONTRAIL_SERVICE_DID = "did:web:contrail-test.devnet.test"; 35 + 36 + /** 37 + * Resolver that points the PLC method at the local devnet PLC on :2582. 38 + * Without this, the default resolver hits plc.directory and 404s on every 39 + * devnet DID. 40 + */ 41 + export function createDevnetResolver() { 42 + return new CompositeDidDocumentResolver({ 43 + methods: { 44 + plc: new PlcDidDocumentResolver({ apiUrl: PLC_URL }), 45 + }, 46 + }); 47 + } 48 + 49 + /** 50 + * Mint an atproto service-auth JWT via the PDS's getServiceAuth endpoint. 51 + * Requires the client to already be authed for a user. Returns the raw JWT 52 + * string suitable for `Authorization: Bearer <token>`. 53 + */ 54 + export async function mintServiceAuthJwt( 55 + client: Client, 56 + opts: { aud: string; lxm?: string; expSeconds?: number }, 57 + ): Promise<string> { 58 + const params: { aud: AtDid; lxm?: Nsid; exp?: number } = { 59 + aud: opts.aud as AtDid, 60 + }; 61 + if (opts.lxm) params.lxm = opts.lxm as Nsid; 62 + if (opts.expSeconds) params.exp = Math.floor(Date.now() / 1000) + opts.expSeconds; 63 + 64 + const res = await client.get("com.atproto.server.getServiceAuth", { params }); 65 + if (!res.ok) { 66 + throw new Error(`getServiceAuth → ${res.status}: ${JSON.stringify(res.data)}`); 67 + } 68 + return res.data.token; 69 + } 18 70 19 71 export type TestAccount = { handle: string; password: string; did: Did }; 20 72
+127
apps/contrail-e2e/tests/spaces-auth.test.ts
··· 1 + /** 2 + * Service-auth JWT end-to-end against spaces XRPCs. 3 + * 4 + * 1. Alice mints a JWT via com.atproto.server.getServiceAuth, calls 5 + * {ns}.space.createSpace → verifier accepts, space is created. 6 + * 2. JWT with wrong audience → verifier rejects, 401. 7 + * 3. JWT with lxm bound to one method, used on a different method → 401. 8 + * 9 + * The full auth path is exercised: PDS signs with Alice's PLC-published 10 + * key, Contrail's resolver reads that key from devnet PLC, real verifier 11 + * checks the signature. No mocks on the auth path. 12 + */ 13 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 14 + import pg from "pg"; 15 + import { CredentialManager, Client } from "@atcute/client"; 16 + import "@atcute/atproto"; 17 + import { Contrail } from "@atmo-dev/contrail"; 18 + import { createHandler } from "@atmo-dev/contrail/server"; 19 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 20 + import { config as baseConfig } from "../config"; 21 + import { 22 + createTestAccount, 23 + createIsolatedSchema, 24 + createDevnetResolver, 25 + mintServiceAuthJwt, 26 + CONTRAIL_SERVICE_DID, 27 + PDS_URL, 28 + type TestAccount, 29 + } from "./helpers"; 30 + 31 + const SPACE_TYPE = "rsvp.atmo.event.space"; 32 + 33 + describe("spaces auth (devnet PDS JWT → Contrail verifier)", () => { 34 + let alice: TestAccount; 35 + let aliceClient: Client; 36 + let pool: pg.Pool; 37 + let cleanupSchema: () => Promise<void>; 38 + let handle: (req: Request) => Promise<Response>; 39 + 40 + beforeAll(async () => { 41 + alice = await createTestAccount(); 42 + const creds = new CredentialManager({ service: PDS_URL }); 43 + await creds.login({ identifier: alice.handle, password: alice.password }); 44 + aliceClient = new Client({ handler: creds }); 45 + 46 + const iso = await createIsolatedSchema("test_spaces_auth"); 47 + pool = iso.pool; 48 + cleanupSchema = iso.cleanup; 49 + const db = createPostgresDatabase(pool); 50 + 51 + const contrail = new Contrail({ 52 + ...baseConfig, 53 + db, 54 + spaces: { 55 + type: SPACE_TYPE, 56 + serviceDid: CONTRAIL_SERVICE_DID, 57 + resolver: createDevnetResolver(), 58 + }, 59 + }); 60 + await contrail.init(); 61 + handle = createHandler(contrail); 62 + }); 63 + 64 + afterAll(async () => { 65 + await cleanupSchema?.(); 66 + }); 67 + 68 + async function callXrpc( 69 + method: "GET" | "POST", 70 + path: string, 71 + opts: { token?: string; body?: unknown } = {}, 72 + ): Promise<Response> { 73 + const headers: Record<string, string> = {}; 74 + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; 75 + if (opts.body !== undefined) headers["content-type"] = "application/json"; 76 + return handle( 77 + new Request(`http://test${path}`, { 78 + method, 79 + headers, 80 + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, 81 + }), 82 + ); 83 + } 84 + 85 + it("accepts a real service-auth JWT and creates a space", async () => { 86 + const token = await mintServiceAuthJwt(aliceClient, { 87 + aud: CONTRAIL_SERVICE_DID, 88 + lxm: "rsvp.atmo.space.createSpace", 89 + }); 90 + 91 + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { 92 + token, 93 + body: {}, 94 + }); 95 + const text = await res.clone().text().catch(() => ""); 96 + expect(res.status, `createSpace → ${res.status}: ${text}`).toBe(200); 97 + 98 + const data = (await res.json()) as { space: { uri: string; ownerDid: string } }; 99 + expect(data.space.uri).toMatch(/^at:\/\//); 100 + expect(data.space.ownerDid).toBe(alice.did); 101 + }); 102 + 103 + it("rejects a JWT minted with the wrong audience", async () => { 104 + const token = await mintServiceAuthJwt(aliceClient, { 105 + aud: "did:web:not-contrail.devnet.test", 106 + }); 107 + 108 + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { 109 + token, 110 + body: {}, 111 + }); 112 + expect(res.status).toBe(401); 113 + }); 114 + 115 + it("rejects a JWT whose lxm binding mismatches the route", async () => { 116 + const token = await mintServiceAuthJwt(aliceClient, { 117 + aud: CONTRAIL_SERVICE_DID, 118 + lxm: "rsvp.atmo.space.listSpaces", 119 + }); 120 + 121 + const res = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { 122 + token, 123 + body: {}, 124 + }); 125 + expect(res.status).toBe(401); 126 + }); 127 + });
+201
apps/contrail-e2e/tests/spaces-firehose-invisibility.test.ts
··· 1 + /** 2 + * Records put into a private space must not appear on the ATProto firehose. 3 + * 4 + * Protocol: 5 + * 1. Subscribe to Jetstream filtered to Alice's DID + the event collection. 6 + * 2. Alice publishes a control record directly to her PDS → MUST appear 7 + * on the firehose (proves the subscriber works). 8 + * 3. Alice puts a record into a space via {ns}.space.putRecord → MUST NOT 9 + * appear on the firehose. 10 + * 11 + * Without (2) as a positive control, a silently broken subscriber would 12 + * make (3) vacuously true. 13 + */ 14 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 15 + import pg from "pg"; 16 + import { CredentialManager, Client } from "@atcute/client"; 17 + import "@atcute/atproto"; 18 + import { JetstreamSubscription, type JetstreamEvent } from "@atcute/jetstream"; 19 + import type { Did as AtDid } from "@atcute/lexicons"; 20 + import { Contrail } from "@atmo-dev/contrail"; 21 + import { createHandler } from "@atmo-dev/contrail/server"; 22 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 23 + import { config as baseConfig } from "../config"; 24 + import { 25 + createTestAccount, 26 + createIsolatedSchema, 27 + createDevnetResolver, 28 + mintServiceAuthJwt, 29 + CONTRAIL_SERVICE_DID, 30 + PDS_URL, 31 + type TestAccount, 32 + } from "./helpers"; 33 + 34 + const EVENT_NSID = "community.lexicon.calendar.event"; 35 + const SPACE_TYPE = "rsvp.atmo.event.space"; 36 + const JETSTREAM_URL = process.env.JETSTREAM_URL ?? "ws://localhost:6008/subscribe"; 37 + const PROPAGATION_MS = 2_500; 38 + 39 + describe("spaces firehose invisibility", () => { 40 + let alice: TestAccount; 41 + let aliceClient: Client; 42 + let pool: pg.Pool; 43 + let cleanupSchema: () => Promise<void>; 44 + let handle: (req: Request) => Promise<Response>; 45 + 46 + beforeAll(async () => { 47 + alice = await createTestAccount(); 48 + const creds = new CredentialManager({ service: PDS_URL }); 49 + await creds.login({ identifier: alice.handle, password: alice.password }); 50 + aliceClient = new Client({ handler: creds }); 51 + 52 + const iso = await createIsolatedSchema("test_firehose_invisibility"); 53 + pool = iso.pool; 54 + cleanupSchema = iso.cleanup; 55 + const db = createPostgresDatabase(pool); 56 + 57 + const contrail = new Contrail({ 58 + ...baseConfig, 59 + db, 60 + spaces: { 61 + type: SPACE_TYPE, 62 + serviceDid: CONTRAIL_SERVICE_DID, 63 + resolver: createDevnetResolver(), 64 + }, 65 + }); 66 + await contrail.init(); 67 + handle = createHandler(contrail); 68 + }); 69 + 70 + afterAll(async () => { 71 + await cleanupSchema?.(); 72 + }); 73 + 74 + async function callXrpc( 75 + method: "GET" | "POST", 76 + path: string, 77 + opts: { token?: string; body?: unknown } = {}, 78 + ): Promise<Response> { 79 + const headers: Record<string, string> = {}; 80 + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; 81 + if (opts.body !== undefined) headers["content-type"] = "application/json"; 82 + return handle( 83 + new Request(`http://test${path}`, { 84 + method, 85 + headers, 86 + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, 87 + }), 88 + ); 89 + } 90 + 91 + it("publishes PDS writes to firehose; space writes stay invisible", async () => { 92 + const observed = new Set<string>(); 93 + const ac = new AbortController(); 94 + const opened = deferred<void>(); 95 + 96 + const sub = new JetstreamSubscription({ 97 + url: JETSTREAM_URL, 98 + wantedCollections: [EVENT_NSID], 99 + wantedDids: [alice.did as unknown as AtDid], 100 + onConnectionOpen: () => opened.resolve(), 101 + }); 102 + 103 + const collector = (async () => { 104 + const iterator = sub[Symbol.asyncIterator](); 105 + try { 106 + while (!ac.signal.aborted) { 107 + const result = await Promise.race([ 108 + iterator.next(), 109 + new Promise<IteratorResult<JetstreamEvent>>((resolve) => { 110 + ac.signal.addEventListener( 111 + "abort", 112 + () => resolve({ value: undefined, done: true }), 113 + { once: true }, 114 + ); 115 + }), 116 + ]); 117 + if (result.done) break; 118 + const ev = result.value; 119 + if (ev.kind === "commit" && ev.did === alice.did) { 120 + observed.add(ev.commit.rkey); 121 + } 122 + } 123 + } finally { 124 + await iterator.return?.(); 125 + } 126 + })(); 127 + 128 + await opened.promise; 129 + 130 + // Control: direct PDS write must land on the firehose. 131 + const controlRes = await aliceClient.post("com.atproto.repo.createRecord", { 132 + input: { 133 + repo: alice.did, 134 + collection: EVENT_NSID as never, 135 + record: eventRecord("firehose-control"), 136 + }, 137 + }); 138 + expect(controlRes.ok, `control createRecord: ${JSON.stringify(controlRes.data)}`).toBe(true); 139 + if (!controlRes.ok) throw new Error("unreachable"); 140 + const controlRkey = controlRes.data.uri.split("/").pop()!; 141 + 142 + // Private: space write must not. 143 + const createToken = await mintServiceAuthJwt(aliceClient, { 144 + aud: CONTRAIL_SERVICE_DID, 145 + lxm: "rsvp.atmo.space.createSpace", 146 + }); 147 + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { 148 + token: createToken, 149 + body: {}, 150 + }); 151 + expect(createRes.status).toBe(200); 152 + const { space } = (await createRes.json()) as { space: { uri: string } }; 153 + 154 + const putToken = await mintServiceAuthJwt(aliceClient, { 155 + aud: CONTRAIL_SERVICE_DID, 156 + lxm: "rsvp.atmo.space.putRecord", 157 + }); 158 + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { 159 + token: putToken, 160 + body: { 161 + spaceUri: space.uri, 162 + collection: EVENT_NSID, 163 + record: eventRecord("private-in-space"), 164 + }, 165 + }); 166 + const putText = await putRes.clone().text().catch(() => ""); 167 + expect(putRes.status, `space.putRecord → ${putRes.status}: ${putText}`).toBe(200); 168 + const { rkey: spaceRkey } = (await putRes.json()) as { rkey: string }; 169 + 170 + await new Promise((r) => setTimeout(r, PROPAGATION_MS)); 171 + 172 + ac.abort(); 173 + await collector; 174 + 175 + expect( 176 + observed.has(controlRkey), 177 + `control PDS record ${controlRkey} must appear on firehose; observed: ${[...observed].join(",") || "(none)"}`, 178 + ).toBe(true); 179 + expect( 180 + observed.has(spaceRkey), 181 + `space record ${spaceRkey} must NOT appear on firehose`, 182 + ).toBe(false); 183 + }); 184 + }); 185 + 186 + function eventRecord(name: string) { 187 + return { 188 + $type: EVENT_NSID, 189 + name, 190 + createdAt: new Date().toISOString(), 191 + startsAt: new Date(Date.now() + 60_000).toISOString(), 192 + mode: `${EVENT_NSID}#inperson`, 193 + status: `${EVENT_NSID}#scheduled`, 194 + }; 195 + } 196 + 197 + function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } { 198 + let resolve!: (value: T) => void; 199 + const promise = new Promise<T>((r) => (resolve = r)); 200 + return { promise, resolve }; 201 + }
+137
apps/contrail-e2e/tests/spaces-table-isolation.test.ts
··· 1 + /** 2 + * Records written via {ns}.space.putRecord land in `spaces_records_<collection>`, 3 + * not `records_<collection>`. The public table must stay empty for the caller. 4 + * 5 + * 1. Alice creates a space and puts an event into it. 6 + * 2. Query postgres directly: 7 + * - records_event → 0 rows for alice.did 8 + * - spaces_records_event → 1 row with matching rkey and space_uri 9 + * 10 + * A leak between these tables would silently expose private records to any 11 + * public {ns}.event.getRecord / listRecords call. 12 + */ 13 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 14 + import pg from "pg"; 15 + import { CredentialManager, Client } from "@atcute/client"; 16 + import "@atcute/atproto"; 17 + import { Contrail } from "@atmo-dev/contrail"; 18 + import { createHandler } from "@atmo-dev/contrail/server"; 19 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 20 + import { config as baseConfig } from "../config"; 21 + import { 22 + createTestAccount, 23 + createIsolatedSchema, 24 + createDevnetResolver, 25 + mintServiceAuthJwt, 26 + CONTRAIL_SERVICE_DID, 27 + PDS_URL, 28 + type TestAccount, 29 + } from "./helpers"; 30 + 31 + const EVENT_NSID = "community.lexicon.calendar.event"; 32 + const SPACE_TYPE = "rsvp.atmo.event.space"; 33 + 34 + describe("spaces table isolation", () => { 35 + let alice: TestAccount; 36 + let aliceClient: Client; 37 + let pool: pg.Pool; 38 + let cleanupSchema: () => Promise<void>; 39 + let handle: (req: Request) => Promise<Response>; 40 + 41 + beforeAll(async () => { 42 + alice = await createTestAccount(); 43 + const creds = new CredentialManager({ service: PDS_URL }); 44 + await creds.login({ identifier: alice.handle, password: alice.password }); 45 + aliceClient = new Client({ handler: creds }); 46 + 47 + const iso = await createIsolatedSchema("test_table_isolation"); 48 + pool = iso.pool; 49 + cleanupSchema = iso.cleanup; 50 + const db = createPostgresDatabase(pool); 51 + 52 + const contrail = new Contrail({ 53 + ...baseConfig, 54 + db, 55 + spaces: { 56 + type: SPACE_TYPE, 57 + serviceDid: CONTRAIL_SERVICE_DID, 58 + resolver: createDevnetResolver(), 59 + }, 60 + }); 61 + await contrail.init(); 62 + handle = createHandler(contrail); 63 + }); 64 + 65 + afterAll(async () => { 66 + await cleanupSchema?.(); 67 + }); 68 + 69 + async function callXrpc( 70 + method: "GET" | "POST", 71 + path: string, 72 + opts: { token?: string; body?: unknown } = {}, 73 + ): Promise<Response> { 74 + const headers: Record<string, string> = {}; 75 + if (opts.token) headers["authorization"] = `Bearer ${opts.token}`; 76 + if (opts.body !== undefined) headers["content-type"] = "application/json"; 77 + return handle( 78 + new Request(`http://test${path}`, { 79 + method, 80 + headers, 81 + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, 82 + }), 83 + ); 84 + } 85 + 86 + it("space records live in spaces_records_event, not records_event", async () => { 87 + const createToken = await mintServiceAuthJwt(aliceClient, { 88 + aud: CONTRAIL_SERVICE_DID, 89 + lxm: "rsvp.atmo.space.createSpace", 90 + }); 91 + const createRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.createSpace", { 92 + token: createToken, 93 + body: {}, 94 + }); 95 + expect(createRes.status).toBe(200); 96 + const { space } = (await createRes.json()) as { space: { uri: string } }; 97 + 98 + const putToken = await mintServiceAuthJwt(aliceClient, { 99 + aud: CONTRAIL_SERVICE_DID, 100 + lxm: "rsvp.atmo.space.putRecord", 101 + }); 102 + const putRes = await callXrpc("POST", "/xrpc/rsvp.atmo.space.putRecord", { 103 + token: putToken, 104 + body: { 105 + spaceUri: space.uri, 106 + collection: EVENT_NSID, 107 + record: { 108 + $type: EVENT_NSID, 109 + name: "isolation-target", 110 + createdAt: new Date().toISOString(), 111 + startsAt: new Date(Date.now() + 60_000).toISOString(), 112 + mode: `${EVENT_NSID}#inperson`, 113 + status: `${EVENT_NSID}#scheduled`, 114 + }, 115 + }, 116 + }); 117 + expect(putRes.status, await putRes.clone().text().catch(() => "")).toBe(200); 118 + const { rkey } = (await putRes.json()) as { rkey: string }; 119 + 120 + const publicRows = await pool.query( 121 + "SELECT COUNT(*)::int AS n FROM records_event WHERE did = $1", 122 + [alice.did], 123 + ); 124 + expect(publicRows.rows[0].n, "records_event must not contain the space record").toBe(0); 125 + 126 + const spaceRows = await pool.query( 127 + "SELECT rkey, space_uri, did FROM spaces_records_event WHERE did = $1", 128 + [alice.did], 129 + ); 130 + expect(spaceRows.rows).toHaveLength(1); 131 + expect(spaceRows.rows[0]).toMatchObject({ 132 + rkey, 133 + space_uri: space.uri, 134 + did: alice.did, 135 + }); 136 + }); 137 + });
+9
pnpm-lock.yaml
··· 61 61 '@atcute/client': 62 62 specifier: ^4.2.1 63 63 version: 4.2.1 64 + '@atcute/identity-resolver': 65 + specifier: ^1.2.2 66 + version: 1.2.2(@atcute/identity@1.1.4) 67 + '@atcute/jetstream': 68 + specifier: ^1.1.2 69 + version: 1.1.2 70 + '@atcute/lexicons': 71 + specifier: ^1.3.0 72 + version: 1.3.0 64 73 '@types/pg': 65 74 specifier: ^8.20.0 66 75 version: 8.20.0