diff --git a/.changeset/itchy-rice-kick.md b/.changeset/itchy-rice-kick.md new file mode 100644 index 0000000..9dae968 --- /dev/null +++ b/.changeset/itchy-rice-kick.md @@ -0,0 +1,19 @@ +--- +"@atmo-dev/contrail": minor +--- + +A third community-creation mode: **provision**. alongside the existing `adopt` (caller already has a `did:plc`) and `mint` (caller wants a DID but brings their own PDS) modes, contrail can now provision a community on a stock `@atproto/pds` end-to-end — minting the `did:plc`, creating and activating the PDS account, generating an app password, and persisting credentials so the existing `community.putRecord` / `.deleteRecord` publish path keeps working. contrail never holds PDS admin credentials. + +**`xrpc/{ns}.community.provision`** runs the five-step PLC + PDS dance (key generation → PLC genesis → `createAccount` → `getRecommendedDidCredentials` + signed PLC update op → `activateAccount`), persists each step in a new `provision_attempts` table so a partially-failed attempt can be resumed, mints an app password, and seeds the session cache. + +**`contrail reap [--all-stuck] [--dry-run]`** new CLI subcommand that cleans up provision attempts which didn't reach `status='activated'` by tombstoning their PLC entries. `--dry-run` is the default; per-row confirmation is required for live reaping unless `--all-stuck` is given. + +custody model: the caller supplies a `rotationKey` and that key sits at `rotationKeys[0]` — the highest-priority rotation slot on the resulting DID. contrail generates a subordinate keypair and persists it (AES-GCM-encrypted under `masterKey`) at `rotationKeys[1]`, so it can submit later PLC ops on the community's behalf — most importantly the post-activation PLC update during provision, and the tombstone op that `reap` issues to clean up stuck DIDs. + +the caller's key dominates: PLC's 72-hour nullification window means any op contrail signs with its subordinate key can be overridden within 72h by an op signed with the caller's key. with this caveat: a tombstone is irrevocable. a malicious or compromised contrail instance could tombstone any DID it provisioned. there is no managed code path, no shared rotation, and `rootCredentials` are returned to the caller in the response so they can also be persisted out-of-band. + +what you need to configure / know: + +- new `community` config block: `masterKey` (32-byte AES-GCM envelope key for the encrypted credential columns), optional `allowedPdsEndpoints` (URL-origin matching, collapses scheme case / default ports / trailing slash / IDN), optional `plcDirectory` override. + +- new tables `provision_attempts` and `community_credentials`. credentials are stored AES-GCM-encrypted under that key; lose the key, lose the ability to mint sessions for previously-provisioned communities. diff --git a/.gitignore b/.gitignore index 09e8139..44f8311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules .wrangler dist +*.tsbuildinfo # Turbo .turbo diff --git a/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts b/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts new file mode 100644 index 0000000..e9d847c --- /dev/null +++ b/apps/contrail-e2e/tests/community-provision-walkthrough.test.ts @@ -0,0 +1,402 @@ +/** + * Provisioned-community lifecycle walkthrough — the "happy day" the PR was + * built for, end-to-end on the same handler. + * + * Each previous test pins one slice (provision-only, publishing-only, + * ACL-only, ingest-only). This one chains them so a regression on any seam + * between modules — provision → ACL → proxy publish → ingest of a RSVP from + * a separate PDS repo — surfaces here even when each unit test still passes. + * + * Flow (each step is also asserted, so the test reads top-to-bottom as docs): + * + * 1. PROVISION — Alice calls `community.provision` with a caller-held + * P-256 rotation key (sovereign mode). Asserts: status=activated, + * DID well-formed, PLC log shows the caller's did:key at + * rotationKeys[0] (the sovereignty invariant). + * + * 2. GRANT — Alice grants Bob `member` on the community's `$publishers` + * space via `community.space.grant`. Asserts: listMembers shows Bob + * with accessLevel=member. + * + * 3. PUBLISH — Bob calls `community.putRecord` to write a public + * `community.lexicon.calendar.event` against the community DID's repo + * (proxied through Contrail's credential vault — Bob never holds the + * community's app password). Asserts: returned URI is rooted at the + * community DID; the record is visible via `com.atproto.repo.listRecords` + * against the community's PDS (proves the proxy actually wrote to the + * community repo, not a local index). + * + * 4. RSVP — Carol, a totally separate PDS account with no relationship + * to the community, writes a `community.lexicon.calendar.rsvp` to her + * own repo with `subject.uri = at:///.../`. + * Anyone can RSVP — no grant required, that's the lexicon contract. + * + * 5. INDEX — The in-process ingester (Jetstream → Postgres) picks up both + * Bob's event and Carol's RSVP. Asserts: querying the event by URI + * shows rsvpsGoingCount=1, with the indexed event `did` being the + * community's DID (not Bob's, not Alice's). + * + * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import pg from "pg"; +import type { Client } from "@atcute/client"; +import "@atcute/atproto"; +import { + Contrail, + generateKeyPair, + runPersistent, +} from "@atmo-dev/contrail"; +import { createHandler } from "@atmo-dev/contrail/server"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { config as baseConfig } from "../config"; +import { + CONTRAIL_SERVICE_DID, + HANDLE_DOMAIN, + PDS_ADMIN_PASSWORD, + PDS_URL, + PLC_URL, + createCaller, + createDevnetResolver, + createIsolatedSchema, + createTestAccount, + devnetRewriteFetch, + getRecordFromPds, + jsonOr, + login, + waitFor, + type CallAs, + type TestAccount, +} from "./helpers"; + +const NS = `${baseConfig.namespace}.community`; +const SPACE_TYPE = "rsvp.atmo.event.space"; +const EVENT_NSID = "community.lexicon.calendar.event"; +const RSVP_NSID = "community.lexicon.calendar.rsvp"; +const TEST_MASTER_KEY = new Uint8Array(32).fill(7); + +describe("community provision → grant → publish → RSVP walkthrough", () => { + // Alice provisions the community (becomes owner of $admin and $publishers). + // Bob is granted member on $publishers and publishes the event on behalf + // of the community via the proxy. Carol is an arm's-length user on the + // same PDS who RSVPs from her own repo — she has no grants on the + // community, which is exactly the open-RSVP contract we want to pin. + let alice: TestAccount; + let bob: TestAccount; + let carol: TestAccount; + + let aliceClient: Client; + let bobClient: Client; + let carolClient: Client; + + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let pdsDid: string; + let handle: (req: Request) => Promise; + let callAs: CallAs; + + let ingestController: AbortController; + let ingestPromise: Promise; + + // Keypair held only by this test process; the public did:key is what we + // pass to provision. The private JWK never leaves the test — that's the + // sovereignty invariant we assert against the PLC log in step 1. + let callerRotation: Awaited>; + + // Carried between tests in declaration order. + let communityDid: string; + let publishersUri: string; + let eventUri: string; + let eventCid: string; + let eventRkey: string; + + beforeAll(async () => { + // Discover the live PDS's DID — the orchestrator uses this as the `aud` + // claim of the service-auth JWT it mints for createAccount, and the + // devnet PDS validates `aud` against its own DID. + const dres = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); + if (!dres.ok) { + throw new Error( + `devnet PDS unreachable at ${PDS_URL}: ${dres.status} ${await dres.text()}`, + ); + } + pdsDid = ((await dres.json()) as { did?: string }).did!; + + [alice, bob, carol] = await Promise.all([ + createTestAccount(), + createTestAccount(), + createTestAccount(), + ]); + + aliceClient = await login(alice); + bobClient = await login(bob); + carolClient = await login(carol); + + callerRotation = await generateKeyPair(); + + const iso = await createIsolatedSchema("test_provision_walkthrough"); + pool = iso.pool; + cleanupSchema = iso.cleanup; + const db = createPostgresDatabase(pool); + + const contrail = new Contrail({ + ...baseConfig, + db, + spaces: { + type: SPACE_TYPE, + serviceDid: CONTRAIL_SERVICE_DID, + resolver: createDevnetResolver(), + }, + community: { + // Provision uses serviceDid as the `aud` of its createAccount + // service-auth JWT — must be the live PDS's DID, not the Contrail + // service DID we use for inbound auth verification. + serviceDid: pdsDid, + masterKey: TEST_MASTER_KEY, + plcDirectory: PLC_URL, + resolver: createDevnetResolver(), + // Devnet PDSes publish https://devnet.test in their DID document's + // atproto_pds entry. Rewrite outgoing requests so the proxied + // publish lands on the host-mapped port. + fetch: devnetRewriteFetch, + allowProvisioning: true, + }, + }); + await contrail.init(); + handle = createHandler(contrail); + callAs = createCaller(handle); + + // Run the ingester in-process so step 5 can see Carol's RSVP land in + // the local index after she writes it directly to her PDS repo. + ingestController = new AbortController(); + ingestPromise = runPersistent(db, baseConfig, { + batchSize: 50, + flushIntervalMs: 500, + signal: ingestController.signal, + }); + }, 30_000); + + afterAll(async () => { + ingestController?.abort(); + await ingestPromise?.catch(() => {}); + await cleanupSchema?.(); + }); + + // ----- helpers -------------------------------------------------------------- + + async function getIndexedRecord(uri: string): Promise { + const url = + `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; + const res = await handle(new Request(url)); + if (res.status === 404) return undefined; + if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); + return await res.json(); + } + + async function mintPdsInvite(): Promise { + const res = 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 (!res.ok) { + throw new Error(`createInviteCode → ${res.status}: ${await res.text()}`); + } + return ((await res.json()) as { code: string }).code; + } + + // ----- step 1: PROVISION --------------------------------------------------- + // Sovereign provision: the caller passes the public did:key of a rotation + // key they hold. Contrail mints a subordinate rotation key, lays down a + // genesis op with [callerKey, contrailKey] in that order, then runs an + // update op to install the PDS-recommended verification methods. The PLC + // log must end with the caller's key still at rotationKeys[0] — without + // that, recovery authority silently moved to Contrail. + + it("step 1 — provisions a sovereign community via XRPC and the PLC log shows the caller's rotation key first", async () => { + const inviteCode = await mintPdsInvite(); + + // Devnet PDS caps the local handle label at 18 chars. `pw-` prefix + + // 8-char suffix keeps the full label well under that. + const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() + .toString(36) + .slice(2, 4)}`; + const newHandle = `pw-${suffix}${HANDLE_DOMAIN}`; + const email = `${suffix}@devnet.test`; + const password = `pw-${suffix}`; + + const res = await callAs(aliceClient, "POST", `${NS}.provision`, { + body: { + handle: newHandle, + email, + password, + inviteCode, + pdsEndpoint: PDS_URL, + rotationKey: callerRotation.publicDidKey, + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as { communityDid: string; status: string }; + expect(body.status).toBe("activated"); + expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); + communityDid = body.communityDid; + + // PLC sovereignty check: latest op (the post-activation update op) keeps + // the caller's did:key at rotationKeys[0]. If this regresses, Contrail's + // subordinate key would silently take rotation priority. + const logRes = await fetch(`${PLC_URL}/${communityDid}/log`); + expect(logRes.ok).toBe(true); + const log = (await logRes.json()) as Array<{ rotationKeys: string[] }>; + expect(log.length).toBeGreaterThanOrEqual(2); + expect(log[log.length - 1]!.rotationKeys[0]).toBe(callerRotation.publicDidKey); + + publishersUri = `ats://${communityDid}/${SPACE_TYPE}/$publishers`; + }, 30_000); + + // ----- step 2: GRANT ------------------------------------------------------- + // bootstrapReservedSpaces seeded $publishers with Alice as owner. To let + // Bob publish on behalf of the community, Alice grants him `member` on + // $publishers — the minimum level the putRecord guard accepts. + + it("step 2 — Alice grants Bob `member` on $publishers and listMembers reflects it", async () => { + expect(communityDid, "step 1 must have provisioned the community").toBeTruthy(); + + const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { + body: { + spaceUri: publishersUri, + subject: { did: bob.did }, + accessLevel: "member", + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { + query: { spaceUri: publishersUri }, + }); + expect(list.status).toBe(200); + const { rows } = (await jsonOr(list)) as { + rows: Array<{ subject: { did?: string }; accessLevel: string }>; + }; + const byDid = Object.fromEntries( + rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), + ); + expect(byDid[alice.did]).toBe("owner"); + expect(byDid[bob.did]).toBe("member"); + }); + + // ----- step 3: PUBLISH ----------------------------------------------------- + // Bob calls community.putRecord. The router checks his level on + // $publishers (member ≥ member, OK), pulls the community's encrypted + // app password from the credential vault, opens a PDS session as the + // community DID, and proxies a com.atproto.repo.createRecord. The + // returned URI is rooted at the community DID — Bob never holds those + // credentials, and his own DID doesn't appear anywhere in the record. + + it("step 3 — Bob (a $publishers member) publishes a public event as the community via proxy", async () => { + expect(publishersUri, "step 2 must have granted bob").toBeTruthy(); + + const eventName = `walkthrough-event ${Date.now()}`; + const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); + + const res = await callAs(bobClient, "POST", `${NS}.putRecord`, { + body: { + communityDid, + collection: EVENT_NSID, + record: { + $type: EVENT_NSID, + name: eventName, + createdAt: new Date().toISOString(), + startsAt, + mode: `${EVENT_NSID}#inperson`, + status: `${EVENT_NSID}#scheduled`, + }, + }, + }); + expect(res.status, await res.clone().text()).toBe(200); + const out = (await res.json()) as { uri: string; cid: string }; + eventUri = out.uri; + eventCid = out.cid; + eventRkey = out.uri.split("/").pop()!; + + // URI is rooted at the community DID, not Bob's. + expect(eventUri).toMatch(new RegExp(`^at://${communityDid}/${EVENT_NSID}/`)); + + // The record is visible via the community PDS's listRecords — proves + // the proxy actually wrote to the community repo, not just Contrail's + // local index. listRecords is the lexicon endpoint the prompt named; + // we round it out with a getRecord on the same rkey to confirm payload. + const listUrl = + `${PDS_URL}/xrpc/com.atproto.repo.listRecords` + + `?repo=${encodeURIComponent(communityDid)}` + + `&collection=${encodeURIComponent(EVENT_NSID)}` + + `&limit=10`; + const listRes = await fetch(listUrl); + expect(listRes.ok, `listRecords ${listRes.status}`).toBe(true); + const listed = (await listRes.json()) as { + records: Array<{ uri: string; cid: string; value: { name?: string } }>; + }; + const found = listed.records.find((r) => r.uri === eventUri); + expect(found, `event ${eventUri} not in PDS listRecords`).toBeDefined(); + expect(found!.value.name).toBe(eventName); + + const onPds = await getRecordFromPds(communityDid, EVENT_NSID, eventRkey); + expect(onPds.status).toBe(200); + expect(onPds.record.name).toBe(eventName); + }, 30_000); + + // ----- step 4: RSVP -------------------------------------------------------- + // Carol writes a community.lexicon.calendar.rsvp to her OWN repo on the + // shared devnet PDS, with subject.uri pointing at the community's event. + // She has zero relationship to the community — that's the open-RSVP + // contract: anyone can RSVP, the record lives in the responder's repo. + + it("step 4 — Carol RSVPs from a separate PDS account by writing to her own repo", async () => { + expect(eventUri, "step 3 must have published the event").toBeTruthy(); + + const rsvpRes = await carolClient.post("com.atproto.repo.createRecord", { + input: { + repo: carol.did, + collection: RSVP_NSID, + record: { + $type: RSVP_NSID, + subject: { uri: eventUri, cid: eventCid }, + status: `${RSVP_NSID}#going`, + createdAt: new Date().toISOString(), + }, + }, + }); + expect(rsvpRes.ok, `RSVP createRecord: ${JSON.stringify(rsvpRes.data)}`).toBe(true); + if (!rsvpRes.ok) throw new Error("unreachable"); + expect(rsvpRes.data.uri).toMatch(new RegExp(`^at://${carol.did}/${RSVP_NSID}/`)); + }); + + // ----- step 5: INDEX ------------------------------------------------------- + // Both records hit Jetstream and the in-process ingester. Querying the + // event by URI from the local handler should hydrate rsvpsGoingCount=1 + // (Carol's RSVP referencing it), and the indexed `did` must be the + // community's, not Bob's — proves end-to-end attribution works. + + it("step 5 — the indexer surfaces the event under the community DID with Carol's RSVP counted", async () => { + expect(eventUri, "step 3 must have published the event").toBeTruthy(); + + const indexed = await waitFor( + async () => { + const r = await getIndexedRecord(eventUri); + return r && r.rsvpsGoingCount >= 1 ? r : undefined; + }, + { label: `indexed ${eventUri} with rsvpsGoingCount>=1`, timeoutMs: 20_000 }, + ); + + expect(indexed.uri).toBe(eventUri); + // Attribution: the event belongs to the community, not the publisher. + expect(indexed.did).toBe(communityDid); + expect(indexed.did).not.toBe(bob.did); + expect(indexed.did).not.toBe(alice.did); + expect(indexed.rsvpsGoingCount).toBe(1); + }, 30_000); +}); diff --git a/apps/contrail-e2e/tests/provision.test.ts b/apps/contrail-e2e/tests/provision.test.ts new file mode 100644 index 0000000..aefed96 --- /dev/null +++ b/apps/contrail-e2e/tests/provision.test.ts @@ -0,0 +1,490 @@ +/** + * 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. +}); diff --git a/apps/contrail-e2e/tests/reap-tombstone.test.ts b/apps/contrail-e2e/tests/reap-tombstone.test.ts new file mode 100644 index 0000000..dfd40af --- /dev/null +++ b/apps/contrail-e2e/tests/reap-tombstone.test.ts @@ -0,0 +1,178 @@ +/** + * Devnet e2e for the tombstone CID derivation used by `contrail reap` (M6). + * + * `cli/commands/reap.ts:146` calls `cidForOp(signed as never)` because the + * helper's declared signed-op union covers genesis/update — not tombstone. + * The DAG-CBOR encoder accepts the smaller tombstone shape, but no other + * test submits a *real* tombstone to live PLC and verifies the CID we + * computed locally matches the one PLC returns from `log/last`. This test + * closes that gap. + * + * The test uses managed-mode provisioning to land a real DID on devnet PLC + * with the rotation key encrypted on the persisted row, then drives the + * tombstone flow (build → sign → cidForOp → submit) with the same helpers + * runReap uses, and asserts the post-submit `log/last` matches. + * + * Tombstones are irrevocable on PLC. This test always operates on a freshly- + * provisioned devnet DID never seen by any other test or user. + * + * 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 { + CommunityAdapter, + CredentialCipher, + ProvisionOrchestrator, + generateKeyPair, + initCommunitySchema, + pdsCreateAccount, + pdsGetRecommendedDidCredentials, + pdsActivateAccount, + pdsCreateAppPassword, + submitGenesisOp, + getLastOpCid, + buildTombstoneOp, + signTombstoneOp, + submitTombstoneOp, + cidForOp, + type PdsClient, + type PlcClient, +} from "@atmo-dev/contrail"; +import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; +import { + PDS_URL, + PLC_URL, + HANDLE_DOMAIN, + PDS_ADMIN_PASSWORD, + createIsolatedSchema, +} from "./helpers"; + +describe("reap tombstone CID matches live PLC log/last (M6)", () => { + let pool: pg.Pool; + let cleanupSchema: () => Promise; + let adapter: CommunityAdapter; + let cipher: CredentialCipher; + let pdsDid: string; + + beforeAll(async () => { + 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_reap_tombstone_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?.(); + }); + + 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), + }; + + 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( + "tombstone op submitted to PLC has the CID we computed locally via cidForOp", + async () => { + // Provision a fresh community to land a real DID on devnet PLC. + const inviteCode = await mintInvite(); + const suffix = `${Date.now().toString(36)}${Math.random() + .toString(36) + .slice(2, 6)}`; + const handle = `tomb-${suffix}${HANDLE_DOMAIN}`; + const email = `${suffix}@devnet.test`; + const password = `pw-${suffix}`; + const attemptId = randomUUID(); + + const callerRotation = await generateKeyPair(); + + 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, + }); + expect(result.status).toBe("activated"); + const did = result.did; + + // Pull the encrypted rotation key off the persisted row — same path + // runReap takes. + const row = await adapter.getProvisionAttempt(attemptId); + expect(row).not.toBeNull(); + expect(row!.encryptedRotationKey).toBeTruthy(); + const rotationJwk = JSON.parse( + await cipher.decryptString(row!.encryptedRotationKey!), + ) as { kty: string; crv: string; x: string; y: string; d: string }; + + // Drive the tombstone path the same way reap.ts does, but inline so + // the test pins the cidForOp CID independent of runReap's archival + // bookkeeping. + const prev = await getLastOpCid(PLC_URL, did); + const unsigned = buildTombstoneOp(prev); + const signed = await signTombstoneOp(unsigned, rotationJwk); + const expectedCid = await cidForOp(signed as never); + + await submitTombstoneOp(PLC_URL, did, signed); + + // PLC's log/last for the DID must now report the same CID we computed. + // If cidForOp's tombstone encoding ever drifts from PLC's, this is the + // failure mode that catches it. + const lastCid = await getLastOpCid(PLC_URL, did); + expect(lastCid).toBe(expectedCid); + }, + 45_000, + ); +}); diff --git a/docs/07-communities.md b/docs/07-communities.md index a7f2e26..77dfa16 100644 --- a/docs/07-communities.md +++ b/docs/07-communities.md @@ -6,12 +6,22 @@ Group-controlled atproto DIDs. A community is a DID whose signing/rotation keys When you want atproto records published under a *shared* identity — a team, a project, a channel — not a single user. Think: a group's published calendar events, a community's published posts. -## Two modes +## Three modes - **Minted** — contrail creates a fresh `did:plc` for the community, holds the signing key plus one rotation key (a second rotation key is returned to the creator once for recovery), and publishes from it. - **Adopted** — contrail takes over an existing account by holding an **app password** issued from its PDS. The owner's identity, signing key, and rotation keys are unchanged; contrail just gets PDS write access via the app password. +- **Provisioned** — contrail creates a fresh `did:plc` and a new PDS account. The caller supplies the rotation key; that key is set as the DID's rotation key in PLC. Contrail receives an app password from the new account and publishes through it. -Either way, the result is the same: a DID that multiple members can act through, gated by access levels. +Whichever the mode, the result is the same shape: a DID that multiple members can act through, gated by access levels. + +## Choosing a mode + +Two questions typically determine the mode: + +1. **Does the community already have a DID?** Yes → **adopt**. No → continue. +2. **How should records be published?** Contrail signs them directly → **mint**. Contrail uses an app password against a PDS account → **provision**. + +The rotation key holder follows from the choice: contrail in mint mode, caller in adopt and provision modes. ## Access levels @@ -39,16 +49,19 @@ The spaces layer stays ignorant of access levels — it just sees "this DID is a ## XRPCs -- `com.example.community.mint | adopt | list | delete` +- `com.example.community.mint | adopt | provision | list | delete` - `com.example.community.invite.create | redeem | revoke | list` - `com.example.community.setAccessLevel | revoke | listMembers` - `com.example.community.space.create | grant | revoke | ...` — community-owned spaces - `com.example.community.putRecord | deleteRecord` — publish records as the community DID +The `contrail reap` CLI subcommand tombstones provisioned DIDs in PLC when provisioning fails partway and orphan rows accumulate. + ## What's not here - No per-record per-level ACLs. Model as spaces. - No auto-rotation on key compromise yet. -- Adoption can be revoked unilaterally by the owner — they revoke the app password on their PDS and contrail loses write access. (Mint mode is the irreversible one: the creator's recovery rotation key, returned once at mint time, is the only path back if contrail's signing/rotation key is compromised.) +- Adopted and provisioned modes: contrail's write access depends on an app password issued from the PDS, which the rotation-key holder can revoke at any time. +- Minted mode: contrail holds the signing key and one rotation key. The creator's recovery rotation key, returned once at mint time, is the only key not held by contrail. The design follows zicklag's [Arbiter design sketch](https://zicklag.leaflet.pub/3mjrvb5pul224) for group management on atproto. The post is an early design note; our implementation will track it as the spec firms up. diff --git a/packages/contrail/src/cli.ts b/packages/contrail/src/cli.ts index 8b1978f..24e119f 100644 --- a/packages/contrail/src/cli.ts +++ b/packages/contrail/src/cli.ts @@ -10,6 +10,7 @@ import { registerBackfill } from "./cli/commands/backfill.js"; import { registerRefresh } from "./cli/commands/refresh.js"; import { registerDev } from "./cli/commands/dev.js"; import { registerAppendScheduled } from "./cli/commands/append-scheduled.js"; +import { registerReap } from "./cli/commands/reap.js"; const cli = cac("contrail"); @@ -17,6 +18,7 @@ registerBackfill(cli); registerRefresh(cli); registerDev(cli); registerAppendScheduled(cli); +registerReap(cli); cli.help(); diff --git a/packages/contrail/src/cli/commands/reap.ts b/packages/contrail/src/cli/commands/reap.ts new file mode 100644 index 0000000..ef30c05 --- /dev/null +++ b/packages/contrail/src/cli/commands/reap.ts @@ -0,0 +1,310 @@ +import type { CAC } from "cac"; +import type { CommunityAdapter } from "../../core/community/adapter.js"; +import type { CredentialCipher } from "../../core/community/credentials.js"; +import type { Database } from "../../core/types.js"; +import { + buildTombstoneOp, + cidForOp, + getLastOpCid, + signTombstoneOp, + submitTombstoneOp, +} from "../../core/community/plc.js"; +import { promptYesNo, resolveAndLoadConfig } from "../shared.js"; + +interface ReapOpts { + config?: string; + root?: string; + remote?: boolean; + binding: string; + attemptId?: string; + allStuck?: boolean; + dryRun?: boolean; + yes?: boolean; +} + +/** Minimal logger interface so `runReap` can be invoked from tests with + * silent stubs and from the CLI shim with `console`. */ +export interface ReapLogger { + log(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +export interface RunReapOptions { + adapter: CommunityAdapter; + cipher: CredentialCipher; + plcDirectory: string; + fetch?: typeof fetch; + logger: ReapLogger; + /** Skip confirmation prompts. The CLI passes this when the user supplied + * --yes; tests always pass true since they don't have a TTY. */ + yes: boolean; + attemptId?: string; + allStuck?: boolean; + dryRun?: boolean; +} + +export interface RunReapResult { + ok: boolean; + /** Validation/precondition error, present when ok=false. */ + error?: string; + /** Number of rows successfully reaped (PLC tombstone submitted + archived). */ + reaped: number; + /** Number of rows skipped because of --dry-run. */ + dryRunSkipped: number; + /** Number of rows that errored out during reap (activated row, PLC error, etc.). */ + errors: number; +} + +/** Core reap logic, decoupled from the CAC plumbing so tests can drive it + * without going through `getPlatformProxy()`. */ +export async function runReap(opts: RunReapOptions): Promise { + const result: RunReapResult = { + ok: true, + reaped: 0, + dryRunSkipped: 0, + errors: 0, + }; + + // Safety default: an operator who omits the flag must NOT trigger + // irrevocable PLC tombstones. Real action requires an explicit + // `dryRun: false` (CLI: `--no-dry-run`). + const dryRun = opts.dryRun ?? true; + + const hasAttemptId = !!opts.attemptId; + const hasAllStuck = !!opts.allStuck; + if (!hasAttemptId && !hasAllStuck) { + return { + ...result, + ok: false, + error: "Specify exactly one of --attempt-id or --all-stuck.", + }; + } + if (hasAttemptId && hasAllStuck) { + return { + ...result, + ok: false, + error: + "--attempt-id and --all-stuck are mutually exclusive; pass exactly one.", + }; + } + + const rows = hasAttemptId + ? await loadSingle(opts.adapter, opts.attemptId!) + : await opts.adapter.listStuckAttempts(); + + if (rows.length === 0) { + opts.logger.log("No stuck provision_attempts to reap."); + return result; + } + + for (const row of rows) { + if (row.status === "activated") { + opts.logger.error( + `Refusing to reap ${row.attemptId}: status is "activated"; reap will not tombstone live communities.` + ); + result.errors += 1; + continue; + } + + opts.logger.log( + `Reaping ${row.attemptId} (did=${row.did}, status=${row.status})` + ); + + if (!row.encryptedRotationKey) { + opts.logger.error( + ` no encrypted_rotation_key on ${row.attemptId}; cannot sign tombstone` + ); + result.errors += 1; + continue; + } + + let rotationJwk: JsonWebKey; + try { + const decoded = await opts.cipher.decryptString(row.encryptedRotationKey); + rotationJwk = JSON.parse(decoded) as JsonWebKey; + } catch (err) { + opts.logger.error( + ` failed to decrypt rotation key for ${row.attemptId}: ${err instanceof Error ? err.message : err}` + ); + result.errors += 1; + continue; + } + + let prev: string; + try { + prev = await getLastOpCid(opts.plcDirectory, row.did, { + fetch: opts.fetch, + }); + } catch (err) { + opts.logger.error( + ` failed to fetch last PLC op cid for ${row.did}: ${err instanceof Error ? err.message : err}` + ); + result.errors += 1; + continue; + } + + const unsigned = buildTombstoneOp(prev); + const signed = await signTombstoneOp(unsigned, rotationJwk); + const opCid = await cidForOp(signed); + + if (dryRun) { + opts.logger.log( + ` [dry-run] would submit tombstone (op cid=${opCid}, prev=${prev})` + ); + result.dryRunSkipped += 1; + continue; + } + + if (!opts.yes) { + const confirmed = await promptYesNo( + `submit PLC tombstone for ${row.did}?`, + false, + false + ); + if (!confirmed) { + opts.logger.log(` skipped ${row.attemptId} (not confirmed)`); + continue; + } + } + + try { + await submitTombstoneOp(opts.plcDirectory, row.did, signed, { + fetch: opts.fetch, + }); + } catch (err) { + opts.logger.error( + ` PLC tombstone submit failed for ${row.did}: ${err instanceof Error ? err.message : err}` + ); + result.errors += 1; + continue; + } + + try { + await opts.adapter.archiveStuckAttempt(row.attemptId, { + tombstoneOpCid: opCid, + }); + } catch (err) { + opts.logger.error( + ` archive failed for ${row.attemptId} after tombstone submit: ${err instanceof Error ? err.message : err}` + ); + result.errors += 1; + continue; + } + + result.reaped += 1; + } + + opts.logger.log( + `Reaped ${result.reaped} attempts (${result.dryRunSkipped} dry-run skipped, ${result.errors} errors).` + ); + return result; +} + +async function loadSingle( + adapter: CommunityAdapter, + attemptId: string +): Promise>> { + const row = await adapter.getProvisionAttempt(attemptId); + return row ? [row] : []; +} + +export function registerReap(cli: CAC): void { + cli + .command( + "reap", + "Tombstone stuck provision_attempts rows in PLC and archive them" + ) + .option("--config ", "Path to Contrail config file") + .option("--root ", "Project root for auto-detection (default: CWD)") + .option("--remote", "Use production D1 bindings") + .option("--binding ", "D1 binding name in wrangler.jsonc", { + default: "DB", + }) + .option("--attempt-id ", "Reap a single attempt by ID") + .option( + "--all-stuck", + "Reap every provision_attempts row that did not reach status=activated" + ) + .option( + "--dry-run", + "Print what would be tombstoned without submitting to PLC (DEFAULT)" + ) + .option( + "--no-dry-run", + "Actually submit tombstones to PLC. Irrevocable. Required for real runs." + ) + .option("--yes", "Auto-confirm prompts") + .action(async (options: ReapOpts) => { + if (!options.attemptId && !options.allStuck) { + console.error("Specify --attempt-id or --all-stuck."); + process.exit(1); + } + if (options.attemptId && options.allStuck) { + console.error( + "--attempt-id and --all-stuck are mutually exclusive; pass exactly one." + ); + process.exit(1); + } + + const config = await resolveAndLoadConfig(options); + const community = config.community; + if (!community) { + console.error( + "config.community is not set; reap requires a configured community module." + ); + process.exit(1); + } + if (!community.plcDirectory) { + console.error( + "config.community.plcDirectory is required for `contrail reap`." + ); + process.exit(1); + } + + const { getPlatformProxy } = await import("wrangler"); + const { env, dispose } = await getPlatformProxy(); + try { + const db = (env as Record)[options.binding] as + | Database + | undefined; + if (!db) { + console.error( + `D1 binding "${options.binding}" not found in wrangler env.` + ); + process.exit(1); + } + + // Lazy-import to avoid pulling adapter into the CLI startup path + // for unrelated subcommands. + const { CommunityAdapter } = await import( + "../../core/community/adapter.js" + ); + const { CredentialCipher } = await import( + "../../core/community/credentials.js" + ); + const adapter = new CommunityAdapter(db); + const cipher = new CredentialCipher(community.masterKey); + + const result = await runReap({ + adapter, + cipher, + plcDirectory: community.plcDirectory, + logger: console, + yes: !!options.yes, + attemptId: options.attemptId, + allStuck: options.allStuck, + dryRun: options.dryRun, + }); + + if (!result.ok) { + console.error(result.error); + process.exit(1); + } + if (result.errors > 0) { + process.exit(1); + } + } finally { + await dispose(); + } + }); +} diff --git a/packages/contrail/src/core/community/adapter.ts b/packages/contrail/src/core/community/adapter.ts index 1587e63..543a5f1 100644 --- a/packages/contrail/src/core/community/adapter.ts +++ b/packages/contrail/src/core/community/adapter.ts @@ -6,6 +6,9 @@ import type { CommunityMode, CommunityRow, CreateCommunityInviteInput, + CreateProvisionAttemptInput, + ProvisionAttemptRow, + ProvisionStatus, } from "./types"; function toNum(v: unknown): number { @@ -51,6 +54,19 @@ export interface CreateMintedCommunityInput { createdBy: string; } +export interface CreateProvisionedCommunityInput { + did: string; + pdsEndpoint: string; + handle: string; + /** Encrypted PDS app password — already-encrypted base64 envelope. The + * orchestrator persisted this on the provision_attempts row after a + * post-activation `createAppPassword` call; the route handler hands it + * through so we keep one source of truth for the credential and avoid + * round-tripping the plaintext password through the adapter. */ + appPasswordEncrypted: string; + createdBy: string; +} + export interface GrantInput { spaceUri: string; subjectDid?: string; @@ -91,6 +107,35 @@ export class CommunityAdapter { }; } + async createFromProvisioned( + input: CreateProvisionedCommunityInput + ): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO communities (did, mode, pds_endpoint, app_password_encrypted, identifier, created_by, created_at) + VALUES (?, 'provision', ?, ?, ?, ?, ?)` + ) + .bind( + input.did, + input.pdsEndpoint, + input.appPasswordEncrypted, + input.handle, + input.createdBy, + now + ) + .run(); + return { + did: input.did, + mode: "provision", + pdsEndpoint: input.pdsEndpoint, + identifier: input.handle, + createdBy: input.createdBy, + createdAt: now, + deletedAt: null, + }; + } + async createMintedCommunity(input: CreateMintedCommunityInput): Promise { const now = Date.now(); await this.db @@ -384,6 +429,203 @@ export class CommunityAdapter { .first(); return row ? mapCommunityInviteRow(row) : null; } + + // ---- Provision attempts ------------------------------------------------ + + async createProvisionAttempt(input: CreateProvisionAttemptInput): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO provision_attempts ( + attempt_id, did, status, pds_endpoint, handle, email, invite_code, + encrypted_signing_key, encrypted_rotation_key, + created_at, updated_at + ) VALUES (?, ?, 'keys_generated', ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + input.attemptId, + input.did, + input.pdsEndpoint, + input.handle, + input.email, + input.inviteCode ?? null, + input.encryptedSigningKey, + input.encryptedRotationKey, + now, + now + ) + .run(); + } + + async getProvisionAttempt(attemptId: string): Promise { + const row = await this.db + .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) + .bind(attemptId) + .first>(); + return row ? rowToProvisionAttempt(row) : null; + } + + async updateProvisionStatus( + attemptId: string, + status: ProvisionStatus, + opts: { lastError?: string; encryptedPassword?: string } = {} + ): Promise { + const now = Date.now(); + const stampCol = ({ + genesis_submitted: "genesis_submitted_at", + account_created: "account_created_at", + did_doc_updated: "did_doc_updated_at", + activated: "activated_at", + } as Record)[status]; + + const sets: string[] = [`status = ?`, `updated_at = ?`]; + const args: any[] = [status, now]; + if (stampCol) { + sets.push(`${stampCol} = ?`); + args.push(now); + } + if (opts.lastError !== undefined) { + sets.push(`last_error = ?`); + args.push(opts.lastError); + } + if (opts.encryptedPassword !== undefined) { + sets.push(`encrypted_password = ?`); + args.push(opts.encryptedPassword); + } + args.push(attemptId); + + await this.db + .prepare(`UPDATE provision_attempts SET ${sets.join(", ")} WHERE attempt_id = ?`) + .bind(...args) + .run(); + } + + /** List every provision attempt that did NOT reach `activated`. These are + * the rows reap can act on: any non-terminal status means the flow stopped + * partway, leaving (typically) a dangling DID in PLC that needs + * tombstoning. */ + async listStuckAttempts(): Promise { + const rows = await this.db + .prepare( + `SELECT * FROM provision_attempts WHERE status != 'activated' ORDER BY updated_at ASC` + ) + .all>(); + return rows.results.map(rowToProvisionAttempt); + } + + /** Move a stuck provision_attempts row into the archive table after reap + * has tombstoned its DID in PLC. The copy is best-effort atomic per row: + * insert into the archive first, then delete from the live table. If the + * delete fails, the archive row records the attempt and the live row is + * still present for retry. */ + async archiveStuckAttempt( + attemptId: string, + opts: { tombstoneOpCid?: string | null; notes?: string | null } = {} + ): Promise { + const row = await this.db + .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) + .bind(attemptId) + .first>(); + if (!row) { + throw new Error(`provision_attempt not found: ${attemptId}`); + } + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO provision_attempts_archive ( + attempt_id, did, pds_endpoint, handle, email, invite_code, + last_status, last_error, + archived_at, tombstone_op_cid, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + row.attempt_id, + row.did, + row.pds_endpoint, + row.handle, + row.email, + row.invite_code ?? null, + row.status, + row.last_error ?? null, + now, + opts.tombstoneOpCid ?? null, + opts.notes ?? null + ) + .run(); + await this.db + .prepare(`DELETE FROM provision_attempts WHERE attempt_id = ?`) + .bind(attemptId) + .run(); + } + + // ---- Community sessions cache ----------------------------------------- + + async getSession(communityDid: string): Promise<{ + accessJwt: string; + refreshJwt: string; + accessExp: number; + } | null> { + const r = await this.db + .prepare( + `SELECT access_jwt, refresh_jwt, access_exp FROM community_sessions WHERE community_did = ?` + ) + .bind(communityDid) + .first<{ access_jwt: string; refresh_jwt: string; access_exp: number }>(); + if (!r) return null; + return { + accessJwt: r.access_jwt, + refreshJwt: r.refresh_jwt, + accessExp: Number(r.access_exp), + }; + } + + async upsertSession( + communityDid: string, + s: { accessJwt: string; refreshJwt: string; accessExp: number } + ): Promise { + const now = Date.now(); + await this.db + .prepare( + `INSERT INTO community_sessions (community_did, access_jwt, refresh_jwt, access_exp, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (community_did) DO UPDATE SET + access_jwt = excluded.access_jwt, + refresh_jwt = excluded.refresh_jwt, + access_exp = excluded.access_exp, + updated_at = excluded.updated_at` + ) + .bind(communityDid, s.accessJwt, s.refreshJwt, s.accessExp, now) + .run(); + } + + async clearSession(communityDid: string): Promise { + await this.db + .prepare(`DELETE FROM community_sessions WHERE community_did = ?`) + .bind(communityDid) + .run(); + } +} + +function rowToProvisionAttempt(r: Record): ProvisionAttemptRow { + return { + attemptId: r.attempt_id, + did: r.did, + status: r.status as ProvisionStatus, + pdsEndpoint: r.pds_endpoint, + handle: r.handle, + email: r.email, + inviteCode: r.invite_code ?? null, + encryptedSigningKey: r.encrypted_signing_key ?? null, + encryptedRotationKey: r.encrypted_rotation_key ?? null, + encryptedPassword: r.encrypted_password ?? null, + genesisSubmittedAt: r.genesis_submitted_at == null ? null : Number(r.genesis_submitted_at), + accountCreatedAt: r.account_created_at == null ? null : Number(r.account_created_at), + didDocUpdatedAt: r.did_doc_updated_at == null ? null : Number(r.did_doc_updated_at), + activatedAt: r.activated_at == null ? null : Number(r.activated_at), + lastError: r.last_error ?? null, + createdAt: Number(r.created_at), + updatedAt: Number(r.updated_at), + }; } function mapCommunityInviteRow(row: any): CommunityInviteRow { diff --git a/packages/contrail/src/core/community/index.ts b/packages/contrail/src/core/community/index.ts index 3fbe1ad..c059df6 100644 --- a/packages/contrail/src/core/community/index.ts +++ b/packages/contrail/src/core/community/index.ts @@ -22,7 +22,19 @@ export { CredentialCipher } from "./credentials"; export { resolveEffectiveLevel, flattenEffectiveMembers, wouldCycle } from "./acl"; export { reconcile } from "./reconcile"; export { initCommunitySchema, buildCommunitySchema } from "./schema"; -export { resolveIdentity, createPdsSession } from "./pds"; +export { + resolveIdentity, + createPdsSession, + pdsCreateAccount, + pdsGetRecommendedDidCredentials, + pdsActivateAccount, + pdsCreateAppPassword, +} from "./pds"; +export type { + PdsCreateAccountBody, + PdsCreateAccountResult, + RecommendedDidCredentials, +} from "./pds"; export { generateKeyPair, buildGenesisOp, @@ -31,5 +43,30 @@ export { submitGenesisOp, encodeDagCbor, jwkToDidKey, + buildUpdateOp, + signUpdateOp, + cidForOp, + getLastOpCid, + buildTombstoneOp, + signTombstoneOp, + submitTombstoneOp, } from "./plc"; -export type { KeyPair, GenesisOpInput, UnsignedGenesisOp, SignedGenesisOp } from "./plc"; +export type { + KeyPair, + GenesisOpInput, + UnsignedGenesisOp, + SignedGenesisOp, + UpdateOpInput, + UnsignedUpdateOp, + SignedUpdateOp, + UnsignedTombstoneOp, + SignedTombstoneOp, +} from "./plc"; +export { ProvisionOrchestrator } from "./provision"; +export type { + PdsClient, + PlcClient, + ProvisionInput, + ProvisionResult, + ProvisionOrchestratorDeps, +} from "./provision"; diff --git a/packages/contrail/src/core/community/pds.ts b/packages/contrail/src/core/community/pds.ts index 2f73181..b8c63cc 100644 --- a/packages/contrail/src/core/community/pds.ts +++ b/packages/contrail/src/core/community/pds.ts @@ -8,6 +8,16 @@ import { type DidDocumentResolver, } from "@atcute/identity-resolver"; +/** Canonicalize a PDS endpoint URL so allowlist comparisons aren't bypassed by + * trailing slash, default port, scheme case, or IDN encoding differences. + * Returns the URL's `origin` — scheme + host + (non-default) port — which + * collapses every variant of a single PDS to one string. Throws if the input + * is not a parseable URL; callers in request paths should catch and respond + * with a 400. */ +export function normalizePdsEndpoint(url: string): string { + return new URL(url).origin; +} + export interface ResolvedIdentity { did: string; handle: string | null; @@ -102,6 +112,50 @@ async function resolveHandleToDid(handle: string, f: typeof fetch): Promise { + const f = input.fetch ?? fetch; + const url = `${input.pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.server.refreshSession`; + const res = await f(url, { + method: "POST", + headers: { authorization: `Bearer ${input.refreshJwt}` }, + }); + if (res.status !== 200) return null; + const body = (await res.json().catch(() => null)) as + | { accessJwt?: string; refreshJwt?: string } + | null; + if (!body?.accessJwt || !body.refreshJwt) return null; + return { + accessJwt: body.accessJwt, + refreshJwt: body.refreshJwt, + accessExp: decodeJwtExp(body.accessJwt), + }; +} + /** Create an atproto session on the given PDS using identifier + app password. * Returns the access/refresh JWTs and the session's DID. */ export async function createPdsSession( @@ -135,3 +189,156 @@ export async function createPdsSession( did: body.did, }; } + +export interface PdsDescribeServerResult { + did: string; + /** Other fields (availableUserDomains, contact, links, inviteCodeRequired) + * are returned by the PDS but unused by Contrail's provisioning flow. */ + [key: string]: unknown; +} + +/** Calls `com.atproto.server.describeServer` on the target PDS to discover + * the DID it publishes for itself. Used as `aud` in the service-auth JWT for + * `createAccount`; the PDS verifies the audience matches its own DID and + * rejects with `BadJwtAudience` otherwise. Resolving dynamically (instead of + * hardcoding to a config value) is what allows a single Contrail instance + * to mint communities on multiple PDSes. */ +export async function pdsDescribeServer( + pdsEndpoint: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.describeServer`; + const res = await f(url); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`describeServer failed (${res.status}): ${text}`); + } + const body = (await res.json()) as PdsDescribeServerResult; + if (!body?.did || typeof body.did !== "string") { + throw new Error("describeServer response missing required `did` field"); + } + return body; +} + +export interface PdsCreateAccountBody { + handle: string; + did: string; + email: string; + password: string; + inviteCode?: string; +} + +export interface PdsCreateAccountResult { + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; +} + +/** Calls `com.atproto.server.createAccount` on the target PDS using a + * service-auth JWT (signed by the iss DID's verificationMethod). The PDS + * verifies `requester === did` against the published DID-doc, validates the + * invite, and creates the account in deactivated state. */ +export async function pdsCreateAccount( + pdsEndpoint: string, + serviceAuthJwt: string, + body: PdsCreateAccountBody, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAccount`; + const res = await f(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${serviceAuthJwt}`, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`createAccount failed (${res.status}): ${text}`); + } + return (await res.json()) as PdsCreateAccountResult; +} + +export interface RecommendedDidCredentials { + rotationKeys: string[]; + verificationMethods: { atproto: string }; + alsoKnownAs: string[]; + services: Record; +} + +/** Calls `com.atproto.identity.getRecommendedDidCredentials` on the target PDS + * using the session's accessJwt (returned by `pdsCreateAccount`, NOT a + * service-auth JWT). Returns the DID-doc fields the PDS would self-publish. */ +export async function pdsGetRecommendedDidCredentials( + pdsEndpoint: string, + accessJwt: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.identity.getRecommendedDidCredentials`; + const res = await f(url, { + headers: { authorization: `Bearer ${accessJwt}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`getRecommendedDidCredentials failed (${res.status}): ${text}`); + } + return (await res.json()) as RecommendedDidCredentials; +} + +/** Calls `com.atproto.server.activateAccount` on the target PDS using the + * session's accessJwt (returned by `pdsCreateAccount`, NOT a service-auth + * JWT). Resolves on success; throws otherwise. */ +export async function pdsActivateAccount( + pdsEndpoint: string, + accessJwt: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.activateAccount`; + const res = await f(url, { + method: "POST", + headers: { authorization: `Bearer ${accessJwt}` }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`activateAccount failed (${res.status}): ${text}`); + } +} + +export interface PdsCreateAppPasswordResult { + name: string; + password: string; + createdAt: string; +} + +/** Calls `com.atproto.server.createAppPassword` on the target PDS using the + * session's accessJwt. Returns the freshly minted app password. We always + * send `privileged: false` so the credential can be revoked without + * affecting the root account. */ +export async function pdsCreateAppPassword( + pdsEndpoint: string, + accessJwt: string, + name: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAppPassword`; + const res = await f(url, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${accessJwt}`, + }, + body: JSON.stringify({ name, privileged: false }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`createAppPassword failed (${res.status}): ${text}`); + } + return (await res.json()) as PdsCreateAppPasswordResult; +} diff --git a/packages/contrail/src/core/community/plc.ts b/packages/contrail/src/core/community/plc.ts index b4d1c5b..51aae58 100644 --- a/packages/contrail/src/core/community/plc.ts +++ b/packages/contrail/src/core/community/plc.ts @@ -54,7 +54,7 @@ const P256_N = BigInt( ); const P256_N_HALF = P256_N >> 1n; -async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise { +export async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise { const key = await crypto.subtle.importKey( "jwk", privateJwk, @@ -155,6 +155,154 @@ export async function computeDidPlc(signedOp: SignedGenesisOp): Promise return "did:plc:" + base32Lower(hash).slice(0, 24); } +// ============================================================================ +// Update op construction (subsequent ops chain via `prev`) +// ============================================================================ + +export interface UpdateOpInput { + prev: string; // CID string of the previous op in the chain + rotationKeys: string[]; + verificationMethodAtproto: string; + alsoKnownAs: string[]; + services: Record; +} + +export interface UnsignedUpdateOp { + type: "plc_operation"; + prev: string; + rotationKeys: string[]; + verificationMethods: { atproto: string }; + alsoKnownAs: string[]; + services: Record; +} + +export interface SignedUpdateOp extends UnsignedUpdateOp { + sig: string; // base64url, unpadded +} + +export function buildUpdateOp(input: UpdateOpInput): UnsignedUpdateOp { + return { + type: "plc_operation", + prev: input.prev, + rotationKeys: input.rotationKeys, + verificationMethods: { atproto: input.verificationMethodAtproto }, + alsoKnownAs: input.alsoKnownAs, + services: input.services, + }; +} + +/** Sign an update op with a rotation key's private JWK. */ +export async function signUpdateOp( + unsigned: UnsignedUpdateOp, + signerPrivateJwk: JsonWebKey +): Promise { + const encoded = encodeDagCbor(unsigned); + const sigBytes = await signBytes(signerPrivateJwk, encoded); + return { ...unsigned, sig: bytesToB64url(sigBytes) }; +} + +/** Compute the CIDv1 for a signed op (genesis, update, or tombstone). + * CIDv1 (0x01) + dag-cbor codec (0x71) + sha2-256 (0x12 0x20) + hash, + * base32-lower with multibase "b" prefix. + * + * The tombstone shape ({type, prev, sig}) is a strict subset of update — + * the DAG-CBOR encoder accepts all three uniformly, and PLC computes its + * stored CID from the same canonical encoding. */ +export async function cidForOp( + signedOp: SignedGenesisOp | SignedUpdateOp | SignedTombstoneOp +): Promise { + const encoded = encodeDagCbor(signedOp); + const hash = new Uint8Array( + await crypto.subtle.digest("SHA-256", encoded as BufferSource) + ); + const cidBytes = new Uint8Array(4 + hash.length); + cidBytes[0] = 0x01; + cidBytes[1] = 0x71; + cidBytes[2] = 0x12; + cidBytes[3] = 0x20; + cidBytes.set(hash, 4); + return "b" + base32Lower(cidBytes); +} + +/** Fetch the CID of the most recent op in a DID's PLC log. Used during + * provision recovery to obtain the genesis op's CID at resume time (we can't + * recompute it locally because ECDSA signatures are randomized) and by the + * reap CLI to chain a tombstone onto the latest op. + * + * PLC's `/log/last` endpoint returns the bare signed op object — no envelope, + * no `cid` field. We compute the CID locally with the same DAG-CBOR encoder + * cidForOp uses; PLC computes its stored CID identically, so the result + * matches the entry's CID in `/log/audit`. */ +export async function getLastOpCid( + plcDirectory: string, + did: string, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${plcDirectory.replace(/\/$/, "")}/${did}/log/last`; + const res = await f(url); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PLC log/last failed (${res.status}): ${text}`); + } + const op = (await res.json()) as + | SignedGenesisOp + | SignedUpdateOp + | SignedTombstoneOp; + return cidForOp(op); +} + +// ============================================================================ +// Tombstone op construction +// A tombstone op marks a DID's PLC log as terminated — no further ops will be +// accepted. Used by `contrail reap` to clean up DIDs whose PDS account is +// permanently unrecoverable. +// ============================================================================ + +export interface UnsignedTombstoneOp { + type: "plc_tombstone"; + prev: string; +} + +export interface SignedTombstoneOp extends UnsignedTombstoneOp { + sig: string; // base64url, unpadded +} + +export function buildTombstoneOp(prev: string): UnsignedTombstoneOp { + return { type: "plc_tombstone", prev }; +} + +/** Sign a tombstone op with a rotation key's private JWK. */ +export async function signTombstoneOp( + op: UnsignedTombstoneOp, + signerPrivateJwk: JsonWebKey +): Promise { + const encoded = encodeDagCbor(op); + const sigBytes = await signBytes(signerPrivateJwk, encoded); + return { ...op, sig: bytesToB64url(sigBytes) }; +} + +/** Submit a signed tombstone op to the PLC directory. PLC accepts genesis, + * update, and tombstone ops at the same `${plcDirectory}/${did}` endpoint. */ +export async function submitTombstoneOp( + plcDirectory: string, + did: string, + signedOp: SignedTombstoneOp, + opts: { fetch?: typeof fetch } = {} +): Promise { + const f = opts.fetch ?? fetch; + const url = `${plcDirectory.replace(/\/$/, "")}/${did}`; + const res = await f(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(signedOp), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PLC tombstone submit failed (${res.status}): ${text}`); + } +} + /** Submit a signed genesis op to the PLC directory. */ export async function submitGenesisOp( plcDirectory: string, diff --git a/packages/contrail/src/core/community/provision.ts b/packages/contrail/src/core/community/provision.ts new file mode 100644 index 0000000..b5ee50e --- /dev/null +++ b/packages/contrail/src/core/community/provision.ts @@ -0,0 +1,403 @@ +/** Provision orchestrator: runs the 5-RPC flow (genesis → createAccount → + * recommendedCreds → PLC update → activate), persisting status after each + * step so a stuck attempt is recognizable to the reap CLI. + * + * Steps and persisted statuses: + * Step 0 generate keys + persist row → keys_generated + * Step 1 PLC genesis op → genesis_submitted + * Step 2 PDS createAccount (service-auth JWT) → account_created + * Step 3 fetch recommended DID credentials (no status change) + * Step 4 PLC update op merging recommended credentials → did_doc_updated + * Step 5 PDS activateAccount → activated + */ + +import { + generateKeyPair, + buildGenesisOp, + signGenesisOp, + computeDidPlc, + buildUpdateOp, + signUpdateOp, + cidForOp, +} from "./plc"; +import type { RecommendedDidCredentials } from "./pds"; +import { decodeJwtExp } from "./pds"; +import { mintServiceAuthJwt } from "./service-auth"; +import type { CommunityAdapter } from "./adapter"; +import type { CredentialCipher } from "./credentials"; + +export interface PlcClient { + submit(did: string, op: any): Promise; +} + +export interface PdsClient { + createAccount(input: { + pdsUrl: string; + serviceAuthJwt: string; + body: { + handle: string; + did: string; + email: string; + password: string; + inviteCode?: string; + }; + }): Promise<{ + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; + }>; + getRecommendedDidCredentials(input: { + pdsUrl: string; + accessJwt: string; + }): Promise; + activateAccount(input: { pdsUrl: string; accessJwt: string }): Promise; + /** Mints a revocable app password on the freshly-activated account. Used by + * the self-sovereign custody mode so Contrail keeps publishing authority + * without holding the account's root password. */ + createAppPassword(input: { + pdsUrl: string; + accessJwt: string; + name: string; + }): Promise<{ password: string }>; + /** Used only by the C3 retry path: when a provision call failed at + * createAppPassword, retrying with the same attemptId needs a fresh + * accessJwt. The session-cache JWT from the failed attempt may have + * expired by the time the caller retries. Optional — non-retry callers + * never invoke this. */ + createSession?(input: { + pdsUrl: string; + identifier: string; + password: string; + }): Promise<{ accessJwt: string; refreshJwt: string; did: string }>; +} + +export interface ProvisionOrchestratorDeps { + adapter: CommunityAdapter; + cipher: CredentialCipher; + plc: PlcClient; + pds: PdsClient; + /** DID of the target PDS; used as `aud` in the service-auth JWT. */ + pdsDid: string; +} + +export interface ProvisionInput { + attemptId: string; + pdsEndpoint: string; + handle: string; + email: string; + password: string; + inviteCode?: string; + /** Caller-supplied rotation public key (did:key:z…). Sits at rotationKeys[0] + * in the genesis op; Contrail's generated key is the subordinate at [1]. + * After activation Contrail mints a revocable app password (via + * createAppPassword) for ongoing publishing — the user's account password + * is never persisted. */ + rotationKey: string; +} + +export interface ProvisionResult { + attemptId: string; + did: string; + status: "activated"; + /** The caller is expected to store these — Contrail does NOT retain the + * user's root password once the app password has been minted. */ + rootCredentials: { + handle: string; + password: string; + recoveryHint: string; + }; +} + +export class ProvisionOrchestrator { + constructor(private deps: ProvisionOrchestratorDeps) {} + + async provision(input: ProvisionInput): Promise { + const { adapter, cipher, plc, pds, pdsDid } = this.deps; + + if (!isDidKeyZ(input.rotationKey)) { + throw new Error( + `rotationKey must be a did:key:z… string (got: ${input.rotationKey.slice(0, 24)}…)` + ); + } + + // Idempotent retry: a caller that gets a 5xx with an attemptId can + // re-invoke provision with the SAME attemptId and the orchestrator picks + // up where it left off. Two recoverable shapes: + // 1. status='activated', encryptedPassword present — the orchestrator + // itself completed successfully but a downstream graduation step + // (e.g. the route's createFromProvisioned, bootstrap of reserved + // spaces) failed. We return success without re-running any PLC/PDS + // work so the caller — or the route — can resume the post-orch path. + // 2. status='activated', no encryptedPassword — activation succeeded + // but the post-activation createAppPassword failed. retryAppPasswordOnly + // re-runs only that final step. + // Other partial states are not resumable through this entry point. + const existing = await adapter.getProvisionAttempt(input.attemptId); + if (existing) { + if (existing.status === "activated" && existing.encryptedPassword) { + return { + attemptId: input.attemptId, + did: existing.did, + status: "activated", + rootCredentials: { + handle: input.handle, + password: input.password, + recoveryHint: "store this — Contrail does not retain it", + }, + }; + } + if (existing.status === "activated" && !existing.encryptedPassword) { + return this.retryAppPasswordOnly(input, existing); + } + throw new Error( + `provision attempt ${input.attemptId} already exists at status="${existing.status}"; ` + + `retry is only supported for attempts that failed at createAppPassword` + ); + } + + // Step 0: keys + persist. Contrail generates a SUBORDINATE rotation key + // (rotationKeys[1]) so it retains a path to submit subsequent PLC ops; + // the caller's key sits at rotationKeys[0]. + const signingKey = await generateKeyPair(); + const contrailRotation = await generateKeyPair(); + const encryptedSigning = await cipher.encrypt( + JSON.stringify(signingKey.privateJwk) + ); + const encryptedRotation = await cipher.encrypt( + JSON.stringify(contrailRotation.privateJwk) + ); + + const unsigned = buildGenesisOp({ + rotationKeys: [input.rotationKey, contrailRotation.publicDidKey], + verificationMethodAtproto: signingKey.publicDidKey, + alsoKnownAs: [`at://${input.handle}`], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: input.pdsEndpoint, + }, + }, + }); + // Genesis is signed with Contrail's subordinate rotation key — it's listed + // at rotationKeys[1] so PLC accepts the signature. + const signedGenesis = await signGenesisOp(unsigned, contrailRotation.privateJwk); + const did = await computeDidPlc(signedGenesis); + + await adapter.createProvisionAttempt({ + attemptId: input.attemptId, + did, + pdsEndpoint: input.pdsEndpoint, + handle: input.handle, + email: input.email, + inviteCode: input.inviteCode ?? null, + encryptedSigningKey: encryptedSigning, + encryptedRotationKey: encryptedRotation, + }); + + // Step 1: PLC genesis + try { + await plc.submit(did, signedGenesis); + await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted"); + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "keys_generated", { + lastError: `plc-genesis: ${err.message}`, + }); + throw err; + } + + // Step 2: createAccount + let session: { + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; + }; + try { + const serviceAuthJwt = await mintServiceAuthJwt({ + privateJwk: signingKey.privateJwk, + iss: did, + aud: pdsDid, + lxm: "com.atproto.server.createAccount", + ttlSec: 60, + }); + session = await pds.createAccount({ + pdsUrl: input.pdsEndpoint, + serviceAuthJwt, + body: { + handle: input.handle, + did, + email: input.email, + password: input.password, + inviteCode: input.inviteCode, + }, + }); + await adapter.updateProvisionStatus(input.attemptId, "account_created"); + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted", { + lastError: `createAccount: ${err.message}`, + }); + throw err; + } + + // Step 3 + 4: getRecommendedDidCredentials + PLC update op + try { + const recommended = await pds.getRecommendedDidCredentials({ + pdsUrl: input.pdsEndpoint, + accessJwt: session.accessJwt, + }); + const baseRotationKeys = [input.rotationKey, contrailRotation.publicDidKey]; + const updatedRotationKeys = [ + ...baseRotationKeys, + ...recommended.rotationKeys.filter((k) => !baseRotationKeys.includes(k)), + ]; + const unsignedUpdate = buildUpdateOp({ + prev: await cidForOp(signedGenesis), + rotationKeys: updatedRotationKeys, + verificationMethodAtproto: recommended.verificationMethods.atproto, + alsoKnownAs: recommended.alsoKnownAs, + services: recommended.services, + }); + const signedUpdate = await signUpdateOp( + unsignedUpdate, + contrailRotation.privateJwk + ); + await plc.submit(did, signedUpdate); + await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated"); + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "account_created", { + lastError: `did-doc-update: ${err.message}`, + }); + throw err; + } + + // Step 5: activateAccount + try { + await pds.activateAccount({ + pdsUrl: input.pdsEndpoint, + accessJwt: session.accessJwt, + }); + await adapter.updateProvisionStatus(input.attemptId, "activated"); + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated", { + lastError: `activateAccount: ${err.message}`, + }); + throw err; + } + + // Seed the session cache with the JWTs createAccount returned, so the + // first publish doesn't waste a createSession round-trip. ensureSession + // refreshes or falls back to the stored password as the JWTs age out. + await adapter.upsertSession(did, { + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + accessExp: decodeJwtExp(session.accessJwt), + }); + + // Mint a revocable app password so we can publish without holding the + // user's root password. Failure here leaves the row at status=activated + // (the account IS activated upstream) with a last_error breadcrumb; the + // caller can retry with the same attemptId and it will pick up at + // createAppPassword only (see retryAppPasswordOnly). + try { + const minted = await pds.createAppPassword({ + pdsUrl: input.pdsEndpoint, + accessJwt: session.accessJwt, + name: `contrail-${input.attemptId}`, + }); + const encryptedPassword = await cipher.encrypt(minted.password); + await adapter.updateProvisionStatus(input.attemptId, "activated", { + encryptedPassword, + }); + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "activated", { + lastError: `createAppPassword: ${err.message}`, + }); + // Wrap the error so the route handler / log readers see immediately + // that this is the recoverable retry case, not a generic PDS failure. + throw new Error(`createAppPassword: ${err.message}`); + } + + return { + attemptId: input.attemptId, + did, + status: "activated", + rootCredentials: { + handle: input.handle, + password: input.password, + recoveryHint: "store this — Contrail does not retain it", + }, + }; + } + + /** C3 retry path. Triggered when provision() is called with an attemptId + * whose row is at status='activated' + no encrypted_password. The DID is + * already on PLC; the PDS account is already created and activated; only + * the post-activation app-password mint failed. We need a fresh accessJwt + * (the cached one may have expired by the time the caller retries), then + * re-run createAppPassword. */ + private async retryAppPasswordOnly( + input: ProvisionInput, + existing: NonNullable>> + ): Promise { + const { adapter, cipher, pds } = this.deps; + if (!pds.createSession) { + throw new Error( + "retry path requires PdsClient.createSession to be wired (production buildOrchestrator does this; tests must stub it)" + ); + } + + const session = await pds.createSession({ + pdsUrl: existing.pdsEndpoint, + identifier: input.handle, + password: input.password, + }); + + let mintedPassword: string; + try { + const minted = await pds.createAppPassword({ + pdsUrl: existing.pdsEndpoint, + accessJwt: session.accessJwt, + name: `contrail-${input.attemptId}`, + }); + mintedPassword = minted.password; + } catch (err: any) { + await adapter.updateProvisionStatus(input.attemptId, "activated", { + lastError: `createAppPassword (retry): ${err.message}`, + }); + throw new Error(`createAppPassword (retry): ${err.message}`); + } + + const encryptedPassword = await cipher.encrypt(mintedPassword); + await adapter.updateProvisionStatus(input.attemptId, "activated", { + encryptedPassword, + }); + // Refresh the session cache with the JWTs we just obtained, so the + // first publish for this community doesn't need another createSession. + await adapter.upsertSession(existing.did, { + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + accessExp: decodeJwtExp(session.accessJwt), + }); + + return { + attemptId: input.attemptId, + did: existing.did, + status: "activated", + rootCredentials: { + handle: input.handle, + password: input.password, + recoveryHint: "store this — Contrail does not retain it", + }, + }; + } + +} + +/** Cheap structural check for did:key:z multibase identifiers. The orchestrator + * trusts the caller's submitted rotation key beyond this; PLC will reject any + * malformed key when the genesis op is submitted. */ +function isDidKeyZ(s: string): boolean { + return typeof s === "string" && s.startsWith("did:key:z") && s.length > 12; +} + diff --git a/packages/contrail/src/core/community/router.ts b/packages/contrail/src/core/community/router.ts index 2897ec0..624bc3c 100644 --- a/packages/contrail/src/core/community/router.ts +++ b/packages/contrail/src/core/community/router.ts @@ -6,7 +6,13 @@ import { buildSpaceUri } from "../spaces/uri"; import { HostedAdapter } from "../spaces/adapter"; import { CommunityAdapter } from "./adapter"; import { CredentialCipher } from "./credentials"; -import { resolveIdentity, createPdsSession } from "./pds"; +import { + resolveIdentity, + createPdsSession, + decodeJwtExp, + tryRefreshSession, + normalizePdsEndpoint, +} from "./pds"; import { generateKeyPair, buildGenesisOp, @@ -14,6 +20,18 @@ import { computeDidPlc, submitGenesisOp, } from "./plc"; +import { + pdsCreateAccount, + pdsGetRecommendedDidCredentials, + pdsActivateAccount, + pdsCreateAppPassword, + pdsDescribeServer, +} from "./pds"; +import { + ProvisionOrchestrator, + type PdsClient, + type PlcClient, +} from "./provision"; import { resolveEffectiveLevel, resolveReachableSpaces, wouldCycle } from "./acl"; import { reconcile } from "./reconcile"; import type { AccessLevel } from "./types"; @@ -210,6 +228,188 @@ export function registerCommunityRoutes( }); }); + app.post(`/xrpc/${NS}.provision`, auth, async (c) => { + // Default-deny gate. Every successful call burns an invite code on the + // target PDS and adds a permanent entry to PLC, so the route refuses + // unless the operator has explicitly opted in via cfg.allowProvisioning. + // Checked BEFORE auth-issued state inspection so an unauthorized op + // doesn't even surface that the route exists in a usable form. + if (!cfg.allowProvisioning) { + return c.json( + { + error: "ProvisioningDisabled", + message: "community.provision is disabled on this Contrail deployment", + }, + 403 + ); + } + const sa = getAuth(c); + const body = (await c.req.json().catch(() => null)) as + | { + attemptId?: string; + handle?: string; + email?: string; + password?: string; + inviteCode?: string; + pdsEndpoint?: string; + rotationKey?: string; + } + | null; + if ( + !body?.handle || + !body.email || + !body.password || + !body.pdsEndpoint || + !body.rotationKey + ) { + return c.json( + { + error: "InvalidRequest", + message: "handle, email, password, pdsEndpoint, rotationKey required", + }, + 400 + ); + } + if ( + !(typeof body.rotationKey === "string" && body.rotationKey.startsWith("did:key:z")) + ) { + return c.json( + { + error: "InvalidRequest", + message: "rotationKey must be a did:key:z…", + }, + 400 + ); + } + + let normalizedPdsEndpoint: string; + try { + normalizedPdsEndpoint = normalizePdsEndpoint(body.pdsEndpoint); + } catch { + return c.json( + { + error: "InvalidRequest", + message: "pdsEndpoint must be a parseable URL", + }, + 400 + ); + } + + const allowed = cfg.allowedPdsEndpoints; + if (allowed && allowed.length > 0) { + const allowedNormalized = allowed.map((e) => { + try { + return normalizePdsEndpoint(e); + } catch { + // An unparseable allowlist entry can never match; treat as the + // original string so an obvious config typo at least produces a + // reject for the caller rather than a server crash. + return e; + } + }); + if (!allowedNormalized.includes(normalizedPdsEndpoint)) { + return c.json( + { + error: "InvalidRequest", + message: `pdsEndpoint not in allowlist`, + }, + 400 + ); + } + } + body.pdsEndpoint = normalizedPdsEndpoint; + + // Resolve the target PDS's DID dynamically. The service-auth JWT's `aud` + // must match what the PDS publishes for itself via describeServer; the + // PDS rejects with BadJwtAudience otherwise. This is what allows a + // single Contrail to mint communities on multiple PDSes — using a + // cfg-pinned value would force a 1:1 Contrail-to-PDS deployment. + let pdsDid: string; + try { + const described = await pdsDescribeServer(body.pdsEndpoint, { + fetch: cfg.fetch, + }); + pdsDid = described.did; + } catch (err: any) { + return c.json( + { + error: "PdsUnreachable", + message: `describeServer failed for ${body.pdsEndpoint}: ${err.message}`, + }, + 502 + ); + } + const orchestrator = buildOrchestrator(cfg, community, cipher, pdsDid); + + const attemptId = body.attemptId ?? crypto.randomUUID(); + let result; + try { + result = await orchestrator.provision({ + attemptId, + pdsEndpoint: body.pdsEndpoint, + handle: body.handle, + email: body.email, + password: body.password, + inviteCode: body.inviteCode, + rotationKey: body.rotationKey, + }); + } catch (err: any) { + // attemptId must always come back to the caller so they can retry + // idempotently (see the C3 retry path in ProvisionOrchestrator). + return c.json( + { error: "ProvisioningFailed", message: err.message, attemptId }, + 502 + ); + } + + // Idempotent graduation: if the community row already exists, the first + // call already wrote both the row and the reserved spaces. A retry with + // the same attemptId should return success without double-creating. + const alreadyGraduated = (await community.getCommunity(result.did)) != null; + if (!alreadyGraduated) { + // Hand the already-encrypted password from the provision_attempts row + // to the communities row, keeping a single source of truth for the + // credential. + const attempt = await community.getProvisionAttempt(attemptId); + if (!attempt?.encryptedPassword) { + return c.json( + { + error: "ProvisioningFailed", + message: "provision attempt missing encryptedPassword after activation", + }, + 502 + ); + } + + await community.createFromProvisioned({ + did: result.did, + pdsEndpoint: body.pdsEndpoint, + handle: body.handle, + appPasswordEncrypted: attempt.encryptedPassword, + createdBy: sa.issuer, + }); + + await bootstrapReservedSpaces({ + communityDid: result.did, + creatorDid: sa.issuer, + spaces, + community, + type: spaceType, + serviceDid: spaceServiceDid, + }); + } + + const responseBody: { + communityDid: string; + status: string; + rootCredentials?: { handle: string; password: string; recoveryHint: string }; + } = { communityDid: result.did, status: result.status }; + if (result.rootCredentials) { + responseBody.rootCredentials = result.rootCredentials; + } + return c.json(responseBody); + }); + app.post(`/xrpc/${NS}.delete`, auth, async (c) => { const sa = getAuth(c); const body = (await c.req.json().catch(() => null)) as @@ -670,6 +870,8 @@ export function registerCommunityRoutes( 400 ); } + // adopt + provision modes both share the credential-proxy publishing path: + // both store {pds_endpoint, identifier, app_password_encrypted}. Falls through. // Caller must be member+ in $publishers. const publishersUri = buildSpaceUri({ @@ -693,7 +895,12 @@ export function registerCommunityRoutes( let session; try { const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + session = await ensureSession({ + community, + did: body.communityDid, + pdsEndpoint: raw.pdsEndpoint, + identifier: raw.identifier, + password: appPassword, fetch: cfg.fetch, }); } catch (err: any) { @@ -722,6 +929,11 @@ export function registerCommunityRoutes( }), } ); + if (res.status === 401) { + // Stale or revoked session: drop the cache so the next request goes + // cold through ensureSession. + await community.clearSession(body.communityDid); + } if (!res.ok) { const text = await res.text().catch(() => ""); return c.json( @@ -749,6 +961,7 @@ export function registerCommunityRoutes( if (row.mode === "mint") { return c.json({ error: "NotSupported" }, 400); } + // adopt + provision: same credential-proxy path; falls through. const publishersUri = buildSpaceUri({ ownerDid: body.communityDid, @@ -767,7 +980,12 @@ export function registerCommunityRoutes( let session; try { const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + session = await ensureSession({ + community, + did: body.communityDid, + pdsEndpoint: raw.pdsEndpoint, + identifier: raw.identifier, + password: appPassword, fetch: cfg.fetch, }); } catch (err: any) { @@ -793,6 +1011,9 @@ export function registerCommunityRoutes( }), } ); + if (res.status === 401) { + await community.clearSession(body.communityDid); + } if (!res.ok) { const text = await res.text().catch(() => ""); return c.json( @@ -904,14 +1125,20 @@ export function registerCommunityRoutes( } } - // Adopted: attempt a session creation. + // Adopted + provisioned: both store an app password against an external PDS. + // Health = we can still create a session with the stored credentials. const raw = await community.getRawCredentials(communityDid); if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { return c.json({ status: "expired" }); } try { const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); - await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { + await ensureSession({ + community, + did: communityDid, + pdsEndpoint: raw.pdsEndpoint, + identifier: raw.identifier, + password: appPassword, fetch: cfg.fetch, }); return c.json({ status: "healthy" }); @@ -1008,6 +1235,41 @@ function generateKey(): string { return out; } +/** Build a ProvisionOrchestrator wired with real PDS/PLC clients backed by + * `cfg.fetch` (so tests can stub the network the same way they do for the + * mint/adopt routes). Mirrors the ad-hoc wrapper used in the live e2e test + * at apps/contrail-e2e/tests/provision.test.ts. */ +function buildOrchestrator( + cfg: import("./types").CommunityConfig, + adapter: CommunityAdapter, + cipher: CredentialCipher, + pdsDid: string +): ProvisionOrchestrator { + const plcDirectory = cfg.plcDirectory ?? "https://plc.directory"; + const fetchOpts = { fetch: cfg.fetch }; + + const plc: PlcClient = { + submit: (did, op) => submitGenesisOp(plcDirectory, did, op as any, fetchOpts), + }; + + const pds: PdsClient = { + createAccount: ({ pdsUrl, serviceAuthJwt, body }) => + pdsCreateAccount(pdsUrl, serviceAuthJwt, body, fetchOpts), + getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => + pdsGetRecommendedDidCredentials(pdsUrl, accessJwt, fetchOpts), + activateAccount: ({ pdsUrl, accessJwt }) => + pdsActivateAccount(pdsUrl, accessJwt, fetchOpts), + createAppPassword: async ({ pdsUrl, accessJwt, name }) => { + const r = await pdsCreateAppPassword(pdsUrl, accessJwt, name, fetchOpts); + return { password: r.password }; + }, + createSession: ({ pdsUrl, identifier, password }) => + createPdsSession(pdsUrl, identifier, password, fetchOpts), + }; + + return new ProvisionOrchestrator({ adapter, cipher, plc, pds, pdsDid }); +} + async function bootstrapReservedSpaces(args: { communityDid: string; creatorDid: string; @@ -1041,3 +1303,45 @@ async function bootstrapReservedSpaces(args: { await args.spaces.applyMembershipDiff(uri, [args.creatorDid], [], args.creatorDid); } } + +/** Ensure a usable PDS session for the given community DID. Tries the cached + * session first (with a 30s skew); if expired, tries refresh; if refresh fails + * (or there's no cache), falls back to creating a fresh session with the + * stored app password. The result is always written back to the cache. */ +async function ensureSession(args: { + community: CommunityAdapter; + did: string; + pdsEndpoint: string; + identifier: string; + password: string; + fetch?: typeof fetch; +}): Promise<{ accessJwt: string; refreshJwt: string }> { + const now = Math.floor(Date.now() / 1000); + const cached = await args.community.getSession(args.did); + if (cached && cached.accessExp > now + 30) { + return { accessJwt: cached.accessJwt, refreshJwt: cached.refreshJwt }; + } + if (cached) { + const refreshed = await tryRefreshSession({ + pdsUrl: args.pdsEndpoint, + refreshJwt: cached.refreshJwt, + fetch: args.fetch, + }); + if (refreshed) { + await args.community.upsertSession(args.did, refreshed); + return { accessJwt: refreshed.accessJwt, refreshJwt: refreshed.refreshJwt }; + } + } + const session = await createPdsSession( + args.pdsEndpoint, + args.identifier, + args.password, + { fetch: args.fetch } + ); + await args.community.upsertSession(args.did, { + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + accessExp: decodeJwtExp(session.accessJwt), + }); + return { accessJwt: session.accessJwt, refreshJwt: session.refreshJwt }; +} diff --git a/packages/contrail/src/core/community/schema.ts b/packages/contrail/src/core/community/schema.ts index b7d382c..3938e30 100644 --- a/packages/contrail/src/core/community/schema.ts +++ b/packages/contrail/src/core/community/schema.ts @@ -44,6 +44,57 @@ export function buildCommunitySchema(dialect: SqlDialect): string[] { note TEXT )`, `CREATE INDEX IF NOT EXISTS idx_community_invites_space ON community_invites(space_uri, created_at DESC)`, + + `CREATE TABLE IF NOT EXISTS provision_attempts ( + attempt_id TEXT PRIMARY KEY NOT NULL, + did TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ( + 'keys_generated', + 'genesis_submitted', + 'account_created', + 'did_doc_updated', + 'activated' + )), + pds_endpoint TEXT NOT NULL, + handle TEXT NOT NULL, + email TEXT NOT NULL, + invite_code TEXT, + encrypted_signing_key TEXT, + encrypted_rotation_key TEXT, + encrypted_password TEXT, + genesis_submitted_at ${dialect.bigintType}, + account_created_at ${dialect.bigintType}, + did_doc_updated_at ${dialect.bigintType}, + activated_at ${dialect.bigintType}, + last_error TEXT, + created_at ${dialect.bigintType} NOT NULL, + updated_at ${dialect.bigintType} NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_provision_attempts_status ON provision_attempts(status, updated_at DESC)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_provision_attempts_did ON provision_attempts(did)`, + + `CREATE TABLE IF NOT EXISTS provision_attempts_archive ( + attempt_id TEXT PRIMARY KEY NOT NULL, + did TEXT NOT NULL, + pds_endpoint TEXT NOT NULL, + handle TEXT NOT NULL, + email TEXT NOT NULL, + invite_code TEXT, + last_status TEXT, + last_error TEXT, + archived_at ${dialect.bigintType} NOT NULL, + tombstone_op_cid TEXT, + notes TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_provision_attempts_archive_archived_at ON provision_attempts_archive(archived_at DESC)`, + + `CREATE TABLE IF NOT EXISTS community_sessions ( + community_did TEXT PRIMARY KEY NOT NULL, + access_jwt TEXT NOT NULL, + refresh_jwt TEXT NOT NULL, + access_exp ${dialect.bigintType} NOT NULL, + updated_at ${dialect.bigintType} NOT NULL + )`, ]; } diff --git a/packages/contrail/src/core/community/service-auth.ts b/packages/contrail/src/core/community/service-auth.ts new file mode 100644 index 0000000..8f92124 --- /dev/null +++ b/packages/contrail/src/core/community/service-auth.ts @@ -0,0 +1,56 @@ +/** Mint an ES256 service-auth JWT for PDS XRPC calls. + * See com.atproto.server.createAccount handler: PDS verifies + * iss === did, aud === pds-did, lxm === lexicon-method, exp not in past. + * Signature is verified against the iss DID's atproto verificationMethod. */ + +import { signBytes } from "./plc"; + +function b64url(bytes: Uint8Array | string): string { + const b = + typeof bytes === "string" + ? new TextEncoder().encode(bytes) + : bytes; + let bin = ""; + for (let i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]!); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlJson(obj: unknown): string { + return b64url(JSON.stringify(obj)); +} + +export interface MintServiceAuthInput { + /** Private JWK for the signing key (atproto verificationMethod). */ + privateJwk: JsonWebKey; + /** Issuer DID (the account's did:plc). */ + iss: string; + /** Audience DID (the target PDS, e.g. did:web:pds.example). */ + aud: string; + /** Lexicon method being authorized, e.g. com.atproto.server.createAccount. */ + lxm: string; + /** Token TTL in seconds. Defaults to 60. */ + ttlSec?: number; + /** Override "now" for deterministic tests; epoch milliseconds. */ + now?: number; +} + +export async function mintServiceAuthJwt(input: MintServiceAuthInput): Promise { + const iat = Math.floor((input.now ?? Date.now()) / 1000); + const ttl = input.ttlSec ?? 60; + // Header: alg+typ only — atproto's service-auth verification doesn't use kid; + // the signing key is resolved from the iss DID's verificationMethod. + const header = { alg: "ES256", typ: "JWT" }; + const payload = { + iat, + iss: input.iss, + aud: input.aud, + exp: iat + ttl, + lxm: input.lxm, + jti: crypto.randomUUID(), + }; + const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`; + // signBytes returns IEEE P1363 r||s (64 bytes), low-S normalized. + // JWT ES256 mandates raw r||s, NOT DER. atproto enforces low-S as well. + const sig = await signBytes(input.privateJwk, new TextEncoder().encode(signingInput)); + return `${signingInput}.${b64url(sig)}`; +} diff --git a/packages/contrail/src/core/community/types.ts b/packages/contrail/src/core/community/types.ts index 7959e72..5e45458 100644 --- a/packages/contrail/src/core/community/types.ts +++ b/packages/contrail/src/core/community/types.ts @@ -21,7 +21,47 @@ export function isAccessLevel(v: unknown): v is AccessLevel { return typeof v === "string" && ACCESS_LEVELS.includes(v as AccessLevel); } -export type CommunityMode = "adopt" | "mint"; +export type CommunityMode = "adopt" | "mint" | "provision"; + +export const PROVISION_STATUSES = [ + "keys_generated", + "genesis_submitted", + "account_created", + "did_doc_updated", + "activated", +] as const; +export type ProvisionStatus = (typeof PROVISION_STATUSES)[number]; + +export interface ProvisionAttemptRow { + attemptId: string; + did: string; + status: ProvisionStatus; + pdsEndpoint: string; + handle: string; + email: string; + inviteCode: string | null; + encryptedSigningKey: string | null; + encryptedRotationKey: string | null; + encryptedPassword: string | null; + genesisSubmittedAt: number | null; + accountCreatedAt: number | null; + didDocUpdatedAt: number | null; + activatedAt: number | null; + lastError: string | null; + createdAt: number; + updatedAt: number; +} + +export interface CreateProvisionAttemptInput { + attemptId: string; + did: string; + pdsEndpoint: string; + handle: string; + email: string; + inviteCode?: string | null; + encryptedSigningKey: string; + encryptedRotationKey: string; +} export interface CommunityConfig { /** Service DID for JWT verification. Falls back to spaces.serviceDid when both modules are enabled. */ @@ -35,6 +75,23 @@ export interface CommunityConfig { resolver?: DidDocumentResolver; /** Optional override for the fetch implementation (useful for tests). */ fetch?: typeof fetch; + /** Allowlist of PDS endpoints accepted by `community.provision`. When set + * to a non-empty array, callers must supply a `pdsEndpoint` that matches + * one of these entries exactly; other values are rejected before any PLC + * op is signed. Undefined or empty array → no restriction (back-compat). + * Operators running on a public/multi-tenant Contrail SHOULD set this so + * callers can't mint PLC entries pointing at attacker-controlled PDSes + * signed by Contrail's rotation key. */ + allowedPdsEndpoints?: string[]; + /** Top-level switch for the `community.provision` route. Default-deny: a + * call with the route present but this flag unset (or false) returns 403 + * ProvisioningDisabled BEFORE any PLC/PDS work runs. Set to `true` only + * when the operator has confirmed the upstream auth middleware restricts + * this route to authorized callers — every successful call burns a real + * invite code on the target PDS and adds a permanent entry to PLC. + * `mint` and `adopt` are not gated by this flag; they don't burn external + * resources. */ + allowProvisioning?: boolean; } /** Public view of a community row. Encrypted credentials are not included here diff --git a/packages/contrail/src/core/db/schema.ts b/packages/contrail/src/core/db/schema.ts index 3f3c676..451ad26 100644 --- a/packages/contrail/src/core/db/schema.ts +++ b/packages/contrail/src/core/db/schema.ts @@ -316,6 +316,12 @@ export async function initSchema( const target = spacesSharesMainDb ? db : spacesDb!; const communityStmts = buildCommunitySchema(dialect); await target.batch(communityStmts.map((s) => target.prepare(s))); + // When community lives on a separate DB from `db`, the runMigrations(db) + // call below won't touch community tables. Run migrations on `target` too; + // the swallow-on-error pattern makes ALTERs against absent tables a no-op. + if (target !== db) { + await runMigrations(target); + } } if (config.labels) { diff --git a/packages/contrail/src/index.ts b/packages/contrail/src/index.ts index 1e0c0cf..6192f55 100644 --- a/packages/contrail/src/index.ts +++ b/packages/contrail/src/index.ts @@ -127,6 +127,21 @@ export { flattenEffectiveMembers, wouldCycle, reconcile, + initCommunitySchema, + resolveIdentity, + createPdsSession, + pdsCreateAccount, + pdsGetRecommendedDidCredentials, + pdsActivateAccount, + pdsCreateAppPassword, + generateKeyPair, + submitGenesisOp, + getLastOpCid, + buildTombstoneOp, + signTombstoneOp, + submitTombstoneOp, + cidForOp, + ProvisionOrchestrator, } from "./core/community"; export type { CommunityConfig, @@ -137,4 +152,11 @@ export type { AccessLevel, AccessLevelRow, ReservedKey, + PdsCreateAccountBody, + PdsCreateAccountResult, + RecommendedDidCredentials, + PdsClient, + PlcClient, + ProvisionInput, + ProvisionResult, } from "./core/community"; diff --git a/packages/contrail/tests/cli-reap.test.ts b/packages/contrail/tests/cli-reap.test.ts new file mode 100644 index 0000000..1e0d9b0 --- /dev/null +++ b/packages/contrail/tests/cli-reap.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { CommunityAdapter } from "../src/core/community/adapter"; +import { CredentialCipher } from "../src/core/community/credentials"; +import { + generateKeyPair, + buildTombstoneOp, + signTombstoneOp, + submitTombstoneOp, + cidForOp, + type SignedGenesisOp, +} from "../src/core/community/plc"; +import { runReap } from "../src/cli/commands/reap"; +import type { Database } from "../src/core/types"; +import { createTestDbWithSchema } from "./helpers"; + +type SeedStatus = + | "keys_generated" + | "genesis_submitted" + | "account_created" + | "did_doc_updated" + | "activated"; + +interface SeedAttemptOpts { + attemptId: string; + did: string; + status: SeedStatus; +} + +async function seedAttempt( + adapter: CommunityAdapter, + cipher: CredentialCipher, + opts: SeedAttemptOpts +): Promise<{ rotationJwk: JsonWebKey }> { + const kp = await generateKeyPair(); + const encryptedRotation = await cipher.encrypt(JSON.stringify(kp.privateJwk)); + await adapter.createProvisionAttempt({ + attemptId: opts.attemptId, + did: opts.did, + pdsEndpoint: "https://pds.test", + handle: `${opts.attemptId}.pds.test`, + email: `${opts.attemptId}@x.test`, + encryptedSigningKey: await cipher.encrypt("{}"), + encryptedRotationKey: encryptedRotation, + }); + // Walk the row forward to its target status. The row starts at + // keys_generated after createProvisionAttempt. + const path: SeedStatus[] = [ + "genesis_submitted", + "account_created", + "did_doc_updated", + "activated", + ]; + for (const next of path) { + if (opts.status === "keys_generated") break; + await adapter.updateProvisionStatus(opts.attemptId, next); + if (next === opts.status) break; + } + return { rotationJwk: kp.privateJwk }; +} + +interface PlcCall { + url: string; + method: string; + body: any; +} + +/** Stand-in for what PLC's `/log/last` actually returns: the bare signed op + * object, no envelope. `getLastOpCid` computes the CID locally via cidForOp. */ +const FAKE_LAST_OP: SignedGenesisOp = { + type: "plc_operation", + prev: null, + rotationKeys: ["did:key:zQ3shfakerotation00000000000000000000000000000000000"], + verificationMethods: { atproto: "did:key:zQ3shfakeverif00000000000000000000000000000000000000" }, + alsoKnownAs: ["at://fixture.pds.test"], + services: { + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, + }, + sig: "fakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfak", +}; + +function makeFakeFetch(calls: PlcCall[]): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/log/last")) { + return new Response(JSON.stringify(FAKE_LAST_OP), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + calls.push({ + url, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return new Response("", { status: 200 }); + }) as typeof fetch; +} + +describe("runReap (cli reap)", () => { + let db: Database; + let adapter: CommunityAdapter; + let cipher: CredentialCipher; + + beforeEach(async () => { + db = await createTestDbWithSchema(); + await initCommunitySchema(db); + cipher = new CredentialCipher(new Uint8Array(32).fill(7)); + adapter = new CommunityAdapter(db); + }); + + it("rejects when neither --attempt-id nor --all-stuck is set", async () => { + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch([]), + logger: { log: () => {}, error: () => {} }, + yes: true, + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/--attempt-id|--all-stuck/i); + }); + + it("rejects when both --attempt-id and --all-stuck are set", async () => { + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch([]), + logger: { log: () => {}, error: () => {} }, + yes: true, + attemptId: "a1", + allStuck: true, + }); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/mutually exclusive|both|exactly one/i); + }); + + it("real run with --attempt-id submits a tombstone and archives the row", async () => { + await seedAttempt(adapter, cipher, { + attemptId: "a-stuck", + did: "did:plc:stuck", + status: "genesis_submitted", + }); + + const calls: PlcCall[] = []; + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch(calls), + logger: { log: () => {}, error: () => {} }, + yes: true, + attemptId: "a-stuck", + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(result.reaped).toBe(1); + expect(result.errors).toBe(0); + expect(calls.length).toBe(1); + expect(calls[0]!.url).toBe("https://plc.test/did:plc:stuck"); + expect(calls[0]!.body.type).toBe("plc_tombstone"); + expect(calls[0]!.body.prev).toBe(await cidForOp(FAKE_LAST_OP)); + + // Original row removed from provision_attempts. + expect(await adapter.getProvisionAttempt("a-stuck")).toBeNull(); + // Archive row populated with the row's last live status. + const archive = await db + .prepare( + "SELECT * FROM provision_attempts_archive WHERE attempt_id = ?" + ) + .bind("a-stuck") + .first>(); + expect(archive).not.toBeNull(); + expect(archive!.did).toBe("did:plc:stuck"); + expect(archive!.last_status).toBe("genesis_submitted"); + expect(archive!.tombstone_op_cid).toBeTruthy(); + }); + + it("defaults to dry-run when dryRun is unspecified (safety default)", async () => { + await seedAttempt(adapter, cipher, { + attemptId: "a-stuck", + did: "did:plc:stuck", + status: "did_doc_updated", + }); + + const calls: PlcCall[] = []; + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch(calls), + logger: { log: () => {}, error: () => {} }, + yes: true, + attemptId: "a-stuck", + // dryRun INTENTIONALLY OMITTED — must default to dry-run. + }); + + expect(result.ok).toBe(true); + expect(result.reaped).toBe(0); + expect(result.dryRunSkipped).toBe(1); + expect(calls.length).toBe(0); + const row = await adapter.getProvisionAttempt("a-stuck"); + expect(row?.status).toBe("did_doc_updated"); + }); + + it("with --all-stuck reaps every non-activated row, regardless of status", async () => { + await seedAttempt(adapter, cipher, { + attemptId: "s1", + did: "did:plc:s1", + status: "keys_generated", + }); + await seedAttempt(adapter, cipher, { + attemptId: "s2", + did: "did:plc:s2", + status: "genesis_submitted", + }); + await seedAttempt(adapter, cipher, { + attemptId: "s3", + did: "did:plc:s3", + status: "did_doc_updated", + }); + // An activated row must NOT be reaped. + await seedAttempt(adapter, cipher, { + attemptId: "live", + did: "did:plc:live", + status: "activated", + }); + + const calls: PlcCall[] = []; + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch(calls), + logger: { log: () => {}, error: () => {} }, + yes: true, + allStuck: true, + dryRun: false, + }); + + expect(result.ok).toBe(true); + expect(result.reaped).toBe(3); + expect(calls.map((c) => c.url).sort()).toEqual([ + "https://plc.test/did:plc:s1", + "https://plc.test/did:plc:s2", + "https://plc.test/did:plc:s3", + ]); + // The activated row is untouched. + const live = await adapter.getProvisionAttempt("live"); + expect(live?.status).toBe("activated"); + }); + + it("refuses to reap an activated row passed via --attempt-id", async () => { + await seedAttempt(adapter, cipher, { + attemptId: "live", + did: "did:plc:live", + status: "activated", + }); + + const calls: PlcCall[] = []; + const result = await runReap({ + adapter, + cipher, + plcDirectory: "https://plc.test", + fetch: makeFakeFetch(calls), + logger: { log: () => {}, error: () => {} }, + yes: true, + attemptId: "live", + dryRun: false, + }); + + expect(calls.length).toBe(0); + expect(result.errors).toBeGreaterThanOrEqual(1); + const live = await adapter.getProvisionAttempt("live"); + expect(live?.status).toBe("activated"); + }); +}); + +describe("plc tombstone helpers", () => { + it("buildTombstoneOp produces the expected shape", () => { + const op = buildTombstoneOp("bafyreigenesis"); + expect(op.type).toBe("plc_tombstone"); + expect(op.prev).toBe("bafyreigenesis"); + }); + + it("signTombstoneOp adds a base64url sig", async () => { + const kp = await generateKeyPair(); + const op = buildTombstoneOp("bafyreigenesis"); + const signed = await signTombstoneOp(op, kp.privateJwk); + expect(signed.type).toBe("plc_tombstone"); + expect(signed.prev).toBe("bafyreigenesis"); + expect(signed.sig).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("submitTombstoneOp POSTs to the PLC directory at the DID URL", async () => { + const kp = await generateKeyPair(); + const signed = await signTombstoneOp( + buildTombstoneOp("bafyreigenesis"), + kp.privateJwk + ); + + let calledUrl = ""; + let calledBody: any = null; + const fakeFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calledUrl = String(input); + calledBody = init?.body ? JSON.parse(String(init.body)) : null; + return new Response("", { status: 200 }); + }) as typeof fetch; + + await submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { + fetch: fakeFetch, + }); + expect(calledUrl).toBe("https://plc.test/did:plc:abc"); + expect(calledBody.type).toBe("plc_tombstone"); + expect(calledBody.prev).toBe("bafyreigenesis"); + expect(calledBody.sig).toBe(signed.sig); + }); + + it("submitTombstoneOp throws on non-2xx", async () => { + const kp = await generateKeyPair(); + const signed = await signTombstoneOp( + buildTombstoneOp("bafyreigenesis"), + kp.privateJwk + ); + const fakeFetch: typeof fetch = (async () => + new Response("denied", { status: 400 })) as typeof fetch; + await expect( + submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { + fetch: fakeFetch, + }) + ).rejects.toThrow(/400.*denied/); + }); +}); diff --git a/packages/contrail/tests/community-provision-attempts.test.ts b/packages/contrail/tests/community-provision-attempts.test.ts new file mode 100644 index 0000000..1296b64 --- /dev/null +++ b/packages/contrail/tests/community-provision-attempts.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { CommunityAdapter } from "../src/core/community/adapter"; +import { createTestDbWithSchema } from "./helpers"; + +describe("provision_attempts adapter", () => { + let adapter: CommunityAdapter; + + beforeEach(async () => { + const db = await createTestDbWithSchema(); + await initCommunitySchema(db); + adapter = new CommunityAdapter(db); + }); + + it("creates and reads a provision attempt", async () => { + const before = Date.now(); + await adapter.createProvisionAttempt({ + attemptId: "a1", + did: "did:plc:abc", + pdsEndpoint: "https://pds.test", + handle: "abc.pds.test", + email: "abc@x.test", + inviteCode: "code-1", + encryptedSigningKey: "sk-enc", + encryptedRotationKey: "rk-enc", + }); + + const row = await adapter.getProvisionAttempt("a1"); + expect(row).not.toBeNull(); + expect(row?.attemptId).toBe("a1"); + expect(row?.did).toBe("did:plc:abc"); + expect(row?.status).toBe("keys_generated"); + expect(row?.pdsEndpoint).toBe("https://pds.test"); + expect(row?.handle).toBe("abc.pds.test"); + expect(row?.email).toBe("abc@x.test"); + expect(row?.inviteCode).toBe("code-1"); + expect(row?.encryptedSigningKey).toBe("sk-enc"); + expect(row?.encryptedRotationKey).toBe("rk-enc"); + expect(row?.encryptedPassword).toBeNull(); + expect(row?.lastError).toBeNull(); + expect(row?.genesisSubmittedAt).toBeNull(); + expect(row?.accountCreatedAt).toBeNull(); + expect(row?.didDocUpdatedAt).toBeNull(); + expect(row?.activatedAt).toBeNull(); + expect(row?.createdAt).toBeGreaterThanOrEqual(before); + expect(row?.updatedAt).toBeGreaterThanOrEqual(before); + }); + + it("getProvisionAttempt returns null for unknown attempt", async () => { + const row = await adapter.getProvisionAttempt("no-such-attempt"); + expect(row).toBeNull(); + }); + + it("treats missing inviteCode as null", async () => { + await adapter.createProvisionAttempt({ + attemptId: "a-no-invite", + did: "did:plc:noinv", + pdsEndpoint: "https://pds.test", + handle: "noinv.pds.test", + email: "noinv@x.test", + encryptedSigningKey: "sk", + encryptedRotationKey: "rk", + }); + const row = await adapter.getProvisionAttempt("a-no-invite"); + expect(row?.inviteCode).toBeNull(); + }); + + it("advances status, stamps the matching timestamp, and persists last_error", async () => { + await adapter.createProvisionAttempt({ + attemptId: "a1", + did: "did:plc:abc", + pdsEndpoint: "https://pds.test", + handle: "abc.pds.test", + email: "abc@x.test", + encryptedSigningKey: "sk-enc", + encryptedRotationKey: "rk-enc", + }); + + const initial = await adapter.getProvisionAttempt("a1"); + const initialUpdated = initial!.updatedAt; + + // Small wait so updated_at can advance on millisecond clocks. + await new Promise((r) => setTimeout(r, 2)); + + await adapter.updateProvisionStatus("a1", "genesis_submitted"); + let row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("genesis_submitted"); + expect(row?.genesisSubmittedAt).toBeTruthy(); + expect(row?.accountCreatedAt).toBeNull(); + expect(row?.didDocUpdatedAt).toBeNull(); + expect(row?.activatedAt).toBeNull(); + expect(row?.updatedAt).toBeGreaterThanOrEqual(initialUpdated); + + await adapter.updateProvisionStatus("a1", "account_created"); + row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("account_created"); + expect(row?.accountCreatedAt).toBeTruthy(); + // Earlier stamp must be preserved on subsequent updates. + expect(row?.genesisSubmittedAt).toBeTruthy(); + + await adapter.updateProvisionStatus("a1", "did_doc_updated", { lastError: "transient PLC error" }); + row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("did_doc_updated"); + expect(row?.lastError).toBe("transient PLC error"); + // Earlier stamps still preserved. + expect(row?.genesisSubmittedAt).toBeTruthy(); + expect(row?.accountCreatedAt).toBeTruthy(); + }); + + it("persists encryptedPassword via updateProvisionStatus", async () => { + await adapter.createProvisionAttempt({ + attemptId: "a-pwd", + did: "did:plc:pwd", + pdsEndpoint: "https://pds.test", + handle: "pwd.pds.test", + email: "pwd@x.test", + encryptedSigningKey: "sk", + encryptedRotationKey: "rk", + }); + expect((await adapter.getProvisionAttempt("a-pwd"))?.encryptedPassword).toBeNull(); + + await adapter.updateProvisionStatus("a-pwd", "account_created", { + encryptedPassword: "pwd-enc", + }); + const row = await adapter.getProvisionAttempt("a-pwd"); + expect(row?.encryptedPassword).toBe("pwd-enc"); + expect(row?.accountCreatedAt).toBeTruthy(); + }); + + it("enforces did uniqueness across attempts", async () => { + await adapter.createProvisionAttempt({ + attemptId: "first", + did: "did:plc:dupe", + pdsEndpoint: "https://pds.test", + handle: "first.pds.test", + email: "first@x.test", + encryptedSigningKey: "sk", + encryptedRotationKey: "rk", + }); + await expect( + adapter.createProvisionAttempt({ + attemptId: "second", + did: "did:plc:dupe", + pdsEndpoint: "https://pds.test", + handle: "second.pds.test", + email: "second@x.test", + encryptedSigningKey: "sk", + encryptedRotationKey: "rk", + }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/contrail/tests/community-provision-pds-allowlist.test.ts b/packages/contrail/tests/community-provision-pds-allowlist.test.ts new file mode 100644 index 0000000..83cbca0 --- /dev/null +++ b/packages/contrail/tests/community-provision-pds-allowlist.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; +import { normalizePdsEndpoint } from "../src/core/community/pds"; + +const ALICE = "did:plc:alice"; +const MASTER_KEY = new Uint8Array(32).fill(99); +const ALLOWED_PDS = "https://allowed.pds.test"; +const ATTACKER_PDS = "https://attacker.pds.test"; +const PLC_DIRECTORY = "https://plc.test"; + +const FAKE_ACCESS_JWT = "head.body.sig"; + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body as string) : {}; + + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.describeServer`) { + return new Response(JSON.stringify({ did: "did:web:allowed.pds.test" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.startsWith(`${PLC_DIRECTORY}/`) && !url.endsWith("/log/last") && method === "POST") { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + if (url.endsWith("/log/last") && method === "GET") { + return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAccount` && method === "POST") { + return new Response( + JSON.stringify({ + did: body.did, + handle: body.handle, + accessJwt: FAKE_ACCESS_JWT, + refreshJwt: "RT", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.identity.getRecommendedDidCredentials`) { + return new Response( + JSON.stringify({ + rotationKeys: [], + verificationMethods: { atproto: "did:key:zPdsSig" }, + alsoKnownAs: ["at://newcomm.allowed.pds.test"], + services: { + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: ALLOWED_PDS }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.activateAccount` && method === "POST") { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { + return new Response( + JSON.stringify({ name: body.name, password: "minted-app-pw" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(`unmocked: ${method} ${url}`, { status: 404 }); +} + +function buildConfig(allowedPdsEndpoints: string[] | undefined): ContrailConfig { + return { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + plcDirectory: PLC_DIRECTORY, + fetch: mockFetch, + allowedPdsEndpoints, + allowProvisioning: true, + }, + }; +} + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: "did:web:test.example#svc", lxm: undefined }); + await next(); + }; +} + +async function makeApp(allowedPdsEndpoints: string[] | undefined): Promise { + const db = createSqliteDatabase(":memory:"); + const cfg = buildConfig(allowedPdsEndpoints); + const resolved = resolveConfig(cfg); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +async function call(app: Hono, body: any): Promise { + return await app.fetch( + new Request(`http://localhost/xrpc/test.comm.community.provision`, { + method: "POST", + headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +describe("provision pdsEndpoint allowlist (M3)", () => { + it("rejects pdsEndpoint not in allowedPdsEndpoints", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.attacker.pds.test", + email: "x@x.test", + password: "secret", + pdsEndpoint: ATTACKER_PDS, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(400); + const j = (await res.json()) as { error: string; message: string }; + expect(j.error).toBe("InvalidRequest"); + expect(j.message).toMatch(/pdsEndpoint/i); + }); + + it("accepts pdsEndpoint that is in allowedPdsEndpoints", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: ALLOWED_PDS, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("when allowedPdsEndpoints is undefined, accepts any pdsEndpoint (back-compat)", async () => { + const app = await makeApp(undefined); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: ALLOWED_PDS, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("when allowedPdsEndpoints is empty array, accepts any pdsEndpoint (back-compat)", async () => { + const app = await makeApp([]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: ALLOWED_PDS, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("matches when caller adds a trailing slash to a slash-less allowlist entry", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: `${ALLOWED_PDS}/`, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("matches when caller uppercases the scheme on an allowlisted endpoint", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: ALLOWED_PDS.replace(/^https/, "HTTPS"), + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("matches when caller appends the default :443 port", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: ALLOWED_PDS.replace(/^https:\/\/([^/]+)/, "https://$1:443"), + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + }); + + it("rejects pdsEndpoint that is not a parseable URL", async () => { + const app = await makeApp([ALLOWED_PDS]); + const res = await call(app, { + handle: "newcomm.allowed.pds.test", + email: "x@x.test", + password: "secret", + pdsEndpoint: "not a url", + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(400); + const j = (await res.json()) as { error: string; message: string }; + expect(j.error).toBe("InvalidRequest"); + expect(j.message).toMatch(/parseable|url/i); + }); +}); + +describe("normalizePdsEndpoint", () => { + it("collapses scheme case", () => { + expect(normalizePdsEndpoint("HTTPS://pds.example.com")).toBe( + "https://pds.example.com" + ); + }); + it("collapses host case", () => { + expect(normalizePdsEndpoint("https://PDS.Example.com")).toBe( + "https://pds.example.com" + ); + }); + it("strips trailing slash", () => { + expect(normalizePdsEndpoint("https://pds.example.com/")).toBe( + "https://pds.example.com" + ); + }); + it("strips default :443 for https", () => { + expect(normalizePdsEndpoint("https://pds.example.com:443")).toBe( + "https://pds.example.com" + ); + }); + it("strips default :80 for http", () => { + expect(normalizePdsEndpoint("http://pds.example.com:80")).toBe( + "http://pds.example.com" + ); + }); + it("preserves a non-default port", () => { + expect(normalizePdsEndpoint("https://pds.example.com:8443")).toBe( + "https://pds.example.com:8443" + ); + }); + it("converts an IDN hostname to its punycode form", () => { + expect(normalizePdsEndpoint("https://exämple.com")).toBe( + "https://xn--exmple-cua.com" + ); + }); + it("throws on an unparseable URL", () => { + expect(() => normalizePdsEndpoint("not a url")).toThrow(); + }); +}); diff --git a/packages/contrail/tests/community-provision-router.test.ts b/packages/contrail/tests/community-provision-router.test.ts new file mode 100644 index 0000000..c29a35d --- /dev/null +++ b/packages/contrail/tests/community-provision-router.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; + +const ALICE = "did:plc:alice"; +const MASTER_KEY = new Uint8Array(32).fill(99); +const PDS_ENDPOINT = "https://pds.test"; +const PLC_DIRECTORY = "https://plc.test"; +/** The DID describeServer claims for this PDS. INTENTIONALLY DIFFERENT from + * CONFIG.spaces.serviceDid so tests can detect a regression where the route + * falls back to the spaces DID instead of resolving the PDS DID dynamically. */ +const PDS_DESCRIBE_DID = "did:web:pds.test"; + +/** Captures upstream calls so we can assert the right RPCs ran. */ +const upstreamCalls: Array<{ url: string; method: string; body: any; authorization?: string }> = []; + +// Placeholder JWT — the orchestrator passes accessJwt through to PDS calls +// untouched; nothing in the contrail flow parses its claims. +const FAKE_ACCESS_JWT = "head.body.sig"; + +async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body as string) : {}; + const headers = new Headers(init?.headers ?? {}); + upstreamCalls.push({ + url, + method, + body, + authorization: headers.get("authorization") ?? undefined, + }); + + // PDS describeServer — used by the route to resolve the target PDS's DID + // for service-auth JWT `aud`. + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer`) { + return new Response(JSON.stringify({ did: PDS_DESCRIBE_DID }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + // PLC submit: POST {plcDirectory}/{did} (genesis + update share the URL). + if (url.startsWith(`${PLC_DIRECTORY}/`) && url.endsWith("/log/last") === false && method === "POST") { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + // PLC log/last: not used in the happy path but be defensive. + if (url.endsWith("/log/last") && method === "GET") { + return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + // PDS createAccount. + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` && method === "POST") { + return new Response( + JSON.stringify({ + did: body.did, + handle: body.handle, + accessJwt: FAKE_ACCESS_JWT, + refreshJwt: "RT", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + // PDS getRecommendedDidCredentials. + if ( + url === `${PDS_ENDPOINT}/xrpc/com.atproto.identity.getRecommendedDidCredentials` + ) { + return new Response( + JSON.stringify({ + rotationKeys: [], + verificationMethods: { atproto: "did:key:zPdsSig" }, + alsoKnownAs: ["at://newcomm.pds.test"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: PDS_ENDPOINT, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + // PDS activateAccount. + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.activateAccount` && method === "POST") { + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + } + // PDS createAppPassword (post-activation, mints publishing credential). + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { + return new Response( + JSON.stringify({ name: body.name, password: "minted-app-pw" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + return new Response(`unmocked: ${method} ${url}`, { status: 404 }); +} + +const CONFIG: ContrailConfig = { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { + masterKey: MASTER_KEY, + plcDirectory: PLC_DIRECTORY, + fetch: mockFetch, + allowProvisioning: true, + }, +}; + +function fakeAuth(): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.serviceDid, lxm: undefined }); + await next(); + }; +} + +async function makeApp(): Promise { + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(CONFIG); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); +} + +async function call( + app: Hono, + method: string, + path: string, + did: string | null, + body?: any +): Promise { + const headers: Record = {}; + if (did !== null) headers["X-Test-Did"] = did; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return await app.fetch( + new Request(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + ); +} + +describe("POST /xrpc/{ns}.community.provision (allowProvisioning gate)", () => { + // Builds an app whose community config OMITS allowProvisioning. The route + // is expected to refuse with 403 ProvisioningDisabled — operators must + // explicitly opt in. The default-deny posture protects deployments where + // the auth middleware allows broader audiences than "operator only" from + // having any authenticated caller mint communities + burn invite codes. + async function makeAppWithoutAllowProvisioning(): Promise { + const db = createSqliteDatabase(":memory:"); + const configWithoutFlag: ContrailConfig = { + ...CONFIG, + community: { ...CONFIG.community!, allowProvisioning: undefined } as any, + }; + const resolved = resolveConfig(configWithoutFlag); + await initSchema(db, resolved); + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + } + + it("returns 403 ProvisioningDisabled when allowProvisioning is not set", async () => { + const app = await makeAppWithoutAllowProvisioning(); + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { + handle: "newcomm.pds.test", + email: "newcomm@x.test", + password: "secret", + pdsEndpoint: PDS_ENDPOINT, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("ProvisioningDisabled"); + }); +}); + +describe("POST /xrpc/{ns}.community.provision", () => { + let app: Hono; + + beforeAll(async () => { + app = await makeApp(); + }); + + it("requires auth", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", null, { + handle: "x.pds.test", + email: "x@x.test", + password: "p", + pdsEndpoint: PDS_ENDPOINT, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(401); + }); + + it("rejects missing required fields", async () => { + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, {}); + expect(res.status).toBe(400); + const j = (await res.json()) as { error: string }; + expect(j.error).toBe("InvalidRequest"); + }); + + it("provisions a community and returns did + status=activated", async () => { + const before = upstreamCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { + handle: "newcomm.pds.test", + email: "newcomm@x.test", + password: "secret", + inviteCode: "code-x", + pdsEndpoint: PDS_ENDPOINT, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { communityDid: string; status: string }; + + expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); + expect(body.status).toBe("activated"); + + // Verify the row was inserted into communities with mode='provision'. + // We round-trip via the GET list endpoint so we don't have to reach into + // the adapter — the route bootstrapped reserved spaces with the caller as + // owner, which makes the community reachable. + const listRes = await call(app, "GET", "/xrpc/test.comm.community.list", ALICE); + expect(listRes.status).toBe(200); + const list = (await listRes.json()) as { + communities: Array<{ did: string; mode: string }>; + }; + const row = list.communities.find((r) => r.did === body.communityDid); + expect(row).toBeDefined(); + expect(row!.mode).toBe("provision"); + + // Confirm we touched all five upstream RPCs: 2 PLC posts (genesis + update), + // createAccount, getRecommendedDidCredentials, activateAccount. + const ourCalls = upstreamCalls.slice(before); + const plcPosts = ourCalls.filter( + (c) => c.url.startsWith(`${PLC_DIRECTORY}/`) && c.method === "POST" + ); + expect(plcPosts.length).toBe(2); + expect( + ourCalls.some((c) => + c.url.endsWith("/xrpc/com.atproto.server.createAccount") + ) + ).toBe(true); + expect( + ourCalls.some((c) => + c.url.endsWith("/xrpc/com.atproto.identity.getRecommendedDidCredentials") + ) + ).toBe(true); + expect( + ourCalls.some((c) => + c.url.endsWith("/xrpc/com.atproto.server.activateAccount") + ) + ).toBe(true); + }); + + it("is idempotent on retry with the same attemptId after a fully-completed first call", async () => { + // The route already returns attemptId on every error response so a + // caller can retry. This guards the case where the first call + // succeeded end-to-end (orchestrator + graduation + reserved spaces) + // but the caller didn't receive the 200 (e.g. lost connection): a + // resent request with the same attemptId must still 200, return the + // same DID, and not double-create rows. + const attemptId = "retry-idem-1"; + const body = { + attemptId, + handle: "retryidem.pds.test", + email: "retryidem@x.test", + password: "secret", + pdsEndpoint: PDS_ENDPOINT, + rotationKey: "did:key:zStubCallerRotationKey", + }; + + const first = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); + expect(first.status).toBe(200); + const firstJson = (await first.json()) as { communityDid: string }; + + const second = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); + expect(second.status).toBe(200); + const secondJson = (await second.json()) as { communityDid: string }; + + expect(secondJson.communityDid).toBe(firstJson.communityDid); + }); + + it("uses the describeServer-returned DID as the service-auth JWT audience (not cfg.serviceDid)", async () => { + const before = upstreamCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { + handle: "audtest.pds.test", + email: "audtest@x.test", + password: "secret", + pdsEndpoint: PDS_ENDPOINT, + rotationKey: "did:key:zStubCallerRotationKey", + }); + expect(res.status).toBe(200); + + const ourCalls = upstreamCalls.slice(before); + + // 1. The route must call describeServer on the target PDS. + const describeCall = ourCalls.find( + (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer` + ); + expect(describeCall).toBeDefined(); + + // 2. The createAccount call's Authorization Bearer JWT must have + // `aud` === the describeServer-returned DID, NOT cfg.serviceDid. + const createAccountCall = ourCalls.find( + (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` + ); + expect(createAccountCall).toBeDefined(); + expect(createAccountCall!.authorization).toMatch(/^Bearer /); + + const jwt = createAccountCall!.authorization!.replace(/^Bearer /, ""); + const payloadSeg = jwt.split(".")[1]!; + const padded = payloadSeg.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (padded.length % 4)) % 4); + const claims = JSON.parse(atob(padded + padding)) as { aud?: string }; + + expect(claims.aud).toBe(PDS_DESCRIBE_DID); + // Sanity: it is NOT the spaces serviceDid (the previous hardcoded value). + expect(claims.aud).not.toBe(CONFIG.spaces!.serviceDid); + }); +}); diff --git a/packages/contrail/tests/community-publish-401-clears-session.test.ts b/packages/contrail/tests/community-publish-401-clears-session.test.ts new file mode 100644 index 0000000..3013d1f --- /dev/null +++ b/packages/contrail/tests/community-publish-401-clears-session.test.ts @@ -0,0 +1,144 @@ +/** L6: A 401 from the publish path used to leave the bad session in the + * cache, so every subsequent publish hit the same 401 permanently. The fix + * is small: on 401, drop the cached session row. The next request goes cold + * through ensureSession, which mints a fresh session from the stored app + * password (or fails permanently if the app password itself was revoked). */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; +import { createSqliteDatabase } from "../src/adapters/sqlite"; +import { initSchema } from "../src/core/db/schema"; +import { createApp } from "../src/core/router"; +import { resolveConfig } from "../src/core/types"; +import type { ContrailConfig } from "../src/core/types"; +import { + CommunityAdapter, + CredentialCipher, + RESERVED_KEYS, +} from "../src/core/community"; +import { HostedAdapter } from "../src/core/spaces/adapter"; +import { buildSpaceUri } from "../src/core/spaces/uri"; + +const ALICE = "did:plc:alice"; +const COMMUNITY_DID = "did:plc:l6comm"; +const HANDLE = "l6.pds.test"; +const PDS = "https://pds.example"; +const MASTER_KEY = new Uint8Array(32).fill(13); +const APP_PASSWORD = "correct-pw"; + +function fakeAuth(spaceServiceDid: string): MiddlewareHandler { + return async (c, next) => { + const did = c.req.header("X-Test-Did"); + if (!did) return c.json({ error: "AuthRequired" }, 401); + c.set("serviceAuth", { + issuer: did, + audience: spaceServiceDid, + lxm: undefined, + }); + await next(); + }; +} + +async function build(): Promise<{ app: Hono; adapter: CommunityAdapter }> { + const fetchImpl: typeof fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { + return new Response(JSON.stringify({ error: "AuthRequired" }), { + status: 401, + }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const config: ContrailConfig = { + namespace: "test.comm", + collections: { message: { collection: "app.event.message" } }, + spaces: { + type: "tools.atmo.event.space", + serviceDid: "did:web:test.example#svc", + }, + community: { masterKey: MASTER_KEY, fetch: fetchImpl }, + }; + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(config); + await initSchema(db, resolved); + const app = createApp(db, resolved, { + spaces: { authMiddleware: fakeAuth(config.spaces!.serviceDid) }, + }); + + const cipher = new CredentialCipher(MASTER_KEY); + const community = new CommunityAdapter(db); + const spaces = new HostedAdapter(db, resolved); + await community.createFromProvisioned({ + did: COMMUNITY_DID, + pdsEndpoint: PDS, + handle: HANDLE, + appPasswordEncrypted: await cipher.encrypt(APP_PASSWORD), + createdBy: ALICE, + }); + for (const key of RESERVED_KEYS) { + const uri = buildSpaceUri({ + ownerDid: COMMUNITY_DID, + type: config.spaces!.type, + key, + }); + await spaces.createSpace({ + uri, + ownerDid: COMMUNITY_DID, + type: config.spaces!.type, + key, + serviceDid: config.spaces!.serviceDid, + appPolicyRef: null, + appPolicy: null, + }); + await community.grant({ + spaceUri: uri, + subjectDid: ALICE, + accessLevel: "owner", + grantedBy: ALICE, + }); + await spaces.applyMembershipDiff(uri, [ALICE], [], ALICE); + } + return { app, adapter: community }; +} + +describe("publish path: 401 clears the session cache (L6)", () => { + let app: Hono; + let adapter: CommunityAdapter; + + beforeEach(async () => { + ({ app, adapter } = await build()); + }); + + it("removes the cached session row when createRecord returns 401", async () => { + // Seed a cached session that will be used (and rejected) by createRecord. + await adapter.upsertSession(COMMUNITY_DID, { + accessJwt: "stale-access", + refreshJwt: "stale-refresh", + accessExp: Math.floor(Date.now() / 1000) + 3600, + }); + expect(await adapter.getSession(COMMUNITY_DID)).not.toBeNull(); + + const res = await app.fetch( + new Request("http://localhost/xrpc/test.comm.community.putRecord", { + method: "POST", + headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, + body: JSON.stringify({ + communityDid: COMMUNITY_DID, + collection: "app.event.message", + record: { text: "hello" }, + }), + }) + ); + expect(res.status).toBe(502); + + // The stale session must be gone, so the next attempt mints a fresh one. + expect(await adapter.getSession(COMMUNITY_DID)).toBeNull(); + }); +}); diff --git a/packages/contrail/tests/community-publishing.test.ts b/packages/contrail/tests/community-publishing.test.ts index 5ab42c3..9a0775f 100644 --- a/packages/contrail/tests/community-publishing.test.ts +++ b/packages/contrail/tests/community-publishing.test.ts @@ -5,12 +5,17 @@ import { createSqliteDatabase } from "../src/adapters/sqlite"; import { initSchema } from "../src/core/db/schema"; import { createApp } from "../src/core/router"; import { resolveConfig } from "../src/core/types"; -import type { ContrailConfig } from "../src/core/types"; +import type { ContrailConfig, Database } from "../src/core/types"; +import { CommunityAdapter, CredentialCipher, RESERVED_KEYS } from "../src/core/community"; +import { HostedAdapter } from "../src/core/spaces/adapter"; +import { buildSpaceUri } from "../src/core/spaces/uri"; const ALICE = "did:plc:alice"; const BOB = "did:plc:bob"; const CHARLIE = "did:plc:charlie"; const COMMUNITY_DID = "did:plc:pubcomm"; +const PROVISION_COMMUNITY_DID = "did:plc:provcomm"; +const PROVISION_HANDLE = "provcomm.pds.test"; const PDS_ENDPOINT = "https://pds.example"; const MASTER_KEY = new Uint8Array(32).fill(42); @@ -35,7 +40,9 @@ const CONFIG: ContrailConfig = { function mockResolver(): any { return { resolve: async (did: string) => { - if (did !== COMMUNITY_DID) throw new Error("unknown did"); + if (did !== COMMUNITY_DID && did !== PROVISION_COMMUNITY_DID) { + throw new Error("unknown did"); + } return { id: did, service: [ @@ -56,8 +63,12 @@ async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise< pdsCalls.push({ url, body }); if (url.endsWith("/xrpc/com.atproto.server.createSession")) { if (body.password === "correct-pw" || body.password === "new-correct-pw") { + // Echo back a DID that matches the identifier so adopt and provision flows + // both look right to any caller checking session.did. + const did = + body.identifier === PROVISION_HANDLE ? PROVISION_COMMUNITY_DID : COMMUNITY_DID; return new Response( - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), + JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did }), { status: 200, headers: { "content-type": "application/json" } } ); } @@ -66,7 +77,7 @@ async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise< if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { return new Response( JSON.stringify({ - uri: `at://${COMMUNITY_DID}/${body.collection}/fakerkey`, + uri: `at://${body.repo}/${body.collection}/fakerkey`, cid: "bafyfake", }), { status: 200, headers: { "content-type": "application/json" } } @@ -87,14 +98,59 @@ function fakeAuth(): MiddlewareHandler { }; } -async function makeApp(): Promise { +async function makeApp(): Promise<{ app: Hono; db: Database }> { const db = createSqliteDatabase(":memory:"); const resolved = resolveConfig(CONFIG); await initSchema(db, resolved); - return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + const app = createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + return { app, db }; } -function call( +/** Seed a provision-mode community + its reserved spaces with `creator` as + * owner. Mirrors what the adopt/provision routes do via `bootstrapReservedSpaces`, + * but skips the route so we don't have to mock PLC + 5 PDS RPCs. */ +async function seedProvisionCommunity( + db: Database, + creator: string, + password: string +): Promise { + const cipher = new CredentialCipher(MASTER_KEY); + const encrypted = await cipher.encrypt(password); + const community = new CommunityAdapter(db); + const spaces = new HostedAdapter(db, resolveConfig(CONFIG)); + await community.createFromProvisioned({ + did: PROVISION_COMMUNITY_DID, + pdsEndpoint: PDS_ENDPOINT, + handle: PROVISION_HANDLE, + appPasswordEncrypted: encrypted, + createdBy: creator, + }); + for (const key of RESERVED_KEYS) { + const uri = buildSpaceUri({ + ownerDid: PROVISION_COMMUNITY_DID, + type: CONFIG.spaces!.type, + key, + }); + await spaces.createSpace({ + uri, + ownerDid: PROVISION_COMMUNITY_DID, + type: CONFIG.spaces!.type, + key, + serviceDid: CONFIG.spaces!.serviceDid, + appPolicyRef: null, + appPolicy: null, + }); + await community.grant({ + spaceUri: uri, + subjectDid: creator, + accessLevel: "owner", + grantedBy: creator, + }); + await spaces.applyMembershipDiff(uri, [creator], [], creator); + } +} + +async function call( app: Hono, method: string, path: string, @@ -103,7 +159,7 @@ function call( ): Promise { const headers: Record = { "X-Test-Did": did }; if (body !== undefined) headers["Content-Type"] = "application/json"; - return app.fetch( + return await app.fetch( new Request(`http://localhost${path}`, { method, headers, @@ -136,7 +192,7 @@ describe("community publishing + reauth — stage 3", () => { const admin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; beforeAll(async () => { - app = await makeApp(); + ({ app } = await makeApp()); await adopt(app, ALICE, "correct-pw"); }); @@ -265,3 +321,300 @@ describe("community publishing + reauth — stage 3", () => { expect(res.status).toBe(401); }); }); + +// Build a small base64url-encoded JWT with a given exp claim. The publishing +// path decodes payload.exp to decide whether to reuse a cached session. +function jwtWithExp(expSeconds: number): string { + // base64url("{}") padding stripped — header content irrelevant to our tests. + const header = "eyJhbGciOiJIUzI1NiJ9"; + const payloadJson = JSON.stringify({ exp: expSeconds }); + const payload = btoa(payloadJson) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${header}.${payload}.sig`; +} + +/** Build an isolated app with a per-test scenario fetch + a fresh provision + * community. The scenario fetch records every call with its url + body + + * authorization header so individual tests can assert exact behavior. */ +async function makeScenarioApp(scenario: { + /** Override response for createSession. Default: success with default JWT. */ + onCreateSession?: () => Response; + /** Override response for refreshSession. Default: 400 (no refresh). */ + onRefreshSession?: () => Response; +}): Promise<{ + app: Hono; + db: Database; + calls: Array<{ url: string; body: any; authorization: string | null }>; +}> { + const calls: Array<{ url: string; body: any; authorization: string | null }> = []; + const scenarioFetch = async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const body = init?.body ? JSON.parse(init.body as string) : {}; + const headers = new Headers((init?.headers as HeadersInit) ?? {}); + const authorization = headers.get("authorization"); + calls.push({ url, body, authorization }); + if (url.endsWith("/xrpc/com.atproto.server.createSession")) { + if (scenario.onCreateSession) return scenario.onCreateSession(); + return new Response( + JSON.stringify({ + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + refreshJwt: "r.r.r", + did: PROVISION_COMMUNITY_DID, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.endsWith("/xrpc/com.atproto.server.refreshSession")) { + if (scenario.onRefreshSession) return scenario.onRefreshSession(); + return new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }); + } + if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { + return new Response( + JSON.stringify({ + uri: `at://${body.repo}/${body.collection}/scenkey`, + cid: "bafyfake", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }; + + const cfg: ContrailConfig = { + ...CONFIG, + community: { ...CONFIG.community!, fetch: scenarioFetch }, + }; + const db = createSqliteDatabase(":memory:"); + const resolved = resolveConfig(cfg); + await initSchema(db, resolved); + const app = createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() } }); + // Seed provision community + Alice as owner of $publishers. + const cipher = new CredentialCipher(MASTER_KEY); + const encrypted = await cipher.encrypt("correct-pw"); + const community = new CommunityAdapter(db); + const spacesAdp = new HostedAdapter(db, resolved); + await community.createFromProvisioned({ + did: PROVISION_COMMUNITY_DID, + pdsEndpoint: PDS_ENDPOINT, + handle: PROVISION_HANDLE, + appPasswordEncrypted: encrypted, + createdBy: ALICE, + }); + for (const key of RESERVED_KEYS) { + const uri = buildSpaceUri({ + ownerDid: PROVISION_COMMUNITY_DID, + type: CONFIG.spaces!.type, + key, + }); + await spacesAdp.createSpace({ + uri, + ownerDid: PROVISION_COMMUNITY_DID, + type: CONFIG.spaces!.type, + key, + serviceDid: CONFIG.spaces!.serviceDid, + appPolicyRef: null, + appPolicy: null, + }); + await community.grant({ + spaceUri: uri, + subjectDid: ALICE, + accessLevel: "owner", + grantedBy: ALICE, + }); + await spacesAdp.applyMembershipDiff(uri, [ALICE], [], ALICE); + } + return { app, db, calls }; +} + +describe("community publishing — session caching (Task 14)", () => { + it("caches PDS sessions across putRecord calls", async () => { + const { app, calls } = await makeScenarioApp({}); + + for (let i = 0; i < 3; i++) { + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + record: { text: `msg ${i}` }, + }); + expect(res.status).toBe(200); + } + + const createSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.createSession") + ).length; + const createRecordCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.repo.createRecord") + ).length; + expect(createSessionCalls).toBe(1); + expect(createRecordCalls).toBe(3); + }); + + it("considers a session valid when accessExp is in the future", async () => { + const { app, db, calls } = await makeScenarioApp({}); + // Pre-seed cache with a clearly-future expiry. + const community = new CommunityAdapter(db); + const cachedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + await community.upsertSession(PROVISION_COMMUNITY_DID, { + accessJwt: cachedAccess, + refreshJwt: "cached-refresh", + accessExp: Math.floor(Date.now() / 1000) + 3600, + }); + + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + record: { text: "uses cached session" }, + }); + expect(res.status).toBe(200); + + const createSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.createSession") + ).length; + expect(createSessionCalls).toBe(0); + // The createRecord call must have used the cached accessJwt. + const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); + expect(cr).toBeDefined(); + expect(cr!.authorization).toBe(`Bearer ${cachedAccess}`); + }); + + it("refreshes a near-expired session via refreshSession", async () => { + const refreshedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + const { app, db, calls } = await makeScenarioApp({ + onRefreshSession: () => + new Response( + JSON.stringify({ accessJwt: refreshedAccess, refreshJwt: "new-refresh" }), + { status: 200, headers: { "content-type": "application/json" } } + ), + }); + const community = new CommunityAdapter(db); + await community.upsertSession(PROVISION_COMMUNITY_DID, { + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshJwt: "old-refresh", + accessExp: Math.floor(Date.now() / 1000) - 60, + }); + + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + record: { text: "after refresh" }, + }); + expect(res.status).toBe(200); + + const createSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.createSession") + ).length; + const refreshSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.refreshSession") + ).length; + expect(createSessionCalls).toBe(0); + expect(refreshSessionCalls).toBe(1); + // createRecord must use the refreshed access JWT. + const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); + expect(cr!.authorization).toBe(`Bearer ${refreshedAccess}`); + }); + + it("falls back to createSession when refresh fails", async () => { + const { app, db, calls } = await makeScenarioApp({ + onRefreshSession: () => + new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }), + }); + const community = new CommunityAdapter(db); + await community.upsertSession(PROVISION_COMMUNITY_DID, { + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), + refreshJwt: "stale-refresh", + accessExp: Math.floor(Date.now() / 1000) - 60, + }); + + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + record: { text: "fallback to create" }, + }); + expect(res.status).toBe(200); + + const createSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.createSession") + ).length; + const refreshSessionCalls = calls.filter((c) => + c.url.endsWith("/xrpc/com.atproto.server.refreshSession") + ).length; + expect(refreshSessionCalls).toBe(1); + expect(createSessionCalls).toBe(1); + }); +}); + +describe("community publishing — provision mode", () => { + let app: Hono; + + beforeAll(async () => { + const built = await makeApp(); + app = built.app; + await seedProvisionCommunity(built.db, ALICE, "correct-pw"); + }); + + it("publishes a record under a provision-mode community", async () => { + const before = pdsCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + record: { text: "hello from a provisioned community" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body.uri).toBe(`at://${PROVISION_COMMUNITY_DID}/app.event.message/fakerkey`); + + const newCalls = pdsCalls.slice(before); + expect( + newCalls.some( + (c) => + c.url.endsWith("/xrpc/com.atproto.server.createSession") && + c.body.identifier === PROVISION_HANDLE + ) + ).toBe(true); + expect( + newCalls.some( + (c) => + c.url.endsWith("/xrpc/com.atproto.repo.createRecord") && + c.body.repo === PROVISION_COMMUNITY_DID + ) + ).toBe(true); + }); + + it("deletes a record under a provision-mode community", async () => { + const before = pdsCalls.length; + const res = await call(app, "POST", "/xrpc/test.comm.community.deleteRecord", ALICE, { + communityDid: PROVISION_COMMUNITY_DID, + collection: "app.event.message", + rkey: "fakerkey", + }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).ok).toBe(true); + + const newCalls = pdsCalls.slice(before); + expect( + newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) + ).toBe(true); + }); + + it("reports healthy for a provision-mode community", async () => { + const res = await call( + app, + "GET", + `/xrpc/test.comm.community.getHealth?communityDid=${PROVISION_COMMUNITY_DID}`, + ALICE + ); + expect(res.status).toBe(200); + expect(((await res.json()) as any).status).toBe("healthy"); + }); +}); diff --git a/packages/contrail/tests/community-sessions-cache.test.ts b/packages/contrail/tests/community-sessions-cache.test.ts new file mode 100644 index 0000000..5c53bea --- /dev/null +++ b/packages/contrail/tests/community-sessions-cache.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { CommunityAdapter } from "../src/core/community/adapter"; +import { createTestDbWithSchema } from "./helpers"; + +describe("community_sessions cache", () => { + let adapter: CommunityAdapter; + + beforeEach(async () => { + const db = await createTestDbWithSchema(); + await initCommunitySchema(db); + adapter = new CommunityAdapter(db); + }); + + it("upserts and reads a cached session", async () => { + await adapter.upsertSession("did:plc:x", { + accessJwt: "atok", + refreshJwt: "rtok", + accessExp: 1234, + }); + const got = await adapter.getSession("did:plc:x"); + expect(got).toEqual({ accessJwt: "atok", refreshJwt: "rtok", accessExp: 1234 }); + }); + + it("returns null for missing did", async () => { + const got = await adapter.getSession("did:plc:nope"); + expect(got).toBeNull(); + }); + + it("clears a session", async () => { + await adapter.upsertSession("did:plc:x", { + accessJwt: "a", + refreshJwt: "r", + accessExp: 1, + }); + await adapter.clearSession("did:plc:x"); + expect(await adapter.getSession("did:plc:x")).toBeNull(); + }); + + it("upsert overwrites existing session for the same did", async () => { + await adapter.upsertSession("did:plc:x", { + accessJwt: "old-a", + refreshJwt: "old-r", + accessExp: 100, + }); + await adapter.upsertSession("did:plc:x", { + accessJwt: "new-a", + refreshJwt: "new-r", + accessExp: 200, + }); + const got = await adapter.getSession("did:plc:x"); + expect(got).toEqual({ accessJwt: "new-a", refreshJwt: "new-r", accessExp: 200 }); + }); + + it("isolates sessions across communities", async () => { + await adapter.upsertSession("did:plc:a", { + accessJwt: "a-tok", + refreshJwt: "a-rtok", + accessExp: 1, + }); + await adapter.upsertSession("did:plc:b", { + accessJwt: "b-tok", + refreshJwt: "b-rtok", + accessExp: 2, + }); + expect(await adapter.getSession("did:plc:a")).toEqual({ + accessJwt: "a-tok", + refreshJwt: "a-rtok", + accessExp: 1, + }); + expect(await adapter.getSession("did:plc:b")).toEqual({ + accessJwt: "b-tok", + refreshJwt: "b-rtok", + accessExp: 2, + }); + await adapter.clearSession("did:plc:a"); + expect(await adapter.getSession("did:plc:a")).toBeNull(); + // Clearing one DID must not affect the other. + expect(await adapter.getSession("did:plc:b")).toEqual({ + accessJwt: "b-tok", + refreshJwt: "b-rtok", + accessExp: 2, + }); + }); +}); diff --git a/packages/contrail/tests/pds-account-ops.test.ts b/packages/contrail/tests/pds-account-ops.test.ts new file mode 100644 index 0000000..2271bb0 --- /dev/null +++ b/packages/contrail/tests/pds-account-ops.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + pdsGetRecommendedDidCredentials, + pdsActivateAccount, +} from "../src/core/community/pds"; + +describe("pdsGetRecommendedDidCredentials", () => { + it("issues GET to the identity endpoint with bearer accessJwt and parses response", async () => { + let received: { url: string; init: any } | null = null; + const fetch = (async (url: string, init: any) => { + received = { url, init }; + return new Response( + JSON.stringify({ + rotationKeys: ["did:key:zRot"], + verificationMethods: { atproto: "did:key:zSig" }, + alsoKnownAs: ["at://h.test"], + services: { + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as unknown as typeof globalThis.fetch; + + const result = await pdsGetRecommendedDidCredentials( + "https://pds.test", + "AT", + { fetch } + ); + + expect(received!.url).toBe( + "https://pds.test/xrpc/com.atproto.identity.getRecommendedDidCredentials" + ); + // Default fetch method is GET when none specified. + expect(received!.init?.method ?? "GET").toBe("GET"); + // Bearer is the session accessJwt, NOT a service-auth JWT. + expect(received!.init.headers.authorization).toBe("Bearer AT"); + expect(result.rotationKeys).toEqual(["did:key:zRot"]); + expect(result.verificationMethods).toEqual({ atproto: "did:key:zSig" }); + expect(result.alsoKnownAs).toEqual(["at://h.test"]); + expect(result.services).toEqual({ + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, + }); + }); + + it("throws with status and body on non-2xx", async () => { + const fetch = (async () => + new Response("session expired", { status: 401 })) as any; + await expect( + pdsGetRecommendedDidCredentials("https://pds.test", "AT", { fetch }) + ).rejects.toThrow(/getRecommendedDidCredentials failed.*401.*session expired/); + }); +}); + +describe("pdsActivateAccount", () => { + it("issues POST to activateAccount with bearer accessJwt and resolves to undefined", async () => { + let received: { url: string; init: any } | null = null; + const fetch = (async (url: string, init: any) => { + received = { url, init }; + return new Response("", { status: 200 }); + }) as unknown as typeof globalThis.fetch; + + const result = await pdsActivateAccount("https://pds.test", "AT", { fetch }); + + expect(received!.url).toBe( + "https://pds.test/xrpc/com.atproto.server.activateAccount" + ); + expect(received!.init.method).toBe("POST"); + // Bearer is the session accessJwt from pdsCreateAccount, NOT a service-auth JWT. + expect(received!.init.headers.authorization).toBe("Bearer AT"); + expect(result).toBeUndefined(); + }); + + it("throws with status and body on non-2xx", async () => { + const fetch = (async () => + new Response("nope", { status: 400 })) as any; + await expect( + pdsActivateAccount("https://pds.test", "AT", { fetch }) + ).rejects.toThrow(/activateAccount failed.*400.*nope/); + }); +}); diff --git a/packages/contrail/tests/pds-create-account.test.ts b/packages/contrail/tests/pds-create-account.test.ts new file mode 100644 index 0000000..4bb6042 --- /dev/null +++ b/packages/contrail/tests/pds-create-account.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { pdsCreateAccount } from "../src/core/community/pds"; + +describe("pdsCreateAccount", () => { + it("posts createAccount with bearer auth and returns session", async () => { + let received: { url: string; init: any } | null = null; + const fetch = (async (url: string, init: any) => { + received = { url, init }; + return new Response( + JSON.stringify({ + accessJwt: "AT", refreshJwt: "RT", handle: "h.test", did: "did:plc:x", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as unknown as typeof globalThis.fetch; + + const result = await pdsCreateAccount( + "https://pds.test", + "JWT-VALUE", + { + handle: "h.test", + did: "did:plc:x", + email: "h@x.test", + password: "p", + inviteCode: "code", + }, + { fetch } + ); + + expect(received!.url).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); + expect(received!.init.method).toBe("POST"); + expect(received!.init.headers.authorization).toBe("Bearer JWT-VALUE"); + expect(JSON.parse(received!.init.body)).toEqual({ + handle: "h.test", + did: "did:plc:x", + email: "h@x.test", + password: "p", + inviteCode: "code", + }); + expect(result.accessJwt).toBe("AT"); + expect(result.did).toBe("did:plc:x"); + }); + + it("strips trailing slash from pdsEndpoint", async () => { + let receivedUrl = ""; + const fetch = (async (url: string) => { + receivedUrl = url; + return new Response( + JSON.stringify({ accessJwt: "AT", refreshJwt: "RT", handle: "h", did: "did:plc:x" }), + { status: 200 } + ); + }) as any; + await pdsCreateAccount( + "https://pds.test/", + "JWT", + { handle: "h", did: "did:plc:x", email: "e", password: "p" }, + { fetch } + ); + expect(receivedUrl).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); + }); + + it("throws on non-2xx", async () => { + const fetch = (async () => + new Response(JSON.stringify({ error: "InvalidRequest", message: "bad" }), { status: 400 })) as any; + await expect( + pdsCreateAccount( + "https://pds.test", + "x", + { handle: "h", did: "did:plc:x", email: "e", password: "p" }, + { fetch } + ) + ).rejects.toThrow(/createAccount failed.*400.*InvalidRequest/); + }); +}); diff --git a/packages/contrail/tests/plc-log-last.test.ts b/packages/contrail/tests/plc-log-last.test.ts new file mode 100644 index 0000000..b662ed1 --- /dev/null +++ b/packages/contrail/tests/plc-log-last.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + cidForOp, + getLastOpCid, + type SignedGenesisOp, + type SignedTombstoneOp, +} from "../src/core/community/plc"; + +const GENESIS_OP: SignedGenesisOp = { + type: "plc_operation", + prev: null, + rotationKeys: ["did:key:zQ3shjNSBChNYuYsW41QDdm2D25zmQkdpfhgbaQBRG4ecg7sk"], + verificationMethods: { + atproto: "did:key:zQ3shmefuqey6KqP7M9cwFwywqTVuCZFXcCAGJ5JGktdAUdD2", + }, + alsoKnownAs: ["at://probe.devnet.test"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://devnet.test", + }, + }, + sig: "xEZ7BS7bXJ-7KqExTH158uJFNhcTi21khw-rCHjt70EwGVhftk29Xjf1IR9JGhSmDPE76Xqc01ydF9TmmPHr2w", +}; + +const TOMBSTONE_OP: SignedTombstoneOp = { + type: "plc_tombstone", + prev: "bafyreiabmto3hekxoflemevicopvpud2k6ypf2fkp3v3g6iu36l4wxxfle", + sig: "abc123", +}; + +describe("getLastOpCid", () => { + it("returns the CID computed locally from the PLC log/last op response", async () => { + let calledUrl = ""; + const fakeFetch: typeof fetch = async (input) => { + calledUrl = String(input); + return new Response(JSON.stringify(GENESIS_OP), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { + fetch: fakeFetch, + }); + expect(calledUrl).toBe("https://plc.test/did:plc:abc/log/last"); + // PLC returns the bare op (no envelope). The function must compute the + // CID with the same DAG-CBOR encoder cidForOp uses so the value matches + // the CID PLC stored when it accepted the op. + expect(cid).toBe(await cidForOp(GENESIS_OP)); + }); + + it("computes the CID from a tombstone op response too", async () => { + const fakeFetch: typeof fetch = async () => + new Response(JSON.stringify(TOMBSTONE_OP), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { + fetch: fakeFetch, + }); + expect(cid).toBe(await cidForOp(TOMBSTONE_OP)); + }); + + it("strips a trailing slash from the directory base", async () => { + let calledUrl = ""; + const fakeFetch: typeof fetch = async (input) => { + calledUrl = String(input); + return new Response(JSON.stringify(GENESIS_OP), { status: 200 }); + }; + await getLastOpCid("https://plc.test/", "did:plc:xyz", { fetch: fakeFetch }); + expect(calledUrl).toBe("https://plc.test/did:plc:xyz/log/last"); + }); + + it("throws on a non-200 response, including the status and body", async () => { + const fakeFetch: typeof fetch = async () => + new Response("not found", { status: 404 }); + await expect( + getLastOpCid("https://plc.test", "did:plc:missing", { fetch: fakeFetch }) + ).rejects.toThrow(/404.*not found/); + }); +}); diff --git a/packages/contrail/tests/plc-update-op.test.ts b/packages/contrail/tests/plc-update-op.test.ts new file mode 100644 index 0000000..02c1587 --- /dev/null +++ b/packages/contrail/tests/plc-update-op.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { + generateKeyPair, + buildGenesisOp, + signGenesisOp, + buildUpdateOp, + signUpdateOp, + cidForOp, +} from "../src/core/community/plc"; + +describe("cidForOp", () => { + it("produces a CIDv1 dag-cbor sha256 base32-lower CID starting with bafyrei", async () => { + const kp = await generateKeyPair(); + const unsigned = buildGenesisOp({ + rotationKeys: [kp.publicDidKey], + verificationMethodAtproto: kp.publicDidKey, + alsoKnownAs: ["at://x.test"], + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, + }); + const signed = await signGenesisOp(unsigned, kp.privateJwk); + const cid = await cidForOp(signed); + expect(cid).toMatch(/^bafyrei/); + expect(cid.length).toBeGreaterThan(50); + }); +}); + +describe("buildUpdateOp + signUpdateOp", () => { + it("produces a plc_operation with prev set and a sig segment", async () => { + const kp = await generateKeyPair(); + const genesis = buildGenesisOp({ + rotationKeys: [kp.publicDidKey], + verificationMethodAtproto: kp.publicDidKey, + alsoKnownAs: ["at://x.test"], + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, + }); + const signedGenesis = await signGenesisOp(genesis, kp.privateJwk); + const prev = await cidForOp(signedGenesis); + + const update = buildUpdateOp({ + prev, + rotationKeys: [kp.publicDidKey, "did:key:zPdsRot"], + verificationMethodAtproto: "did:key:zPdsSig", + alsoKnownAs: ["at://x.test"], + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, + }); + expect(update.type).toBe("plc_operation"); + expect(update.prev).toBe(prev); + + const signedUpdate = await signUpdateOp(update, kp.privateJwk); + expect(signedUpdate.sig).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); diff --git a/packages/contrail/tests/provision-orchestrator.test.ts b/packages/contrail/tests/provision-orchestrator.test.ts new file mode 100644 index 0000000..968427d --- /dev/null +++ b/packages/contrail/tests/provision-orchestrator.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { CommunityAdapter } from "../src/core/community/adapter"; +import { CredentialCipher } from "../src/core/community/credentials"; +import { ProvisionOrchestrator } from "../src/core/community/provision"; +import { createTestDbWithSchema } from "./helpers"; + +const STUB_ROTATION_KEY = "did:key:zStubCallerRotationKeyForTests"; + +function mockPlc() { + const ops: any[] = []; + return { + ops, + async submit(did: string, op: any) { + ops.push({ did, op }); + return { ok: true }; + }, + }; +} + +function mockPds() { + return { + async createAccount() { + return { + did: "did:plc:x", + handle: "h.test", + accessJwt: "AT", + refreshJwt: "RT", + }; + }, + async getRecommendedDidCredentials() { + return { + rotationKeys: ["did:key:zPdsRot"], + verificationMethods: { atproto: "did:key:zPdsSig" }, + alsoKnownAs: ["at://h.test"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://pds.test", + }, + }, + }; + }, + async activateAccount() { + return; + }, + async createAppPassword() { + return { password: "minted-app-pw" }; + }, + }; +} + +describe("ProvisionOrchestrator", () => { + let adapter: CommunityAdapter; + let cipher: CredentialCipher; + beforeEach(async () => { + const db = await createTestDbWithSchema(); + await initCommunitySchema(db); + cipher = new CredentialCipher(new Uint8Array(32).fill(99)); + adapter = new CommunityAdapter(db); + }); + + it("runs end-to-end and lands at status=activated", async () => { + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: mockPlc(), + pds: mockPds(), + pdsDid: "did:web:pds.test", + }); + + const result = await orch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + inviteCode: "code", + rotationKey: STUB_ROTATION_KEY, + }); + + expect(result.did).toBeTruthy(); + expect(result.status).toBe("activated"); + const row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("activated"); + expect(row?.encryptedSigningKey).toBeTruthy(); + expect(row?.encryptedRotationKey).toBeTruthy(); + expect(row?.encryptedPassword).toBeTruthy(); + }); + + it("seeds the community_sessions cache with the createAccount JWTs after activation", async () => { + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: mockPlc(), + pds: mockPds(), + pdsDid: "did:web:pds.test", + }); + + const result = await orch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + inviteCode: "code", + rotationKey: STUB_ROTATION_KEY, + }); + + const cached = await adapter.getSession(result.did); + expect(cached).not.toBeNull(); + expect(cached?.accessJwt).toBe("AT"); + expect(cached?.refreshJwt).toBe("RT"); + }); + + it("persists status=genesis_submitted before createAccount runs", async () => { + let createCalled = false; + const pds = { + async createAccount() { + // Inspect state at this exact moment. + const row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("genesis_submitted"); + createCalled = true; + return { + did: "did:plc:x", + handle: "h.test", + accessJwt: "AT", + refreshJwt: "RT", + }; + }, + async getRecommendedDidCredentials() { + return { + rotationKeys: [], + verificationMethods: { atproto: "did:key:zSig" }, + alsoKnownAs: ["at://h.test"], + services: { + atproto_pds: { type: "x", endpoint: "https://pds.test" }, + }, + }; + }, + async activateAccount() {}, + async createAppPassword() { + return { password: "minted" }; + }, + }; + + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: mockPlc(), + pds, + pdsDid: "did:web:pds.test", + }); + await orch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + rotationKey: STUB_ROTATION_KEY, + }); + expect(createCalled).toBe(true); + }); + + it("marks last_error and rethrows when createAccount fails", async () => { + const pds = { + ...mockPds(), + async createAccount() { + throw new Error("createAccount 400: bad invite"); + }, + } as any; + + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: mockPlc(), + pds, + pdsDid: "did:web:pds.test", + }); + + await expect( + orch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + rotationKey: STUB_ROTATION_KEY, + }) + ).rejects.toThrow(/bad invite/); + const row = await adapter.getProvisionAttempt("a1"); + expect(row?.status).toBe("genesis_submitted"); // last successful step + expect(row?.lastError).toMatch(/bad invite/); + }); + + it("re-invoking with the same attemptId on a fully-completed row returns success without redoing PLC/PDS work", async () => { + // Scenario: the orchestrator finished cleanly (status=activated + + // encryptedPassword set), but a *downstream* step (router's + // createFromProvisioned or bootstrapReservedSpaces) failed and the + // caller retries with the same attemptId. The orchestrator must not + // throw "already exists" — it should report success so the route can + // resume the graduation steps. + const plc = mockPlc(); + const pds: any = mockPds(); + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc, + pds, + pdsDid: "did:web:pds.test", + }); + + const first = await orch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + inviteCode: "code", + rotationKey: STUB_ROTATION_KEY, + }); + expect(first.status).toBe("activated"); + const opsAfterFirst = plc.ops.length; + + // Wire a fresh PDS mock whose every method throws — if the retry path + // calls any of them, the test fails loudly. createSession is allowed + // because the C3 retry path is wired for the not-yet-completed case; + // a fully-completed row should NOT hit it either. + const explodingPds: any = { + createAccount: () => { throw new Error("createAccount should not be called on a completed retry"); }, + getRecommendedDidCredentials: () => { throw new Error("getRecommendedDidCredentials should not be called"); }, + activateAccount: () => { throw new Error("activateAccount should not be called"); }, + createAppPassword: () => { throw new Error("createAppPassword should not be called on a completed retry"); }, + createSession: () => { throw new Error("createSession should not be called on a completed retry"); }, + }; + const retryOrch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: { submit: () => { throw new Error("plc.submit should not be called on a completed retry"); } }, + pds: explodingPds, + pdsDid: "did:web:pds.test", + }); + + const second = await retryOrch.provision({ + attemptId: "a1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + inviteCode: "code", + rotationKey: STUB_ROTATION_KEY, + }); + expect(second.status).toBe("activated"); + expect(second.did).toBe(first.did); + expect(second.attemptId).toBe("a1"); + expect(plc.ops.length).toBe(opsAfterFirst); + }); + +}); diff --git a/packages/contrail/tests/provision-self-sovereign.test.ts b/packages/contrail/tests/provision-self-sovereign.test.ts new file mode 100644 index 0000000..c737e2b --- /dev/null +++ b/packages/contrail/tests/provision-self-sovereign.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { CommunityAdapter } from "../src/core/community/adapter"; +import { CredentialCipher } from "../src/core/community/credentials"; +import { ProvisionOrchestrator } from "../src/core/community/provision"; +import { generateKeyPair } from "../src/core/community/plc"; +import { createTestDbWithSchema } from "./helpers"; + +/** Mock PLC client that records every submitted op so tests can inspect the + * genesis op (in particular, its rotationKeys array). */ +function mockPlc() { + const ops: Array<{ did: string; op: any }> = []; + return { + ops, + async submit(did: string, op: any) { + ops.push({ did, op }); + return { ok: true }; + }, + }; +} + +/** Mock PDS client that records calls to createAppPassword so tests can assert + * on its arguments (or its absence). The minted password is deterministic so + * decryption assertions can compare. */ +function mockPds(opts: { mintedPassword?: string } = {}) { + const calls: { createAppPassword: Array<{ pdsUrl: string; accessJwt: string; name: string }> } = { + createAppPassword: [], + }; + return { + calls, + async createAccount() { + return { + did: "did:plc:x", + handle: "h.test", + accessJwt: "AT", + refreshJwt: "RT", + }; + }, + async getRecommendedDidCredentials() { + return { + rotationKeys: ["did:key:zPdsRot"], + verificationMethods: { atproto: "did:key:zPdsSig" }, + alsoKnownAs: ["at://h.test"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://pds.test", + }, + }, + }; + }, + async activateAccount() { + return; + }, + async createAppPassword(input: { pdsUrl: string; accessJwt: string; name: string }) { + calls.createAppPassword.push(input); + return { password: opts.mintedPassword ?? "minted-app-pass-XXXX" }; + }, + }; +} + +describe("ProvisionOrchestrator — caller-supplied rotation key", () => { + let adapter: CommunityAdapter; + let cipher: CredentialCipher; + beforeEach(async () => { + const db = await createTestDbWithSchema(); + await initCommunitySchema(db); + cipher = new CredentialCipher(new Uint8Array(32).fill(99)); + adapter = new CommunityAdapter(db); + }); + + it("genesis includes caller rotation key, mints app password, response carries rootCredentials", async () => { + const callerKeyPair = await generateKeyPair(); + const callerRotationDidKey = callerKeyPair.publicDidKey; + const userPassword = "user-supplied-root-pw"; + const mintedPassword = "minted-app-pw-1234"; + + const plc = mockPlc(); + const pds = mockPds({ mintedPassword }); + + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc, + pds, + pdsDid: "did:web:pds.test", + }); + + const result = await orch.provision({ + attemptId: "ss1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: userPassword, + inviteCode: "code", + rotationKey: callerRotationDidKey, + }); + + // Status unchanged in shape. + expect(result.status).toBe("activated"); + expect(result.did).toBeTruthy(); + + // Response carries root credentials so the caller can keep their root password. + expect(result.rootCredentials).toBeDefined(); + expect(result.rootCredentials!.password).toBe(userPassword); + expect(result.rootCredentials!.handle).toBe("h.test"); + expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); + + // Persisted attempt row reaches activated status. + const row = await adapter.getProvisionAttempt("ss1"); + expect(row).toBeTruthy(); + expect(row!.status).toBe("activated"); + + // Genesis op submitted to PLC has BOTH rotation keys, with the caller's first. + expect(plc.ops.length).toBeGreaterThanOrEqual(1); + const genesis = plc.ops[0]!.op; + expect(Array.isArray(genesis.rotationKeys)).toBe(true); + expect(genesis.rotationKeys[0]).toBe(callerRotationDidKey); + expect(genesis.rotationKeys.length).toBe(2); + expect(genesis.rotationKeys[1]).toBeTruthy(); + expect(genesis.rotationKeys[1]).not.toBe(callerRotationDidKey); + + // createAppPassword was invoked post-activation with the session's accessJwt. + expect(pds.calls.createAppPassword.length).toBe(1); + const apCall = pds.calls.createAppPassword[0]!; + expect(apCall.pdsUrl).toBe("https://pds.test"); + expect(apCall.accessJwt).toBe("AT"); + expect(apCall.name).toContain("ss1"); + + // encrypted_password column re-decrypts to the MINTED app password, + // not the user's supplied password. + expect(row!.encryptedPassword).toBeTruthy(); + const decryptedPw = await cipher.decryptString(row!.encryptedPassword!); + expect(decryptedPw).toBe(mintedPassword); + expect(decryptedPw).not.toBe(userPassword); + + // Subordinate rotation private JWK persisted in encrypted_rotation_key + // must NOT decrypt to anything containing the caller's did:key fingerprint. + expect(row!.encryptedRotationKey).toBeTruthy(); + const decryptedRot = await cipher.decryptString(row!.encryptedRotationKey!); + expect(decryptedRot).not.toContain(callerRotationDidKey); + + // Negative invariant: caller's did:key must not appear in any encrypted + // column (after decryption). + const encryptedSigning = row!.encryptedSigningKey; + if (encryptedSigning) { + const decryptedSig = await cipher.decryptString(encryptedSigning); + expect(decryptedSig).not.toContain(callerRotationDidKey); + } + }); + + it("PLC update op preserves caller's rotation key at index 0", async () => { + // H2 regression guard. The update op (plc.ops[1]) must keep the caller's + // did:key as rotationKeys[0]. Without threading it through + // runUpdateAndActivate, the caller's key is dropped and Contrail's + // subordinate becomes the highest-priority rotation key — caller has 72h + // to nullify before losing rotation authority on a DID they own. + const callerKeyPair = await generateKeyPair(); + const callerRotationDidKey = callerKeyPair.publicDidKey; + + const plc = mockPlc(); + const pds = mockPds(); + + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc, + pds, + pdsDid: "did:web:pds.test", + }); + + await orch.provision({ + attemptId: "ss-update", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "pw", + inviteCode: "code", + rotationKey: callerRotationDidKey, + }); + + // Genesis op already asserted in the prior test; here we focus on the update op. + expect(plc.ops.length).toBeGreaterThanOrEqual(2); + const update = plc.ops[1]!.op; + expect(Array.isArray(update.rotationKeys)).toBe(true); + expect(update.rotationKeys[0]).toBe(callerRotationDidKey); + // Contrail's subordinate must remain in the chain (we still need to sign + // future update ops). + expect(update.rotationKeys.length).toBeGreaterThanOrEqual(2); + expect(update.rotationKeys.slice(1)).not.toContain(callerRotationDidKey); + // PDS-recommended key is merged in after the contrail subordinate. + expect(update.rotationKeys).toContain("did:key:zPdsRot"); + }); + + it("createAppPassword failure persists last_error at status=activated and throws (no encryptedPassword)", async () => { + const callerKeyPair = await generateKeyPair(); + const plc = mockPlc(); + const pds = { + ...mockPds(), + async createAppPassword(_: any) { + throw new Error("PDS rejected: rate limited"); + }, + }; + + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc, + pds: pds as any, + pdsDid: "did:web:pds.test", + }); + + await expect( + orch.provision({ + attemptId: "ss-fail", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "pw", + rotationKey: callerKeyPair.publicDidKey, + }) + ).rejects.toThrow(/createAppPassword/); + + const row = await adapter.getProvisionAttempt("ss-fail"); + expect(row).toBeTruthy(); + expect(row!.status).toBe("activated"); + expect(row!.encryptedPassword).toBeFalsy(); + expect(row!.lastError).toMatch(/createAppPassword/); + }); + + it("retry with same attemptId after createAppPassword failure picks up at createAppPassword (no re-mint, no re-createAccount)", async () => { + // Simulate the failure-then-retry shape: a first provision call got all + // the way to createAppPassword and failed; the caller retries with the + // same attemptId. The orchestrator must NOT re-submit PLC ops, NOT + // re-call createAccount, only run createAppPassword. + const callerKeyPair = await generateKeyPair(); + const callerDidKey = callerKeyPair.publicDidKey; + const mintedPassword = "minted-on-retry-99"; + + // First attempt: createAppPassword throws; everything else succeeds. + const firstPds = { + ...mockPds(), + async createAppPassword(_: any) { + throw new Error("PDS transient: 503"); + }, + }; + const firstPlc = mockPlc(); + const firstOrch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: firstPlc, + pds: firstPlc && (firstPds as any), + pdsDid: "did:web:pds.test", + }); + await expect( + firstOrch.provision({ + attemptId: "ss-retry", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "user-root-pw", + rotationKey: callerDidKey, + }) + ).rejects.toThrow(); + + // Sanity: row is in the failure state we expect. + const failRow = await adapter.getProvisionAttempt("ss-retry"); + expect(failRow!.status).toBe("activated"); + expect(failRow!.encryptedPassword).toBeFalsy(); + expect(firstPlc.ops.length).toBe(2); // genesis + update + + // Second attempt with the SAME attemptId. Use a fresh mock that would + // EXPLODE if createAccount or any PLC op was re-issued. + const retryPlc = { + ops: [] as Array<{ did: string; op: any }>, + async submit(_did: string, _op: any) { + throw new Error("retry must not re-submit PLC ops"); + }, + }; + const retryAppPasswordCalls: any[] = []; + const retryPds = { + async createAccount() { + throw new Error("retry must not re-call createAccount"); + }, + async getRecommendedDidCredentials() { + throw new Error("retry must not re-fetch recommended creds"); + }, + async activateAccount() { + throw new Error("retry must not re-activate"); + }, + async createAppPassword(input: any) { + retryAppPasswordCalls.push(input); + return { password: mintedPassword }; + }, + async createSession(input: { pdsUrl: string; identifier: string; password: string }) { + // Verify the retry uses the user's root password to obtain a fresh + // accessJwt (the cached one from the failed attempt may have expired). + expect(input.password).toBe("user-root-pw"); + return { accessJwt: "AT-fresh", refreshJwt: "RT-fresh", did: "did:plc:x" }; + }, + }; + const retryOrch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: retryPlc as any, + pds: retryPds as any, + pdsDid: "did:web:pds.test", + }); + + const result = await retryOrch.provision({ + attemptId: "ss-retry", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "user-root-pw", + rotationKey: callerDidKey, + }); + + // Retry succeeded. + expect(result.status).toBe("activated"); + expect(result.did).toBe(failRow!.did); // SAME DID, not a new one + expect(result.rootCredentials).toBeDefined(); + expect(retryAppPasswordCalls.length).toBe(1); + + // Row now has the encrypted (minted) password. + const finalRow = await adapter.getProvisionAttempt("ss-retry"); + expect(finalRow!.status).toBe("activated"); + expect(finalRow!.encryptedPassword).toBeTruthy(); + const decrypted = await cipher.decryptString(finalRow!.encryptedPassword!); + expect(decrypted).toBe(mintedPassword); + }); + + it("rejects rotationKey that is not did:key:z…", async () => { + const orch = new ProvisionOrchestrator({ + adapter, + cipher, + plc: mockPlc(), + pds: mockPds(), + pdsDid: "did:web:pds.test", + }); + + await expect( + orch.provision({ + attemptId: "bad1", + pdsEndpoint: "https://pds.test", + handle: "h.test", + email: "h@x.test", + password: "p", + rotationKey: "not-a-did-key", + }) + ).rejects.toThrow(/rotationKey/); + }); +}); diff --git a/packages/contrail/tests/schema.test.ts b/packages/contrail/tests/schema.test.ts index 6197818..ca84665 100644 --- a/packages/contrail/tests/schema.test.ts +++ b/packages/contrail/tests/schema.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { initSchema } from "../src/core/db/schema"; -import { createTestDb, TEST_CONFIG } from "./helpers"; +import { initCommunitySchema } from "../src/core/community/schema"; +import { createTestDb, createTestDbWithSchema, TEST_CONFIG } from "./helpers"; describe("initSchema", () => { it("creates all required tables", async () => { @@ -55,3 +56,19 @@ describe("initSchema", () => { await initSchema(db, TEST_CONFIG); }); }); + +describe("provision_attempts schema", () => { + it("enforces status enum", async () => { + const db = await createTestDbWithSchema(); + await initCommunitySchema(db); + await expect( + db + .prepare( + "INSERT INTO provision_attempts (attempt_id, did, status, created_at, updated_at, pds_endpoint, handle, email) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind("a1", "did:plc:x", "bogus", 1, 1, "https://pds", "h.test", "x@x") + .run() + ).rejects.toThrow(); + }); +}); + diff --git a/packages/contrail/tests/service-auth.test.ts b/packages/contrail/tests/service-auth.test.ts new file mode 100644 index 0000000..9b53912 --- /dev/null +++ b/packages/contrail/tests/service-auth.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { mintServiceAuthJwt } from "../src/core/community/service-auth"; +import { generateKeyPair } from "../src/core/community/plc"; + +function b64urlDecode(s: string): Uint8Array { + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function decodeJwtJson(seg: string): Record { + return JSON.parse(new TextDecoder().decode(b64urlDecode(seg))); +} + +/** P-256 curve order; used for low-S threshold. */ +const P256_N = BigInt( + "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" +); +const P256_N_HALF = P256_N >> 1n; + +function bytesToBigInt(b: Uint8Array): bigint { + let v = 0n; + for (const byte of b) v = (v << 8n) | BigInt(byte); + return v; +} + +describe("mintServiceAuthJwt", () => { + it("round-trips: signature verifies against the keypair's public key (P1363, not DER)", async () => { + // This is the canary: if the signer accidentally returns DER instead of raw r||s, + // Web Crypto's verify with raw form will reject it — and so will atproto PDSes. + const kp = await generateKeyPair(); + const jwt = await mintServiceAuthJwt({ + privateJwk: kp.privateJwk, + iss: "did:plc:abc", + aud: "did:web:pds.test", + lxm: "com.atproto.server.createAccount", + }); + + // Reconstruct the public JWK from the private JWK (drop d, key_ops, ext). + const priv = kp.privateJwk as Record; + const publicJwk: JsonWebKey = { + kty: priv.kty as string, + crv: priv.crv as string, + x: priv.x as string, + y: priv.y as string, + }; + const publicKey = await crypto.subtle.importKey( + "jwk", + publicJwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"] + ); + + const [h, p, s] = jwt.split("."); + const signedBytes = new TextEncoder().encode(`${h}.${p}`); + const sig = b64urlDecode(s!); + + // P1363 form is exactly 64 bytes for P-256. DER would be ~70-72 and start with 0x30. + expect(sig.length).toBe(64); + + const ok = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + sig as BufferSource, + signedBytes as BufferSource + ); + expect(ok).toBe(true); + }); + + it("emits low-S signatures across many independent signings", async () => { + // Without normalization, ~50% of ECDSA signatures have s > n/2. + // Across 12 fresh keypairs, the probability of *all* being naturally low-S is + // ~1/4096. If the test ever sees a high-S signature, normalization is broken. + const kp = await generateKeyPair(); + for (let i = 0; i < 12; i++) { + const jwt = await mintServiceAuthJwt({ + privateJwk: kp.privateJwk, + iss: "did:plc:abc", + aud: "did:web:pds.test", + lxm: "com.atproto.server.createAccount", + // Force a fresh signature each iteration; ECDSA k is randomized per sign. + }); + const sig = b64urlDecode(jwt.split(".")[2]!); + const s = bytesToBigInt(sig.slice(32)); + expect(s).toBeLessThanOrEqual(P256_N_HALF); + } + }); + + it("encodes header and claims as base64url JSON with the expected shape", async () => { + const kp = await generateKeyPair(); + const fixedNow = 1_700_000_000_000; + const jwt = await mintServiceAuthJwt({ + privateJwk: kp.privateJwk, + iss: "did:plc:abc", + aud: "did:web:pds.test", + lxm: "com.atproto.server.createAccount", + ttlSec: 60, + now: fixedNow, + }); + + const [h, p] = jwt.split("."); + const header = decodeJwtJson(h!); + const payload = decodeJwtJson(p!); + + // Header MUST be exactly {alg,typ}; presence of kid would change the + // signed bytes and break PDS verification (which doesn't use kid). + expect(header).toEqual({ alg: "ES256", typ: "JWT" }); + + expect(payload.iss).toBe("did:plc:abc"); + expect(payload.aud).toBe("did:web:pds.test"); + expect(payload.lxm).toBe("com.atproto.server.createAccount"); + expect(payload.iat).toBe(Math.floor(fixedNow / 1000)); + expect(payload.exp).toBe(Math.floor(fixedNow / 1000) + 60); + expect(typeof payload.jti).toBe("string"); + expect((payload.jti as string).length).toBeGreaterThan(0); + }); + + it("uses unique jti per call (replay-protection sanity)", async () => { + const kp = await generateKeyPair(); + const mk = () => + mintServiceAuthJwt({ + privateJwk: kp.privateJwk, + iss: "did:plc:abc", + aud: "did:web:pds.test", + lxm: "com.atproto.server.createAccount", + }); + const a = decodeJwtJson((await mk()).split(".")[1]!); + const b = decodeJwtJson((await mk()).split(".")[1]!); + expect(a.jti).not.toBe(b.jti); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e648bf..6ba10ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,130 @@ importers: specifier: ^5.7.3 version: 5.9.3 + apps/atproto-starter: + dependencies: + '@atcute/jetstream': + specifier: ^1.1.2 + version: 1.1.2 + '@atmo-dev/contrail': + specifier: ^0.4.1 + version: 0.4.1(pg@8.20.0)(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + '@atmo-dev/contrail-lexicons': + specifier: ^0.4.1 + version: 0.4.1(pg@8.20.0)(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + '@foxui/core': + specifier: ^0.9.1 + version: 0.9.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/social': + specifier: ^0.8.10 + version: 0.8.10(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(@tiptap/extension-code-block@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))(highlight.js@11.11.1)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + '@foxui/time': + specifier: ^0.8.5 + version: 0.8.5(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(tailwindcss@4.2.4) + valibot: + specifier: ^1.3.1 + version: 1.3.1(typescript@6.0.3) + devDependencies: + '@atcute/atproto': + specifier: ^3.1.10 + version: 3.1.11 + '@atcute/bluesky': + specifier: ^3.3.0 + version: 3.3.3 + '@atcute/client': + specifier: ^4.2.1 + version: 4.2.1 + '@atcute/identity-resolver': + specifier: ^1.2.2 + version: 1.2.2(@atcute/identity@1.1.4) + '@atcute/lex-cli': + specifier: ^2.5.3 + version: 2.8.1 + '@atcute/lexicon-doc': + specifier: ^2.1.2 + version: 2.2.0 + '@atcute/lexicons': + specifier: ^1.2.9 + version: 1.3.0 + '@atcute/oauth-node-client': + specifier: ^1.1.0 + version: 1.1.0 + '@atcute/tid': + specifier: ^1.1.2 + version: 1.1.2 + '@cloudflare/workers-types': + specifier: ^4.20260317.1 + version: 4.20260424.1 + '@eslint/compat': + specifier: ^2.0.3 + version: 2.0.5(eslint@10.2.1(jiti@2.6.1)) + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) + '@sveltejs/adapter-cloudflare': + specifier: ^7.2.8 + version: 7.2.8(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + '@sveltejs/kit': + specifier: ^2.55.0 + version: 2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.0.0 + version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + '@tailwindcss/forms': + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@4.2.4) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)) + bits-ui: + specifier: ^2.16.4 + version: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.58.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.5(@typescript-eslint/types@8.59.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + eslint: + specifier: ^10.1.0 + version: 10.2.1(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-svelte: + specifier: ^3.16.0 + version: 3.17.1(eslint@10.2.1(jiti@2.6.1))(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + globals: + specifier: ^17.4.0 + version: 17.5.0 + prettier: + specifier: ^3.8.1 + version: 3.8.3 + prettier-plugin-svelte: + specifier: ^3.5.1 + version: 3.5.1(prettier@3.8.3)(svelte@5.55.5(@typescript-eslint/types@8.59.0)) + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.3(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.5(@typescript-eslint/types@8.59.0)))(prettier@3.8.3) + svelte: + specifier: ^5.55.0 + version: 5.55.5(@typescript-eslint/types@8.59.0) + svelte-check: + specifier: ^4.4.5 + version: 4.4.6(picomatch@4.0.4)(svelte@5.55.5(@typescript-eslint/types@8.59.0))(typescript@6.0.3) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.4 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.57.2 + version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + vite: + specifier: ^8.0.3 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0) + wrangler: + specifier: ^4.78.0 + version: 4.84.1(@cloudflare/workers-types@4.20260424.1) + apps/cloudflare-workers: dependencies: '@atmo-dev/contrail': @@ -534,6 +658,22 @@ packages: '@atcute/xrpc-server@0.1.12': resolution: {integrity: sha512-70KIerQlljp5+s6t0u6YNN9klEboQUZa2hhoi/hmXIO1cIKEORettTMctnyjfcCJaSfAuj42dxPu51GTZBlm8w==} + '@atmo-dev/contrail-lexicons@0.4.1': + resolution: {integrity: sha512-nhkNG0l9n/jBdm/Zc1SywfvZaLkl5joB3J9mIaf3IHZN8+MnCLHcFcb7DiXLL2Eh6G3ev2sLnNnCpa9OrCnyFQ==} + hasBin: true + + '@atmo-dev/contrail@0.4.1': + resolution: {integrity: sha512-unipt2ZiLbX7WAXz/82s732VqVvJFb4xMxgyRxwCKO7wXcNyL+CxpA8YnG3DjKyE0t0pp13Sh31JTP2XBggkBQ==} + hasBin: true + peerDependencies: + pg: ^8.0.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + pg: + optional: true + wrangler: + optional: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -3796,6 +3936,35 @@ snapshots: '@badrap/valita': 0.4.6 nanoid: 5.1.9 + '@atmo-dev/contrail-lexicons@0.4.1(pg@8.20.0)(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': + dependencies: + '@atcute/lex-cli': 2.8.1 + '@atmo-dev/contrail': 0.4.1(pg@8.20.0)(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1)) + transitivePeerDependencies: + - pg + - react + - wrangler + + '@atmo-dev/contrail@0.4.1(pg@8.20.0)(wrangler@4.84.1(@cloudflare/workers-types@4.20260424.1))': + dependencies: + '@atcute/atproto': 3.1.11 + '@atcute/cbor': 2.3.2 + '@atcute/cid': 2.4.1 + '@atcute/client': 4.2.1 + '@atcute/identity': 1.1.4 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.4) + '@atcute/jetstream': 1.1.2 + '@atcute/lexicons': 1.3.0 + '@atcute/xrpc-server': 0.1.12 + cac: 7.0.0 + hono: 4.12.15 + jiti: 2.6.1 + optionalDependencies: + pg: 8.20.0 + wrangler: 4.84.1(@cloudflare/workers-types@4.20260424.1) + transitivePeerDependencies: + - react + '@babel/runtime@7.29.2': {} '@badrap/valita@0.4.6': {}