/** * End-to-end test exercising the full ProvisionOrchestrator flow against the * live devnet stack (PDS on :4000, PLC on :2582). Validates the 5-RPC sequence * genesis op → createAccount → getRecommendedDidCredentials * → PLC update op → activateAccount * lands an activated account. * * Catches integration bugs that mocks can't: * - hand-rolled ES256 service-auth JWT vs real atproto verifier * - hand-rolled DAG-CBOR encoder output vs real PLC parser * - genesis-op DID computation matches what PLC expects * - cidForOp output accepted by PLC as `prev` for the update op * - low-S signature normalization * * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { randomUUID } from "node:crypto"; import pg from "pg"; import type { Client } from "@atcute/client"; import { CommunityAdapter, CredentialCipher, Contrail, ProvisionOrchestrator, initCommunitySchema, pdsCreateAccount, pdsGetRecommendedDidCredentials, pdsActivateAccount, pdsCreateAppPassword, generateKeyPair, createPdsSession, submitGenesisOp, type PdsClient, type PlcClient, } from "@atmo-dev/contrail"; import { createHandler } from "@atmo-dev/contrail/server"; import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; import { PDS_URL, PLC_URL, HANDLE_DOMAIN, PDS_ADMIN_PASSWORD, CONTRAIL_SERVICE_DID, createCaller, createDevnetResolver, createIsolatedSchema, createTestAccount, login, type CallAs, type TestAccount, } from "./helpers"; describe("ProvisionOrchestrator devnet e2e", () => { let pool: pg.Pool; let cleanupSchema: () => Promise; let adapter: CommunityAdapter; let cipher: CredentialCipher; let pdsDid: string; beforeAll(async () => { // Discover the live PDS's DID via describeServer — used as the `aud` // claim in the service-auth JWT we mint for createAccount. const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); if (!res.ok) { throw new Error( `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, ); } const body = (await res.json()) as { did?: string }; if (!body.did) { throw new Error(`describeServer response missing did: ${JSON.stringify(body)}`); } pdsDid = body.did; const iso = await createIsolatedSchema("test_provision_e2e"); pool = iso.pool; cleanupSchema = iso.cleanup; const db = createPostgresDatabase(pool); await initCommunitySchema(db); adapter = new CommunityAdapter(db); cipher = new CredentialCipher(new Uint8Array(32).fill(7)); }, 15_000); afterAll(async () => { await cleanupSchema?.(); }); // Adapt our bare module-level functions to the orchestrator's wrapper // interfaces. Shared by the managed and self-sovereign tests so they hit // the same live PDS surface (Task 16 added createAppPassword to PdsClient). const pdsClient: PdsClient = { createAccount: ({ pdsUrl, serviceAuthJwt, body }) => pdsCreateAccount(pdsUrl, serviceAuthJwt, body), getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => pdsGetRecommendedDidCredentials(pdsUrl, accessJwt), activateAccount: ({ pdsUrl, accessJwt }) => pdsActivateAccount(pdsUrl, accessJwt), createAppPassword: ({ pdsUrl, accessJwt, name }) => pdsCreateAppPassword(pdsUrl, accessJwt, name), }; const plcClient: PlcClient = { submit: (did, op) => submitGenesisOp(PLC_URL, did, op as any), }; /** Mint a single-use invite via the PDS admin API. Shared helper for both * the managed and self-sovereign tests. */ async function mintInvite(): Promise { const inviteRes = await fetch( `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { method: "POST", headers: { "content-type": "application/json", authorization: `Basic ${Buffer.from( `admin:${PDS_ADMIN_PASSWORD}`, ).toString("base64")}`, }, body: JSON.stringify({ useCount: 1 }), }, ); if (!inviteRes.ok) { throw new Error( `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, ); } return ((await inviteRes.json()) as { code: string }).code; } it( "provisions a self-sovereign community: caller holds rotation key, contrail mints app password", async () => { // Caller-held rotation keypair. The private JWK never leaves this test — // only callerRotation.publicDidKey is passed to the orchestrator. That's // the negative invariant we assert below: no encrypted_* column on the // persisted row contains the caller's did:key after decrypt. const callerRotation = await generateKeyPair(); const inviteCode = await mintInvite(); // Keep handle short — devnet caps the local label at 18 chars. // `ss-` (3) + 8-char suffix = 11 chars on the local label. const suffix = `${Date.now().toString(36).slice(-5)}${Math.random() .toString(36) .slice(2, 5)}`; const handle = `ss-${suffix}${HANDLE_DOMAIN}`; const email = `${suffix}@devnet.test`; const password = `pw-${suffix}`; const attemptId = randomUUID(); const orch = new ProvisionOrchestrator({ adapter, cipher, plc: plcClient, pds: pdsClient, pdsDid, }); const result = await orch.provision({ attemptId, pdsEndpoint: PDS_URL, handle, email, password, inviteCode, rotationKey: callerRotation.publicDidKey, }); // Result-shape assertions: status activated; rootCredentials returned // with the user's *root* password, not the minted app password. expect(result.attemptId).toBe(attemptId); expect(result.did).toMatch(/^did:plc:[a-z2-7]{24}$/); expect(result.status).toBe("activated"); expect(result.rootCredentials).toBeDefined(); expect(result.rootCredentials!.handle).toBe(handle); expect(result.rootCredentials!.password).toBe(password); expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); expect(result.rootCredentials!.recoveryHint.length).toBeGreaterThan(0); // Persisted-row assertions: self-sovereign mode persists an *encrypted // app password* — never the user's root password. Contrail's rotation // key is the SUBORDINATE (rotationKeys[1]); the caller's did:key is // rotationKeys[0] in the genesis op and lives only in PLC, never in // any encrypted_* column. const row = await adapter.getProvisionAttempt(attemptId); expect(row).not.toBeNull(); expect(row!.status).toBe("activated"); expect(row!.did).toBe(result.did); expect(row!.handle).toBe(handle); expect(row!.encryptedSigningKey).toBeTruthy(); expect(row!.encryptedRotationKey).toBeTruthy(); expect(row!.encryptedPassword).toBeTruthy(); expect(row!.activatedAt).toBeTruthy(); expect(row!.lastError).toBeNull(); // Decrypt the persisted password — it must be the *minted app password*, // distinct from the user's root password we supplied. const decryptedAppPassword = await cipher.decryptString( row!.encryptedPassword!, ); expect(decryptedAppPassword).not.toBe(password); expect(decryptedAppPassword.length).toBeGreaterThan(0); // Decrypt the persisted rotation JWK — it must be Contrail's subordinate // key (a fresh P-256 keypair), NOT the caller's. We assert NOT-equal on // the JWK shape, including the `d` (private) coordinate which the caller // never sent. const decryptedRotationJwk = JSON.parse( await cipher.decryptString(row!.encryptedRotationKey!), ) as { kty?: string; crv?: string; x?: string; y?: string; d?: string }; expect(decryptedRotationJwk.kty).toBe("EC"); expect(decryptedRotationJwk.crv).toBe("P-256"); // The caller's private `d` coordinate must never appear in Contrail's // persistence — the strongest single-bit invariant of self-sovereign mode. expect(decryptedRotationJwk.d).not.toBe(callerRotation.privateJwk.d); // The public x/y must also differ — the persisted rotation key is a // subordinate Contrail-generated key, not a re-derivation of the caller's. expect(decryptedRotationJwk.x).not.toBe(callerRotation.privateJwk.x); expect(decryptedRotationJwk.y).not.toBe(callerRotation.privateJwk.y); // Negative invariant: the caller's public did:key string must NOT appear // inside ANY encrypted column after decryption. Encrypted_signing_key // is a JWK; encrypted_rotation_key is the subordinate JWK; the password // is opaque — none of them should contain the caller's did:key. const decryptedSigningKey = await cipher.decryptString( row!.encryptedSigningKey!, ); const callerDidKey = callerRotation.publicDidKey; expect(decryptedSigningKey.indexOf(callerDidKey)).toBe(-1); expect( await cipher .decryptString(row!.encryptedRotationKey!) .then((s) => s.indexOf(callerDidKey)), ).toBe(-1); expect(decryptedAppPassword.indexOf(callerDidKey)).toBe(-1); // Prove the *minted* app password works against the live PDS. // createPdsSession throws if the PDS rejects. const appSession = await createPdsSession( PDS_URL, handle, decryptedAppPassword, ); expect(appSession.did).toBe(result.did); expect(appSession.accessJwt).toBeTruthy(); // And the user's root password also still works — PDS supports multiple // credentials per account, so the caller's root creds remain valid. const rootSession = await createPdsSession(PDS_URL, handle, password); expect(rootSession.did).toBe(result.did); expect(rootSession.accessJwt).toBeTruthy(); // PLC-log assertion (H2): the post-activation update op must keep the // caller's did:key at rotationKeys[0]. Without this, contrail's // subordinate would silently take rotation priority and the caller // would lose self-sovereign recovery authority. const logRes = await fetch(`${PLC_URL}/${result.did}/log`); expect(logRes.ok).toBe(true); const log = (await logRes.json()) as Array<{ rotationKeys: string[]; }>; expect(log.length).toBeGreaterThanOrEqual(2); const lastOp = log[log.length - 1]!; expect(lastOp.rotationKeys[0]).toBe(callerRotation.publicDidKey); }, 30_000, ); }); /** * Routed end-to-end coverage for the XRPC surface of the provision flow: * `${NS}.community.provision` then `${NS}.community.putRecord` against the * same provisioned community. Differs from the orchestrator-only test above * by exercising the full Hono app — auth middleware, DB persistence, * bootstrapReservedSpaces, and the credential-proxy publish path that * Tasks 13/14 added. * * Also pins the Task 14 session-cache behavior: two sequential putRecords * should perform exactly **one** `com.atproto.server.createSession` call to * the PDS — the second hits the cached session. */ describe("community.provision + putRecord via XRPC route (devnet)", () => { const NS = "rsvp.atmo.community"; const SPACE_TYPE = "rsvp.atmo.event.space"; const POST_NSID = "app.bsky.feed.post"; const TEST_MASTER_KEY = new Uint8Array(32).fill(7); let pool: pg.Pool; let cleanupSchema: () => Promise; let pdsDid: string; let alice: TestAccount; let aliceClient: Client; let handle: (req: Request) => Promise; let callAs: CallAs; // Counts createSession calls to the live PDS so we can assert that the // session cache is reused across publishes (Task 14). let createSessionCount = 0; beforeAll(async () => { // Discover the live PDS's DID — needed as `aud` in the orchestrator's // service-auth JWT for createAccount. const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); if (!res.ok) { throw new Error( `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, ); } pdsDid = ((await res.json()) as { did?: string }).did!; const iso = await createIsolatedSchema("test_provision_router_e2e"); pool = iso.pool; cleanupSchema = iso.cleanup; const db = createPostgresDatabase(pool); // Wrap fetch to count createSession calls. Everything else passes // through unchanged so the orchestrator + publish paths hit real devnet. const countingFetch: typeof fetch = (input, init) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/xrpc/com.atproto.server.createSession")) { createSessionCount++; } return fetch(input as any, init); }; const contrail = new Contrail({ ...{ namespace: "rsvp.atmo", collections: { // Minimal collection set — we only need community routes registered; // no Jetstream ingestion is required for this test. post: { collection: POST_NSID }, }, }, db, spaces: { type: SPACE_TYPE, serviceDid: CONTRAIL_SERVICE_DID, resolver: createDevnetResolver(), }, community: { // The orchestrator uses cfg.serviceDid as the `aud` of the // createAccount service-auth JWT. The live devnet PDS validates // `aud` against its own DID, so this must be the PDS's DID — not // the Contrail service DID used for inbound JWT verification. serviceDid: pdsDid, masterKey: TEST_MASTER_KEY, plcDirectory: PLC_URL, resolver: createDevnetResolver(), fetch: countingFetch, allowProvisioning: true, }, }); await contrail.init(); handle = createHandler(contrail); callAs = createCaller(handle); // Alice acts as the provisioning caller — she becomes owner of the new // community's $admin and $publishers spaces, which lets her publish. alice = await createTestAccount(); aliceClient = await login(alice); }, 30_000); afterAll(async () => { await cleanupSchema?.(); }); it( "provisions via the XRPC route and publishes a record (one cached session across two putRecords)", async () => { // Mint an invite code via the PDS admin API — same pattern as the // orchestrator-only test above. const inviteRes = await fetch( `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { method: "POST", headers: { "content-type": "application/json", authorization: `Basic ${Buffer.from( `admin:${PDS_ADMIN_PASSWORD}`, ).toString("base64")}`, }, body: JSON.stringify({ useCount: 1 }), }, ); if (!inviteRes.ok) { throw new Error( `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, ); } const { code: inviteCode } = (await inviteRes.json()) as { code: string }; // Keep total label under PDS's 18-char limit: `r-` (2) + 8-char suffix // = 10 chars, well under the cap. const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() .toString(36) .slice(2, 4)}`; const newHandle = `r-${suffix}${HANDLE_DOMAIN}`; const email = `${suffix}@devnet.test`; const password = `pw-${suffix}`; const callerRotation = await generateKeyPair(); const baselineCreateSessionCount = createSessionCount; // ---- POST /xrpc/${NS}.provision ----------------------------------- const provRes = await callAs(aliceClient, "POST", `${NS}.provision`, { body: { handle: newHandle, email, password, inviteCode, pdsEndpoint: PDS_URL, rotationKey: callerRotation.publicDidKey, }, }); const provText = await provRes.clone().text(); expect(provRes.status, provText).toBe(200); const provBody = (await provRes.json()) as { communityDid: string; status: string; }; expect(provBody.status).toBe("activated"); expect(provBody.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); const communityDid = provBody.communityDid; // The orchestrator never calls createSession during provision — it // gets accessJwt + refreshJwt directly from the createAccount response // and seeds the community_sessions cache before returning, so the first // publish hits a warm cache. const provisionCreateSessionCount = createSessionCount - baselineCreateSessionCount; expect(provisionCreateSessionCount).toBe(0); // ---- First putRecord: cache miss → createSession ------------------ const firstRecord = { $type: POST_NSID, text: `routed-e2e first ${suffix}`, createdAt: new Date().toISOString(), }; const put1 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { body: { communityDid, collection: POST_NSID, record: firstRecord, }, }); const put1Text = await put1.clone().text(); expect(put1.status, put1Text).toBe(200); const put1Body = (await put1.json()) as { uri: string; cid: string }; expect(put1Body.uri).toMatch( new RegExp(`^at://${communityDid}/${POST_NSID}/`), ); expect(put1Body.cid).toBeTruthy(); // ---- Second putRecord: cache hit → no createSession --------------- const secondRecord = { $type: POST_NSID, text: `routed-e2e second ${suffix}`, createdAt: new Date().toISOString(), }; const put2 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { body: { communityDid, collection: POST_NSID, record: secondRecord, }, }); const put2Text = await put2.clone().text(); expect(put2.status, put2Text).toBe(200); const put2Body = (await put2.json()) as { uri: string; cid: string }; expect(put2Body.uri).toMatch( new RegExp(`^at://${communityDid}/${POST_NSID}/`), ); // Zero createSession across the whole flow: provision pre-warms the // cache, then both publishes hit the 30s-skew cache. const publishesCreateSessionCount = createSessionCount - baselineCreateSessionCount; expect(publishesCreateSessionCount).toBe(0); }, 60_000, ); // TODO: cover stale-session auto-recovery (provision Task 14, ensureSession // refresh path). Hard to exercise deterministically against live devnet // without aging out a real `accessExp` past the 30s skew; covered by unit // tests in packages/contrail/tests/community-publishing.test.ts. });