[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(contrail): community DID provisioning on stock PDS (#31)

* feat(contrail-community): provision schema, types, adapter CRUD

* feat(contrail-community): PDS + PLC + service-auth helpers

* feat(contrail-community): provision orchestrator + XRPC route + session cache

* feat(contrail-community): reap CLI for stuck provisioning rows

* test+docs(contrail): provision e2e, docs, changeset, deps

* fix(contrail-community): harden provision allowlist + reap (PR #31 review)

Security/review follow-ups on the community DID provisioning feature:

- Provisioning fails closed: with allowProvisioning=true, an empty/undefined
allowlist no longer accepts any caller pdsEndpoint (fail-open). Require a
non-empty allowlist, or the explicit, loud allowAnyProvisionPdsEndpoint.
Renamed allowedPdsEndpoints -> allowedProvisionPdsEndpoints to scope it to
provisioning (Contrail still reads/indexes from every PDS).

- reap --all-stuck gains a mandatory age floor (listStuckAttempts(olderThanMs)
+ --older-than <minutes>, default 30) so a bulk run can't tombstone an
in-flight provision. --attempt-id still targets a known row regardless.

- reap is reachable: ship it as a contrail-community bin (under pnpm the core
contrail CLI can't resolve the community package; the dynamic import there
now logs instead of an empty catch). Adds Postgres support via
--db/DATABASE_URL alongside the D1 binding.

- archiveStuckAttempt is now atomic (db.batch) + idempotent (ON CONFLICT DO
NOTHING), so a retry after a partial failure can't PK-conflict.

- Minor: export bytesToB64url from plc and reuse in service-auth; drop the
duplicate reap mutual-exclusion validator.

mint-route allowlist gap (pre-existing on main) tracked separately in om-vyok.

* test(contrail-community): drop tautological builder-shape assertions

Remove the 'buildTombstoneOp produces the expected shape' case and the two
pass-through type/prev assertions in the update-op test: both assert literals
the builder just set or args just passed in, exercising object-literal
semantics rather than runtime behavior. The real signing behavior
(signTombstoneOp / signUpdateOp base64url sig) and the live-PLC CID round-trip
remain covered.

* docs(contrail-community): document SSRF exposure of allowAnyProvisionPdsEndpoint

The accept-any escape hatch bypasses endpoint validation: the caller-supplied
pdsEndpoint reaches describeServer + createAccount fetches with no
private-IP/metadata guard. Note inline that it must be paired with trusted auth
+ egress network policy + IMDSv2; the default allowlist path contains this by
construction. No behavior change.

authored by

Tom Scanlan and committed by
GitHub
(Jun 5, 2026, 10:56 AM EDT) bea0dd2c d7e09366

+6127 -26
+22
.changeset/itchy-rice-kick.md
··· 1 + --- 2 + "@atmo-dev/contrail-community": minor 3 + "@atmo-dev/contrail": minor 4 + --- 5 + 6 + 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. 7 + 8 + **`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. 9 + 10 + **`contrail-community reap [--all-stuck] [--older-than <minutes>] [--db <url>] [--dry-run]`** new CLI (a bin shipped by `@atmo-dev/contrail-community`) 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. `--all-stuck` only acts on rows idle at least `--older-than` minutes (default 30) so a bulk run can't tombstone an in-flight provision. Runs against the Cloudflare D1 binding by default, or against the decoupled Postgres index when `--db`/`DATABASE_URL` is set. It ships as a contrail-community bin because the PR #30 package split removed contrail's edge into community code: under pnpm's isolated `node_modules` the core `contrail` CLI can't resolve `@atmo-dev/contrail-community`, so `contrail reap` only registers in hoisted installs where both packages sit together. 11 + 12 + 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. 13 + 14 + 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. 15 + 16 + what you need to configure / know: 17 + 18 + - new `community` config block: `masterKey` (32-byte AES-GCM envelope key for the encrypted credential columns), `allowedProvisionPdsEndpoints` (URL-origin matching, collapses scheme case / default ports / trailing slash / IDN), optional `plcDirectory` override. 19 + 20 + - **provisioning fails closed.** When `allowProvisioning` is true, `allowedProvisionPdsEndpoints` MUST be non-empty — a missing/empty allowlist no longer means "accept any PDS" (that was a fail-open hole: any caller could have a PLC genesis op signed by Contrail's rotation key against an attacker-chosen PDS). To deliberately accept any endpoint, set the separate, loud `allowAnyProvisionPdsEndpoint: true`. The field was renamed from `allowedPdsEndpoints` to make clear it gates *provisioning* only, not which PDSes Contrail reads/indexes. 21 + 22 + - 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.
+1
.gitignore
··· 1 1 node_modules 2 2 .wrangler 3 3 dist 4 + *.tsbuildinfo 4 5 5 6 # Turbo 6 7 .turbo
+402
apps/contrail-e2e/tests/community-provision-walkthrough.test.ts
··· 1 + /** 2 + * Provisioned-community lifecycle walkthrough — the "happy day" the PR was 3 + * built for, end-to-end on the same handler. 4 + * 5 + * Each previous test pins one slice (provision-only, publishing-only, 6 + * ACL-only, ingest-only). This one chains them so a regression on any seam 7 + * between modules — provision → ACL → proxy publish → ingest of a RSVP from 8 + * a separate PDS repo — surfaces here even when each unit test still passes. 9 + * 10 + * Flow (each step is also asserted, so the test reads top-to-bottom as docs): 11 + * 12 + * 1. PROVISION — Alice calls `community.provision` with a caller-held 13 + * P-256 rotation key (sovereign mode). Asserts: status=activated, 14 + * DID well-formed, PLC log shows the caller's did:key at 15 + * rotationKeys[0] (the sovereignty invariant). 16 + * 17 + * 2. GRANT — Alice grants Bob `member` on the community's `$publishers` 18 + * space via `community.space.grant`. Asserts: listMembers shows Bob 19 + * with accessLevel=member. 20 + * 21 + * 3. PUBLISH — Bob calls `community.putRecord` to write a public 22 + * `community.lexicon.calendar.event` against the community DID's repo 23 + * (proxied through Contrail's credential vault — Bob never holds the 24 + * community's app password). Asserts: returned URI is rooted at the 25 + * community DID; the record is visible via `com.atproto.repo.listRecords` 26 + * against the community's PDS (proves the proxy actually wrote to the 27 + * community repo, not a local index). 28 + * 29 + * 4. RSVP — Carol, a totally separate PDS account with no relationship 30 + * to the community, writes a `community.lexicon.calendar.rsvp` to her 31 + * own repo with `subject.uri = at://<communityDid>/.../<rkey>`. 32 + * Anyone can RSVP — no grant required, that's the lexicon contract. 33 + * 34 + * 5. INDEX — The in-process ingester (Jetstream → Postgres) picks up both 35 + * Bob's event and Carol's RSVP. Asserts: querying the event by URI 36 + * shows rsvpsGoingCount=1, with the indexed event `did` being the 37 + * community's DID (not Bob's, not Alice's). 38 + * 39 + * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). 40 + */ 41 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 42 + import pg from "pg"; 43 + import type { Client } from "@atcute/client"; 44 + import "@atcute/atproto"; 45 + import { 46 + Contrail, 47 + generateKeyPair, 48 + runPersistent, 49 + } from "@atmo-dev/contrail"; 50 + import { createHandler } from "@atmo-dev/contrail/server"; 51 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 52 + import { config as baseConfig } from "../config"; 53 + import { 54 + CONTRAIL_SERVICE_DID, 55 + HANDLE_DOMAIN, 56 + PDS_ADMIN_PASSWORD, 57 + PDS_URL, 58 + PLC_URL, 59 + createCaller, 60 + createDevnetResolver, 61 + createIsolatedSchema, 62 + createTestAccount, 63 + devnetRewriteFetch, 64 + getRecordFromPds, 65 + jsonOr, 66 + login, 67 + waitFor, 68 + type CallAs, 69 + type TestAccount, 70 + } from "./helpers"; 71 + 72 + const NS = `${baseConfig.namespace}.community`; 73 + const SPACE_TYPE = "rsvp.atmo.event.space"; 74 + const EVENT_NSID = "community.lexicon.calendar.event"; 75 + const RSVP_NSID = "community.lexicon.calendar.rsvp"; 76 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 77 + 78 + describe("community provision → grant → publish → RSVP walkthrough", () => { 79 + // Alice provisions the community (becomes owner of $admin and $publishers). 80 + // Bob is granted member on $publishers and publishes the event on behalf 81 + // of the community via the proxy. Carol is an arm's-length user on the 82 + // same PDS who RSVPs from her own repo — she has no grants on the 83 + // community, which is exactly the open-RSVP contract we want to pin. 84 + let alice: TestAccount; 85 + let bob: TestAccount; 86 + let carol: TestAccount; 87 + 88 + let aliceClient: Client; 89 + let bobClient: Client; 90 + let carolClient: Client; 91 + 92 + let pool: pg.Pool; 93 + let cleanupSchema: () => Promise<void>; 94 + let pdsDid: string; 95 + let handle: (req: Request) => Promise<Response>; 96 + let callAs: CallAs; 97 + 98 + let ingestController: AbortController; 99 + let ingestPromise: Promise<void>; 100 + 101 + // Keypair held only by this test process; the public did:key is what we 102 + // pass to provision. The private JWK never leaves the test — that's the 103 + // sovereignty invariant we assert against the PLC log in step 1. 104 + let callerRotation: Awaited<ReturnType<typeof generateKeyPair>>; 105 + 106 + // Carried between tests in declaration order. 107 + let communityDid: string; 108 + let publishersUri: string; 109 + let eventUri: string; 110 + let eventCid: string; 111 + let eventRkey: string; 112 + 113 + beforeAll(async () => { 114 + // Discover the live PDS's DID — the orchestrator uses this as the `aud` 115 + // claim of the service-auth JWT it mints for createAccount, and the 116 + // devnet PDS validates `aud` against its own DID. 117 + const dres = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); 118 + if (!dres.ok) { 119 + throw new Error( 120 + `devnet PDS unreachable at ${PDS_URL}: ${dres.status} ${await dres.text()}`, 121 + ); 122 + } 123 + pdsDid = ((await dres.json()) as { did?: string }).did!; 124 + 125 + [alice, bob, carol] = await Promise.all([ 126 + createTestAccount(), 127 + createTestAccount(), 128 + createTestAccount(), 129 + ]); 130 + 131 + aliceClient = await login(alice); 132 + bobClient = await login(bob); 133 + carolClient = await login(carol); 134 + 135 + callerRotation = await generateKeyPair(); 136 + 137 + const iso = await createIsolatedSchema("test_provision_walkthrough"); 138 + pool = iso.pool; 139 + cleanupSchema = iso.cleanup; 140 + const db = createPostgresDatabase(pool); 141 + 142 + const contrail = new Contrail({ 143 + ...baseConfig, 144 + db, 145 + spaces: { 146 + type: SPACE_TYPE, 147 + serviceDid: CONTRAIL_SERVICE_DID, 148 + resolver: createDevnetResolver(), 149 + }, 150 + community: { 151 + // Provision uses serviceDid as the `aud` of its createAccount 152 + // service-auth JWT — must be the live PDS's DID, not the Contrail 153 + // service DID we use for inbound auth verification. 154 + serviceDid: pdsDid, 155 + masterKey: TEST_MASTER_KEY, 156 + plcDirectory: PLC_URL, 157 + resolver: createDevnetResolver(), 158 + // Devnet PDSes publish https://devnet.test in their DID document's 159 + // atproto_pds entry. Rewrite outgoing requests so the proxied 160 + // publish lands on the host-mapped port. 161 + fetch: devnetRewriteFetch, 162 + allowProvisioning: true, 163 + }, 164 + }); 165 + await contrail.init(); 166 + handle = createHandler(contrail); 167 + callAs = createCaller(handle); 168 + 169 + // Run the ingester in-process so step 5 can see Carol's RSVP land in 170 + // the local index after she writes it directly to her PDS repo. 171 + ingestController = new AbortController(); 172 + ingestPromise = runPersistent(db, baseConfig, { 173 + batchSize: 50, 174 + flushIntervalMs: 500, 175 + signal: ingestController.signal, 176 + }); 177 + }, 30_000); 178 + 179 + afterAll(async () => { 180 + ingestController?.abort(); 181 + await ingestPromise?.catch(() => {}); 182 + await cleanupSchema?.(); 183 + }); 184 + 185 + // ----- helpers -------------------------------------------------------------- 186 + 187 + async function getIndexedRecord(uri: string): Promise<any | undefined> { 188 + const url = 189 + `http://test/xrpc/${baseConfig.namespace}.event.getRecord?uri=${encodeURIComponent(uri)}`; 190 + const res = await handle(new Request(url)); 191 + if (res.status === 404) return undefined; 192 + if (!res.ok) throw new Error(`getRecord ${uri} → ${res.status}: ${await res.text()}`); 193 + return await res.json(); 194 + } 195 + 196 + async function mintPdsInvite(): Promise<string> { 197 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, { 198 + method: "POST", 199 + headers: { 200 + "content-type": "application/json", 201 + authorization: `Basic ${Buffer.from( 202 + `admin:${PDS_ADMIN_PASSWORD}`, 203 + ).toString("base64")}`, 204 + }, 205 + body: JSON.stringify({ useCount: 1 }), 206 + }); 207 + if (!res.ok) { 208 + throw new Error(`createInviteCode → ${res.status}: ${await res.text()}`); 209 + } 210 + return ((await res.json()) as { code: string }).code; 211 + } 212 + 213 + // ----- step 1: PROVISION --------------------------------------------------- 214 + // Sovereign provision: the caller passes the public did:key of a rotation 215 + // key they hold. Contrail mints a subordinate rotation key, lays down a 216 + // genesis op with [callerKey, contrailKey] in that order, then runs an 217 + // update op to install the PDS-recommended verification methods. The PLC 218 + // log must end with the caller's key still at rotationKeys[0] — without 219 + // that, recovery authority silently moved to Contrail. 220 + 221 + it("step 1 — provisions a sovereign community via XRPC and the PLC log shows the caller's rotation key first", async () => { 222 + const inviteCode = await mintPdsInvite(); 223 + 224 + // Devnet PDS caps the local handle label at 18 chars. `pw-` prefix + 225 + // 8-char suffix keeps the full label well under that. 226 + const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() 227 + .toString(36) 228 + .slice(2, 4)}`; 229 + const newHandle = `pw-${suffix}${HANDLE_DOMAIN}`; 230 + const email = `${suffix}@devnet.test`; 231 + const password = `pw-${suffix}`; 232 + 233 + const res = await callAs(aliceClient, "POST", `${NS}.provision`, { 234 + body: { 235 + handle: newHandle, 236 + email, 237 + password, 238 + inviteCode, 239 + pdsEndpoint: PDS_URL, 240 + rotationKey: callerRotation.publicDidKey, 241 + }, 242 + }); 243 + expect(res.status, await res.clone().text()).toBe(200); 244 + const body = (await res.json()) as { communityDid: string; status: string }; 245 + expect(body.status).toBe("activated"); 246 + expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); 247 + communityDid = body.communityDid; 248 + 249 + // PLC sovereignty check: latest op (the post-activation update op) keeps 250 + // the caller's did:key at rotationKeys[0]. If this regresses, Contrail's 251 + // subordinate key would silently take rotation priority. 252 + const logRes = await fetch(`${PLC_URL}/${communityDid}/log`); 253 + expect(logRes.ok).toBe(true); 254 + const log = (await logRes.json()) as Array<{ rotationKeys: string[] }>; 255 + expect(log.length).toBeGreaterThanOrEqual(2); 256 + expect(log[log.length - 1]!.rotationKeys[0]).toBe(callerRotation.publicDidKey); 257 + 258 + publishersUri = `ats://${communityDid}/${SPACE_TYPE}/$publishers`; 259 + }, 30_000); 260 + 261 + // ----- step 2: GRANT ------------------------------------------------------- 262 + // bootstrapReservedSpaces seeded $publishers with Alice as owner. To let 263 + // Bob publish on behalf of the community, Alice grants him `member` on 264 + // $publishers — the minimum level the putRecord guard accepts. 265 + 266 + it("step 2 — Alice grants Bob `member` on $publishers and listMembers reflects it", async () => { 267 + expect(communityDid, "step 1 must have provisioned the community").toBeTruthy(); 268 + 269 + const res = await callAs(aliceClient, "POST", `${NS}.space.grant`, { 270 + body: { 271 + spaceUri: publishersUri, 272 + subject: { did: bob.did }, 273 + accessLevel: "member", 274 + }, 275 + }); 276 + expect(res.status, await res.clone().text()).toBe(200); 277 + 278 + const list = await callAs(aliceClient, "GET", `${NS}.space.listMembers`, { 279 + query: { spaceUri: publishersUri }, 280 + }); 281 + expect(list.status).toBe(200); 282 + const { rows } = (await jsonOr(list)) as { 283 + rows: Array<{ subject: { did?: string }; accessLevel: string }>; 284 + }; 285 + const byDid = Object.fromEntries( 286 + rows.filter((r) => r.subject.did).map((r) => [r.subject.did, r.accessLevel]), 287 + ); 288 + expect(byDid[alice.did]).toBe("owner"); 289 + expect(byDid[bob.did]).toBe("member"); 290 + }); 291 + 292 + // ----- step 3: PUBLISH ----------------------------------------------------- 293 + // Bob calls community.putRecord. The router checks his level on 294 + // $publishers (member ≥ member, OK), pulls the community's encrypted 295 + // app password from the credential vault, opens a PDS session as the 296 + // community DID, and proxies a com.atproto.repo.createRecord. The 297 + // returned URI is rooted at the community DID — Bob never holds those 298 + // credentials, and his own DID doesn't appear anywhere in the record. 299 + 300 + it("step 3 — Bob (a $publishers member) publishes a public event as the community via proxy", async () => { 301 + expect(publishersUri, "step 2 must have granted bob").toBeTruthy(); 302 + 303 + const eventName = `walkthrough-event ${Date.now()}`; 304 + const startsAt = new Date(Date.now() + 60 * 60_000).toISOString(); 305 + 306 + const res = await callAs(bobClient, "POST", `${NS}.putRecord`, { 307 + body: { 308 + communityDid, 309 + collection: EVENT_NSID, 310 + record: { 311 + $type: EVENT_NSID, 312 + name: eventName, 313 + createdAt: new Date().toISOString(), 314 + startsAt, 315 + mode: `${EVENT_NSID}#inperson`, 316 + status: `${EVENT_NSID}#scheduled`, 317 + }, 318 + }, 319 + }); 320 + expect(res.status, await res.clone().text()).toBe(200); 321 + const out = (await res.json()) as { uri: string; cid: string }; 322 + eventUri = out.uri; 323 + eventCid = out.cid; 324 + eventRkey = out.uri.split("/").pop()!; 325 + 326 + // URI is rooted at the community DID, not Bob's. 327 + expect(eventUri).toMatch(new RegExp(`^at://${communityDid}/${EVENT_NSID}/`)); 328 + 329 + // The record is visible via the community PDS's listRecords — proves 330 + // the proxy actually wrote to the community repo, not just Contrail's 331 + // local index. listRecords is the lexicon endpoint the prompt named; 332 + // we round it out with a getRecord on the same rkey to confirm payload. 333 + const listUrl = 334 + `${PDS_URL}/xrpc/com.atproto.repo.listRecords` + 335 + `?repo=${encodeURIComponent(communityDid)}` + 336 + `&collection=${encodeURIComponent(EVENT_NSID)}` + 337 + `&limit=10`; 338 + const listRes = await fetch(listUrl); 339 + expect(listRes.ok, `listRecords ${listRes.status}`).toBe(true); 340 + const listed = (await listRes.json()) as { 341 + records: Array<{ uri: string; cid: string; value: { name?: string } }>; 342 + }; 343 + const found = listed.records.find((r) => r.uri === eventUri); 344 + expect(found, `event ${eventUri} not in PDS listRecords`).toBeDefined(); 345 + expect(found!.value.name).toBe(eventName); 346 + 347 + const onPds = await getRecordFromPds(communityDid, EVENT_NSID, eventRkey); 348 + expect(onPds.status).toBe(200); 349 + expect(onPds.record.name).toBe(eventName); 350 + }, 30_000); 351 + 352 + // ----- step 4: RSVP -------------------------------------------------------- 353 + // Carol writes a community.lexicon.calendar.rsvp to her OWN repo on the 354 + // shared devnet PDS, with subject.uri pointing at the community's event. 355 + // She has zero relationship to the community — that's the open-RSVP 356 + // contract: anyone can RSVP, the record lives in the responder's repo. 357 + 358 + it("step 4 — Carol RSVPs from a separate PDS account by writing to her own repo", async () => { 359 + expect(eventUri, "step 3 must have published the event").toBeTruthy(); 360 + 361 + const rsvpRes = await carolClient.post("com.atproto.repo.createRecord", { 362 + input: { 363 + repo: carol.did, 364 + collection: RSVP_NSID, 365 + record: { 366 + $type: RSVP_NSID, 367 + subject: { uri: eventUri, cid: eventCid }, 368 + status: `${RSVP_NSID}#going`, 369 + createdAt: new Date().toISOString(), 370 + }, 371 + }, 372 + }); 373 + expect(rsvpRes.ok, `RSVP createRecord: ${JSON.stringify(rsvpRes.data)}`).toBe(true); 374 + if (!rsvpRes.ok) throw new Error("unreachable"); 375 + expect(rsvpRes.data.uri).toMatch(new RegExp(`^at://${carol.did}/${RSVP_NSID}/`)); 376 + }); 377 + 378 + // ----- step 5: INDEX ------------------------------------------------------- 379 + // Both records hit Jetstream and the in-process ingester. Querying the 380 + // event by URI from the local handler should hydrate rsvpsGoingCount=1 381 + // (Carol's RSVP referencing it), and the indexed `did` must be the 382 + // community's, not Bob's — proves end-to-end attribution works. 383 + 384 + it("step 5 — the indexer surfaces the event under the community DID with Carol's RSVP counted", async () => { 385 + expect(eventUri, "step 3 must have published the event").toBeTruthy(); 386 + 387 + const indexed = await waitFor( 388 + async () => { 389 + const r = await getIndexedRecord(eventUri); 390 + return r && r.rsvpsGoingCount >= 1 ? r : undefined; 391 + }, 392 + { label: `indexed ${eventUri} with rsvpsGoingCount>=1`, timeoutMs: 20_000 }, 393 + ); 394 + 395 + expect(indexed.uri).toBe(eventUri); 396 + // Attribution: the event belongs to the community, not the publisher. 397 + expect(indexed.did).toBe(communityDid); 398 + expect(indexed.did).not.toBe(bob.did); 399 + expect(indexed.did).not.toBe(alice.did); 400 + expect(indexed.rsvpsGoingCount).toBe(1); 401 + }, 30_000); 402 + });
+490
apps/contrail-e2e/tests/provision.test.ts
··· 1 + /** 2 + * End-to-end test exercising the full ProvisionOrchestrator flow against the 3 + * live devnet stack (PDS on :4000, PLC on :2582). Validates the 5-RPC sequence 4 + * genesis op → createAccount → getRecommendedDidCredentials 5 + * → PLC update op → activateAccount 6 + * lands an activated account. 7 + * 8 + * Catches integration bugs that mocks can't: 9 + * - hand-rolled ES256 service-auth JWT vs real atproto verifier 10 + * - hand-rolled DAG-CBOR encoder output vs real PLC parser 11 + * - genesis-op DID computation matches what PLC expects 12 + * - cidForOp output accepted by PLC as `prev` for the update op 13 + * - low-S signature normalization 14 + * 15 + * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). 16 + */ 17 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 18 + import { randomUUID } from "node:crypto"; 19 + import pg from "pg"; 20 + import type { Client } from "@atcute/client"; 21 + import { 22 + CommunityAdapter, 23 + CredentialCipher, 24 + Contrail, 25 + ProvisionOrchestrator, 26 + initCommunitySchema, 27 + pdsCreateAccount, 28 + pdsGetRecommendedDidCredentials, 29 + pdsActivateAccount, 30 + pdsCreateAppPassword, 31 + generateKeyPair, 32 + createPdsSession, 33 + submitGenesisOp, 34 + type PdsClient, 35 + type PlcClient, 36 + } from "@atmo-dev/contrail"; 37 + import { createHandler } from "@atmo-dev/contrail/server"; 38 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 39 + import { 40 + PDS_URL, 41 + PLC_URL, 42 + HANDLE_DOMAIN, 43 + PDS_ADMIN_PASSWORD, 44 + CONTRAIL_SERVICE_DID, 45 + createCaller, 46 + createDevnetResolver, 47 + createIsolatedSchema, 48 + createTestAccount, 49 + login, 50 + type CallAs, 51 + type TestAccount, 52 + } from "./helpers"; 53 + 54 + describe("ProvisionOrchestrator devnet e2e", () => { 55 + let pool: pg.Pool; 56 + let cleanupSchema: () => Promise<void>; 57 + let adapter: CommunityAdapter; 58 + let cipher: CredentialCipher; 59 + let pdsDid: string; 60 + 61 + beforeAll(async () => { 62 + // Discover the live PDS's DID via describeServer — used as the `aud` 63 + // claim in the service-auth JWT we mint for createAccount. 64 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); 65 + if (!res.ok) { 66 + throw new Error( 67 + `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, 68 + ); 69 + } 70 + const body = (await res.json()) as { did?: string }; 71 + if (!body.did) { 72 + throw new Error(`describeServer response missing did: ${JSON.stringify(body)}`); 73 + } 74 + pdsDid = body.did; 75 + 76 + const iso = await createIsolatedSchema("test_provision_e2e"); 77 + pool = iso.pool; 78 + cleanupSchema = iso.cleanup; 79 + const db = createPostgresDatabase(pool); 80 + await initCommunitySchema(db); 81 + adapter = new CommunityAdapter(db); 82 + cipher = new CredentialCipher(new Uint8Array(32).fill(7)); 83 + }, 15_000); 84 + 85 + afterAll(async () => { 86 + await cleanupSchema?.(); 87 + }); 88 + 89 + // Adapt our bare module-level functions to the orchestrator's wrapper 90 + // interfaces. Shared by the managed and self-sovereign tests so they hit 91 + // the same live PDS surface (Task 16 added createAppPassword to PdsClient). 92 + const pdsClient: PdsClient = { 93 + createAccount: ({ pdsUrl, serviceAuthJwt, body }) => 94 + pdsCreateAccount(pdsUrl, serviceAuthJwt, body), 95 + getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => 96 + pdsGetRecommendedDidCredentials(pdsUrl, accessJwt), 97 + activateAccount: ({ pdsUrl, accessJwt }) => 98 + pdsActivateAccount(pdsUrl, accessJwt), 99 + createAppPassword: ({ pdsUrl, accessJwt, name }) => 100 + pdsCreateAppPassword(pdsUrl, accessJwt, name), 101 + }; 102 + 103 + const plcClient: PlcClient = { 104 + submit: (did, op) => submitGenesisOp(PLC_URL, did, op as any), 105 + }; 106 + 107 + /** Mint a single-use invite via the PDS admin API. Shared helper for both 108 + * the managed and self-sovereign tests. */ 109 + async function mintInvite(): Promise<string> { 110 + const inviteRes = await fetch( 111 + `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, 112 + { 113 + method: "POST", 114 + headers: { 115 + "content-type": "application/json", 116 + authorization: `Basic ${Buffer.from( 117 + `admin:${PDS_ADMIN_PASSWORD}`, 118 + ).toString("base64")}`, 119 + }, 120 + body: JSON.stringify({ useCount: 1 }), 121 + }, 122 + ); 123 + if (!inviteRes.ok) { 124 + throw new Error( 125 + `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, 126 + ); 127 + } 128 + return ((await inviteRes.json()) as { code: string }).code; 129 + } 130 + 131 + it( 132 + "provisions a self-sovereign community: caller holds rotation key, contrail mints app password", 133 + async () => { 134 + // Caller-held rotation keypair. The private JWK never leaves this test — 135 + // only callerRotation.publicDidKey is passed to the orchestrator. That's 136 + // the negative invariant we assert below: no encrypted_* column on the 137 + // persisted row contains the caller's did:key after decrypt. 138 + const callerRotation = await generateKeyPair(); 139 + 140 + const inviteCode = await mintInvite(); 141 + 142 + // Keep handle short — devnet caps the local label at 18 chars. 143 + // `ss-` (3) + 8-char suffix = 11 chars on the local label. 144 + const suffix = `${Date.now().toString(36).slice(-5)}${Math.random() 145 + .toString(36) 146 + .slice(2, 5)}`; 147 + const handle = `ss-${suffix}${HANDLE_DOMAIN}`; 148 + const email = `${suffix}@devnet.test`; 149 + const password = `pw-${suffix}`; 150 + const attemptId = randomUUID(); 151 + 152 + const orch = new ProvisionOrchestrator({ 153 + adapter, 154 + cipher, 155 + plc: plcClient, 156 + pds: pdsClient, 157 + pdsDid, 158 + }); 159 + 160 + const result = await orch.provision({ 161 + attemptId, 162 + pdsEndpoint: PDS_URL, 163 + handle, 164 + email, 165 + password, 166 + inviteCode, 167 + rotationKey: callerRotation.publicDidKey, 168 + }); 169 + 170 + // Result-shape assertions: status activated; rootCredentials returned 171 + // with the user's *root* password, not the minted app password. 172 + expect(result.attemptId).toBe(attemptId); 173 + expect(result.did).toMatch(/^did:plc:[a-z2-7]{24}$/); 174 + expect(result.status).toBe("activated"); 175 + expect(result.rootCredentials).toBeDefined(); 176 + expect(result.rootCredentials!.handle).toBe(handle); 177 + expect(result.rootCredentials!.password).toBe(password); 178 + expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); 179 + expect(result.rootCredentials!.recoveryHint.length).toBeGreaterThan(0); 180 + 181 + // Persisted-row assertions: self-sovereign mode persists an *encrypted 182 + // app password* — never the user's root password. Contrail's rotation 183 + // key is the SUBORDINATE (rotationKeys[1]); the caller's did:key is 184 + // rotationKeys[0] in the genesis op and lives only in PLC, never in 185 + // any encrypted_* column. 186 + const row = await adapter.getProvisionAttempt(attemptId); 187 + expect(row).not.toBeNull(); 188 + expect(row!.status).toBe("activated"); 189 + expect(row!.did).toBe(result.did); 190 + expect(row!.handle).toBe(handle); 191 + expect(row!.encryptedSigningKey).toBeTruthy(); 192 + expect(row!.encryptedRotationKey).toBeTruthy(); 193 + expect(row!.encryptedPassword).toBeTruthy(); 194 + expect(row!.activatedAt).toBeTruthy(); 195 + expect(row!.lastError).toBeNull(); 196 + 197 + // Decrypt the persisted password — it must be the *minted app password*, 198 + // distinct from the user's root password we supplied. 199 + const decryptedAppPassword = await cipher.decryptString( 200 + row!.encryptedPassword!, 201 + ); 202 + expect(decryptedAppPassword).not.toBe(password); 203 + expect(decryptedAppPassword.length).toBeGreaterThan(0); 204 + 205 + // Decrypt the persisted rotation JWK — it must be Contrail's subordinate 206 + // key (a fresh P-256 keypair), NOT the caller's. We assert NOT-equal on 207 + // the JWK shape, including the `d` (private) coordinate which the caller 208 + // never sent. 209 + const decryptedRotationJwk = JSON.parse( 210 + await cipher.decryptString(row!.encryptedRotationKey!), 211 + ) as { kty?: string; crv?: string; x?: string; y?: string; d?: string }; 212 + expect(decryptedRotationJwk.kty).toBe("EC"); 213 + expect(decryptedRotationJwk.crv).toBe("P-256"); 214 + // The caller's private `d` coordinate must never appear in Contrail's 215 + // persistence — the strongest single-bit invariant of self-sovereign mode. 216 + expect(decryptedRotationJwk.d).not.toBe(callerRotation.privateJwk.d); 217 + // The public x/y must also differ — the persisted rotation key is a 218 + // subordinate Contrail-generated key, not a re-derivation of the caller's. 219 + expect(decryptedRotationJwk.x).not.toBe(callerRotation.privateJwk.x); 220 + expect(decryptedRotationJwk.y).not.toBe(callerRotation.privateJwk.y); 221 + 222 + // Negative invariant: the caller's public did:key string must NOT appear 223 + // inside ANY encrypted column after decryption. Encrypted_signing_key 224 + // is a JWK; encrypted_rotation_key is the subordinate JWK; the password 225 + // is opaque — none of them should contain the caller's did:key. 226 + const decryptedSigningKey = await cipher.decryptString( 227 + row!.encryptedSigningKey!, 228 + ); 229 + const callerDidKey = callerRotation.publicDidKey; 230 + expect(decryptedSigningKey.indexOf(callerDidKey)).toBe(-1); 231 + expect( 232 + await cipher 233 + .decryptString(row!.encryptedRotationKey!) 234 + .then((s) => s.indexOf(callerDidKey)), 235 + ).toBe(-1); 236 + expect(decryptedAppPassword.indexOf(callerDidKey)).toBe(-1); 237 + 238 + // Prove the *minted* app password works against the live PDS. 239 + // createPdsSession throws if the PDS rejects. 240 + const appSession = await createPdsSession( 241 + PDS_URL, 242 + handle, 243 + decryptedAppPassword, 244 + ); 245 + expect(appSession.did).toBe(result.did); 246 + expect(appSession.accessJwt).toBeTruthy(); 247 + 248 + // And the user's root password also still works — PDS supports multiple 249 + // credentials per account, so the caller's root creds remain valid. 250 + const rootSession = await createPdsSession(PDS_URL, handle, password); 251 + expect(rootSession.did).toBe(result.did); 252 + expect(rootSession.accessJwt).toBeTruthy(); 253 + 254 + // PLC-log assertion (H2): the post-activation update op must keep the 255 + // caller's did:key at rotationKeys[0]. Without this, contrail's 256 + // subordinate would silently take rotation priority and the caller 257 + // would lose self-sovereign recovery authority. 258 + const logRes = await fetch(`${PLC_URL}/${result.did}/log`); 259 + expect(logRes.ok).toBe(true); 260 + const log = (await logRes.json()) as Array<{ 261 + rotationKeys: string[]; 262 + }>; 263 + expect(log.length).toBeGreaterThanOrEqual(2); 264 + const lastOp = log[log.length - 1]!; 265 + expect(lastOp.rotationKeys[0]).toBe(callerRotation.publicDidKey); 266 + }, 267 + 30_000, 268 + ); 269 + }); 270 + 271 + /** 272 + * Routed end-to-end coverage for the XRPC surface of the provision flow: 273 + * `${NS}.community.provision` then `${NS}.community.putRecord` against the 274 + * same provisioned community. Differs from the orchestrator-only test above 275 + * by exercising the full Hono app — auth middleware, DB persistence, 276 + * bootstrapReservedSpaces, and the credential-proxy publish path that 277 + * Tasks 13/14 added. 278 + * 279 + * Also pins the Task 14 session-cache behavior: two sequential putRecords 280 + * should perform exactly **one** `com.atproto.server.createSession` call to 281 + * the PDS — the second hits the cached session. 282 + */ 283 + describe("community.provision + putRecord via XRPC route (devnet)", () => { 284 + const NS = "rsvp.atmo.community"; 285 + const SPACE_TYPE = "rsvp.atmo.event.space"; 286 + const POST_NSID = "app.bsky.feed.post"; 287 + const TEST_MASTER_KEY = new Uint8Array(32).fill(7); 288 + 289 + let pool: pg.Pool; 290 + let cleanupSchema: () => Promise<void>; 291 + let pdsDid: string; 292 + let alice: TestAccount; 293 + let aliceClient: Client; 294 + let handle: (req: Request) => Promise<Response>; 295 + let callAs: CallAs; 296 + 297 + // Counts createSession calls to the live PDS so we can assert that the 298 + // session cache is reused across publishes (Task 14). 299 + let createSessionCount = 0; 300 + 301 + beforeAll(async () => { 302 + // Discover the live PDS's DID — needed as `aud` in the orchestrator's 303 + // service-auth JWT for createAccount. 304 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); 305 + if (!res.ok) { 306 + throw new Error( 307 + `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, 308 + ); 309 + } 310 + pdsDid = ((await res.json()) as { did?: string }).did!; 311 + 312 + const iso = await createIsolatedSchema("test_provision_router_e2e"); 313 + pool = iso.pool; 314 + cleanupSchema = iso.cleanup; 315 + const db = createPostgresDatabase(pool); 316 + 317 + // Wrap fetch to count createSession calls. Everything else passes 318 + // through unchanged so the orchestrator + publish paths hit real devnet. 319 + const countingFetch: typeof fetch = (input, init) => { 320 + const url = typeof input === "string" ? input : input.toString(); 321 + if (url.includes("/xrpc/com.atproto.server.createSession")) { 322 + createSessionCount++; 323 + } 324 + return fetch(input as any, init); 325 + }; 326 + 327 + const contrail = new Contrail({ 328 + ...{ 329 + namespace: "rsvp.atmo", 330 + collections: { 331 + // Minimal collection set — we only need community routes registered; 332 + // no Jetstream ingestion is required for this test. 333 + post: { collection: POST_NSID }, 334 + }, 335 + }, 336 + db, 337 + spaces: { 338 + type: SPACE_TYPE, 339 + serviceDid: CONTRAIL_SERVICE_DID, 340 + resolver: createDevnetResolver(), 341 + }, 342 + community: { 343 + // The orchestrator uses cfg.serviceDid as the `aud` of the 344 + // createAccount service-auth JWT. The live devnet PDS validates 345 + // `aud` against its own DID, so this must be the PDS's DID — not 346 + // the Contrail service DID used for inbound JWT verification. 347 + serviceDid: pdsDid, 348 + masterKey: TEST_MASTER_KEY, 349 + plcDirectory: PLC_URL, 350 + resolver: createDevnetResolver(), 351 + fetch: countingFetch, 352 + allowProvisioning: true, 353 + }, 354 + }); 355 + await contrail.init(); 356 + handle = createHandler(contrail); 357 + callAs = createCaller(handle); 358 + 359 + // Alice acts as the provisioning caller — she becomes owner of the new 360 + // community's $admin and $publishers spaces, which lets her publish. 361 + alice = await createTestAccount(); 362 + aliceClient = await login(alice); 363 + }, 30_000); 364 + 365 + afterAll(async () => { 366 + await cleanupSchema?.(); 367 + }); 368 + 369 + it( 370 + "provisions via the XRPC route and publishes a record (one cached session across two putRecords)", 371 + async () => { 372 + // Mint an invite code via the PDS admin API — same pattern as the 373 + // orchestrator-only test above. 374 + const inviteRes = await fetch( 375 + `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, 376 + { 377 + method: "POST", 378 + headers: { 379 + "content-type": "application/json", 380 + authorization: `Basic ${Buffer.from( 381 + `admin:${PDS_ADMIN_PASSWORD}`, 382 + ).toString("base64")}`, 383 + }, 384 + body: JSON.stringify({ useCount: 1 }), 385 + }, 386 + ); 387 + if (!inviteRes.ok) { 388 + throw new Error( 389 + `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, 390 + ); 391 + } 392 + const { code: inviteCode } = (await inviteRes.json()) as { code: string }; 393 + 394 + // Keep total label under PDS's 18-char limit: `r-` (2) + 8-char suffix 395 + // = 10 chars, well under the cap. 396 + const suffix = `${Date.now().toString(36).slice(-6)}${Math.random() 397 + .toString(36) 398 + .slice(2, 4)}`; 399 + const newHandle = `r-${suffix}${HANDLE_DOMAIN}`; 400 + const email = `${suffix}@devnet.test`; 401 + const password = `pw-${suffix}`; 402 + 403 + const callerRotation = await generateKeyPair(); 404 + 405 + const baselineCreateSessionCount = createSessionCount; 406 + 407 + // ---- POST /xrpc/${NS}.provision ----------------------------------- 408 + const provRes = await callAs(aliceClient, "POST", `${NS}.provision`, { 409 + body: { 410 + handle: newHandle, 411 + email, 412 + password, 413 + inviteCode, 414 + pdsEndpoint: PDS_URL, 415 + rotationKey: callerRotation.publicDidKey, 416 + }, 417 + }); 418 + const provText = await provRes.clone().text(); 419 + expect(provRes.status, provText).toBe(200); 420 + const provBody = (await provRes.json()) as { 421 + communityDid: string; 422 + status: string; 423 + }; 424 + expect(provBody.status).toBe("activated"); 425 + expect(provBody.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); 426 + const communityDid = provBody.communityDid; 427 + 428 + // The orchestrator never calls createSession during provision — it 429 + // gets accessJwt + refreshJwt directly from the createAccount response 430 + // and seeds the community_sessions cache before returning, so the first 431 + // publish hits a warm cache. 432 + const provisionCreateSessionCount = 433 + createSessionCount - baselineCreateSessionCount; 434 + expect(provisionCreateSessionCount).toBe(0); 435 + 436 + // ---- First putRecord: cache miss → createSession ------------------ 437 + const firstRecord = { 438 + $type: POST_NSID, 439 + text: `routed-e2e first ${suffix}`, 440 + createdAt: new Date().toISOString(), 441 + }; 442 + const put1 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { 443 + body: { 444 + communityDid, 445 + collection: POST_NSID, 446 + record: firstRecord, 447 + }, 448 + }); 449 + const put1Text = await put1.clone().text(); 450 + expect(put1.status, put1Text).toBe(200); 451 + const put1Body = (await put1.json()) as { uri: string; cid: string }; 452 + expect(put1Body.uri).toMatch( 453 + new RegExp(`^at://${communityDid}/${POST_NSID}/`), 454 + ); 455 + expect(put1Body.cid).toBeTruthy(); 456 + 457 + // ---- Second putRecord: cache hit → no createSession --------------- 458 + const secondRecord = { 459 + $type: POST_NSID, 460 + text: `routed-e2e second ${suffix}`, 461 + createdAt: new Date().toISOString(), 462 + }; 463 + const put2 = await callAs(aliceClient, "POST", `${NS}.putRecord`, { 464 + body: { 465 + communityDid, 466 + collection: POST_NSID, 467 + record: secondRecord, 468 + }, 469 + }); 470 + const put2Text = await put2.clone().text(); 471 + expect(put2.status, put2Text).toBe(200); 472 + const put2Body = (await put2.json()) as { uri: string; cid: string }; 473 + expect(put2Body.uri).toMatch( 474 + new RegExp(`^at://${communityDid}/${POST_NSID}/`), 475 + ); 476 + 477 + // Zero createSession across the whole flow: provision pre-warms the 478 + // cache, then both publishes hit the 30s-skew cache. 479 + const publishesCreateSessionCount = 480 + createSessionCount - baselineCreateSessionCount; 481 + expect(publishesCreateSessionCount).toBe(0); 482 + }, 483 + 60_000, 484 + ); 485 + 486 + // TODO: cover stale-session auto-recovery (provision Task 14, ensureSession 487 + // refresh path). Hard to exercise deterministically against live devnet 488 + // without aging out a real `accessExp` past the 30s skew; covered by unit 489 + // tests in packages/contrail/tests/community-publishing.test.ts. 490 + });
+178
apps/contrail-e2e/tests/reap-tombstone.test.ts
··· 1 + /** 2 + * Devnet e2e for the tombstone CID derivation used by `contrail reap` (M6). 3 + * 4 + * `cli/commands/reap.ts:146` calls `cidForOp(signed as never)` because the 5 + * helper's declared signed-op union covers genesis/update — not tombstone. 6 + * The DAG-CBOR encoder accepts the smaller tombstone shape, but no other 7 + * test submits a *real* tombstone to live PLC and verifies the CID we 8 + * computed locally matches the one PLC returns from `log/last`. This test 9 + * closes that gap. 10 + * 11 + * The test uses managed-mode provisioning to land a real DID on devnet PLC 12 + * with the rotation key encrypted on the persisted row, then drives the 13 + * tombstone flow (build → sign → cidForOp → submit) with the same helpers 14 + * runReap uses, and asserts the post-submit `log/last` matches. 15 + * 16 + * Tombstones are irrevocable on PLC. This test always operates on a freshly- 17 + * provisioned devnet DID never seen by any other test or user. 18 + * 19 + * Prereqs: `pnpm stack:up` (devnet PDS+PLC + postgres reachable). 20 + */ 21 + import { describe, it, expect, beforeAll, afterAll } from "vitest"; 22 + import { randomUUID } from "node:crypto"; 23 + import pg from "pg"; 24 + import { 25 + CommunityAdapter, 26 + CredentialCipher, 27 + ProvisionOrchestrator, 28 + generateKeyPair, 29 + initCommunitySchema, 30 + pdsCreateAccount, 31 + pdsGetRecommendedDidCredentials, 32 + pdsActivateAccount, 33 + pdsCreateAppPassword, 34 + submitGenesisOp, 35 + getLastOpCid, 36 + buildTombstoneOp, 37 + signTombstoneOp, 38 + submitTombstoneOp, 39 + cidForOp, 40 + type PdsClient, 41 + type PlcClient, 42 + } from "@atmo-dev/contrail"; 43 + import { createPostgresDatabase } from "@atmo-dev/contrail/postgres"; 44 + import { 45 + PDS_URL, 46 + PLC_URL, 47 + HANDLE_DOMAIN, 48 + PDS_ADMIN_PASSWORD, 49 + createIsolatedSchema, 50 + } from "./helpers"; 51 + 52 + describe("reap tombstone CID matches live PLC log/last (M6)", () => { 53 + let pool: pg.Pool; 54 + let cleanupSchema: () => Promise<void>; 55 + let adapter: CommunityAdapter; 56 + let cipher: CredentialCipher; 57 + let pdsDid: string; 58 + 59 + beforeAll(async () => { 60 + const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.describeServer`); 61 + if (!res.ok) { 62 + throw new Error( 63 + `devnet PDS unreachable at ${PDS_URL}: ${res.status} ${await res.text()}`, 64 + ); 65 + } 66 + pdsDid = ((await res.json()) as { did: string }).did; 67 + 68 + const iso = await createIsolatedSchema("test_reap_tombstone_e2e"); 69 + pool = iso.pool; 70 + cleanupSchema = iso.cleanup; 71 + const db = createPostgresDatabase(pool); 72 + await initCommunitySchema(db); 73 + adapter = new CommunityAdapter(db); 74 + cipher = new CredentialCipher(new Uint8Array(32).fill(7)); 75 + }, 15_000); 76 + 77 + afterAll(async () => { 78 + await cleanupSchema?.(); 79 + }); 80 + 81 + const pdsClient: PdsClient = { 82 + createAccount: ({ pdsUrl, serviceAuthJwt, body }) => 83 + pdsCreateAccount(pdsUrl, serviceAuthJwt, body), 84 + getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => 85 + pdsGetRecommendedDidCredentials(pdsUrl, accessJwt), 86 + activateAccount: ({ pdsUrl, accessJwt }) => pdsActivateAccount(pdsUrl, accessJwt), 87 + createAppPassword: ({ pdsUrl, accessJwt, name }) => 88 + pdsCreateAppPassword(pdsUrl, accessJwt, name), 89 + }; 90 + 91 + const plcClient: PlcClient = { 92 + submit: (did, op) => submitGenesisOp(PLC_URL, did, op as any), 93 + }; 94 + 95 + async function mintInvite(): Promise<string> { 96 + const inviteRes = await fetch( 97 + `${PDS_URL}/xrpc/com.atproto.server.createInviteCode`, 98 + { 99 + method: "POST", 100 + headers: { 101 + "content-type": "application/json", 102 + authorization: `Basic ${Buffer.from( 103 + `admin:${PDS_ADMIN_PASSWORD}`, 104 + ).toString("base64")}`, 105 + }, 106 + body: JSON.stringify({ useCount: 1 }), 107 + }, 108 + ); 109 + if (!inviteRes.ok) { 110 + throw new Error( 111 + `createInviteCode failed (${inviteRes.status}): ${await inviteRes.text()}`, 112 + ); 113 + } 114 + return ((await inviteRes.json()) as { code: string }).code; 115 + } 116 + 117 + it( 118 + "tombstone op submitted to PLC has the CID we computed locally via cidForOp", 119 + async () => { 120 + // Provision a fresh community to land a real DID on devnet PLC. 121 + const inviteCode = await mintInvite(); 122 + const suffix = `${Date.now().toString(36)}${Math.random() 123 + .toString(36) 124 + .slice(2, 6)}`; 125 + const handle = `tomb-${suffix}${HANDLE_DOMAIN}`; 126 + const email = `${suffix}@devnet.test`; 127 + const password = `pw-${suffix}`; 128 + const attemptId = randomUUID(); 129 + 130 + const callerRotation = await generateKeyPair(); 131 + 132 + const orch = new ProvisionOrchestrator({ 133 + adapter, 134 + cipher, 135 + plc: plcClient, 136 + pds: pdsClient, 137 + pdsDid, 138 + }); 139 + const result = await orch.provision({ 140 + attemptId, 141 + pdsEndpoint: PDS_URL, 142 + handle, 143 + email, 144 + password, 145 + inviteCode, 146 + rotationKey: callerRotation.publicDidKey, 147 + }); 148 + expect(result.status).toBe("activated"); 149 + const did = result.did; 150 + 151 + // Pull the encrypted rotation key off the persisted row — same path 152 + // runReap takes. 153 + const row = await adapter.getProvisionAttempt(attemptId); 154 + expect(row).not.toBeNull(); 155 + expect(row!.encryptedRotationKey).toBeTruthy(); 156 + const rotationJwk = JSON.parse( 157 + await cipher.decryptString(row!.encryptedRotationKey!), 158 + ) as { kty: string; crv: string; x: string; y: string; d: string }; 159 + 160 + // Drive the tombstone path the same way reap.ts does, but inline so 161 + // the test pins the cidForOp CID independent of runReap's archival 162 + // bookkeeping. 163 + const prev = await getLastOpCid(PLC_URL, did); 164 + const unsigned = buildTombstoneOp(prev); 165 + const signed = await signTombstoneOp(unsigned, rotationJwk); 166 + const expectedCid = await cidForOp(signed as never); 167 + 168 + await submitTombstoneOp(PLC_URL, did, signed); 169 + 170 + // PLC's log/last for the DID must now report the same CID we computed. 171 + // If cidForOp's tombstone encoding ever drifts from PLC's, this is the 172 + // failure mode that catches it. 173 + const lastCid = await getLastOpCid(PLC_URL, did); 174 + expect(lastCid).toBe(expectedCid); 175 + }, 176 + 45_000, 177 + ); 178 + });
+21 -4
docs/07-communities.md
··· 56 56 57 57 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. 58 58 59 - ## Two modes 59 + ## Three modes 60 60 61 61 - **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. 62 62 - **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. 63 + - **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. 64 + 65 + Whichever the mode, the result is the same shape: a DID that multiple members can act through, gated by access levels. 66 + 67 + ## Choosing a mode 63 68 64 - Either way, the result is the same: a DID that multiple members can act through, gated by access levels. 69 + Two questions typically determine the mode: 70 + 71 + 1. **Does the community already have a DID?** Yes → **adopt**. No → continue. 72 + 2. **How should records be published?** Contrail signs them directly → **mint**. Contrail uses an app password against a PDS account → **provision**. 73 + 74 + The rotation key holder follows from the choice: contrail in mint mode, caller in adopt and provision modes. 65 75 66 76 ## Access levels 67 77 ··· 84 94 85 95 ## XRPCs 86 96 87 - - `<ns>.community.mint | adopt | list | delete` 97 + - `<ns>.community.mint | adopt | provision | list | delete` 88 98 - `<ns>.community.invite.create | redeem | revoke | list` 89 99 - `<ns>.community.setAccessLevel | revoke | listMembers` 90 100 - `<ns>.community.space.create | grant | revoke | ...` — community-owned spaces 91 101 - `<ns>.community.putRecord | deleteRecord` — publish records as the community DID 92 102 103 + The `contrail-community reap` CLI tombstones provisioned DIDs in PLC when provisioning fails partway and orphan rows accumulate. It ships as a bin in the `@atmo-dev/contrail-community` package (`npx contrail-community reap`); under pnpm's isolated `node_modules` the core `contrail` CLI cannot resolve the community package, so `contrail reap` only works in hoisted installs where both packages sit together. 104 + 105 + - Default is dry-run; pass `--no-dry-run` to actually submit (irrevocable) tombstones. 106 + - `--all-stuck` only reaps rows idle at least `--older-than <minutes>` (default 30), so a bulk run can't tombstone an in-flight provision that is mid-state-machine. `--attempt-id <uuid>` targets a single known row and ignores the age floor. 107 + - **D1 and Postgres:** by default reap acquires the Cloudflare D1 binding via `wrangler getPlatformProxy()`. For the decoupled external (Postgres) index, pass `--db <connection-string>` or set `DATABASE_URL` and reap runs against Postgres instead (`--db` wins over the env var). The orchestrator and adapter SQL are dialect-agnostic, so both paths share the same reap logic. 108 + 93 109 ## What's not here 94 110 95 111 - No per-record per-level ACLs. Model as spaces. 96 112 - No auto-rotation on key compromise yet. 97 - - 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.) 113 + - 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. 114 + - 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. 98 115 99 116 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.
+19
packages/contrail-community/package.json
··· 16 16 "import": "./dist/index.js" 17 17 } 18 18 }, 19 + "bin": { 20 + "contrail-community": "./dist/cli/index.js" 21 + }, 19 22 "repository": { 20 23 "type": "git", 21 24 "url": "https://github.com/flo-bit/contrail.git", ··· 42 45 "@atcute/xrpc-server": "^0.1.12", 43 46 "@atmo-dev/contrail": "workspace:*", 44 47 "@atmo-dev/contrail-base": "workspace:*", 48 + "cac": "^7.0.0", 45 49 "hono": "^4.12.8" 46 50 }, 51 + "peerDependencies": { 52 + "pg": "^8.0.0", 53 + "wrangler": "^4.0.0" 54 + }, 55 + "peerDependenciesMeta": { 56 + "pg": { 57 + "optional": true 58 + }, 59 + "wrangler": { 60 + "optional": true 61 + } 62 + }, 47 63 "devDependencies": { 64 + "@types/node": "^25.5.0", 65 + "@types/pg": "^8.20.0", 66 + "pg": "^8.20.0", 48 67 "tsup": "^8.5.0", 49 68 "typescript": "^5.7.3", 50 69 "vitest": "^4.1.0"
+254
packages/contrail-community/src/adapter.ts
··· 6 6 CommunityMode, 7 7 CommunityRow, 8 8 CreateCommunityInviteInput, 9 + CreateProvisionAttemptInput, 10 + ProvisionAttemptRow, 11 + ProvisionStatus, 9 12 } from "./types"; 10 13 11 14 function toNum(v: unknown): number { ··· 51 54 createdBy: string; 52 55 } 53 56 57 + export interface CreateProvisionedCommunityInput { 58 + did: string; 59 + pdsEndpoint: string; 60 + handle: string; 61 + /** Encrypted PDS app password — already-encrypted base64 envelope. The 62 + * orchestrator persisted this on the provision_attempts row after a 63 + * post-activation `createAppPassword` call; the route handler hands it 64 + * through so we keep one source of truth for the credential and avoid 65 + * round-tripping the plaintext password through the adapter. */ 66 + appPasswordEncrypted: string; 67 + createdBy: string; 68 + } 69 + 54 70 export interface GrantInput { 55 71 spaceUri: string; 56 72 subjectDid?: string; ··· 85 101 mode: "adopt", 86 102 pdsEndpoint: input.pdsEndpoint, 87 103 identifier: input.identifier, 104 + createdBy: input.createdBy, 105 + createdAt: now, 106 + deletedAt: null, 107 + }; 108 + } 109 + 110 + async createFromProvisioned( 111 + input: CreateProvisionedCommunityInput 112 + ): Promise<CommunityRow> { 113 + const now = Date.now(); 114 + await this.db 115 + .prepare( 116 + `INSERT INTO communities (did, mode, pds_endpoint, app_password_encrypted, identifier, created_by, created_at) 117 + VALUES (?, 'provision', ?, ?, ?, ?, ?)` 118 + ) 119 + .bind( 120 + input.did, 121 + input.pdsEndpoint, 122 + input.appPasswordEncrypted, 123 + input.handle, 124 + input.createdBy, 125 + now 126 + ) 127 + .run(); 128 + return { 129 + did: input.did, 130 + mode: "provision", 131 + pdsEndpoint: input.pdsEndpoint, 132 + identifier: input.handle, 88 133 createdBy: input.createdBy, 89 134 createdAt: now, 90 135 deletedAt: null, ··· 384 429 .first<any>(); 385 430 return row ? mapCommunityInviteRow(row) : null; 386 431 } 432 + 433 + // ---- Provision attempts ------------------------------------------------ 434 + 435 + async createProvisionAttempt(input: CreateProvisionAttemptInput): Promise<void> { 436 + const now = Date.now(); 437 + await this.db 438 + .prepare( 439 + `INSERT INTO provision_attempts ( 440 + attempt_id, did, status, pds_endpoint, handle, email, invite_code, 441 + encrypted_signing_key, encrypted_rotation_key, 442 + created_at, updated_at 443 + ) VALUES (?, ?, 'keys_generated', ?, ?, ?, ?, ?, ?, ?, ?)` 444 + ) 445 + .bind( 446 + input.attemptId, 447 + input.did, 448 + input.pdsEndpoint, 449 + input.handle, 450 + input.email, 451 + input.inviteCode ?? null, 452 + input.encryptedSigningKey, 453 + input.encryptedRotationKey, 454 + now, 455 + now 456 + ) 457 + .run(); 458 + } 459 + 460 + async getProvisionAttempt(attemptId: string): Promise<ProvisionAttemptRow | null> { 461 + const row = await this.db 462 + .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) 463 + .bind(attemptId) 464 + .first<Record<string, any>>(); 465 + return row ? rowToProvisionAttempt(row) : null; 466 + } 467 + 468 + async updateProvisionStatus( 469 + attemptId: string, 470 + status: ProvisionStatus, 471 + opts: { lastError?: string; encryptedPassword?: string } = {} 472 + ): Promise<void> { 473 + const now = Date.now(); 474 + const stampCol = ({ 475 + genesis_submitted: "genesis_submitted_at", 476 + account_created: "account_created_at", 477 + did_doc_updated: "did_doc_updated_at", 478 + activated: "activated_at", 479 + } as Record<string, string | undefined>)[status]; 480 + 481 + const sets: string[] = [`status = ?`, `updated_at = ?`]; 482 + const args: any[] = [status, now]; 483 + if (stampCol) { 484 + sets.push(`${stampCol} = ?`); 485 + args.push(now); 486 + } 487 + if (opts.lastError !== undefined) { 488 + sets.push(`last_error = ?`); 489 + args.push(opts.lastError); 490 + } 491 + if (opts.encryptedPassword !== undefined) { 492 + sets.push(`encrypted_password = ?`); 493 + args.push(opts.encryptedPassword); 494 + } 495 + args.push(attemptId); 496 + 497 + await this.db 498 + .prepare(`UPDATE provision_attempts SET ${sets.join(", ")} WHERE attempt_id = ?`) 499 + .bind(...args) 500 + .run(); 501 + } 502 + 503 + /** List provision attempts that did NOT reach `activated` AND have been 504 + * idle for at least `olderThanMs` (no update within that window). These are 505 + * the rows reap can act on: a non-terminal status means the flow stopped 506 + * partway, leaving (typically) a dangling DID in PLC that needs 507 + * tombstoning. The age floor is mandatory and exists so a bulk reap can 508 + * never select an in-flight attempt that is mid-state-machine (seconds old) 509 + * and tombstone a DID that was about to activate. Pass `0` to disable the 510 + * floor (e.g. a test, or an operator who has confirmed nothing is running). */ 511 + async listStuckAttempts(olderThanMs: number): Promise<ProvisionAttemptRow[]> { 512 + const cutoff = Date.now() - olderThanMs; 513 + const rows = await this.db 514 + .prepare( 515 + `SELECT * FROM provision_attempts 516 + WHERE status != 'activated' AND updated_at <= ? 517 + ORDER BY updated_at ASC` 518 + ) 519 + .bind(cutoff) 520 + .all<Record<string, any>>(); 521 + return rows.results.map(rowToProvisionAttempt); 522 + } 523 + 524 + /** Move a stuck provision_attempts row into the archive table after reap 525 + * has tombstoned its DID in PLC. Insert + delete run as a single 526 + * `db.batch()`, which is atomic on Postgres (BEGIN/COMMIT) and D1; on the 527 + * plain sqlite adapter it is not, so the archive INSERT is also idempotent 528 + * (`ON CONFLICT (attempt_id) DO NOTHING`). Together that makes a 529 + * retry-after-partial-failure safe: if a prior run's INSERT landed but its 530 + * DELETE did not, the row is stranded in both tables; re-running archives 531 + * cleanly (the INSERT is a no-op, the DELETE removes the live row) instead 532 + * of dying on a PRIMARY KEY conflict. */ 533 + async archiveStuckAttempt( 534 + attemptId: string, 535 + opts: { tombstoneOpCid?: string | null; notes?: string | null } = {} 536 + ): Promise<void> { 537 + const row = await this.db 538 + .prepare(`SELECT * FROM provision_attempts WHERE attempt_id = ?`) 539 + .bind(attemptId) 540 + .first<Record<string, any>>(); 541 + if (!row) { 542 + throw new Error(`provision_attempt not found: ${attemptId}`); 543 + } 544 + const now = Date.now(); 545 + const insert = this.db 546 + .prepare( 547 + `INSERT INTO provision_attempts_archive ( 548 + attempt_id, did, pds_endpoint, handle, email, invite_code, 549 + last_status, last_error, 550 + archived_at, tombstone_op_cid, notes 551 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 552 + ON CONFLICT (attempt_id) DO NOTHING` 553 + ) 554 + .bind( 555 + row.attempt_id, 556 + row.did, 557 + row.pds_endpoint, 558 + row.handle, 559 + row.email, 560 + row.invite_code ?? null, 561 + row.status, 562 + row.last_error ?? null, 563 + now, 564 + opts.tombstoneOpCid ?? null, 565 + opts.notes ?? null 566 + ); 567 + const del = this.db 568 + .prepare(`DELETE FROM provision_attempts WHERE attempt_id = ?`) 569 + .bind(attemptId); 570 + await this.db.batch([insert, del]); 571 + } 572 + 573 + // ---- Community sessions cache ----------------------------------------- 574 + 575 + async getSession(communityDid: string): Promise<{ 576 + accessJwt: string; 577 + refreshJwt: string; 578 + accessExp: number; 579 + } | null> { 580 + const r = await this.db 581 + .prepare( 582 + `SELECT access_jwt, refresh_jwt, access_exp FROM community_sessions WHERE community_did = ?` 583 + ) 584 + .bind(communityDid) 585 + .first<{ access_jwt: string; refresh_jwt: string; access_exp: number }>(); 586 + if (!r) return null; 587 + return { 588 + accessJwt: r.access_jwt, 589 + refreshJwt: r.refresh_jwt, 590 + accessExp: Number(r.access_exp), 591 + }; 592 + } 593 + 594 + async upsertSession( 595 + communityDid: string, 596 + s: { accessJwt: string; refreshJwt: string; accessExp: number } 597 + ): Promise<void> { 598 + const now = Date.now(); 599 + await this.db 600 + .prepare( 601 + `INSERT INTO community_sessions (community_did, access_jwt, refresh_jwt, access_exp, updated_at) 602 + VALUES (?, ?, ?, ?, ?) 603 + ON CONFLICT (community_did) DO UPDATE SET 604 + access_jwt = excluded.access_jwt, 605 + refresh_jwt = excluded.refresh_jwt, 606 + access_exp = excluded.access_exp, 607 + updated_at = excluded.updated_at` 608 + ) 609 + .bind(communityDid, s.accessJwt, s.refreshJwt, s.accessExp, now) 610 + .run(); 611 + } 612 + 613 + async clearSession(communityDid: string): Promise<void> { 614 + await this.db 615 + .prepare(`DELETE FROM community_sessions WHERE community_did = ?`) 616 + .bind(communityDid) 617 + .run(); 618 + } 619 + } 620 + 621 + function rowToProvisionAttempt(r: Record<string, any>): ProvisionAttemptRow { 622 + return { 623 + attemptId: r.attempt_id, 624 + did: r.did, 625 + status: r.status as ProvisionStatus, 626 + pdsEndpoint: r.pds_endpoint, 627 + handle: r.handle, 628 + email: r.email, 629 + inviteCode: r.invite_code ?? null, 630 + encryptedSigningKey: r.encrypted_signing_key ?? null, 631 + encryptedRotationKey: r.encrypted_rotation_key ?? null, 632 + encryptedPassword: r.encrypted_password ?? null, 633 + genesisSubmittedAt: r.genesis_submitted_at == null ? null : Number(r.genesis_submitted_at), 634 + accountCreatedAt: r.account_created_at == null ? null : Number(r.account_created_at), 635 + didDocUpdatedAt: r.did_doc_updated_at == null ? null : Number(r.did_doc_updated_at), 636 + activatedAt: r.activated_at == null ? null : Number(r.activated_at), 637 + lastError: r.last_error ?? null, 638 + createdAt: Number(r.created_at), 639 + updatedAt: Number(r.updated_at), 640 + }; 387 641 } 388 642 389 643 function mapCommunityInviteRow(row: any): CommunityInviteRow {
+49
packages/contrail-community/src/cli/index.ts
··· 1 + #!/usr/bin/env node 2 + /** 3 + * contrail-community — CLI entrypoint. 4 + * 5 + * Exposes `reap` (tombstone stuck provision_attempts in PLC). This lives here, 6 + * as contrail-community's own bin, rather than only as a dynamically-imported 7 + * subcommand of `contrail`: the PR #30 package split removed contrail's 8 + * dependency edge into community code, so under pnpm's isolated node_modules 9 + * `contrail` cannot resolve `@atmo-dev/contrail-community` and the dynamic 10 + * import there silently no-ops. Shipping the bin here guarantees `reap` is 11 + * always reachable wherever contrail-community is installed. 12 + * 13 + * Config loading is reconstructed from contrail's public `./cli-config` 14 + * helpers so contrail-community needs no copy of contrail's CLI plumbing and 15 + * no new dependency edge (it already depends on `@atmo-dev/contrail`). 16 + */ 17 + import { cac } from "cac"; 18 + import { 19 + findConfigFile, 20 + loadConfig, 21 + CONFIG_CANDIDATES_MESSAGE, 22 + } from "@atmo-dev/contrail/cli-config"; 23 + import { registerReap } from "./reap.js"; 24 + 25 + const cli = cac("contrail-community"); 26 + 27 + registerReap(cli, { 28 + resolveAndLoadConfig: async (opts) => { 29 + const root = opts.root ?? process.cwd(); 30 + const path = findConfigFile(root, opts.config); 31 + if (!path) { 32 + console.error( 33 + "Could not find a Contrail config. Pass --config <path> or place one at\n" + 34 + ` ${CONFIG_CANDIDATES_MESSAGE}` 35 + ); 36 + process.exit(1); 37 + } 38 + return loadConfig(path); 39 + }, 40 + }); 41 + 42 + cli.help(); 43 + 44 + try { 45 + cli.parse(); 46 + } catch (err) { 47 + console.error(err); 48 + process.exit(1); 49 + }
+397
packages/contrail-community/src/cli/reap.ts
··· 1 + import type { CAC } from "cac"; 2 + import { createInterface } from "node:readline/promises"; 3 + import { stdin as input, stdout as output } from "node:process"; 4 + import type { Database } from "@atmo-dev/contrail-base"; 5 + import { CommunityAdapter } from "../adapter.js"; 6 + import { CredentialCipher } from "../credentials.js"; 7 + import { 8 + buildTombstoneOp, 9 + cidForOp, 10 + getLastOpCid, 11 + signTombstoneOp, 12 + submitTombstoneOp, 13 + } from "../plc.js"; 14 + 15 + interface ReapOpts { 16 + config?: string; 17 + root?: string; 18 + remote?: boolean; 19 + binding: string; 20 + attemptId?: string; 21 + allStuck?: boolean; 22 + dryRun?: boolean; 23 + yes?: boolean; 24 + olderThan?: number; 25 + db?: string; 26 + } 27 + 28 + /** Minimal logger interface so `runReap` can be invoked from tests with 29 + * silent stubs and from the CLI shim with `console`. */ 30 + export interface ReapLogger { 31 + log(...args: unknown[]): void; 32 + error(...args: unknown[]): void; 33 + } 34 + 35 + /** Default idle window for `--all-stuck`: a row must have gone untouched this 36 + * long before bulk reap will consider tombstoning it. Provisioning is a 37 + * fast (seconds) state machine, so 30 minutes is a wide safety margin that 38 + * still reaps genuinely-abandoned rows. Operators can override via 39 + * `--older-than <minutes>`. */ 40 + export const DEFAULT_REAP_AGE_FLOOR_MS = 30 * 60 * 1000; 41 + 42 + /** Where reap should read its provision_attempts rows from. Postgres (the 43 + * decoupled external index) is selected by an explicit `--db <url>` or, as a 44 + * convenience matching the apps/postgres deployment, a `DATABASE_URL` in the 45 + * environment. Otherwise reap uses the Cloudflare D1 binding via wrangler. 46 + * An explicit `--db` always wins over the env var. */ 47 + export type ReapDbSource = 48 + | { kind: "postgres"; url: string } 49 + | { kind: "d1" }; 50 + 51 + export function chooseReapDbSource(opts: { 52 + db?: string; 53 + databaseUrl?: string; 54 + }): ReapDbSource { 55 + const url = opts.db ?? opts.databaseUrl; 56 + return url ? { kind: "postgres", url } : { kind: "d1" }; 57 + } 58 + 59 + export interface RunReapOptions { 60 + adapter: CommunityAdapter; 61 + cipher: CredentialCipher; 62 + plcDirectory: string; 63 + fetch?: typeof fetch; 64 + logger: ReapLogger; 65 + /** Skip confirmation prompts. The CLI passes this when the user supplied 66 + * --yes; tests always pass true since they don't have a TTY. */ 67 + yes: boolean; 68 + attemptId?: string; 69 + allStuck?: boolean; 70 + dryRun?: boolean; 71 + /** Idle-age floor for `--all-stuck` (milliseconds). Rows updated more 72 + * recently than this are left alone so a bulk reap can't tombstone an 73 + * in-flight provision. Ignored for the single `--attempt-id` path, which 74 + * is an explicit operator action on a known row. Defaults to 75 + * DEFAULT_REAP_AGE_FLOOR_MS. */ 76 + olderThanMs?: number; 77 + } 78 + 79 + export interface RunReapResult { 80 + ok: boolean; 81 + /** Validation/precondition error, present when ok=false. */ 82 + error?: string; 83 + /** Number of rows successfully reaped (PLC tombstone submitted + archived). */ 84 + reaped: number; 85 + /** Number of rows skipped because of --dry-run. */ 86 + dryRunSkipped: number; 87 + /** Number of rows that errored out during reap (activated row, PLC error, etc.). */ 88 + errors: number; 89 + } 90 + 91 + /** Yes/no prompt that respects --yes (auto-accept) and falls back to the 92 + * provided default in non-TTY environments — the user isn't there to answer. */ 93 + async function promptYesNo( 94 + question: string, 95 + defaultYes: boolean, 96 + autoYes: boolean 97 + ): Promise<boolean> { 98 + if (autoYes) return true; 99 + if (!input.isTTY) return false; 100 + const rl = createInterface({ input, output }); 101 + try { 102 + const hint = defaultYes ? "[Y/n]" : "[y/N]"; 103 + const ans = (await rl.question(`${question} ${hint} `)).trim().toLowerCase(); 104 + if (ans === "") return defaultYes; 105 + return ans === "y" || ans === "yes"; 106 + } finally { 107 + rl.close(); 108 + } 109 + } 110 + 111 + /** Core reap logic, decoupled from the CAC plumbing so tests can drive it 112 + * without going through `getPlatformProxy()`. */ 113 + export async function runReap(opts: RunReapOptions): Promise<RunReapResult> { 114 + const result: RunReapResult = { 115 + ok: true, 116 + reaped: 0, 117 + dryRunSkipped: 0, 118 + errors: 0, 119 + }; 120 + 121 + // Safety default: an operator who omits the flag must NOT trigger 122 + // irrevocable PLC tombstones. Real action requires an explicit 123 + // `dryRun: false` (CLI: `--no-dry-run`). 124 + const dryRun = opts.dryRun ?? true; 125 + 126 + const hasAttemptId = !!opts.attemptId; 127 + const hasAllStuck = !!opts.allStuck; 128 + if (!hasAttemptId && !hasAllStuck) { 129 + return { 130 + ...result, 131 + ok: false, 132 + error: "Specify exactly one of --attempt-id <uuid> or --all-stuck.", 133 + }; 134 + } 135 + if (hasAttemptId && hasAllStuck) { 136 + return { 137 + ...result, 138 + ok: false, 139 + error: 140 + "--attempt-id and --all-stuck are mutually exclusive; pass exactly one.", 141 + }; 142 + } 143 + 144 + const olderThanMs = opts.olderThanMs ?? DEFAULT_REAP_AGE_FLOOR_MS; 145 + const rows = hasAttemptId 146 + ? await loadSingle(opts.adapter, opts.attemptId!) 147 + : await opts.adapter.listStuckAttempts(olderThanMs); 148 + 149 + if (rows.length === 0) { 150 + opts.logger.log("No stuck provision_attempts to reap."); 151 + return result; 152 + } 153 + 154 + for (const row of rows) { 155 + if (row.status === "activated") { 156 + opts.logger.error( 157 + `Refusing to reap ${row.attemptId}: status is "activated"; reap will not tombstone live communities.` 158 + ); 159 + result.errors += 1; 160 + continue; 161 + } 162 + 163 + opts.logger.log( 164 + `Reaping ${row.attemptId} (did=${row.did}, status=${row.status})` 165 + ); 166 + 167 + if (!row.encryptedRotationKey) { 168 + opts.logger.error( 169 + ` no encrypted_rotation_key on ${row.attemptId}; cannot sign tombstone` 170 + ); 171 + result.errors += 1; 172 + continue; 173 + } 174 + 175 + let rotationJwk: JsonWebKey; 176 + try { 177 + const decoded = await opts.cipher.decryptString(row.encryptedRotationKey); 178 + rotationJwk = JSON.parse(decoded) as JsonWebKey; 179 + } catch (err) { 180 + opts.logger.error( 181 + ` failed to decrypt rotation key for ${row.attemptId}: ${err instanceof Error ? err.message : err}` 182 + ); 183 + result.errors += 1; 184 + continue; 185 + } 186 + 187 + let prev: string; 188 + try { 189 + prev = await getLastOpCid(opts.plcDirectory, row.did, { 190 + fetch: opts.fetch, 191 + }); 192 + } catch (err) { 193 + opts.logger.error( 194 + ` failed to fetch last PLC op cid for ${row.did}: ${err instanceof Error ? err.message : err}` 195 + ); 196 + result.errors += 1; 197 + continue; 198 + } 199 + 200 + const unsigned = buildTombstoneOp(prev); 201 + const signed = await signTombstoneOp(unsigned, rotationJwk); 202 + const opCid = await cidForOp(signed); 203 + 204 + if (dryRun) { 205 + opts.logger.log( 206 + ` [dry-run] would submit tombstone (op cid=${opCid}, prev=${prev})` 207 + ); 208 + result.dryRunSkipped += 1; 209 + continue; 210 + } 211 + 212 + if (!opts.yes) { 213 + const confirmed = await promptYesNo( 214 + `submit PLC tombstone for ${row.did}?`, 215 + false, 216 + false 217 + ); 218 + if (!confirmed) { 219 + opts.logger.log(` skipped ${row.attemptId} (not confirmed)`); 220 + continue; 221 + } 222 + } 223 + 224 + try { 225 + await submitTombstoneOp(opts.plcDirectory, row.did, signed, { 226 + fetch: opts.fetch, 227 + }); 228 + } catch (err) { 229 + opts.logger.error( 230 + ` PLC tombstone submit failed for ${row.did}: ${err instanceof Error ? err.message : err}` 231 + ); 232 + result.errors += 1; 233 + continue; 234 + } 235 + 236 + try { 237 + await opts.adapter.archiveStuckAttempt(row.attemptId, { 238 + tombstoneOpCid: opCid, 239 + }); 240 + } catch (err) { 241 + opts.logger.error( 242 + ` archive failed for ${row.attemptId} after tombstone submit: ${err instanceof Error ? err.message : err}` 243 + ); 244 + result.errors += 1; 245 + continue; 246 + } 247 + 248 + result.reaped += 1; 249 + } 250 + 251 + opts.logger.log( 252 + `Reaped ${result.reaped} attempts (${result.dryRunSkipped} dry-run skipped, ${result.errors} errors).` 253 + ); 254 + return result; 255 + } 256 + 257 + async function loadSingle( 258 + adapter: CommunityAdapter, 259 + attemptId: string 260 + ): Promise<Awaited<ReturnType<CommunityAdapter["listStuckAttempts"]>>> { 261 + const row = await adapter.getProvisionAttempt(attemptId); 262 + return row ? [row] : []; 263 + } 264 + 265 + /** Dependency-injection shape: host CLI (contrail) loads its own config and 266 + * passes the result, so contrail-community doesn't depend on contrail's 267 + * cli-config infrastructure. */ 268 + export interface ReapHostDeps { 269 + /** Returns a contrail config; host is expected to exit(1) on its own if 270 + * no config is found. The shape is opaque to reap. */ 271 + resolveAndLoadConfig: (opts: { config?: string; root?: string }) => Promise<{ 272 + community?: { 273 + plcDirectory?: string; 274 + masterKey: Uint8Array; 275 + }; 276 + }>; 277 + } 278 + 279 + export function registerReap(cli: CAC, host: ReapHostDeps): void { 280 + cli 281 + .command( 282 + "reap", 283 + "Tombstone stuck provision_attempts rows in PLC and archive them" 284 + ) 285 + .option("--config <path>", "Path to Contrail config file") 286 + .option("--root <path>", "Project root for auto-detection (default: CWD)") 287 + .option("--remote", "Use production D1 bindings") 288 + .option("--binding <name>", "D1 binding name in wrangler.jsonc", { 289 + default: "DB", 290 + }) 291 + .option( 292 + "--db <url>", 293 + "Postgres connection string for the decoupled external index. When set (or DATABASE_URL is in the env), reap runs against Postgres instead of the D1 binding." 294 + ) 295 + .option("--attempt-id <uuid>", "Reap a single attempt by ID") 296 + .option( 297 + "--all-stuck", 298 + "Reap every provision_attempts row that did not reach status=activated and has been idle past --older-than" 299 + ) 300 + .option( 301 + "--older-than <minutes>", 302 + "With --all-stuck, only reap rows idle at least this many minutes (guards in-flight provisions)", 303 + { default: DEFAULT_REAP_AGE_FLOOR_MS / 60_000 } 304 + ) 305 + .option( 306 + "--dry-run", 307 + "Print what would be tombstoned without submitting to PLC (DEFAULT)" 308 + ) 309 + .option( 310 + "--no-dry-run", 311 + "Actually submit tombstones to PLC. Irrevocable. Required for real runs." 312 + ) 313 + .option("--yes", "Auto-confirm prompts") 314 + .action(async (options: ReapOpts) => { 315 + // Flag validation (exactly one of --attempt-id / --all-stuck) lives in 316 + // runReap, the testable core; the `!result.ok` branch below surfaces its 317 + // error and exits non-zero, so there's no second copy of it here. 318 + const config = await host.resolveAndLoadConfig(options); 319 + const community = config.community; 320 + if (!community) { 321 + console.error( 322 + "config.community is not set; reap requires a configured community module." 323 + ); 324 + process.exit(1); 325 + } 326 + if (!community.plcDirectory) { 327 + console.error( 328 + "config.community.plcDirectory is required for `reap`." 329 + ); 330 + process.exit(1); 331 + } 332 + 333 + // Run reap against the acquired db, then exit non-zero on any failure. 334 + const reapAndExit = async (db: Database): Promise<void> => { 335 + const result = await runReap({ 336 + adapter: new CommunityAdapter(db), 337 + cipher: new CredentialCipher(community.masterKey), 338 + plcDirectory: community.plcDirectory!, 339 + logger: console, 340 + yes: !!options.yes, 341 + attemptId: options.attemptId, 342 + allStuck: options.allStuck, 343 + dryRun: options.dryRun, 344 + olderThanMs: 345 + options.olderThan !== undefined 346 + ? Number(options.olderThan) * 60_000 347 + : undefined, 348 + }); 349 + if (!result.ok) { 350 + console.error(result.error); 351 + process.exit(1); 352 + } 353 + if (result.errors > 0) { 354 + process.exit(1); 355 + } 356 + }; 357 + 358 + const source = chooseReapDbSource({ 359 + db: options.db, 360 + databaseUrl: process.env.DATABASE_URL, 361 + }); 362 + 363 + if (source.kind === "postgres") { 364 + // Decoupled external index. Build a pool, run, then close it. 365 + const pg = (await import("pg")).default; 366 + const { createPostgresDatabase } = await import( 367 + "@atmo-dev/contrail/postgres" as string 368 + ); 369 + console.log("reap: using Postgres index"); 370 + const pool = new pg.Pool({ connectionString: source.url }); 371 + try { 372 + await reapAndExit(createPostgresDatabase(pool)); 373 + } finally { 374 + await pool.end(); 375 + } 376 + return; 377 + } 378 + 379 + // D1 via wrangler. 380 + const { getPlatformProxy } = await import("wrangler"); 381 + const { env, dispose } = await getPlatformProxy(); 382 + try { 383 + const db = (env as Record<string, unknown>)[options.binding] as 384 + | Database 385 + | undefined; 386 + if (!db) { 387 + console.error( 388 + `D1 binding "${options.binding}" not found in wrangler env.` 389 + ); 390 + process.exit(1); 391 + } 392 + await reapAndExit(db); 393 + } finally { 394 + await dispose(); 395 + } 396 + }); 397 + }
+48 -2
packages/contrail-community/src/index.ts
··· 24 24 export { createCommunityInviteHandler } from "./invite-handler"; 25 25 export { createCommunityWhoamiExtension } from "./whoami"; 26 26 export { initCommunitySchema, buildCommunitySchema } from "./schema"; 27 - export { resolveIdentity, createPdsSession } from "./pds"; 27 + export { 28 + resolveIdentity, 29 + createPdsSession, 30 + pdsCreateAccount, 31 + pdsGetRecommendedDidCredentials, 32 + pdsActivateAccount, 33 + pdsCreateAppPassword, 34 + } from "./pds"; 35 + export type { 36 + PdsCreateAccountBody, 37 + PdsCreateAccountResult, 38 + RecommendedDidCredentials, 39 + } from "./pds"; 28 40 export { 29 41 generateKeyPair, 30 42 buildGenesisOp, ··· 33 45 submitGenesisOp, 34 46 encodeDagCbor, 35 47 jwkToDidKey, 48 + buildUpdateOp, 49 + signUpdateOp, 50 + cidForOp, 51 + getLastOpCid, 52 + buildTombstoneOp, 53 + signTombstoneOp, 54 + submitTombstoneOp, 36 55 } from "./plc"; 37 - export type { KeyPair, GenesisOpInput, UnsignedGenesisOp, SignedGenesisOp } from "./plc"; 56 + export type { 57 + KeyPair, 58 + GenesisOpInput, 59 + UnsignedGenesisOp, 60 + SignedGenesisOp, 61 + UpdateOpInput, 62 + UnsignedUpdateOp, 63 + SignedUpdateOp, 64 + UnsignedTombstoneOp, 65 + SignedTombstoneOp, 66 + } from "./plc"; 38 67 39 68 // The headline export — wire community into a contrail app via: 40 69 // const community = createCommunityIntegration({ db, config }); 41 70 // const app = createApp(db, config, { community }); 42 71 export { createCommunityIntegration } from "./integration"; 43 72 export type { CommunityIntegrationOptions } from "./integration"; 73 + 74 + export { ProvisionOrchestrator } from "./provision"; 75 + export type { 76 + PdsClient, 77 + PlcClient, 78 + ProvisionInput, 79 + ProvisionResult, 80 + ProvisionOrchestratorDeps, 81 + } from "./provision"; 82 + 83 + export { registerReap, runReap } from "./cli/reap"; 84 + export type { 85 + ReapLogger, 86 + ReapHostDeps, 87 + RunReapOptions, 88 + RunReapResult, 89 + } from "./cli/reap";
+207
packages/contrail-community/src/pds.ts
··· 8 8 type DidDocumentResolver, 9 9 } from "@atcute/identity-resolver"; 10 10 11 + /** Canonicalize a PDS endpoint URL so allowlist comparisons aren't bypassed by 12 + * trailing slash, default port, scheme case, or IDN encoding differences. 13 + * Returns the URL's `origin` — scheme + host + (non-default) port — which 14 + * collapses every variant of a single PDS to one string. Throws if the input 15 + * is not a parseable URL; callers in request paths should catch and respond 16 + * with a 400. */ 17 + export function normalizePdsEndpoint(url: string): string { 18 + return new URL(url).origin; 19 + } 20 + 11 21 export interface ResolvedIdentity { 12 22 did: string; 13 23 handle: string | null; ··· 102 112 throw new Error(`could not resolve handle ${handle}`); 103 113 } 104 114 115 + /** Decode the `exp` claim from a JWT's payload (in seconds since epoch). Used 116 + * by the session cache to decide if a cached access token is still usable. 117 + * Returns 0 if the claim is missing or the token is malformed — callers should 118 + * treat 0 as "expired, refresh now". Avoids `Buffer` so it works in Workers. */ 119 + export function decodeJwtExp(jwt: string): number { 120 + const parts = jwt.split("."); 121 + if (parts.length < 2) return 0; 122 + const payload = parts[1]!; 123 + const padded = payload.replace(/-/g, "+").replace(/_/g, "/"); 124 + const padding = "=".repeat((4 - (padded.length % 4)) % 4); 125 + try { 126 + const json = atob(padded + padding); 127 + const claims = JSON.parse(json) as { exp?: number }; 128 + return Number(claims.exp ?? 0); 129 + } catch { 130 + return 0; 131 + } 132 + } 133 + 134 + /** POST com.atproto.server.refreshSession with the refresh JWT in Authorization. 135 + * Returns null on any non-200 — callers fall back to `createPdsSession`. */ 136 + export async function tryRefreshSession(input: { 137 + pdsUrl: string; 138 + refreshJwt: string; 139 + fetch?: typeof fetch; 140 + }): Promise<{ accessJwt: string; refreshJwt: string; accessExp: number } | null> { 141 + const f = input.fetch ?? fetch; 142 + const url = `${input.pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.server.refreshSession`; 143 + const res = await f(url, { 144 + method: "POST", 145 + headers: { authorization: `Bearer ${input.refreshJwt}` }, 146 + }); 147 + if (res.status !== 200) return null; 148 + const body = (await res.json().catch(() => null)) as 149 + | { accessJwt?: string; refreshJwt?: string } 150 + | null; 151 + if (!body?.accessJwt || !body.refreshJwt) return null; 152 + return { 153 + accessJwt: body.accessJwt, 154 + refreshJwt: body.refreshJwt, 155 + accessExp: decodeJwtExp(body.accessJwt), 156 + }; 157 + } 158 + 105 159 /** Create an atproto session on the given PDS using identifier + app password. 106 160 * Returns the access/refresh JWTs and the session's DID. */ 107 161 export async function createPdsSession( ··· 135 189 did: body.did, 136 190 }; 137 191 } 192 + 193 + export interface PdsDescribeServerResult { 194 + did: string; 195 + /** Other fields (availableUserDomains, contact, links, inviteCodeRequired) 196 + * are returned by the PDS but unused by Contrail's provisioning flow. */ 197 + [key: string]: unknown; 198 + } 199 + 200 + /** Calls `com.atproto.server.describeServer` on the target PDS to discover 201 + * the DID it publishes for itself. Used as `aud` in the service-auth JWT for 202 + * `createAccount`; the PDS verifies the audience matches its own DID and 203 + * rejects with `BadJwtAudience` otherwise. Resolving dynamically (instead of 204 + * hardcoding to a config value) is what allows a single Contrail instance 205 + * to mint communities on multiple PDSes. */ 206 + export async function pdsDescribeServer( 207 + pdsEndpoint: string, 208 + opts: { fetch?: typeof fetch } = {} 209 + ): Promise<PdsDescribeServerResult> { 210 + const f = opts.fetch ?? fetch; 211 + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.describeServer`; 212 + const res = await f(url); 213 + if (!res.ok) { 214 + const text = await res.text().catch(() => ""); 215 + throw new Error(`describeServer failed (${res.status}): ${text}`); 216 + } 217 + const body = (await res.json()) as PdsDescribeServerResult; 218 + if (!body?.did || typeof body.did !== "string") { 219 + throw new Error("describeServer response missing required `did` field"); 220 + } 221 + return body; 222 + } 223 + 224 + export interface PdsCreateAccountBody { 225 + handle: string; 226 + did: string; 227 + email: string; 228 + password: string; 229 + inviteCode?: string; 230 + } 231 + 232 + export interface PdsCreateAccountResult { 233 + did: string; 234 + handle: string; 235 + accessJwt: string; 236 + refreshJwt: string; 237 + } 238 + 239 + /** Calls `com.atproto.server.createAccount` on the target PDS using a 240 + * service-auth JWT (signed by the iss DID's verificationMethod). The PDS 241 + * verifies `requester === did` against the published DID-doc, validates the 242 + * invite, and creates the account in deactivated state. */ 243 + export async function pdsCreateAccount( 244 + pdsEndpoint: string, 245 + serviceAuthJwt: string, 246 + body: PdsCreateAccountBody, 247 + opts: { fetch?: typeof fetch } = {} 248 + ): Promise<PdsCreateAccountResult> { 249 + const f = opts.fetch ?? fetch; 250 + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAccount`; 251 + const res = await f(url, { 252 + method: "POST", 253 + headers: { 254 + "content-type": "application/json", 255 + authorization: `Bearer ${serviceAuthJwt}`, 256 + }, 257 + body: JSON.stringify(body), 258 + }); 259 + if (!res.ok) { 260 + const text = await res.text().catch(() => ""); 261 + throw new Error(`createAccount failed (${res.status}): ${text}`); 262 + } 263 + return (await res.json()) as PdsCreateAccountResult; 264 + } 265 + 266 + export interface RecommendedDidCredentials { 267 + rotationKeys: string[]; 268 + verificationMethods: { atproto: string }; 269 + alsoKnownAs: string[]; 270 + services: Record<string, { type: string; endpoint: string }>; 271 + } 272 + 273 + /** Calls `com.atproto.identity.getRecommendedDidCredentials` on the target PDS 274 + * using the session's accessJwt (returned by `pdsCreateAccount`, NOT a 275 + * service-auth JWT). Returns the DID-doc fields the PDS would self-publish. */ 276 + export async function pdsGetRecommendedDidCredentials( 277 + pdsEndpoint: string, 278 + accessJwt: string, 279 + opts: { fetch?: typeof fetch } = {} 280 + ): Promise<RecommendedDidCredentials> { 281 + const f = opts.fetch ?? fetch; 282 + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.identity.getRecommendedDidCredentials`; 283 + const res = await f(url, { 284 + headers: { authorization: `Bearer ${accessJwt}` }, 285 + }); 286 + if (!res.ok) { 287 + const text = await res.text().catch(() => ""); 288 + throw new Error(`getRecommendedDidCredentials failed (${res.status}): ${text}`); 289 + } 290 + return (await res.json()) as RecommendedDidCredentials; 291 + } 292 + 293 + /** Calls `com.atproto.server.activateAccount` on the target PDS using the 294 + * session's accessJwt (returned by `pdsCreateAccount`, NOT a service-auth 295 + * JWT). Resolves on success; throws otherwise. */ 296 + export async function pdsActivateAccount( 297 + pdsEndpoint: string, 298 + accessJwt: string, 299 + opts: { fetch?: typeof fetch } = {} 300 + ): Promise<void> { 301 + const f = opts.fetch ?? fetch; 302 + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.activateAccount`; 303 + const res = await f(url, { 304 + method: "POST", 305 + headers: { authorization: `Bearer ${accessJwt}` }, 306 + }); 307 + if (!res.ok) { 308 + const text = await res.text().catch(() => ""); 309 + throw new Error(`activateAccount failed (${res.status}): ${text}`); 310 + } 311 + } 312 + 313 + export interface PdsCreateAppPasswordResult { 314 + name: string; 315 + password: string; 316 + createdAt: string; 317 + } 318 + 319 + /** Calls `com.atproto.server.createAppPassword` on the target PDS using the 320 + * session's accessJwt. Returns the freshly minted app password. We always 321 + * send `privileged: false` so the credential can be revoked without 322 + * affecting the root account. */ 323 + export async function pdsCreateAppPassword( 324 + pdsEndpoint: string, 325 + accessJwt: string, 326 + name: string, 327 + opts: { fetch?: typeof fetch } = {} 328 + ): Promise<PdsCreateAppPasswordResult> { 329 + const f = opts.fetch ?? fetch; 330 + const url = `${pdsEndpoint.replace(/\/$/, "")}/xrpc/com.atproto.server.createAppPassword`; 331 + const res = await f(url, { 332 + method: "POST", 333 + headers: { 334 + "content-type": "application/json", 335 + authorization: `Bearer ${accessJwt}`, 336 + }, 337 + body: JSON.stringify({ name, privileged: false }), 338 + }); 339 + if (!res.ok) { 340 + const text = await res.text().catch(() => ""); 341 + throw new Error(`createAppPassword failed (${res.status}): ${text}`); 342 + } 343 + return (await res.json()) as PdsCreateAppPasswordResult; 344 + }
+150 -2
packages/contrail-community/src/plc.ts
··· 54 54 ); 55 55 const P256_N_HALF = P256_N >> 1n; 56 56 57 - async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise<Uint8Array> { 57 + export async function signBytes(privateJwk: JsonWebKey, bytes: Uint8Array): Promise<Uint8Array> { 58 58 const key = await crypto.subtle.importKey( 59 59 "jwk", 60 60 privateJwk, ··· 155 155 return "did:plc:" + base32Lower(hash).slice(0, 24); 156 156 } 157 157 158 + // ============================================================================ 159 + // Update op construction (subsequent ops chain via `prev`) 160 + // ============================================================================ 161 + 162 + export interface UpdateOpInput { 163 + prev: string; // CID string of the previous op in the chain 164 + rotationKeys: string[]; 165 + verificationMethodAtproto: string; 166 + alsoKnownAs: string[]; 167 + services: Record<string, { type: string; endpoint: string }>; 168 + } 169 + 170 + export interface UnsignedUpdateOp { 171 + type: "plc_operation"; 172 + prev: string; 173 + rotationKeys: string[]; 174 + verificationMethods: { atproto: string }; 175 + alsoKnownAs: string[]; 176 + services: Record<string, { type: string; endpoint: string }>; 177 + } 178 + 179 + export interface SignedUpdateOp extends UnsignedUpdateOp { 180 + sig: string; // base64url, unpadded 181 + } 182 + 183 + export function buildUpdateOp(input: UpdateOpInput): UnsignedUpdateOp { 184 + return { 185 + type: "plc_operation", 186 + prev: input.prev, 187 + rotationKeys: input.rotationKeys, 188 + verificationMethods: { atproto: input.verificationMethodAtproto }, 189 + alsoKnownAs: input.alsoKnownAs, 190 + services: input.services, 191 + }; 192 + } 193 + 194 + /** Sign an update op with a rotation key's private JWK. */ 195 + export async function signUpdateOp( 196 + unsigned: UnsignedUpdateOp, 197 + signerPrivateJwk: JsonWebKey 198 + ): Promise<SignedUpdateOp> { 199 + const encoded = encodeDagCbor(unsigned); 200 + const sigBytes = await signBytes(signerPrivateJwk, encoded); 201 + return { ...unsigned, sig: bytesToB64url(sigBytes) }; 202 + } 203 + 204 + /** Compute the CIDv1 for a signed op (genesis, update, or tombstone). 205 + * CIDv1 (0x01) + dag-cbor codec (0x71) + sha2-256 (0x12 0x20) + hash, 206 + * base32-lower with multibase "b" prefix. 207 + * 208 + * The tombstone shape ({type, prev, sig}) is a strict subset of update — 209 + * the DAG-CBOR encoder accepts all three uniformly, and PLC computes its 210 + * stored CID from the same canonical encoding. */ 211 + export async function cidForOp( 212 + signedOp: SignedGenesisOp | SignedUpdateOp | SignedTombstoneOp 213 + ): Promise<string> { 214 + const encoded = encodeDagCbor(signedOp); 215 + const hash = new Uint8Array( 216 + await crypto.subtle.digest("SHA-256", encoded as BufferSource) 217 + ); 218 + const cidBytes = new Uint8Array(4 + hash.length); 219 + cidBytes[0] = 0x01; 220 + cidBytes[1] = 0x71; 221 + cidBytes[2] = 0x12; 222 + cidBytes[3] = 0x20; 223 + cidBytes.set(hash, 4); 224 + return "b" + base32Lower(cidBytes); 225 + } 226 + 227 + /** Fetch the CID of the most recent op in a DID's PLC log. Used during 228 + * provision recovery to obtain the genesis op's CID at resume time (we can't 229 + * recompute it locally because ECDSA signatures are randomized) and by the 230 + * reap CLI to chain a tombstone onto the latest op. 231 + * 232 + * PLC's `/log/last` endpoint returns the bare signed op object — no envelope, 233 + * no `cid` field. We compute the CID locally with the same DAG-CBOR encoder 234 + * cidForOp uses; PLC computes its stored CID identically, so the result 235 + * matches the entry's CID in `/log/audit`. */ 236 + export async function getLastOpCid( 237 + plcDirectory: string, 238 + did: string, 239 + opts: { fetch?: typeof fetch } = {} 240 + ): Promise<string> { 241 + const f = opts.fetch ?? fetch; 242 + const url = `${plcDirectory.replace(/\/$/, "")}/${did}/log/last`; 243 + const res = await f(url); 244 + if (!res.ok) { 245 + const text = await res.text().catch(() => ""); 246 + throw new Error(`PLC log/last failed (${res.status}): ${text}`); 247 + } 248 + const op = (await res.json()) as 249 + | SignedGenesisOp 250 + | SignedUpdateOp 251 + | SignedTombstoneOp; 252 + return cidForOp(op); 253 + } 254 + 255 + // ============================================================================ 256 + // Tombstone op construction 257 + // A tombstone op marks a DID's PLC log as terminated — no further ops will be 258 + // accepted. Used by `contrail reap` to clean up DIDs whose PDS account is 259 + // permanently unrecoverable. 260 + // ============================================================================ 261 + 262 + export interface UnsignedTombstoneOp { 263 + type: "plc_tombstone"; 264 + prev: string; 265 + } 266 + 267 + export interface SignedTombstoneOp extends UnsignedTombstoneOp { 268 + sig: string; // base64url, unpadded 269 + } 270 + 271 + export function buildTombstoneOp(prev: string): UnsignedTombstoneOp { 272 + return { type: "plc_tombstone", prev }; 273 + } 274 + 275 + /** Sign a tombstone op with a rotation key's private JWK. */ 276 + export async function signTombstoneOp( 277 + op: UnsignedTombstoneOp, 278 + signerPrivateJwk: JsonWebKey 279 + ): Promise<SignedTombstoneOp> { 280 + const encoded = encodeDagCbor(op); 281 + const sigBytes = await signBytes(signerPrivateJwk, encoded); 282 + return { ...op, sig: bytesToB64url(sigBytes) }; 283 + } 284 + 285 + /** Submit a signed tombstone op to the PLC directory. PLC accepts genesis, 286 + * update, and tombstone ops at the same `${plcDirectory}/${did}` endpoint. */ 287 + export async function submitTombstoneOp( 288 + plcDirectory: string, 289 + did: string, 290 + signedOp: SignedTombstoneOp, 291 + opts: { fetch?: typeof fetch } = {} 292 + ): Promise<void> { 293 + const f = opts.fetch ?? fetch; 294 + const url = `${plcDirectory.replace(/\/$/, "")}/${did}`; 295 + const res = await f(url, { 296 + method: "POST", 297 + headers: { "content-type": "application/json" }, 298 + body: JSON.stringify(signedOp), 299 + }); 300 + if (!res.ok) { 301 + const text = await res.text().catch(() => ""); 302 + throw new Error(`PLC tombstone submit failed (${res.status}): ${text}`); 303 + } 304 + } 305 + 158 306 /** Submit a signed genesis op to the PLC directory. */ 159 307 export async function submitGenesisOp( 160 308 plcDirectory: string, ··· 334 482 return out; 335 483 } 336 484 337 - function bytesToB64url(bytes: Uint8Array): string { 485 + export function bytesToB64url(bytes: Uint8Array): string { 338 486 let bin = ""; 339 487 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!); 340 488 return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+403
packages/contrail-community/src/provision.ts
··· 1 + /** Provision orchestrator: runs the 5-RPC flow (genesis → createAccount → 2 + * recommendedCreds → PLC update → activate), persisting status after each 3 + * step so a stuck attempt is recognizable to the reap CLI. 4 + * 5 + * Steps and persisted statuses: 6 + * Step 0 generate keys + persist row → keys_generated 7 + * Step 1 PLC genesis op → genesis_submitted 8 + * Step 2 PDS createAccount (service-auth JWT) → account_created 9 + * Step 3 fetch recommended DID credentials (no status change) 10 + * Step 4 PLC update op merging recommended credentials → did_doc_updated 11 + * Step 5 PDS activateAccount → activated 12 + */ 13 + 14 + import { 15 + generateKeyPair, 16 + buildGenesisOp, 17 + signGenesisOp, 18 + computeDidPlc, 19 + buildUpdateOp, 20 + signUpdateOp, 21 + cidForOp, 22 + } from "./plc"; 23 + import type { RecommendedDidCredentials } from "./pds"; 24 + import { decodeJwtExp } from "./pds"; 25 + import { mintServiceAuthJwt } from "./service-auth"; 26 + import type { CommunityAdapter } from "./adapter"; 27 + import type { CredentialCipher } from "./credentials"; 28 + 29 + export interface PlcClient { 30 + submit(did: string, op: any): Promise<unknown>; 31 + } 32 + 33 + export interface PdsClient { 34 + createAccount(input: { 35 + pdsUrl: string; 36 + serviceAuthJwt: string; 37 + body: { 38 + handle: string; 39 + did: string; 40 + email: string; 41 + password: string; 42 + inviteCode?: string; 43 + }; 44 + }): Promise<{ 45 + did: string; 46 + handle: string; 47 + accessJwt: string; 48 + refreshJwt: string; 49 + }>; 50 + getRecommendedDidCredentials(input: { 51 + pdsUrl: string; 52 + accessJwt: string; 53 + }): Promise<RecommendedDidCredentials>; 54 + activateAccount(input: { pdsUrl: string; accessJwt: string }): Promise<void>; 55 + /** Mints a revocable app password on the freshly-activated account. Used by 56 + * the self-sovereign custody mode so Contrail keeps publishing authority 57 + * without holding the account's root password. */ 58 + createAppPassword(input: { 59 + pdsUrl: string; 60 + accessJwt: string; 61 + name: string; 62 + }): Promise<{ password: string }>; 63 + /** Used only by the C3 retry path: when a provision call failed at 64 + * createAppPassword, retrying with the same attemptId needs a fresh 65 + * accessJwt. The session-cache JWT from the failed attempt may have 66 + * expired by the time the caller retries. Optional — non-retry callers 67 + * never invoke this. */ 68 + createSession?(input: { 69 + pdsUrl: string; 70 + identifier: string; 71 + password: string; 72 + }): Promise<{ accessJwt: string; refreshJwt: string; did: string }>; 73 + } 74 + 75 + export interface ProvisionOrchestratorDeps { 76 + adapter: CommunityAdapter; 77 + cipher: CredentialCipher; 78 + plc: PlcClient; 79 + pds: PdsClient; 80 + /** DID of the target PDS; used as `aud` in the service-auth JWT. */ 81 + pdsDid: string; 82 + } 83 + 84 + export interface ProvisionInput { 85 + attemptId: string; 86 + pdsEndpoint: string; 87 + handle: string; 88 + email: string; 89 + password: string; 90 + inviteCode?: string; 91 + /** Caller-supplied rotation public key (did:key:z…). Sits at rotationKeys[0] 92 + * in the genesis op; Contrail's generated key is the subordinate at [1]. 93 + * After activation Contrail mints a revocable app password (via 94 + * createAppPassword) for ongoing publishing — the user's account password 95 + * is never persisted. */ 96 + rotationKey: string; 97 + } 98 + 99 + export interface ProvisionResult { 100 + attemptId: string; 101 + did: string; 102 + status: "activated"; 103 + /** The caller is expected to store these — Contrail does NOT retain the 104 + * user's root password once the app password has been minted. */ 105 + rootCredentials: { 106 + handle: string; 107 + password: string; 108 + recoveryHint: string; 109 + }; 110 + } 111 + 112 + export class ProvisionOrchestrator { 113 + constructor(private deps: ProvisionOrchestratorDeps) {} 114 + 115 + async provision(input: ProvisionInput): Promise<ProvisionResult> { 116 + const { adapter, cipher, plc, pds, pdsDid } = this.deps; 117 + 118 + if (!isDidKeyZ(input.rotationKey)) { 119 + throw new Error( 120 + `rotationKey must be a did:key:z… string (got: ${input.rotationKey.slice(0, 24)}…)` 121 + ); 122 + } 123 + 124 + // Idempotent retry: a caller that gets a 5xx with an attemptId can 125 + // re-invoke provision with the SAME attemptId and the orchestrator picks 126 + // up where it left off. Two recoverable shapes: 127 + // 1. status='activated', encryptedPassword present — the orchestrator 128 + // itself completed successfully but a downstream graduation step 129 + // (e.g. the route's createFromProvisioned, bootstrap of reserved 130 + // spaces) failed. We return success without re-running any PLC/PDS 131 + // work so the caller — or the route — can resume the post-orch path. 132 + // 2. status='activated', no encryptedPassword — activation succeeded 133 + // but the post-activation createAppPassword failed. retryAppPasswordOnly 134 + // re-runs only that final step. 135 + // Other partial states are not resumable through this entry point. 136 + const existing = await adapter.getProvisionAttempt(input.attemptId); 137 + if (existing) { 138 + if (existing.status === "activated" && existing.encryptedPassword) { 139 + return { 140 + attemptId: input.attemptId, 141 + did: existing.did, 142 + status: "activated", 143 + rootCredentials: { 144 + handle: input.handle, 145 + password: input.password, 146 + recoveryHint: "store this — Contrail does not retain it", 147 + }, 148 + }; 149 + } 150 + if (existing.status === "activated" && !existing.encryptedPassword) { 151 + return this.retryAppPasswordOnly(input, existing); 152 + } 153 + throw new Error( 154 + `provision attempt ${input.attemptId} already exists at status="${existing.status}"; ` + 155 + `retry is only supported for attempts that failed at createAppPassword` 156 + ); 157 + } 158 + 159 + // Step 0: keys + persist. Contrail generates a SUBORDINATE rotation key 160 + // (rotationKeys[1]) so it retains a path to submit subsequent PLC ops; 161 + // the caller's key sits at rotationKeys[0]. 162 + const signingKey = await generateKeyPair(); 163 + const contrailRotation = await generateKeyPair(); 164 + const encryptedSigning = await cipher.encrypt( 165 + JSON.stringify(signingKey.privateJwk) 166 + ); 167 + const encryptedRotation = await cipher.encrypt( 168 + JSON.stringify(contrailRotation.privateJwk) 169 + ); 170 + 171 + const unsigned = buildGenesisOp({ 172 + rotationKeys: [input.rotationKey, contrailRotation.publicDidKey], 173 + verificationMethodAtproto: signingKey.publicDidKey, 174 + alsoKnownAs: [`at://${input.handle}`], 175 + services: { 176 + atproto_pds: { 177 + type: "AtprotoPersonalDataServer", 178 + endpoint: input.pdsEndpoint, 179 + }, 180 + }, 181 + }); 182 + // Genesis is signed with Contrail's subordinate rotation key — it's listed 183 + // at rotationKeys[1] so PLC accepts the signature. 184 + const signedGenesis = await signGenesisOp(unsigned, contrailRotation.privateJwk); 185 + const did = await computeDidPlc(signedGenesis); 186 + 187 + await adapter.createProvisionAttempt({ 188 + attemptId: input.attemptId, 189 + did, 190 + pdsEndpoint: input.pdsEndpoint, 191 + handle: input.handle, 192 + email: input.email, 193 + inviteCode: input.inviteCode ?? null, 194 + encryptedSigningKey: encryptedSigning, 195 + encryptedRotationKey: encryptedRotation, 196 + }); 197 + 198 + // Step 1: PLC genesis 199 + try { 200 + await plc.submit(did, signedGenesis); 201 + await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted"); 202 + } catch (err: any) { 203 + await adapter.updateProvisionStatus(input.attemptId, "keys_generated", { 204 + lastError: `plc-genesis: ${err.message}`, 205 + }); 206 + throw err; 207 + } 208 + 209 + // Step 2: createAccount 210 + let session: { 211 + did: string; 212 + handle: string; 213 + accessJwt: string; 214 + refreshJwt: string; 215 + }; 216 + try { 217 + const serviceAuthJwt = await mintServiceAuthJwt({ 218 + privateJwk: signingKey.privateJwk, 219 + iss: did, 220 + aud: pdsDid, 221 + lxm: "com.atproto.server.createAccount", 222 + ttlSec: 60, 223 + }); 224 + session = await pds.createAccount({ 225 + pdsUrl: input.pdsEndpoint, 226 + serviceAuthJwt, 227 + body: { 228 + handle: input.handle, 229 + did, 230 + email: input.email, 231 + password: input.password, 232 + inviteCode: input.inviteCode, 233 + }, 234 + }); 235 + await adapter.updateProvisionStatus(input.attemptId, "account_created"); 236 + } catch (err: any) { 237 + await adapter.updateProvisionStatus(input.attemptId, "genesis_submitted", { 238 + lastError: `createAccount: ${err.message}`, 239 + }); 240 + throw err; 241 + } 242 + 243 + // Step 3 + 4: getRecommendedDidCredentials + PLC update op 244 + try { 245 + const recommended = await pds.getRecommendedDidCredentials({ 246 + pdsUrl: input.pdsEndpoint, 247 + accessJwt: session.accessJwt, 248 + }); 249 + const baseRotationKeys = [input.rotationKey, contrailRotation.publicDidKey]; 250 + const updatedRotationKeys = [ 251 + ...baseRotationKeys, 252 + ...recommended.rotationKeys.filter((k) => !baseRotationKeys.includes(k)), 253 + ]; 254 + const unsignedUpdate = buildUpdateOp({ 255 + prev: await cidForOp(signedGenesis), 256 + rotationKeys: updatedRotationKeys, 257 + verificationMethodAtproto: recommended.verificationMethods.atproto, 258 + alsoKnownAs: recommended.alsoKnownAs, 259 + services: recommended.services, 260 + }); 261 + const signedUpdate = await signUpdateOp( 262 + unsignedUpdate, 263 + contrailRotation.privateJwk 264 + ); 265 + await plc.submit(did, signedUpdate); 266 + await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated"); 267 + } catch (err: any) { 268 + await adapter.updateProvisionStatus(input.attemptId, "account_created", { 269 + lastError: `did-doc-update: ${err.message}`, 270 + }); 271 + throw err; 272 + } 273 + 274 + // Step 5: activateAccount 275 + try { 276 + await pds.activateAccount({ 277 + pdsUrl: input.pdsEndpoint, 278 + accessJwt: session.accessJwt, 279 + }); 280 + await adapter.updateProvisionStatus(input.attemptId, "activated"); 281 + } catch (err: any) { 282 + await adapter.updateProvisionStatus(input.attemptId, "did_doc_updated", { 283 + lastError: `activateAccount: ${err.message}`, 284 + }); 285 + throw err; 286 + } 287 + 288 + // Seed the session cache with the JWTs createAccount returned, so the 289 + // first publish doesn't waste a createSession round-trip. ensureSession 290 + // refreshes or falls back to the stored password as the JWTs age out. 291 + await adapter.upsertSession(did, { 292 + accessJwt: session.accessJwt, 293 + refreshJwt: session.refreshJwt, 294 + accessExp: decodeJwtExp(session.accessJwt), 295 + }); 296 + 297 + // Mint a revocable app password so we can publish without holding the 298 + // user's root password. Failure here leaves the row at status=activated 299 + // (the account IS activated upstream) with a last_error breadcrumb; the 300 + // caller can retry with the same attemptId and it will pick up at 301 + // createAppPassword only (see retryAppPasswordOnly). 302 + try { 303 + const minted = await pds.createAppPassword({ 304 + pdsUrl: input.pdsEndpoint, 305 + accessJwt: session.accessJwt, 306 + name: `contrail-${input.attemptId}`, 307 + }); 308 + const encryptedPassword = await cipher.encrypt(minted.password); 309 + await adapter.updateProvisionStatus(input.attemptId, "activated", { 310 + encryptedPassword, 311 + }); 312 + } catch (err: any) { 313 + await adapter.updateProvisionStatus(input.attemptId, "activated", { 314 + lastError: `createAppPassword: ${err.message}`, 315 + }); 316 + // Wrap the error so the route handler / log readers see immediately 317 + // that this is the recoverable retry case, not a generic PDS failure. 318 + throw new Error(`createAppPassword: ${err.message}`); 319 + } 320 + 321 + return { 322 + attemptId: input.attemptId, 323 + did, 324 + status: "activated", 325 + rootCredentials: { 326 + handle: input.handle, 327 + password: input.password, 328 + recoveryHint: "store this — Contrail does not retain it", 329 + }, 330 + }; 331 + } 332 + 333 + /** C3 retry path. Triggered when provision() is called with an attemptId 334 + * whose row is at status='activated' + no encrypted_password. The DID is 335 + * already on PLC; the PDS account is already created and activated; only 336 + * the post-activation app-password mint failed. We need a fresh accessJwt 337 + * (the cached one may have expired by the time the caller retries), then 338 + * re-run createAppPassword. */ 339 + private async retryAppPasswordOnly( 340 + input: ProvisionInput, 341 + existing: NonNullable<Awaited<ReturnType<CommunityAdapter["getProvisionAttempt"]>>> 342 + ): Promise<ProvisionResult> { 343 + const { adapter, cipher, pds } = this.deps; 344 + if (!pds.createSession) { 345 + throw new Error( 346 + "retry path requires PdsClient.createSession to be wired (production buildOrchestrator does this; tests must stub it)" 347 + ); 348 + } 349 + 350 + const session = await pds.createSession({ 351 + pdsUrl: existing.pdsEndpoint, 352 + identifier: input.handle, 353 + password: input.password, 354 + }); 355 + 356 + let mintedPassword: string; 357 + try { 358 + const minted = await pds.createAppPassword({ 359 + pdsUrl: existing.pdsEndpoint, 360 + accessJwt: session.accessJwt, 361 + name: `contrail-${input.attemptId}`, 362 + }); 363 + mintedPassword = minted.password; 364 + } catch (err: any) { 365 + await adapter.updateProvisionStatus(input.attemptId, "activated", { 366 + lastError: `createAppPassword (retry): ${err.message}`, 367 + }); 368 + throw new Error(`createAppPassword (retry): ${err.message}`); 369 + } 370 + 371 + const encryptedPassword = await cipher.encrypt(mintedPassword); 372 + await adapter.updateProvisionStatus(input.attemptId, "activated", { 373 + encryptedPassword, 374 + }); 375 + // Refresh the session cache with the JWTs we just obtained, so the 376 + // first publish for this community doesn't need another createSession. 377 + await adapter.upsertSession(existing.did, { 378 + accessJwt: session.accessJwt, 379 + refreshJwt: session.refreshJwt, 380 + accessExp: decodeJwtExp(session.accessJwt), 381 + }); 382 + 383 + return { 384 + attemptId: input.attemptId, 385 + did: existing.did, 386 + status: "activated", 387 + rootCredentials: { 388 + handle: input.handle, 389 + password: input.password, 390 + recoveryHint: "store this — Contrail does not retain it", 391 + }, 392 + }; 393 + } 394 + 395 + } 396 + 397 + /** Cheap structural check for did:key:z multibase identifiers. The orchestrator 398 + * trusts the caller's submitted rotation key beyond this; PLC will reject any 399 + * malformed key when the genesis op is submitted. */ 400 + function isDidKeyZ(s: string): boolean { 401 + return typeof s === "string" && s.startsWith("did:key:z") && s.length > 12; 402 + } 403 +
+332 -5
packages/contrail-community/src/router.ts
··· 8 8 import { buildSpaceUri, HostedAdapter } from "@atmo-dev/contrail"; 9 9 import { CommunityAdapter } from "./adapter"; 10 10 import { CredentialCipher } from "./credentials"; 11 - import { resolveIdentity, createPdsSession } from "./pds"; 11 + import { 12 + resolveIdentity, 13 + createPdsSession, 14 + decodeJwtExp, 15 + tryRefreshSession, 16 + normalizePdsEndpoint, 17 + } from "./pds"; 12 18 import { 13 19 generateKeyPair, 14 20 buildGenesisOp, ··· 16 22 computeDidPlc, 17 23 submitGenesisOp, 18 24 } from "./plc"; 25 + import { 26 + pdsCreateAccount, 27 + pdsGetRecommendedDidCredentials, 28 + pdsActivateAccount, 29 + pdsCreateAppPassword, 30 + pdsDescribeServer, 31 + } from "./pds"; 32 + import { 33 + ProvisionOrchestrator, 34 + type PdsClient, 35 + type PlcClient, 36 + } from "./provision"; 19 37 import { resolveEffectiveLevel, resolveReachableSpaces, wouldCycle } from "./acl"; 20 38 import { reconcile } from "./reconcile"; 21 39 import type { AccessLevel } from "./types"; ··· 212 230 }); 213 231 }); 214 232 233 + app.post(`/xrpc/${NS}.provision`, auth, async (c) => { 234 + // Default-deny gate. Every successful call burns an invite code on the 235 + // target PDS and adds a permanent entry to PLC, so the route refuses 236 + // unless the operator has explicitly opted in via cfg.allowProvisioning. 237 + // Checked BEFORE auth-issued state inspection so an unauthorized op 238 + // doesn't even surface that the route exists in a usable form. 239 + if (!cfg.allowProvisioning) { 240 + return c.json( 241 + { 242 + error: "ProvisioningDisabled", 243 + message: "community.provision is disabled on this Contrail deployment", 244 + }, 245 + 403 246 + ); 247 + } 248 + const sa = getAuth(c); 249 + const body = (await c.req.json().catch(() => null)) as 250 + | { 251 + attemptId?: string; 252 + handle?: string; 253 + email?: string; 254 + password?: string; 255 + inviteCode?: string; 256 + pdsEndpoint?: string; 257 + rotationKey?: string; 258 + } 259 + | null; 260 + if ( 261 + !body?.handle || 262 + !body.email || 263 + !body.password || 264 + !body.pdsEndpoint || 265 + !body.rotationKey 266 + ) { 267 + return c.json( 268 + { 269 + error: "InvalidRequest", 270 + message: "handle, email, password, pdsEndpoint, rotationKey required", 271 + }, 272 + 400 273 + ); 274 + } 275 + if ( 276 + !(typeof body.rotationKey === "string" && body.rotationKey.startsWith("did:key:z")) 277 + ) { 278 + return c.json( 279 + { 280 + error: "InvalidRequest", 281 + message: "rotationKey must be a did:key:z…", 282 + }, 283 + 400 284 + ); 285 + } 286 + 287 + let normalizedPdsEndpoint: string; 288 + try { 289 + normalizedPdsEndpoint = normalizePdsEndpoint(body.pdsEndpoint); 290 + } catch { 291 + return c.json( 292 + { 293 + error: "InvalidRequest", 294 + message: "pdsEndpoint must be a parseable URL", 295 + }, 296 + 400 297 + ); 298 + } 299 + 300 + const allowed = cfg.allowedProvisionPdsEndpoints; 301 + if (allowed && allowed.length > 0) { 302 + const allowedNormalized = allowed.map((e) => { 303 + try { 304 + return normalizePdsEndpoint(e); 305 + } catch { 306 + // An unparseable allowlist entry can never match; treat as the 307 + // original string so an obvious config typo at least produces a 308 + // reject for the caller rather than a server crash. 309 + return e; 310 + } 311 + }); 312 + if (!allowedNormalized.includes(normalizedPdsEndpoint)) { 313 + return c.json( 314 + { 315 + error: "InvalidRequest", 316 + message: `pdsEndpoint not in allowlist`, 317 + }, 318 + 400 319 + ); 320 + } 321 + } else if (!cfg.allowAnyProvisionPdsEndpoint) { 322 + // Fail closed: provisioning is enabled but no allowlist is configured. 323 + // An empty/undefined allowlist must NOT mean "sign a genesis op for any 324 + // caller-supplied PDS" — that's the exact attack the allowlist prevents. 325 + // Refuse unless the operator has explicitly opted into the dangerous 326 + // accept-any mode via allowAnyProvisionPdsEndpoint. 327 + // 328 + // SSRF note: allowAnyProvisionPdsEndpoint also bypasses any endpoint 329 + // guard — the caller-supplied pdsEndpoint flows straight to 330 + // describeServer + the createAccount fetches below with no private-IP / 331 + // link-local / cloud-metadata protection. Only enable it behind trusted 332 + // auth, and pair it with an egress network policy (block 169.254.0.0/16) 333 + // + IMDSv2 on the host. The allowlist path above contains this by 334 + // construction. 335 + return c.json( 336 + { 337 + error: "ProvisioningMisconfigured", 338 + message: 339 + "community.provision requires a non-empty allowedProvisionPdsEndpoints allowlist; " + 340 + "set the allowlist, or set allowAnyProvisionPdsEndpoint:true to deliberately accept any PDS", 341 + }, 342 + 403 343 + ); 344 + } 345 + body.pdsEndpoint = normalizedPdsEndpoint; 346 + 347 + // Resolve the target PDS's DID dynamically. The service-auth JWT's `aud` 348 + // must match what the PDS publishes for itself via describeServer; the 349 + // PDS rejects with BadJwtAudience otherwise. This is what allows a 350 + // single Contrail to mint communities on multiple PDSes — using a 351 + // cfg-pinned value would force a 1:1 Contrail-to-PDS deployment. 352 + let pdsDid: string; 353 + try { 354 + const described = await pdsDescribeServer(body.pdsEndpoint, { 355 + fetch: cfg.fetch, 356 + }); 357 + pdsDid = described.did; 358 + } catch (err: any) { 359 + return c.json( 360 + { 361 + error: "PdsUnreachable", 362 + message: `describeServer failed for ${body.pdsEndpoint}: ${err.message}`, 363 + }, 364 + 502 365 + ); 366 + } 367 + const orchestrator = buildOrchestrator(cfg, community, cipher, pdsDid); 368 + 369 + const attemptId = body.attemptId ?? crypto.randomUUID(); 370 + let result; 371 + try { 372 + result = await orchestrator.provision({ 373 + attemptId, 374 + pdsEndpoint: body.pdsEndpoint, 375 + handle: body.handle, 376 + email: body.email, 377 + password: body.password, 378 + inviteCode: body.inviteCode, 379 + rotationKey: body.rotationKey, 380 + }); 381 + } catch (err: any) { 382 + // attemptId must always come back to the caller so they can retry 383 + // idempotently (see the C3 retry path in ProvisionOrchestrator). 384 + return c.json( 385 + { error: "ProvisioningFailed", message: err.message, attemptId }, 386 + 502 387 + ); 388 + } 389 + 390 + // Idempotent graduation: if the community row already exists, the first 391 + // call already wrote both the row and the reserved spaces. A retry with 392 + // the same attemptId should return success without double-creating. 393 + const alreadyGraduated = (await community.getCommunity(result.did)) != null; 394 + if (!alreadyGraduated) { 395 + // Hand the already-encrypted password from the provision_attempts row 396 + // to the communities row, keeping a single source of truth for the 397 + // credential. 398 + const attempt = await community.getProvisionAttempt(attemptId); 399 + if (!attempt?.encryptedPassword) { 400 + return c.json( 401 + { 402 + error: "ProvisioningFailed", 403 + message: "provision attempt missing encryptedPassword after activation", 404 + }, 405 + 502 406 + ); 407 + } 408 + 409 + await community.createFromProvisioned({ 410 + did: result.did, 411 + pdsEndpoint: body.pdsEndpoint, 412 + handle: body.handle, 413 + appPasswordEncrypted: attempt.encryptedPassword, 414 + createdBy: sa.issuer, 415 + }); 416 + 417 + await bootstrapReservedSpaces({ 418 + communityDid: result.did, 419 + creatorDid: sa.issuer, 420 + spaces, 421 + community, 422 + type: spaceType, 423 + serviceDid: spaceServiceDid, 424 + }); 425 + } 426 + 427 + const responseBody: { 428 + communityDid: string; 429 + status: string; 430 + rootCredentials?: { handle: string; password: string; recoveryHint: string }; 431 + } = { communityDid: result.did, status: result.status }; 432 + if (result.rootCredentials) { 433 + responseBody.rootCredentials = result.rootCredentials; 434 + } 435 + return c.json(responseBody); 436 + }); 437 + 215 438 app.post(`/xrpc/${NS}.delete`, auth, async (c) => { 216 439 const sa = getAuth(c); 217 440 const body = (await c.req.json().catch(() => null)) as ··· 672 895 400 673 896 ); 674 897 } 898 + // adopt + provision modes both share the credential-proxy publishing path: 899 + // both store {pds_endpoint, identifier, app_password_encrypted}. Falls through. 675 900 676 901 // Caller must be member+ in $publishers. 677 902 const publishersUri = buildSpaceUri({ ··· 695 920 let session; 696 921 try { 697 922 const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); 698 - session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { 923 + session = await ensureSession({ 924 + community, 925 + did: body.communityDid, 926 + pdsEndpoint: raw.pdsEndpoint, 927 + identifier: raw.identifier, 928 + password: appPassword, 699 929 fetch: cfg.fetch, 700 930 }); 701 931 } catch (err: any) { ··· 724 954 }), 725 955 } 726 956 ); 957 + if (res.status === 401) { 958 + // Stale or revoked session: drop the cache so the next request goes 959 + // cold through ensureSession. 960 + await community.clearSession(body.communityDid); 961 + } 727 962 if (!res.ok) { 728 963 const text = await res.text().catch(() => ""); 729 964 return c.json( ··· 751 986 if (row.mode === "mint") { 752 987 return c.json({ error: "NotSupported" }, 400); 753 988 } 989 + // adopt + provision: same credential-proxy path; falls through. 754 990 755 991 const publishersUri = buildSpaceUri({ 756 992 ownerDid: body.communityDid, ··· 769 1005 let session; 770 1006 try { 771 1007 const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); 772 - session = await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { 1008 + session = await ensureSession({ 1009 + community, 1010 + did: body.communityDid, 1011 + pdsEndpoint: raw.pdsEndpoint, 1012 + identifier: raw.identifier, 1013 + password: appPassword, 773 1014 fetch: cfg.fetch, 774 1015 }); 775 1016 } catch (err: any) { ··· 795 1036 }), 796 1037 } 797 1038 ); 1039 + if (res.status === 401) { 1040 + await community.clearSession(body.communityDid); 1041 + } 798 1042 if (!res.ok) { 799 1043 const text = await res.text().catch(() => ""); 800 1044 return c.json( ··· 906 1150 } 907 1151 } 908 1152 909 - // Adopted: attempt a session creation. 1153 + // Adopted + provisioned: both store an app password against an external PDS. 1154 + // Health = we can still create a session with the stored credentials. 910 1155 const raw = await community.getRawCredentials(communityDid); 911 1156 if (!raw?.appPasswordEncrypted || !raw.pdsEndpoint || !raw.identifier) { 912 1157 return c.json({ status: "expired" }); 913 1158 } 914 1159 try { 915 1160 const appPassword = await cipher.decryptString(raw.appPasswordEncrypted); 916 - await createPdsSession(raw.pdsEndpoint, raw.identifier, appPassword, { 1161 + await ensureSession({ 1162 + community, 1163 + did: communityDid, 1164 + pdsEndpoint: raw.pdsEndpoint, 1165 + identifier: raw.identifier, 1166 + password: appPassword, 917 1167 fetch: cfg.fetch, 918 1168 }); 919 1169 return c.json({ status: "healthy" }); ··· 1008 1258 let out = ""; 1009 1259 for (let i = 0; i < 13; i++) out += chars[bytes[i]! % 32]; 1010 1260 return out; 1261 + } 1262 + 1263 + /** Build a ProvisionOrchestrator wired with real PDS/PLC clients backed by 1264 + * `cfg.fetch` (so tests can stub the network the same way they do for the 1265 + * mint/adopt routes). Mirrors the ad-hoc wrapper used in the live e2e test 1266 + * at apps/contrail-e2e/tests/provision.test.ts. */ 1267 + function buildOrchestrator( 1268 + cfg: import("./types").CommunityConfig, 1269 + adapter: CommunityAdapter, 1270 + cipher: CredentialCipher, 1271 + pdsDid: string 1272 + ): ProvisionOrchestrator { 1273 + const plcDirectory = cfg.plcDirectory ?? "https://plc.directory"; 1274 + const fetchOpts = { fetch: cfg.fetch }; 1275 + 1276 + const plc: PlcClient = { 1277 + submit: (did, op) => submitGenesisOp(plcDirectory, did, op as any, fetchOpts), 1278 + }; 1279 + 1280 + const pds: PdsClient = { 1281 + createAccount: ({ pdsUrl, serviceAuthJwt, body }) => 1282 + pdsCreateAccount(pdsUrl, serviceAuthJwt, body, fetchOpts), 1283 + getRecommendedDidCredentials: ({ pdsUrl, accessJwt }) => 1284 + pdsGetRecommendedDidCredentials(pdsUrl, accessJwt, fetchOpts), 1285 + activateAccount: ({ pdsUrl, accessJwt }) => 1286 + pdsActivateAccount(pdsUrl, accessJwt, fetchOpts), 1287 + createAppPassword: async ({ pdsUrl, accessJwt, name }) => { 1288 + const r = await pdsCreateAppPassword(pdsUrl, accessJwt, name, fetchOpts); 1289 + return { password: r.password }; 1290 + }, 1291 + createSession: ({ pdsUrl, identifier, password }) => 1292 + createPdsSession(pdsUrl, identifier, password, fetchOpts), 1293 + }; 1294 + 1295 + return new ProvisionOrchestrator({ adapter, cipher, plc, pds, pdsDid }); 1011 1296 } 1012 1297 1013 1298 async function bootstrapReservedSpaces(args: { ··· 1043 1328 await args.spaces.applyMembershipDiff(uri, [args.creatorDid], [], args.creatorDid); 1044 1329 } 1045 1330 } 1331 + 1332 + /** Ensure a usable PDS session for the given community DID. Tries the cached 1333 + * session first (with a 30s skew); if expired, tries refresh; if refresh fails 1334 + * (or there's no cache), falls back to creating a fresh session with the 1335 + * stored app password. The result is always written back to the cache. */ 1336 + async function ensureSession(args: { 1337 + community: CommunityAdapter; 1338 + did: string; 1339 + pdsEndpoint: string; 1340 + identifier: string; 1341 + password: string; 1342 + fetch?: typeof fetch; 1343 + }): Promise<{ accessJwt: string; refreshJwt: string }> { 1344 + const now = Math.floor(Date.now() / 1000); 1345 + const cached = await args.community.getSession(args.did); 1346 + if (cached && cached.accessExp > now + 30) { 1347 + return { accessJwt: cached.accessJwt, refreshJwt: cached.refreshJwt }; 1348 + } 1349 + if (cached) { 1350 + const refreshed = await tryRefreshSession({ 1351 + pdsUrl: args.pdsEndpoint, 1352 + refreshJwt: cached.refreshJwt, 1353 + fetch: args.fetch, 1354 + }); 1355 + if (refreshed) { 1356 + await args.community.upsertSession(args.did, refreshed); 1357 + return { accessJwt: refreshed.accessJwt, refreshJwt: refreshed.refreshJwt }; 1358 + } 1359 + } 1360 + const session = await createPdsSession( 1361 + args.pdsEndpoint, 1362 + args.identifier, 1363 + args.password, 1364 + { fetch: args.fetch } 1365 + ); 1366 + await args.community.upsertSession(args.did, { 1367 + accessJwt: session.accessJwt, 1368 + refreshJwt: session.refreshJwt, 1369 + accessExp: decodeJwtExp(session.accessJwt), 1370 + }); 1371 + return { accessJwt: session.accessJwt, refreshJwt: session.refreshJwt }; 1372 + }
+51
packages/contrail-community/src/schema.ts
··· 43 43 note TEXT 44 44 )`, 45 45 `CREATE INDEX IF NOT EXISTS idx_community_invites_space ON community_invites(space_uri, created_at DESC)`, 46 + 47 + `CREATE TABLE IF NOT EXISTS provision_attempts ( 48 + attempt_id TEXT PRIMARY KEY NOT NULL, 49 + did TEXT NOT NULL, 50 + status TEXT NOT NULL CHECK (status IN ( 51 + 'keys_generated', 52 + 'genesis_submitted', 53 + 'account_created', 54 + 'did_doc_updated', 55 + 'activated' 56 + )), 57 + pds_endpoint TEXT NOT NULL, 58 + handle TEXT NOT NULL, 59 + email TEXT NOT NULL, 60 + invite_code TEXT, 61 + encrypted_signing_key TEXT, 62 + encrypted_rotation_key TEXT, 63 + encrypted_password TEXT, 64 + genesis_submitted_at ${dialect.bigintType}, 65 + account_created_at ${dialect.bigintType}, 66 + did_doc_updated_at ${dialect.bigintType}, 67 + activated_at ${dialect.bigintType}, 68 + last_error TEXT, 69 + created_at ${dialect.bigintType} NOT NULL, 70 + updated_at ${dialect.bigintType} NOT NULL 71 + )`, 72 + `CREATE INDEX IF NOT EXISTS idx_provision_attempts_status ON provision_attempts(status, updated_at DESC)`, 73 + `CREATE UNIQUE INDEX IF NOT EXISTS idx_provision_attempts_did ON provision_attempts(did)`, 74 + 75 + `CREATE TABLE IF NOT EXISTS provision_attempts_archive ( 76 + attempt_id TEXT PRIMARY KEY NOT NULL, 77 + did TEXT NOT NULL, 78 + pds_endpoint TEXT NOT NULL, 79 + handle TEXT NOT NULL, 80 + email TEXT NOT NULL, 81 + invite_code TEXT, 82 + last_status TEXT, 83 + last_error TEXT, 84 + archived_at ${dialect.bigintType} NOT NULL, 85 + tombstone_op_cid TEXT, 86 + notes TEXT 87 + )`, 88 + `CREATE INDEX IF NOT EXISTS idx_provision_attempts_archive_archived_at ON provision_attempts_archive(archived_at DESC)`, 89 + 90 + `CREATE TABLE IF NOT EXISTS community_sessions ( 91 + community_did TEXT PRIMARY KEY NOT NULL, 92 + access_jwt TEXT NOT NULL, 93 + refresh_jwt TEXT NOT NULL, 94 + access_exp ${dialect.bigintType} NOT NULL, 95 + updated_at ${dialect.bigintType} NOT NULL 96 + )`, 46 97 ]; 47 98 } 48 99
+51
packages/contrail-community/src/service-auth.ts
··· 1 + /** Mint an ES256 service-auth JWT for PDS XRPC calls. 2 + * See com.atproto.server.createAccount handler: PDS verifies 3 + * iss === did, aud === pds-did, lxm === lexicon-method, exp not in past. 4 + * Signature is verified against the iss DID's atproto verificationMethod. */ 5 + 6 + import { signBytes, bytesToB64url } from "./plc"; 7 + 8 + function b64url(input: Uint8Array | string): string { 9 + const b = typeof input === "string" ? new TextEncoder().encode(input) : input; 10 + return bytesToB64url(b); 11 + } 12 + 13 + function b64urlJson(obj: unknown): string { 14 + return b64url(JSON.stringify(obj)); 15 + } 16 + 17 + export interface MintServiceAuthInput { 18 + /** Private JWK for the signing key (atproto verificationMethod). */ 19 + privateJwk: JsonWebKey; 20 + /** Issuer DID (the account's did:plc). */ 21 + iss: string; 22 + /** Audience DID (the target PDS, e.g. did:web:pds.example). */ 23 + aud: string; 24 + /** Lexicon method being authorized, e.g. com.atproto.server.createAccount. */ 25 + lxm: string; 26 + /** Token TTL in seconds. Defaults to 60. */ 27 + ttlSec?: number; 28 + /** Override "now" for deterministic tests; epoch milliseconds. */ 29 + now?: number; 30 + } 31 + 32 + export async function mintServiceAuthJwt(input: MintServiceAuthInput): Promise<string> { 33 + const iat = Math.floor((input.now ?? Date.now()) / 1000); 34 + const ttl = input.ttlSec ?? 60; 35 + // Header: alg+typ only — atproto's service-auth verification doesn't use kid; 36 + // the signing key is resolved from the iss DID's verificationMethod. 37 + const header = { alg: "ES256", typ: "JWT" }; 38 + const payload = { 39 + iat, 40 + iss: input.iss, 41 + aud: input.aud, 42 + exp: iat + ttl, 43 + lxm: input.lxm, 44 + jti: crypto.randomUUID(), 45 + }; 46 + const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`; 47 + // signBytes returns IEEE P1363 r||s (64 bytes), low-S normalized. 48 + // JWT ES256 mandates raw r||s, NOT DER. atproto enforces low-S as well. 49 + const sig = await signBytes(input.privateJwk, new TextEncoder().encode(signingInput)); 50 + return `${signingInput}.${b64url(sig)}`; 51 + }
+72 -1
packages/contrail-community/src/types.ts
··· 21 21 return typeof v === "string" && ACCESS_LEVELS.includes(v as AccessLevel); 22 22 } 23 23 24 - export type CommunityMode = "adopt" | "mint"; 24 + export type CommunityMode = "adopt" | "mint" | "provision"; 25 + 26 + export const PROVISION_STATUSES = [ 27 + "keys_generated", 28 + "genesis_submitted", 29 + "account_created", 30 + "did_doc_updated", 31 + "activated", 32 + ] as const; 33 + export type ProvisionStatus = (typeof PROVISION_STATUSES)[number]; 34 + 35 + export interface ProvisionAttemptRow { 36 + attemptId: string; 37 + did: string; 38 + status: ProvisionStatus; 39 + pdsEndpoint: string; 40 + handle: string; 41 + email: string; 42 + inviteCode: string | null; 43 + encryptedSigningKey: string | null; 44 + encryptedRotationKey: string | null; 45 + encryptedPassword: string | null; 46 + genesisSubmittedAt: number | null; 47 + accountCreatedAt: number | null; 48 + didDocUpdatedAt: number | null; 49 + activatedAt: number | null; 50 + lastError: string | null; 51 + createdAt: number; 52 + updatedAt: number; 53 + } 54 + 55 + export interface CreateProvisionAttemptInput { 56 + attemptId: string; 57 + did: string; 58 + pdsEndpoint: string; 59 + handle: string; 60 + email: string; 61 + inviteCode?: string | null; 62 + encryptedSigningKey: string; 63 + encryptedRotationKey: string; 64 + } 25 65 26 66 export interface CommunityConfig { 27 67 /** Service DID for JWT verification. Falls back to spaces.serviceDid when both modules are enabled. */ ··· 35 75 resolver?: DidDocumentResolver; 36 76 /** Optional override for the fetch implementation (useful for tests). */ 37 77 fetch?: typeof fetch; 78 + /** Allowlist of PDS endpoints `community.provision` may create a community 79 + * account on. Callers must supply a `pdsEndpoint` that matches one of these 80 + * entries (after normalization); other values are rejected before any PLC 81 + * op is signed. This gates ONLY provisioning — Contrail still reads/indexes 82 + * records from every PDS on the network; this is not a global PDS filter. 83 + * 84 + * Fail-closed: when `allowProvisioning` is true this list MUST be non-empty, 85 + * otherwise `community.provision` is refused. An empty/undefined allowlist 86 + * no longer means "any PDS" — to genuinely accept any caller-supplied 87 + * endpoint, set `allowAnyProvisionPdsEndpoint: true` (a separate, loud 88 + * opt-in). Operators running a public/multi-tenant Contrail MUST keep a 89 + * real allowlist here so callers can't mint PLC entries pointing at 90 + * attacker-controlled PDSes signed by Contrail's rotation key. */ 91 + allowedProvisionPdsEndpoints?: string[]; 92 + /** Explicit, loud opt-in to accept ANY caller-supplied `pdsEndpoint` when 93 + * provisioning is enabled. Only honored when `allowProvisioning` is true. 94 + * This is the dangerous mode the allowlist exists to prevent: every 95 + * successful call signs a permanent PLC genesis op pointing at a 96 + * caller-controlled endpoint with Contrail's rotation key. Leave unset 97 + * (or false) on any public/multi-tenant deployment and use 98 + * `allowedProvisionPdsEndpoints` instead. */ 99 + allowAnyProvisionPdsEndpoint?: boolean; 100 + /** Top-level switch for the `community.provision` route. Default-deny: a 101 + * call with the route present but this flag unset (or false) returns 403 102 + * ProvisioningDisabled BEFORE any PLC/PDS work runs. Set to `true` only 103 + * when the operator has confirmed the upstream auth middleware restricts 104 + * this route to authorized callers — every successful call burns a real 105 + * invite code on the target PDS and adds a permanent entry to PLC. 106 + * `mint` and `adopt` are not gated by this flag; they don't burn external 107 + * resources. */ 108 + allowProvisioning?: boolean; 38 109 } 39 110 40 111 /** Public view of a community row. Encrypted credentials are not included here
+388
packages/contrail-community/tests/cli-reap.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import type { Database } from "@atmo-dev/contrail-base"; 3 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 4 + import { initCommunitySchema } from "../src/schema"; 5 + import { CommunityAdapter } from "../src/adapter"; 6 + import { CredentialCipher } from "../src/credentials"; 7 + import { 8 + generateKeyPair, 9 + buildTombstoneOp, 10 + signTombstoneOp, 11 + submitTombstoneOp, 12 + cidForOp, 13 + type SignedGenesisOp, 14 + } from "../src/plc"; 15 + import { runReap, chooseReapDbSource } from "../src/cli/reap"; 16 + 17 + type SeedStatus = 18 + | "keys_generated" 19 + | "genesis_submitted" 20 + | "account_created" 21 + | "did_doc_updated" 22 + | "activated"; 23 + 24 + interface SeedAttemptOpts { 25 + attemptId: string; 26 + did: string; 27 + status: SeedStatus; 28 + } 29 + 30 + async function seedAttempt( 31 + adapter: CommunityAdapter, 32 + cipher: CredentialCipher, 33 + opts: SeedAttemptOpts 34 + ): Promise<{ rotationJwk: JsonWebKey }> { 35 + const kp = await generateKeyPair(); 36 + const encryptedRotation = await cipher.encrypt(JSON.stringify(kp.privateJwk)); 37 + await adapter.createProvisionAttempt({ 38 + attemptId: opts.attemptId, 39 + did: opts.did, 40 + pdsEndpoint: "https://pds.test", 41 + handle: `${opts.attemptId}.pds.test`, 42 + email: `${opts.attemptId}@x.test`, 43 + encryptedSigningKey: await cipher.encrypt("{}"), 44 + encryptedRotationKey: encryptedRotation, 45 + }); 46 + // Walk the row forward to its target status. The row starts at 47 + // keys_generated after createProvisionAttempt. 48 + const path: SeedStatus[] = [ 49 + "genesis_submitted", 50 + "account_created", 51 + "did_doc_updated", 52 + "activated", 53 + ]; 54 + for (const next of path) { 55 + if (opts.status === "keys_generated") break; 56 + await adapter.updateProvisionStatus(opts.attemptId, next); 57 + if (next === opts.status) break; 58 + } 59 + return { rotationJwk: kp.privateJwk }; 60 + } 61 + 62 + interface PlcCall { 63 + url: string; 64 + method: string; 65 + body: any; 66 + } 67 + 68 + /** Stand-in for what PLC's `/log/last` actually returns: the bare signed op 69 + * object, no envelope. `getLastOpCid` computes the CID locally via cidForOp. */ 70 + const FAKE_LAST_OP: SignedGenesisOp = { 71 + type: "plc_operation", 72 + prev: null, 73 + rotationKeys: ["did:key:zQ3shfakerotation00000000000000000000000000000000000"], 74 + verificationMethods: { atproto: "did:key:zQ3shfakeverif00000000000000000000000000000000000000" }, 75 + alsoKnownAs: ["at://fixture.pds.test"], 76 + services: { 77 + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, 78 + }, 79 + sig: "fakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfakesigfak", 80 + }; 81 + 82 + function makeFakeFetch(calls: PlcCall[]): typeof fetch { 83 + return (async (input: RequestInfo | URL, init?: RequestInit) => { 84 + const url = String(input); 85 + if (url.endsWith("/log/last")) { 86 + return new Response(JSON.stringify(FAKE_LAST_OP), { 87 + status: 200, 88 + headers: { "content-type": "application/json" }, 89 + }); 90 + } 91 + calls.push({ 92 + url, 93 + method: init?.method ?? "GET", 94 + body: init?.body ? JSON.parse(String(init.body)) : null, 95 + }); 96 + return new Response("", { status: 200 }); 97 + }) as typeof fetch; 98 + } 99 + 100 + describe("runReap (cli reap)", () => { 101 + let db: Database; 102 + let adapter: CommunityAdapter; 103 + let cipher: CredentialCipher; 104 + 105 + beforeEach(async () => { 106 + db = createSqliteDatabase(":memory:"); 107 + await initCommunitySchema(db); 108 + cipher = new CredentialCipher(new Uint8Array(32).fill(7)); 109 + adapter = new CommunityAdapter(db); 110 + }); 111 + 112 + it("rejects when neither --attempt-id nor --all-stuck is set", async () => { 113 + const result = await runReap({ 114 + adapter, 115 + cipher, 116 + plcDirectory: "https://plc.test", 117 + fetch: makeFakeFetch([]), 118 + logger: { log: () => {}, error: () => {} }, 119 + yes: true, 120 + }); 121 + expect(result.ok).toBe(false); 122 + expect(result.error).toMatch(/--attempt-id|--all-stuck/i); 123 + }); 124 + 125 + it("rejects when both --attempt-id and --all-stuck are set", async () => { 126 + const result = await runReap({ 127 + adapter, 128 + cipher, 129 + plcDirectory: "https://plc.test", 130 + fetch: makeFakeFetch([]), 131 + logger: { log: () => {}, error: () => {} }, 132 + yes: true, 133 + attemptId: "a1", 134 + allStuck: true, 135 + }); 136 + expect(result.ok).toBe(false); 137 + expect(result.error).toMatch(/mutually exclusive|both|exactly one/i); 138 + }); 139 + 140 + it("real run with --attempt-id submits a tombstone and archives the row", async () => { 141 + await seedAttempt(adapter, cipher, { 142 + attemptId: "a-stuck", 143 + did: "did:plc:stuck", 144 + status: "genesis_submitted", 145 + }); 146 + 147 + const calls: PlcCall[] = []; 148 + const result = await runReap({ 149 + adapter, 150 + cipher, 151 + plcDirectory: "https://plc.test", 152 + fetch: makeFakeFetch(calls), 153 + logger: { log: () => {}, error: () => {} }, 154 + yes: true, 155 + attemptId: "a-stuck", 156 + dryRun: false, 157 + }); 158 + 159 + expect(result.ok).toBe(true); 160 + expect(result.reaped).toBe(1); 161 + expect(result.errors).toBe(0); 162 + expect(calls.length).toBe(1); 163 + expect(calls[0]!.url).toBe("https://plc.test/did:plc:stuck"); 164 + expect(calls[0]!.body.type).toBe("plc_tombstone"); 165 + expect(calls[0]!.body.prev).toBe(await cidForOp(FAKE_LAST_OP)); 166 + 167 + // Original row removed from provision_attempts. 168 + expect(await adapter.getProvisionAttempt("a-stuck")).toBeNull(); 169 + // Archive row populated with the row's last live status. 170 + const archive = await db 171 + .prepare( 172 + "SELECT * FROM provision_attempts_archive WHERE attempt_id = ?" 173 + ) 174 + .bind("a-stuck") 175 + .first<Record<string, any>>(); 176 + expect(archive).not.toBeNull(); 177 + expect(archive!.did).toBe("did:plc:stuck"); 178 + expect(archive!.last_status).toBe("genesis_submitted"); 179 + expect(archive!.tombstone_op_cid).toBeTruthy(); 180 + }); 181 + 182 + it("defaults to dry-run when dryRun is unspecified (safety default)", async () => { 183 + await seedAttempt(adapter, cipher, { 184 + attemptId: "a-stuck", 185 + did: "did:plc:stuck", 186 + status: "did_doc_updated", 187 + }); 188 + 189 + const calls: PlcCall[] = []; 190 + const result = await runReap({ 191 + adapter, 192 + cipher, 193 + plcDirectory: "https://plc.test", 194 + fetch: makeFakeFetch(calls), 195 + logger: { log: () => {}, error: () => {} }, 196 + yes: true, 197 + attemptId: "a-stuck", 198 + // dryRun INTENTIONALLY OMITTED — must default to dry-run. 199 + }); 200 + 201 + expect(result.ok).toBe(true); 202 + expect(result.reaped).toBe(0); 203 + expect(result.dryRunSkipped).toBe(1); 204 + expect(calls.length).toBe(0); 205 + const row = await adapter.getProvisionAttempt("a-stuck"); 206 + expect(row?.status).toBe("did_doc_updated"); 207 + }); 208 + 209 + it("with --all-stuck reaps every non-activated row, regardless of status", async () => { 210 + await seedAttempt(adapter, cipher, { 211 + attemptId: "s1", 212 + did: "did:plc:s1", 213 + status: "keys_generated", 214 + }); 215 + await seedAttempt(adapter, cipher, { 216 + attemptId: "s2", 217 + did: "did:plc:s2", 218 + status: "genesis_submitted", 219 + }); 220 + await seedAttempt(adapter, cipher, { 221 + attemptId: "s3", 222 + did: "did:plc:s3", 223 + status: "did_doc_updated", 224 + }); 225 + // An activated row must NOT be reaped. 226 + await seedAttempt(adapter, cipher, { 227 + attemptId: "live", 228 + did: "did:plc:live", 229 + status: "activated", 230 + }); 231 + 232 + const calls: PlcCall[] = []; 233 + const result = await runReap({ 234 + adapter, 235 + cipher, 236 + plcDirectory: "https://plc.test", 237 + fetch: makeFakeFetch(calls), 238 + logger: { log: () => {}, error: () => {} }, 239 + yes: true, 240 + allStuck: true, 241 + dryRun: false, 242 + olderThanMs: 0, // these rows are freshly seeded; disable the age floor 243 + }); 244 + 245 + expect(result.ok).toBe(true); 246 + expect(result.reaped).toBe(3); 247 + expect(calls.map((c) => c.url).sort()).toEqual([ 248 + "https://plc.test/did:plc:s1", 249 + "https://plc.test/did:plc:s2", 250 + "https://plc.test/did:plc:s3", 251 + ]); 252 + // The activated row is untouched. 253 + const live = await adapter.getProvisionAttempt("live"); 254 + expect(live?.status).toBe("activated"); 255 + }); 256 + 257 + it("--all-stuck skips freshly-updated in-flight rows under the default age floor", async () => { 258 + // A row mid-state-machine (updated_at ~ now) must survive a default 259 + // --all-stuck run so reap can't tombstone a DID about to activate. 260 + await seedAttempt(adapter, cipher, { 261 + attemptId: "in-flight", 262 + did: "did:plc:inflight", 263 + status: "genesis_submitted", 264 + }); 265 + 266 + const calls: PlcCall[] = []; 267 + const result = await runReap({ 268 + adapter, 269 + cipher, 270 + plcDirectory: "https://plc.test", 271 + fetch: makeFakeFetch(calls), 272 + logger: { log: () => {}, error: () => {} }, 273 + yes: true, 274 + allStuck: true, 275 + dryRun: false, 276 + // olderThanMs OMITTED — must apply the default 30-min floor. 277 + }); 278 + 279 + expect(result.ok).toBe(true); 280 + expect(result.reaped).toBe(0); 281 + expect(calls.length).toBe(0); 282 + // The in-flight row is untouched. 283 + expect((await adapter.getProvisionAttempt("in-flight"))?.status).toBe( 284 + "genesis_submitted" 285 + ); 286 + }); 287 + 288 + it("refuses to reap an activated row passed via --attempt-id", async () => { 289 + await seedAttempt(adapter, cipher, { 290 + attemptId: "live", 291 + did: "did:plc:live", 292 + status: "activated", 293 + }); 294 + 295 + const calls: PlcCall[] = []; 296 + const result = await runReap({ 297 + adapter, 298 + cipher, 299 + plcDirectory: "https://plc.test", 300 + fetch: makeFakeFetch(calls), 301 + logger: { log: () => {}, error: () => {} }, 302 + yes: true, 303 + attemptId: "live", 304 + dryRun: false, 305 + }); 306 + 307 + expect(calls.length).toBe(0); 308 + expect(result.errors).toBeGreaterThanOrEqual(1); 309 + const live = await adapter.getProvisionAttempt("live"); 310 + expect(live?.status).toBe("activated"); 311 + }); 312 + }); 313 + 314 + describe("chooseReapDbSource", () => { 315 + it("selects Postgres when --db is given", () => { 316 + expect( 317 + chooseReapDbSource({ db: "postgres://x", databaseUrl: undefined }) 318 + ).toEqual({ kind: "postgres", url: "postgres://x" }); 319 + }); 320 + 321 + it("selects Postgres from DATABASE_URL when --db is absent", () => { 322 + expect( 323 + chooseReapDbSource({ db: undefined, databaseUrl: "postgres://env" }) 324 + ).toEqual({ kind: "postgres", url: "postgres://env" }); 325 + }); 326 + 327 + it("prefers an explicit --db over DATABASE_URL", () => { 328 + expect( 329 + chooseReapDbSource({ db: "postgres://flag", databaseUrl: "postgres://env" }) 330 + ).toEqual({ kind: "postgres", url: "postgres://flag" }); 331 + }); 332 + 333 + it("falls back to the D1 binding when neither is set", () => { 334 + expect(chooseReapDbSource({ db: undefined, databaseUrl: undefined })).toEqual({ 335 + kind: "d1", 336 + }); 337 + }); 338 + }); 339 + 340 + describe("plc tombstone helpers", () => { 341 + it("signTombstoneOp adds a base64url sig", async () => { 342 + const kp = await generateKeyPair(); 343 + const op = buildTombstoneOp("bafyreigenesis"); 344 + const signed = await signTombstoneOp(op, kp.privateJwk); 345 + expect(signed.type).toBe("plc_tombstone"); 346 + expect(signed.prev).toBe("bafyreigenesis"); 347 + expect(signed.sig).toMatch(/^[A-Za-z0-9_-]+$/); 348 + }); 349 + 350 + it("submitTombstoneOp POSTs to the PLC directory at the DID URL", async () => { 351 + const kp = await generateKeyPair(); 352 + const signed = await signTombstoneOp( 353 + buildTombstoneOp("bafyreigenesis"), 354 + kp.privateJwk 355 + ); 356 + 357 + let calledUrl = ""; 358 + let calledBody: any = null; 359 + const fakeFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { 360 + calledUrl = String(input); 361 + calledBody = init?.body ? JSON.parse(String(init.body)) : null; 362 + return new Response("", { status: 200 }); 363 + }) as typeof fetch; 364 + 365 + await submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { 366 + fetch: fakeFetch, 367 + }); 368 + expect(calledUrl).toBe("https://plc.test/did:plc:abc"); 369 + expect(calledBody.type).toBe("plc_tombstone"); 370 + expect(calledBody.prev).toBe("bafyreigenesis"); 371 + expect(calledBody.sig).toBe(signed.sig); 372 + }); 373 + 374 + it("submitTombstoneOp throws on non-2xx", async () => { 375 + const kp = await generateKeyPair(); 376 + const signed = await signTombstoneOp( 377 + buildTombstoneOp("bafyreigenesis"), 378 + kp.privateJwk 379 + ); 380 + const fakeFetch: typeof fetch = (async () => 381 + new Response("denied", { status: 400 })) as typeof fetch; 382 + await expect( 383 + submitTombstoneOp("https://plc.test", "did:plc:abc", signed, { 384 + fetch: fakeFetch, 385 + }) 386 + ).rejects.toThrow(/400.*denied/); 387 + }); 388 + });
+247
packages/contrail-community/tests/community-provision-attempts.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import type { Database } from "@atmo-dev/contrail-base"; 3 + import { initCommunitySchema } from "../src/schema"; 4 + import { CommunityAdapter } from "../src/adapter"; 5 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 6 + 7 + describe("provision_attempts adapter", () => { 8 + let db: Database; 9 + let adapter: CommunityAdapter; 10 + 11 + beforeEach(async () => { 12 + db = createSqliteDatabase(":memory:"); 13 + await initCommunitySchema(db); 14 + adapter = new CommunityAdapter(db); 15 + }); 16 + 17 + it("creates and reads a provision attempt", async () => { 18 + const before = Date.now(); 19 + await adapter.createProvisionAttempt({ 20 + attemptId: "a1", 21 + did: "did:plc:abc", 22 + pdsEndpoint: "https://pds.test", 23 + handle: "abc.pds.test", 24 + email: "abc@x.test", 25 + inviteCode: "code-1", 26 + encryptedSigningKey: "sk-enc", 27 + encryptedRotationKey: "rk-enc", 28 + }); 29 + 30 + const row = await adapter.getProvisionAttempt("a1"); 31 + expect(row).not.toBeNull(); 32 + expect(row?.attemptId).toBe("a1"); 33 + expect(row?.did).toBe("did:plc:abc"); 34 + expect(row?.status).toBe("keys_generated"); 35 + expect(row?.pdsEndpoint).toBe("https://pds.test"); 36 + expect(row?.handle).toBe("abc.pds.test"); 37 + expect(row?.email).toBe("abc@x.test"); 38 + expect(row?.inviteCode).toBe("code-1"); 39 + expect(row?.encryptedSigningKey).toBe("sk-enc"); 40 + expect(row?.encryptedRotationKey).toBe("rk-enc"); 41 + expect(row?.encryptedPassword).toBeNull(); 42 + expect(row?.lastError).toBeNull(); 43 + expect(row?.genesisSubmittedAt).toBeNull(); 44 + expect(row?.accountCreatedAt).toBeNull(); 45 + expect(row?.didDocUpdatedAt).toBeNull(); 46 + expect(row?.activatedAt).toBeNull(); 47 + expect(row?.createdAt).toBeGreaterThanOrEqual(before); 48 + expect(row?.updatedAt).toBeGreaterThanOrEqual(before); 49 + }); 50 + 51 + it("getProvisionAttempt returns null for unknown attempt", async () => { 52 + const row = await adapter.getProvisionAttempt("no-such-attempt"); 53 + expect(row).toBeNull(); 54 + }); 55 + 56 + it("treats missing inviteCode as null", async () => { 57 + await adapter.createProvisionAttempt({ 58 + attemptId: "a-no-invite", 59 + did: "did:plc:noinv", 60 + pdsEndpoint: "https://pds.test", 61 + handle: "noinv.pds.test", 62 + email: "noinv@x.test", 63 + encryptedSigningKey: "sk", 64 + encryptedRotationKey: "rk", 65 + }); 66 + const row = await adapter.getProvisionAttempt("a-no-invite"); 67 + expect(row?.inviteCode).toBeNull(); 68 + }); 69 + 70 + it("advances status, stamps the matching timestamp, and persists last_error", async () => { 71 + await adapter.createProvisionAttempt({ 72 + attemptId: "a1", 73 + did: "did:plc:abc", 74 + pdsEndpoint: "https://pds.test", 75 + handle: "abc.pds.test", 76 + email: "abc@x.test", 77 + encryptedSigningKey: "sk-enc", 78 + encryptedRotationKey: "rk-enc", 79 + }); 80 + 81 + const initial = await adapter.getProvisionAttempt("a1"); 82 + const initialUpdated = initial!.updatedAt; 83 + 84 + // Small wait so updated_at can advance on millisecond clocks. 85 + await new Promise((r) => setTimeout(r, 2)); 86 + 87 + await adapter.updateProvisionStatus("a1", "genesis_submitted"); 88 + let row = await adapter.getProvisionAttempt("a1"); 89 + expect(row?.status).toBe("genesis_submitted"); 90 + expect(row?.genesisSubmittedAt).toBeTruthy(); 91 + expect(row?.accountCreatedAt).toBeNull(); 92 + expect(row?.didDocUpdatedAt).toBeNull(); 93 + expect(row?.activatedAt).toBeNull(); 94 + expect(row?.updatedAt).toBeGreaterThanOrEqual(initialUpdated); 95 + 96 + await adapter.updateProvisionStatus("a1", "account_created"); 97 + row = await adapter.getProvisionAttempt("a1"); 98 + expect(row?.status).toBe("account_created"); 99 + expect(row?.accountCreatedAt).toBeTruthy(); 100 + // Earlier stamp must be preserved on subsequent updates. 101 + expect(row?.genesisSubmittedAt).toBeTruthy(); 102 + 103 + await adapter.updateProvisionStatus("a1", "did_doc_updated", { lastError: "transient PLC error" }); 104 + row = await adapter.getProvisionAttempt("a1"); 105 + expect(row?.status).toBe("did_doc_updated"); 106 + expect(row?.lastError).toBe("transient PLC error"); 107 + // Earlier stamps still preserved. 108 + expect(row?.genesisSubmittedAt).toBeTruthy(); 109 + expect(row?.accountCreatedAt).toBeTruthy(); 110 + }); 111 + 112 + it("persists encryptedPassword via updateProvisionStatus", async () => { 113 + await adapter.createProvisionAttempt({ 114 + attemptId: "a-pwd", 115 + did: "did:plc:pwd", 116 + pdsEndpoint: "https://pds.test", 117 + handle: "pwd.pds.test", 118 + email: "pwd@x.test", 119 + encryptedSigningKey: "sk", 120 + encryptedRotationKey: "rk", 121 + }); 122 + expect((await adapter.getProvisionAttempt("a-pwd"))?.encryptedPassword).toBeNull(); 123 + 124 + await adapter.updateProvisionStatus("a-pwd", "account_created", { 125 + encryptedPassword: "pwd-enc", 126 + }); 127 + const row = await adapter.getProvisionAttempt("a-pwd"); 128 + expect(row?.encryptedPassword).toBe("pwd-enc"); 129 + expect(row?.accountCreatedAt).toBeTruthy(); 130 + }); 131 + 132 + describe("listStuckAttempts age threshold", () => { 133 + async function seedStuck(attemptId: string, did: string): Promise<void> { 134 + await adapter.createProvisionAttempt({ 135 + attemptId, 136 + did, 137 + pdsEndpoint: "https://pds.test", 138 + handle: `${attemptId}.pds.test`, 139 + email: `${attemptId}@x.test`, 140 + encryptedSigningKey: "sk", 141 + encryptedRotationKey: "rk", 142 + }); 143 + } 144 + /** Backdate a row's updated_at so it looks old to the age filter. */ 145 + async function ageRow(attemptId: string, ageMs: number): Promise<void> { 146 + await db 147 + .prepare(`UPDATE provision_attempts SET updated_at = ? WHERE attempt_id = ?`) 148 + .bind(Date.now() - ageMs, attemptId) 149 + .run(); 150 + } 151 + 152 + it("excludes a freshly-updated (in-flight) non-activated row", async () => { 153 + await seedStuck("fresh", "did:plc:fresh"); 154 + // updated_at is ~now; a 30-minute floor must not select it. 155 + const rows = await adapter.listStuckAttempts(30 * 60 * 1000); 156 + expect(rows.map((r) => r.attemptId)).not.toContain("fresh"); 157 + }); 158 + 159 + it("includes a row older than the threshold", async () => { 160 + await seedStuck("old", "did:plc:old"); 161 + await ageRow("old", 2 * 60 * 60 * 1000); // 2 hours ago 162 + const rows = await adapter.listStuckAttempts(30 * 60 * 1000); 163 + expect(rows.map((r) => r.attemptId)).toContain("old"); 164 + }); 165 + 166 + it("with a zero threshold returns every non-activated row", async () => { 167 + await seedStuck("a", "did:plc:a"); 168 + await seedStuck("b", "did:plc:b"); 169 + const rows = await adapter.listStuckAttempts(0); 170 + expect(rows.map((r) => r.attemptId).sort()).toEqual(["a", "b"]); 171 + }); 172 + }); 173 + 174 + describe("archiveStuckAttempt idempotency", () => { 175 + it("retry after a partial failure (archive row already present, live row stranded) does not throw and finishes the move", async () => { 176 + await adapter.createProvisionAttempt({ 177 + attemptId: "partial", 178 + did: "did:plc:partial", 179 + pdsEndpoint: "https://pds.test", 180 + handle: "partial.pds.test", 181 + email: "partial@x.test", 182 + encryptedSigningKey: "sk", 183 + encryptedRotationKey: "rk", 184 + }); 185 + // Simulate the first reap: its archive INSERT landed, but the live-row 186 + // DELETE failed, leaving the row in BOTH tables. 187 + await db 188 + .prepare( 189 + `INSERT INTO provision_attempts_archive 190 + (attempt_id, did, pds_endpoint, handle, email, invite_code, 191 + last_status, last_error, archived_at, tombstone_op_cid, notes) 192 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 193 + ) 194 + .bind( 195 + "partial", 196 + "did:plc:partial", 197 + "https://pds.test", 198 + "partial.pds.test", 199 + "partial@x.test", 200 + null, 201 + "genesis_submitted", 202 + null, 203 + Date.now(), 204 + "cid-first", 205 + null 206 + ) 207 + .run(); 208 + 209 + // The retry must not hit a PRIMARY KEY conflict on the archive INSERT. 210 + await expect( 211 + adapter.archiveStuckAttempt("partial", { tombstoneOpCid: "cid-retry" }) 212 + ).resolves.toBeUndefined(); 213 + 214 + // Live row is now gone; the archive row remains (the original landed copy). 215 + expect(await adapter.getProvisionAttempt("partial")).toBeNull(); 216 + const archive = await db 217 + .prepare("SELECT * FROM provision_attempts_archive WHERE attempt_id = ?") 218 + .bind("partial") 219 + .first<Record<string, any>>(); 220 + expect(archive).not.toBeNull(); 221 + expect(archive!.tombstone_op_cid).toBe("cid-first"); 222 + }); 223 + }); 224 + 225 + it("enforces did uniqueness across attempts", async () => { 226 + await adapter.createProvisionAttempt({ 227 + attemptId: "first", 228 + did: "did:plc:dupe", 229 + pdsEndpoint: "https://pds.test", 230 + handle: "first.pds.test", 231 + email: "first@x.test", 232 + encryptedSigningKey: "sk", 233 + encryptedRotationKey: "rk", 234 + }); 235 + await expect( 236 + adapter.createProvisionAttempt({ 237 + attemptId: "second", 238 + did: "did:plc:dupe", 239 + pdsEndpoint: "https://pds.test", 240 + handle: "second.pds.test", 241 + email: "second@x.test", 242 + encryptedSigningKey: "sk", 243 + encryptedRotationKey: "rk", 244 + }) 245 + ).rejects.toThrow(); 246 + }); 247 + });
+301
packages/contrail-community/tests/community-provision-pds-allowlist.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { Hono } from "hono"; 3 + import type { MiddlewareHandler } from "hono"; 4 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 5 + import { initSchema } from "@atmo-dev/contrail"; 6 + import { createApp } from "@atmo-dev/contrail"; 7 + import { resolveConfig } from "@atmo-dev/contrail"; 8 + import type { ContrailConfig } from "@atmo-dev/contrail"; 9 + import { createCommunityIntegration } from "../src/integration"; 10 + import { normalizePdsEndpoint } from "../src/pds"; 11 + 12 + const ALICE = "did:plc:alice"; 13 + const MASTER_KEY = new Uint8Array(32).fill(99); 14 + const ALLOWED_PDS = "https://allowed.pds.test"; 15 + const ATTACKER_PDS = "https://attacker.pds.test"; 16 + const PLC_DIRECTORY = "https://plc.test"; 17 + 18 + const FAKE_ACCESS_JWT = "head.body.sig"; 19 + 20 + async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { 21 + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; 22 + const method = init?.method ?? "GET"; 23 + const body = init?.body ? JSON.parse(init.body as string) : {}; 24 + 25 + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.describeServer`) { 26 + return new Response(JSON.stringify({ did: "did:web:allowed.pds.test" }), { 27 + status: 200, 28 + headers: { "content-type": "application/json" }, 29 + }); 30 + } 31 + if (url.startsWith(`${PLC_DIRECTORY}/`) && !url.endsWith("/log/last") && method === "POST") { 32 + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); 33 + } 34 + if (url.endsWith("/log/last") && method === "GET") { 35 + return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { 36 + status: 200, 37 + headers: { "content-type": "application/json" }, 38 + }); 39 + } 40 + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAccount` && method === "POST") { 41 + return new Response( 42 + JSON.stringify({ 43 + did: body.did, 44 + handle: body.handle, 45 + accessJwt: FAKE_ACCESS_JWT, 46 + refreshJwt: "RT", 47 + }), 48 + { status: 200, headers: { "content-type": "application/json" } } 49 + ); 50 + } 51 + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.identity.getRecommendedDidCredentials`) { 52 + return new Response( 53 + JSON.stringify({ 54 + rotationKeys: [], 55 + verificationMethods: { atproto: "did:key:zPdsSig" }, 56 + alsoKnownAs: ["at://newcomm.allowed.pds.test"], 57 + services: { 58 + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: ALLOWED_PDS }, 59 + }, 60 + }), 61 + { status: 200, headers: { "content-type": "application/json" } } 62 + ); 63 + } 64 + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.activateAccount` && method === "POST") { 65 + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); 66 + } 67 + if (url === `${ALLOWED_PDS}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { 68 + return new Response( 69 + JSON.stringify({ name: body.name, password: "minted-app-pw" }), 70 + { status: 200, headers: { "content-type": "application/json" } } 71 + ); 72 + } 73 + return new Response(`unmocked: ${method} ${url}`, { status: 404 }); 74 + } 75 + 76 + function buildConfig( 77 + allowedProvisionPdsEndpoints: string[] | undefined, 78 + extra: { allowAnyProvisionPdsEndpoint?: boolean } = {} 79 + ): ContrailConfig { 80 + return { 81 + namespace: "test.comm", 82 + collections: { message: { collection: "app.event.message" } }, 83 + spaces: { 84 + authority: { 85 + type: "tools.atmo.event.space", 86 + serviceDid: "did:web:test.example#svc", 87 + }, 88 + recordHost: {}, 89 + }, 90 + community: { 91 + masterKey: MASTER_KEY, 92 + plcDirectory: PLC_DIRECTORY, 93 + fetch: mockFetch, 94 + allowedProvisionPdsEndpoints, 95 + allowProvisioning: true, 96 + allowAnyProvisionPdsEndpoint: extra.allowAnyProvisionPdsEndpoint, 97 + }, 98 + }; 99 + } 100 + 101 + function fakeAuth(): MiddlewareHandler { 102 + return async (c, next) => { 103 + const did = c.req.header("X-Test-Did"); 104 + if (!did) return c.json({ error: "AuthRequired" }, 401); 105 + c.set("serviceAuth", { issuer: did, audience: "did:web:test.example#svc", lxm: undefined }); 106 + await next(); 107 + }; 108 + } 109 + 110 + async function makeApp( 111 + allowedProvisionPdsEndpoints: string[] | undefined, 112 + extra: { allowAnyProvisionPdsEndpoint?: boolean } = {} 113 + ): Promise<Hono> { 114 + const db = createSqliteDatabase(":memory:"); 115 + const cfg = buildConfig(allowedProvisionPdsEndpoints, extra); 116 + const resolved = resolveConfig(cfg); 117 + const community = createCommunityIntegration({ db, config: resolved }); 118 + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); 119 + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); 120 + } 121 + 122 + async function call(app: Hono, body: any): Promise<Response> { 123 + return await app.fetch( 124 + new Request(`http://localhost/xrpc/test.comm.community.provision`, { 125 + method: "POST", 126 + headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, 127 + body: JSON.stringify(body), 128 + }) 129 + ); 130 + } 131 + 132 + describe("provision pdsEndpoint allowlist (M3)", () => { 133 + it("rejects pdsEndpoint not in allowedProvisionPdsEndpoints", async () => { 134 + const app = await makeApp([ALLOWED_PDS]); 135 + const res = await call(app, { 136 + handle: "newcomm.attacker.pds.test", 137 + email: "x@x.test", 138 + password: "secret", 139 + pdsEndpoint: ATTACKER_PDS, 140 + rotationKey: "did:key:zStubCallerRotationKey", 141 + }); 142 + expect(res.status).toBe(400); 143 + const j = (await res.json()) as { error: string; message: string }; 144 + expect(j.error).toBe("InvalidRequest"); 145 + expect(j.message).toMatch(/pdsEndpoint/i); 146 + }); 147 + 148 + it("accepts pdsEndpoint that is in allowedProvisionPdsEndpoints", async () => { 149 + const app = await makeApp([ALLOWED_PDS]); 150 + const res = await call(app, { 151 + handle: "newcomm.allowed.pds.test", 152 + email: "x@x.test", 153 + password: "secret", 154 + inviteCode: "code-x", 155 + pdsEndpoint: ALLOWED_PDS, 156 + rotationKey: "did:key:zStubCallerRotationKey", 157 + }); 158 + expect(res.status).toBe(200); 159 + }); 160 + 161 + it("fails closed: provisioning enabled + undefined allowlist → rejected", async () => { 162 + const app = await makeApp(undefined); 163 + const res = await call(app, { 164 + handle: "newcomm.allowed.pds.test", 165 + email: "x@x.test", 166 + password: "secret", 167 + inviteCode: "code-x", 168 + pdsEndpoint: ALLOWED_PDS, 169 + rotationKey: "did:key:zStubCallerRotationKey", 170 + }); 171 + expect(res.status).toBe(403); 172 + const j = (await res.json()) as { error: string; message: string }; 173 + expect(j.error).toBe("ProvisioningMisconfigured"); 174 + expect(j.message).toMatch( 175 + /allowlist|allowedProvisionPdsEndpoints|allowAnyProvisionPdsEndpoint/i 176 + ); 177 + }); 178 + 179 + it("fails closed: provisioning enabled + empty allowlist → rejected", async () => { 180 + const app = await makeApp([]); 181 + const res = await call(app, { 182 + handle: "newcomm.allowed.pds.test", 183 + email: "x@x.test", 184 + password: "secret", 185 + inviteCode: "code-x", 186 + pdsEndpoint: ALLOWED_PDS, 187 + rotationKey: "did:key:zStubCallerRotationKey", 188 + }); 189 + expect(res.status).toBe(403); 190 + const j = (await res.json()) as { error: string; message: string }; 191 + expect(j.error).toBe("ProvisioningMisconfigured"); 192 + }); 193 + 194 + it("allowAnyProvisionPdsEndpoint=true is a loud opt-in: empty allowlist accepts any pdsEndpoint", async () => { 195 + const app = await makeApp([], { allowAnyProvisionPdsEndpoint: true }); 196 + const res = await call(app, { 197 + handle: "newcomm.allowed.pds.test", 198 + email: "x@x.test", 199 + password: "secret", 200 + inviteCode: "code-x", 201 + pdsEndpoint: ALLOWED_PDS, 202 + rotationKey: "did:key:zStubCallerRotationKey", 203 + }); 204 + expect(res.status).toBe(200); 205 + }); 206 + 207 + it("matches when caller adds a trailing slash to a slash-less allowlist entry", async () => { 208 + const app = await makeApp([ALLOWED_PDS]); 209 + const res = await call(app, { 210 + handle: "newcomm.allowed.pds.test", 211 + email: "x@x.test", 212 + password: "secret", 213 + inviteCode: "code-x", 214 + pdsEndpoint: `${ALLOWED_PDS}/`, 215 + rotationKey: "did:key:zStubCallerRotationKey", 216 + }); 217 + expect(res.status).toBe(200); 218 + }); 219 + 220 + it("matches when caller uppercases the scheme on an allowlisted endpoint", async () => { 221 + const app = await makeApp([ALLOWED_PDS]); 222 + const res = await call(app, { 223 + handle: "newcomm.allowed.pds.test", 224 + email: "x@x.test", 225 + password: "secret", 226 + inviteCode: "code-x", 227 + pdsEndpoint: ALLOWED_PDS.replace(/^https/, "HTTPS"), 228 + rotationKey: "did:key:zStubCallerRotationKey", 229 + }); 230 + expect(res.status).toBe(200); 231 + }); 232 + 233 + it("matches when caller appends the default :443 port", async () => { 234 + const app = await makeApp([ALLOWED_PDS]); 235 + const res = await call(app, { 236 + handle: "newcomm.allowed.pds.test", 237 + email: "x@x.test", 238 + password: "secret", 239 + inviteCode: "code-x", 240 + pdsEndpoint: ALLOWED_PDS.replace(/^https:\/\/([^/]+)/, "https://$1:443"), 241 + rotationKey: "did:key:zStubCallerRotationKey", 242 + }); 243 + expect(res.status).toBe(200); 244 + }); 245 + 246 + it("rejects pdsEndpoint that is not a parseable URL", async () => { 247 + const app = await makeApp([ALLOWED_PDS]); 248 + const res = await call(app, { 249 + handle: "newcomm.allowed.pds.test", 250 + email: "x@x.test", 251 + password: "secret", 252 + pdsEndpoint: "not a url", 253 + rotationKey: "did:key:zStubCallerRotationKey", 254 + }); 255 + expect(res.status).toBe(400); 256 + const j = (await res.json()) as { error: string; message: string }; 257 + expect(j.error).toBe("InvalidRequest"); 258 + expect(j.message).toMatch(/parseable|url/i); 259 + }); 260 + }); 261 + 262 + describe("normalizePdsEndpoint", () => { 263 + it("collapses scheme case", () => { 264 + expect(normalizePdsEndpoint("HTTPS://pds.example.com")).toBe( 265 + "https://pds.example.com" 266 + ); 267 + }); 268 + it("collapses host case", () => { 269 + expect(normalizePdsEndpoint("https://PDS.Example.com")).toBe( 270 + "https://pds.example.com" 271 + ); 272 + }); 273 + it("strips trailing slash", () => { 274 + expect(normalizePdsEndpoint("https://pds.example.com/")).toBe( 275 + "https://pds.example.com" 276 + ); 277 + }); 278 + it("strips default :443 for https", () => { 279 + expect(normalizePdsEndpoint("https://pds.example.com:443")).toBe( 280 + "https://pds.example.com" 281 + ); 282 + }); 283 + it("strips default :80 for http", () => { 284 + expect(normalizePdsEndpoint("http://pds.example.com:80")).toBe( 285 + "http://pds.example.com" 286 + ); 287 + }); 288 + it("preserves a non-default port", () => { 289 + expect(normalizePdsEndpoint("https://pds.example.com:8443")).toBe( 290 + "https://pds.example.com:8443" 291 + ); 292 + }); 293 + it("converts an IDN hostname to its punycode form", () => { 294 + expect(normalizePdsEndpoint("https://exämple.com")).toBe( 295 + "https://xn--exmple-cua.com" 296 + ); 297 + }); 298 + it("throws on an unparseable URL", () => { 299 + expect(() => normalizePdsEndpoint("not a url")).toThrow(); 300 + }); 301 + });
+336
packages/contrail-community/tests/community-provision-router.test.ts
··· 1 + import { describe, it, expect, beforeAll } from "vitest"; 2 + import { Hono } from "hono"; 3 + import type { MiddlewareHandler } from "hono"; 4 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 5 + import { initSchema } from "@atmo-dev/contrail"; 6 + import { createApp } from "@atmo-dev/contrail"; 7 + import { createCommunityIntegration } from "../src/integration"; 8 + import { resolveConfig } from "@atmo-dev/contrail"; 9 + import type { ContrailConfig } from "@atmo-dev/contrail"; 10 + 11 + const ALICE = "did:plc:alice"; 12 + const MASTER_KEY = new Uint8Array(32).fill(99); 13 + const PDS_ENDPOINT = "https://pds.test"; 14 + const PLC_DIRECTORY = "https://plc.test"; 15 + /** The DID describeServer claims for this PDS. INTENTIONALLY DIFFERENT from 16 + * CONFIG.spaces.serviceDid so tests can detect a regression where the route 17 + * falls back to the spaces DID instead of resolving the PDS DID dynamically. */ 18 + const PDS_DESCRIBE_DID = "did:web:pds.test"; 19 + 20 + /** Captures upstream calls so we can assert the right RPCs ran. */ 21 + const upstreamCalls: Array<{ url: string; method: string; body: any; authorization?: string }> = []; 22 + 23 + // Placeholder JWT — the orchestrator passes accessJwt through to PDS calls 24 + // untouched; nothing in the contrail flow parses its claims. 25 + const FAKE_ACCESS_JWT = "head.body.sig"; 26 + 27 + async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { 28 + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; 29 + const method = init?.method ?? "GET"; 30 + const body = init?.body ? JSON.parse(init.body as string) : {}; 31 + const headers = new Headers(init?.headers ?? {}); 32 + upstreamCalls.push({ 33 + url, 34 + method, 35 + body, 36 + authorization: headers.get("authorization") ?? undefined, 37 + }); 38 + 39 + // PDS describeServer — used by the route to resolve the target PDS's DID 40 + // for service-auth JWT `aud`. 41 + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer`) { 42 + return new Response(JSON.stringify({ did: PDS_DESCRIBE_DID }), { 43 + status: 200, 44 + headers: { "content-type": "application/json" }, 45 + }); 46 + } 47 + // PLC submit: POST {plcDirectory}/{did} (genesis + update share the URL). 48 + if (url.startsWith(`${PLC_DIRECTORY}/`) && url.endsWith("/log/last") === false && method === "POST") { 49 + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); 50 + } 51 + // PLC log/last: not used in the happy path but be defensive. 52 + if (url.endsWith("/log/last") && method === "GET") { 53 + return new Response(JSON.stringify({ cid: "bafyreitestcid" }), { 54 + status: 200, 55 + headers: { "content-type": "application/json" }, 56 + }); 57 + } 58 + // PDS createAccount. 59 + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` && method === "POST") { 60 + return new Response( 61 + JSON.stringify({ 62 + did: body.did, 63 + handle: body.handle, 64 + accessJwt: FAKE_ACCESS_JWT, 65 + refreshJwt: "RT", 66 + }), 67 + { status: 200, headers: { "content-type": "application/json" } } 68 + ); 69 + } 70 + // PDS getRecommendedDidCredentials. 71 + if ( 72 + url === `${PDS_ENDPOINT}/xrpc/com.atproto.identity.getRecommendedDidCredentials` 73 + ) { 74 + return new Response( 75 + JSON.stringify({ 76 + rotationKeys: [], 77 + verificationMethods: { atproto: "did:key:zPdsSig" }, 78 + alsoKnownAs: ["at://newcomm.pds.test"], 79 + services: { 80 + atproto_pds: { 81 + type: "AtprotoPersonalDataServer", 82 + endpoint: PDS_ENDPOINT, 83 + }, 84 + }, 85 + }), 86 + { status: 200, headers: { "content-type": "application/json" } } 87 + ); 88 + } 89 + // PDS activateAccount. 90 + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.activateAccount` && method === "POST") { 91 + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); 92 + } 93 + // PDS createAppPassword (post-activation, mints publishing credential). 94 + if (url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAppPassword` && method === "POST") { 95 + return new Response( 96 + JSON.stringify({ name: body.name, password: "minted-app-pw" }), 97 + { status: 200, headers: { "content-type": "application/json" } } 98 + ); 99 + } 100 + 101 + return new Response(`unmocked: ${method} ${url}`, { status: 404 }); 102 + } 103 + 104 + const CONFIG: ContrailConfig = { 105 + namespace: "test.comm", 106 + collections: { message: { collection: "app.event.message" } }, 107 + spaces: { 108 + authority: { 109 + type: "tools.atmo.event.space", 110 + serviceDid: "did:web:test.example#svc", 111 + }, 112 + recordHost: {}, 113 + }, 114 + community: { 115 + masterKey: MASTER_KEY, 116 + plcDirectory: PLC_DIRECTORY, 117 + fetch: mockFetch, 118 + allowProvisioning: true, 119 + // Fail-closed requires a non-empty allowlist when provisioning is enabled. 120 + allowedProvisionPdsEndpoints: [PDS_ENDPOINT], 121 + }, 122 + }; 123 + 124 + function fakeAuth(): MiddlewareHandler { 125 + return async (c, next) => { 126 + const did = c.req.header("X-Test-Did"); 127 + if (!did) return c.json({ error: "AuthRequired" }, 401); 128 + c.set("serviceAuth", { issuer: did, audience: CONFIG.spaces!.authority!.serviceDid, lxm: undefined }); 129 + await next(); 130 + }; 131 + } 132 + 133 + async function makeApp(): Promise<Hono> { 134 + const db = createSqliteDatabase(":memory:"); 135 + const resolved = resolveConfig(CONFIG); 136 + const community = createCommunityIntegration({ db, config: resolved }); 137 + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); 138 + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); 139 + } 140 + 141 + async function call( 142 + app: Hono, 143 + method: string, 144 + path: string, 145 + did: string | null, 146 + body?: any 147 + ): Promise<Response> { 148 + const headers: Record<string, string> = {}; 149 + if (did !== null) headers["X-Test-Did"] = did; 150 + if (body !== undefined) headers["Content-Type"] = "application/json"; 151 + return await app.fetch( 152 + new Request(`http://localhost${path}`, { 153 + method, 154 + headers, 155 + body: body !== undefined ? JSON.stringify(body) : undefined, 156 + }) 157 + ); 158 + } 159 + 160 + describe("POST /xrpc/{ns}.community.provision (allowProvisioning gate)", () => { 161 + // Builds an app whose community config OMITS allowProvisioning. The route 162 + // is expected to refuse with 403 ProvisioningDisabled — operators must 163 + // explicitly opt in. The default-deny posture protects deployments where 164 + // the auth middleware allows broader audiences than "operator only" from 165 + // having any authenticated caller mint communities + burn invite codes. 166 + async function makeAppWithoutAllowProvisioning(): Promise<Hono> { 167 + const db = createSqliteDatabase(":memory:"); 168 + const configWithoutFlag: ContrailConfig = { 169 + ...CONFIG, 170 + community: { ...CONFIG.community!, allowProvisioning: undefined } as any, 171 + }; 172 + const resolved = resolveConfig(configWithoutFlag); 173 + const community = createCommunityIntegration({ db, config: resolved }); 174 + await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); 175 + return createApp(db, resolved, { spaces: { authMiddleware: fakeAuth() }, community }); 176 + } 177 + 178 + it("returns 403 ProvisioningDisabled when allowProvisioning is not set", async () => { 179 + const app = await makeAppWithoutAllowProvisioning(); 180 + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { 181 + handle: "newcomm.pds.test", 182 + email: "newcomm@x.test", 183 + password: "secret", 184 + pdsEndpoint: PDS_ENDPOINT, 185 + rotationKey: "did:key:zStubCallerRotationKey", 186 + }); 187 + expect(res.status).toBe(403); 188 + const body = (await res.json()) as { error: string }; 189 + expect(body.error).toBe("ProvisioningDisabled"); 190 + }); 191 + }); 192 + 193 + describe("POST /xrpc/{ns}.community.provision", () => { 194 + let app: Hono; 195 + 196 + beforeAll(async () => { 197 + app = await makeApp(); 198 + }); 199 + 200 + it("requires auth", async () => { 201 + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", null, { 202 + handle: "x.pds.test", 203 + email: "x@x.test", 204 + password: "p", 205 + pdsEndpoint: PDS_ENDPOINT, 206 + rotationKey: "did:key:zStubCallerRotationKey", 207 + }); 208 + expect(res.status).toBe(401); 209 + }); 210 + 211 + it("rejects missing required fields", async () => { 212 + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, {}); 213 + expect(res.status).toBe(400); 214 + const j = (await res.json()) as { error: string }; 215 + expect(j.error).toBe("InvalidRequest"); 216 + }); 217 + 218 + it("provisions a community and returns did + status=activated", async () => { 219 + const before = upstreamCalls.length; 220 + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { 221 + handle: "newcomm.pds.test", 222 + email: "newcomm@x.test", 223 + password: "secret", 224 + inviteCode: "code-x", 225 + pdsEndpoint: PDS_ENDPOINT, 226 + rotationKey: "did:key:zStubCallerRotationKey", 227 + }); 228 + expect(res.status).toBe(200); 229 + const body = (await res.json()) as { communityDid: string; status: string }; 230 + 231 + expect(body.communityDid).toMatch(/^did:plc:[a-z2-7]{24}$/); 232 + expect(body.status).toBe("activated"); 233 + 234 + // Verify the row was inserted into communities with mode='provision'. 235 + // We round-trip via the GET list endpoint so we don't have to reach into 236 + // the adapter — the route bootstrapped reserved spaces with the caller as 237 + // owner, which makes the community reachable. 238 + const listRes = await call(app, "GET", "/xrpc/test.comm.community.list", ALICE); 239 + expect(listRes.status).toBe(200); 240 + const list = (await listRes.json()) as { 241 + communities: Array<{ did: string; mode: string }>; 242 + }; 243 + const row = list.communities.find((r) => r.did === body.communityDid); 244 + expect(row).toBeDefined(); 245 + expect(row!.mode).toBe("provision"); 246 + 247 + // Confirm we touched all five upstream RPCs: 2 PLC posts (genesis + update), 248 + // createAccount, getRecommendedDidCredentials, activateAccount. 249 + const ourCalls = upstreamCalls.slice(before); 250 + const plcPosts = ourCalls.filter( 251 + (c) => c.url.startsWith(`${PLC_DIRECTORY}/`) && c.method === "POST" 252 + ); 253 + expect(plcPosts.length).toBe(2); 254 + expect( 255 + ourCalls.some((c) => 256 + c.url.endsWith("/xrpc/com.atproto.server.createAccount") 257 + ) 258 + ).toBe(true); 259 + expect( 260 + ourCalls.some((c) => 261 + c.url.endsWith("/xrpc/com.atproto.identity.getRecommendedDidCredentials") 262 + ) 263 + ).toBe(true); 264 + expect( 265 + ourCalls.some((c) => 266 + c.url.endsWith("/xrpc/com.atproto.server.activateAccount") 267 + ) 268 + ).toBe(true); 269 + }); 270 + 271 + it("is idempotent on retry with the same attemptId after a fully-completed first call", async () => { 272 + // The route already returns attemptId on every error response so a 273 + // caller can retry. This guards the case where the first call 274 + // succeeded end-to-end (orchestrator + graduation + reserved spaces) 275 + // but the caller didn't receive the 200 (e.g. lost connection): a 276 + // resent request with the same attemptId must still 200, return the 277 + // same DID, and not double-create rows. 278 + const attemptId = "retry-idem-1"; 279 + const body = { 280 + attemptId, 281 + handle: "retryidem.pds.test", 282 + email: "retryidem@x.test", 283 + password: "secret", 284 + pdsEndpoint: PDS_ENDPOINT, 285 + rotationKey: "did:key:zStubCallerRotationKey", 286 + }; 287 + 288 + const first = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); 289 + expect(first.status).toBe(200); 290 + const firstJson = (await first.json()) as { communityDid: string }; 291 + 292 + const second = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, body); 293 + expect(second.status).toBe(200); 294 + const secondJson = (await second.json()) as { communityDid: string }; 295 + 296 + expect(secondJson.communityDid).toBe(firstJson.communityDid); 297 + }); 298 + 299 + it("uses the describeServer-returned DID as the service-auth JWT audience (not cfg.serviceDid)", async () => { 300 + const before = upstreamCalls.length; 301 + const res = await call(app, "POST", "/xrpc/test.comm.community.provision", ALICE, { 302 + handle: "audtest.pds.test", 303 + email: "audtest@x.test", 304 + password: "secret", 305 + pdsEndpoint: PDS_ENDPOINT, 306 + rotationKey: "did:key:zStubCallerRotationKey", 307 + }); 308 + expect(res.status).toBe(200); 309 + 310 + const ourCalls = upstreamCalls.slice(before); 311 + 312 + // 1. The route must call describeServer on the target PDS. 313 + const describeCall = ourCalls.find( 314 + (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.describeServer` 315 + ); 316 + expect(describeCall).toBeDefined(); 317 + 318 + // 2. The createAccount call's Authorization Bearer JWT must have 319 + // `aud` === the describeServer-returned DID, NOT cfg.serviceDid. 320 + const createAccountCall = ourCalls.find( 321 + (c) => c.url === `${PDS_ENDPOINT}/xrpc/com.atproto.server.createAccount` 322 + ); 323 + expect(createAccountCall).toBeDefined(); 324 + expect(createAccountCall!.authorization).toMatch(/^Bearer /); 325 + 326 + const jwt = createAccountCall!.authorization!.replace(/^Bearer /, ""); 327 + const payloadSeg = jwt.split(".")[1]!; 328 + const padded = payloadSeg.replace(/-/g, "+").replace(/_/g, "/"); 329 + const padding = "=".repeat((4 - (padded.length % 4)) % 4); 330 + const claims = JSON.parse(atob(padded + padding)) as { aud?: string }; 331 + 332 + expect(claims.aud).toBe(PDS_DESCRIBE_DID); 333 + // Sanity: it is NOT the spaces serviceDid (the previous hardcoded value). 334 + expect(claims.aud).not.toBe(CONFIG.spaces!.authority!.serviceDid); 335 + }); 336 + });
+150
packages/contrail-community/tests/community-publish-401-clears-session.test.ts
··· 1 + /** L6: A 401 from the publish path used to leave the bad session in the 2 + * cache, so every subsequent publish hit the same 401 permanently. The fix 3 + * is small: on 401, drop the cached session row. The next request goes cold 4 + * through ensureSession, which mints a fresh session from the stored app 5 + * password (or fails permanently if the app password itself was revoked). */ 6 + 7 + import { describe, it, expect, beforeEach } from "vitest"; 8 + import { Hono } from "hono"; 9 + import type { MiddlewareHandler } from "hono"; 10 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 11 + import { initSchema } from "@atmo-dev/contrail"; 12 + import { createApp } from "@atmo-dev/contrail"; 13 + import { createCommunityIntegration } from "../src/integration"; 14 + import { resolveConfig } from "@atmo-dev/contrail"; 15 + import type { ContrailConfig } from "@atmo-dev/contrail"; 16 + import { 17 + CommunityAdapter, 18 + CredentialCipher, 19 + RESERVED_KEYS, 20 + } from "../src"; 21 + import { HostedAdapter } from "@atmo-dev/contrail"; 22 + import { buildSpaceUri } from "@atmo-dev/contrail"; 23 + 24 + const ALICE = "did:plc:alice"; 25 + const COMMUNITY_DID = "did:plc:l6comm"; 26 + const HANDLE = "l6.pds.test"; 27 + const PDS = "https://pds.example"; 28 + const MASTER_KEY = new Uint8Array(32).fill(13); 29 + const APP_PASSWORD = "correct-pw"; 30 + 31 + function fakeAuth(spaceServiceDid: string): MiddlewareHandler { 32 + return async (c, next) => { 33 + const did = c.req.header("X-Test-Did"); 34 + if (!did) return c.json({ error: "AuthRequired" }, 401); 35 + c.set("serviceAuth", { 36 + issuer: did, 37 + audience: spaceServiceDid, 38 + lxm: undefined, 39 + }); 40 + await next(); 41 + }; 42 + } 43 + 44 + async function build(): Promise<{ app: Hono; adapter: CommunityAdapter }> { 45 + const fetchImpl: typeof fetch = (async (input: RequestInfo | URL) => { 46 + const url = 47 + typeof input === "string" 48 + ? input 49 + : input instanceof URL 50 + ? input.href 51 + : input.url; 52 + if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { 53 + return new Response(JSON.stringify({ error: "AuthRequired" }), { 54 + status: 401, 55 + }); 56 + } 57 + return new Response("not found", { status: 404 }); 58 + }) as typeof fetch; 59 + 60 + const config: ContrailConfig = { 61 + namespace: "test.comm", 62 + collections: { message: { collection: "app.event.message" } }, 63 + spaces: { 64 + authority: { 65 + type: "tools.atmo.event.space", 66 + serviceDid: "did:web:test.example#svc", 67 + }, 68 + recordHost: {}, 69 + }, 70 + community: { masterKey: MASTER_KEY, fetch: fetchImpl }, 71 + }; 72 + const db = createSqliteDatabase(":memory:"); 73 + const resolved = resolveConfig(config); 74 + const communityIntegration = createCommunityIntegration({ db, config: resolved }); 75 + await initSchema(db, resolved, { extraSchemas: [communityIntegration.applySchema] }); 76 + const app = createApp(db, resolved, { 77 + spaces: { authMiddleware: fakeAuth(config.spaces!.authority!.serviceDid) }, 78 + community: communityIntegration, 79 + }); 80 + 81 + const cipher = new CredentialCipher(MASTER_KEY); 82 + const community = new CommunityAdapter(db); 83 + const spaces = new HostedAdapter(db, resolved); 84 + await community.createFromProvisioned({ 85 + did: COMMUNITY_DID, 86 + pdsEndpoint: PDS, 87 + handle: HANDLE, 88 + appPasswordEncrypted: await cipher.encrypt(APP_PASSWORD), 89 + createdBy: ALICE, 90 + }); 91 + for (const key of RESERVED_KEYS) { 92 + const uri = buildSpaceUri({ 93 + ownerDid: COMMUNITY_DID, 94 + type: config.spaces!.authority!.type, 95 + key, 96 + }); 97 + await spaces.createSpace({ 98 + uri, 99 + ownerDid: COMMUNITY_DID, 100 + type: config.spaces!.authority!.type, 101 + key, 102 + serviceDid: config.spaces!.authority!.serviceDid, 103 + appPolicyRef: null, 104 + appPolicy: null, 105 + }); 106 + await community.grant({ 107 + spaceUri: uri, 108 + subjectDid: ALICE, 109 + accessLevel: "owner", 110 + grantedBy: ALICE, 111 + }); 112 + await spaces.applyMembershipDiff(uri, [ALICE], [], ALICE); 113 + } 114 + return { app, adapter: community }; 115 + } 116 + 117 + describe("publish path: 401 clears the session cache (L6)", () => { 118 + let app: Hono; 119 + let adapter: CommunityAdapter; 120 + 121 + beforeEach(async () => { 122 + ({ app, adapter } = await build()); 123 + }); 124 + 125 + it("removes the cached session row when createRecord returns 401", async () => { 126 + // Seed a cached session that will be used (and rejected) by createRecord. 127 + await adapter.upsertSession(COMMUNITY_DID, { 128 + accessJwt: "stale-access", 129 + refreshJwt: "stale-refresh", 130 + accessExp: Math.floor(Date.now() / 1000) + 3600, 131 + }); 132 + expect(await adapter.getSession(COMMUNITY_DID)).not.toBeNull(); 133 + 134 + const res = await app.fetch( 135 + new Request("http://localhost/xrpc/test.comm.community.putRecord", { 136 + method: "POST", 137 + headers: { "X-Test-Did": ALICE, "Content-Type": "application/json" }, 138 + body: JSON.stringify({ 139 + communityDid: COMMUNITY_DID, 140 + collection: "app.event.message", 141 + record: { text: "hello" }, 142 + }), 143 + }) 144 + ); 145 + expect(res.status).toBe(502); 146 + 147 + // The stale session must be gone, so the next attempt mints a fresh one. 148 + expect(await adapter.getSession(COMMUNITY_DID)).toBeNull(); 149 + }); 150 + });
+366 -9
packages/contrail-community/tests/community-publishing.test.ts
··· 5 5 import { initSchema } from "@atmo-dev/contrail"; 6 6 import { createApp } from "@atmo-dev/contrail"; 7 7 import { resolveConfig } from "@atmo-dev/contrail"; 8 - import type { ContrailConfig } from "@atmo-dev/contrail"; 8 + import type { ContrailConfig, Database } from "@atmo-dev/contrail"; 9 9 import { createCommunityIntegration } from "../src/integration"; 10 + import { CommunityAdapter, CredentialCipher, RESERVED_KEYS } from "../src"; 11 + import { HostedAdapter } from "@atmo-dev/contrail"; 12 + import { buildSpaceUri } from "@atmo-dev/contrail"; 10 13 11 14 const ALICE = "did:plc:alice"; 12 15 const BOB = "did:plc:bob"; 13 16 const CHARLIE = "did:plc:charlie"; 14 17 const COMMUNITY_DID = "did:plc:pubcomm"; 18 + const PROVISION_COMMUNITY_DID = "did:plc:provcomm"; 19 + const PROVISION_HANDLE = "provcomm.pds.test"; 15 20 const PDS_ENDPOINT = "https://pds.example"; 16 21 17 22 const MASTER_KEY = new Uint8Array(32).fill(42); ··· 39 44 function mockResolver(): any { 40 45 return { 41 46 resolve: async (did: string) => { 42 - if (did !== COMMUNITY_DID) throw new Error("unknown did"); 47 + if (did !== COMMUNITY_DID && did !== PROVISION_COMMUNITY_DID) { 48 + throw new Error("unknown did"); 49 + } 43 50 return { 44 51 id: did, 45 52 service: [ ··· 60 67 pdsCalls.push({ url, body }); 61 68 if (url.endsWith("/xrpc/com.atproto.server.createSession")) { 62 69 if (body.password === "correct-pw" || body.password === "new-correct-pw") { 70 + // Echo back a DID that matches the identifier so adopt and provision flows 71 + // both look right to any caller checking session.did. 72 + const did = 73 + body.identifier === PROVISION_HANDLE ? PROVISION_COMMUNITY_DID : COMMUNITY_DID; 63 74 return new Response( 64 - JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did: COMMUNITY_DID }), 75 + JSON.stringify({ accessJwt: "a.b.c", refreshJwt: "r.r.r", did }), 65 76 { status: 200, headers: { "content-type": "application/json" } } 66 77 ); 67 78 } ··· 70 81 if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { 71 82 return new Response( 72 83 JSON.stringify({ 73 - uri: `at://${COMMUNITY_DID}/${body.collection}/fakerkey`, 84 + uri: `at://${body.repo}/${body.collection}/fakerkey`, 74 85 cid: "bafyfake", 75 86 }), 76 87 { status: 200, headers: { "content-type": "application/json" } } ··· 91 102 }; 92 103 } 93 104 94 - async function makeApp(): Promise<Hono> { 105 + async function makeApp(): Promise<{ app: Hono; db: Database }> { 95 106 const db = createSqliteDatabase(":memory:"); 96 107 const resolved = resolveConfig(CONFIG); 97 108 const community = createCommunityIntegration({ db, config: resolved }); 98 109 await initSchema(db, resolved, { extraSchemas: [community.applySchema] }); 99 - return createApp(db, resolved, { 110 + const app = createApp(db, resolved, { 100 111 spaces: { authMiddleware: fakeAuth() }, 101 112 community, 102 113 }); 114 + return { app, db }; 103 115 } 104 116 105 - function call( 117 + /** Seed a provision-mode community + its reserved spaces with `creator` as 118 + * owner. Mirrors what the adopt/provision routes do via `bootstrapReservedSpaces`, 119 + * but skips the route so we don't have to mock PLC + 5 PDS RPCs. */ 120 + async function seedProvisionCommunity( 121 + db: Database, 122 + creator: string, 123 + password: string 124 + ): Promise<void> { 125 + const cipher = new CredentialCipher(MASTER_KEY); 126 + const encrypted = await cipher.encrypt(password); 127 + const community = new CommunityAdapter(db); 128 + const spaces = new HostedAdapter(db, resolveConfig(CONFIG)); 129 + await community.createFromProvisioned({ 130 + did: PROVISION_COMMUNITY_DID, 131 + pdsEndpoint: PDS_ENDPOINT, 132 + handle: PROVISION_HANDLE, 133 + appPasswordEncrypted: encrypted, 134 + createdBy: creator, 135 + }); 136 + for (const key of RESERVED_KEYS) { 137 + const uri = buildSpaceUri({ 138 + ownerDid: PROVISION_COMMUNITY_DID, 139 + type: CONFIG.spaces!.authority!.type, 140 + key, 141 + }); 142 + await spaces.createSpace({ 143 + uri, 144 + ownerDid: PROVISION_COMMUNITY_DID, 145 + type: CONFIG.spaces!.authority!.type, 146 + key, 147 + serviceDid: CONFIG.spaces!.authority!.serviceDid, 148 + appPolicyRef: null, 149 + appPolicy: null, 150 + }); 151 + await community.grant({ 152 + spaceUri: uri, 153 + subjectDid: creator, 154 + accessLevel: "owner", 155 + grantedBy: creator, 156 + }); 157 + await spaces.applyMembershipDiff(uri, [creator], [], creator); 158 + } 159 + } 160 + 161 + async function call( 106 162 app: Hono, 107 163 method: string, 108 164 path: string, ··· 111 167 ): Promise<Response> { 112 168 const headers: Record<string, string> = { "X-Test-Did": did }; 113 169 if (body !== undefined) headers["Content-Type"] = "application/json"; 114 - return app.fetch( 170 + return await app.fetch( 115 171 new Request(`http://localhost${path}`, { 116 172 method, 117 173 headers, ··· 144 200 const admin = `ats://${COMMUNITY_DID}/tools.atmo.event.space/$admin`; 145 201 146 202 beforeAll(async () => { 147 - app = await makeApp(); 203 + ({ app } = await makeApp()); 148 204 await adopt(app, ALICE, "correct-pw"); 149 205 }); 150 206 ··· 273 329 expect(res.status).toBe(401); 274 330 }); 275 331 }); 332 + 333 + // Build a small base64url-encoded JWT with a given exp claim. The publishing 334 + // path decodes payload.exp to decide whether to reuse a cached session. 335 + function jwtWithExp(expSeconds: number): string { 336 + // base64url("{}") padding stripped — header content irrelevant to our tests. 337 + const header = "eyJhbGciOiJIUzI1NiJ9"; 338 + const payloadJson = JSON.stringify({ exp: expSeconds }); 339 + const payload = btoa(payloadJson) 340 + .replace(/\+/g, "-") 341 + .replace(/\//g, "_") 342 + .replace(/=+$/, ""); 343 + return `${header}.${payload}.sig`; 344 + } 345 + 346 + /** Build an isolated app with a per-test scenario fetch + a fresh provision 347 + * community. The scenario fetch records every call with its url + body + 348 + * authorization header so individual tests can assert exact behavior. */ 349 + async function makeScenarioApp(scenario: { 350 + /** Override response for createSession. Default: success with default JWT. */ 351 + onCreateSession?: () => Response; 352 + /** Override response for refreshSession. Default: 400 (no refresh). */ 353 + onRefreshSession?: () => Response; 354 + }): Promise<{ 355 + app: Hono; 356 + db: Database; 357 + calls: Array<{ url: string; body: any; authorization: string | null }>; 358 + }> { 359 + const calls: Array<{ url: string; body: any; authorization: string | null }> = []; 360 + const scenarioFetch = async ( 361 + input: RequestInfo | URL, 362 + init?: RequestInit 363 + ): Promise<Response> => { 364 + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; 365 + const body = init?.body ? JSON.parse(init.body as string) : {}; 366 + const headers = new Headers((init?.headers as HeadersInit) ?? {}); 367 + const authorization = headers.get("authorization"); 368 + calls.push({ url, body, authorization }); 369 + if (url.endsWith("/xrpc/com.atproto.server.createSession")) { 370 + if (scenario.onCreateSession) return scenario.onCreateSession(); 371 + return new Response( 372 + JSON.stringify({ 373 + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), 374 + refreshJwt: "r.r.r", 375 + did: PROVISION_COMMUNITY_DID, 376 + }), 377 + { status: 200, headers: { "content-type": "application/json" } } 378 + ); 379 + } 380 + if (url.endsWith("/xrpc/com.atproto.server.refreshSession")) { 381 + if (scenario.onRefreshSession) return scenario.onRefreshSession(); 382 + return new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }); 383 + } 384 + if (url.endsWith("/xrpc/com.atproto.repo.createRecord")) { 385 + return new Response( 386 + JSON.stringify({ 387 + uri: `at://${body.repo}/${body.collection}/scenkey`, 388 + cid: "bafyfake", 389 + }), 390 + { status: 200, headers: { "content-type": "application/json" } } 391 + ); 392 + } 393 + if (url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) { 394 + return new Response("{}", { 395 + status: 200, 396 + headers: { "content-type": "application/json" }, 397 + }); 398 + } 399 + return new Response("not found", { status: 404 }); 400 + }; 401 + 402 + const cfg: ContrailConfig = { 403 + ...CONFIG, 404 + community: { ...CONFIG.community!, fetch: scenarioFetch }, 405 + }; 406 + const db = createSqliteDatabase(":memory:"); 407 + const resolved = resolveConfig(cfg); 408 + const communityIntegration = createCommunityIntegration({ db, config: resolved }); 409 + await initSchema(db, resolved, { extraSchemas: [communityIntegration.applySchema] }); 410 + const app = createApp(db, resolved, { 411 + spaces: { authMiddleware: fakeAuth() }, 412 + community: communityIntegration, 413 + }); 414 + // Seed provision community + Alice as owner of $publishers. 415 + const cipher = new CredentialCipher(MASTER_KEY); 416 + const encrypted = await cipher.encrypt("correct-pw"); 417 + const community = new CommunityAdapter(db); 418 + const spacesAdp = new HostedAdapter(db, resolved); 419 + await community.createFromProvisioned({ 420 + did: PROVISION_COMMUNITY_DID, 421 + pdsEndpoint: PDS_ENDPOINT, 422 + handle: PROVISION_HANDLE, 423 + appPasswordEncrypted: encrypted, 424 + createdBy: ALICE, 425 + }); 426 + for (const key of RESERVED_KEYS) { 427 + const uri = buildSpaceUri({ 428 + ownerDid: PROVISION_COMMUNITY_DID, 429 + type: CONFIG.spaces!.authority!.type, 430 + key, 431 + }); 432 + await spacesAdp.createSpace({ 433 + uri, 434 + ownerDid: PROVISION_COMMUNITY_DID, 435 + type: CONFIG.spaces!.authority!.type, 436 + key, 437 + serviceDid: CONFIG.spaces!.authority!.serviceDid, 438 + appPolicyRef: null, 439 + appPolicy: null, 440 + }); 441 + await community.grant({ 442 + spaceUri: uri, 443 + subjectDid: ALICE, 444 + accessLevel: "owner", 445 + grantedBy: ALICE, 446 + }); 447 + await spacesAdp.applyMembershipDiff(uri, [ALICE], [], ALICE); 448 + } 449 + return { app, db, calls }; 450 + } 451 + 452 + describe("community publishing — session caching (Task 14)", () => { 453 + it("caches PDS sessions across putRecord calls", async () => { 454 + const { app, calls } = await makeScenarioApp({}); 455 + 456 + for (let i = 0; i < 3; i++) { 457 + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { 458 + communityDid: PROVISION_COMMUNITY_DID, 459 + collection: "app.event.message", 460 + record: { text: `msg ${i}` }, 461 + }); 462 + expect(res.status).toBe(200); 463 + } 464 + 465 + const createSessionCalls = calls.filter((c) => 466 + c.url.endsWith("/xrpc/com.atproto.server.createSession") 467 + ).length; 468 + const createRecordCalls = calls.filter((c) => 469 + c.url.endsWith("/xrpc/com.atproto.repo.createRecord") 470 + ).length; 471 + expect(createSessionCalls).toBe(1); 472 + expect(createRecordCalls).toBe(3); 473 + }); 474 + 475 + it("considers a session valid when accessExp is in the future", async () => { 476 + const { app, db, calls } = await makeScenarioApp({}); 477 + // Pre-seed cache with a clearly-future expiry. 478 + const community = new CommunityAdapter(db); 479 + const cachedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); 480 + await community.upsertSession(PROVISION_COMMUNITY_DID, { 481 + accessJwt: cachedAccess, 482 + refreshJwt: "cached-refresh", 483 + accessExp: Math.floor(Date.now() / 1000) + 3600, 484 + }); 485 + 486 + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { 487 + communityDid: PROVISION_COMMUNITY_DID, 488 + collection: "app.event.message", 489 + record: { text: "uses cached session" }, 490 + }); 491 + expect(res.status).toBe(200); 492 + 493 + const createSessionCalls = calls.filter((c) => 494 + c.url.endsWith("/xrpc/com.atproto.server.createSession") 495 + ).length; 496 + expect(createSessionCalls).toBe(0); 497 + // The createRecord call must have used the cached accessJwt. 498 + const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); 499 + expect(cr).toBeDefined(); 500 + expect(cr!.authorization).toBe(`Bearer ${cachedAccess}`); 501 + }); 502 + 503 + it("refreshes a near-expired session via refreshSession", async () => { 504 + const refreshedAccess = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); 505 + const { app, db, calls } = await makeScenarioApp({ 506 + onRefreshSession: () => 507 + new Response( 508 + JSON.stringify({ accessJwt: refreshedAccess, refreshJwt: "new-refresh" }), 509 + { status: 200, headers: { "content-type": "application/json" } } 510 + ), 511 + }); 512 + const community = new CommunityAdapter(db); 513 + await community.upsertSession(PROVISION_COMMUNITY_DID, { 514 + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), 515 + refreshJwt: "old-refresh", 516 + accessExp: Math.floor(Date.now() / 1000) - 60, 517 + }); 518 + 519 + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { 520 + communityDid: PROVISION_COMMUNITY_DID, 521 + collection: "app.event.message", 522 + record: { text: "after refresh" }, 523 + }); 524 + expect(res.status).toBe(200); 525 + 526 + const createSessionCalls = calls.filter((c) => 527 + c.url.endsWith("/xrpc/com.atproto.server.createSession") 528 + ).length; 529 + const refreshSessionCalls = calls.filter((c) => 530 + c.url.endsWith("/xrpc/com.atproto.server.refreshSession") 531 + ).length; 532 + expect(createSessionCalls).toBe(0); 533 + expect(refreshSessionCalls).toBe(1); 534 + // createRecord must use the refreshed access JWT. 535 + const cr = calls.find((c) => c.url.endsWith("/xrpc/com.atproto.repo.createRecord")); 536 + expect(cr!.authorization).toBe(`Bearer ${refreshedAccess}`); 537 + }); 538 + 539 + it("falls back to createSession when refresh fails", async () => { 540 + const { app, db, calls } = await makeScenarioApp({ 541 + onRefreshSession: () => 542 + new Response(JSON.stringify({ error: "ExpiredToken" }), { status: 400 }), 543 + }); 544 + const community = new CommunityAdapter(db); 545 + await community.upsertSession(PROVISION_COMMUNITY_DID, { 546 + accessJwt: jwtWithExp(Math.floor(Date.now() / 1000) - 60), 547 + refreshJwt: "stale-refresh", 548 + accessExp: Math.floor(Date.now() / 1000) - 60, 549 + }); 550 + 551 + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { 552 + communityDid: PROVISION_COMMUNITY_DID, 553 + collection: "app.event.message", 554 + record: { text: "fallback to create" }, 555 + }); 556 + expect(res.status).toBe(200); 557 + 558 + const createSessionCalls = calls.filter((c) => 559 + c.url.endsWith("/xrpc/com.atproto.server.createSession") 560 + ).length; 561 + const refreshSessionCalls = calls.filter((c) => 562 + c.url.endsWith("/xrpc/com.atproto.server.refreshSession") 563 + ).length; 564 + expect(refreshSessionCalls).toBe(1); 565 + expect(createSessionCalls).toBe(1); 566 + }); 567 + }); 568 + 569 + describe("community publishing — provision mode", () => { 570 + let app: Hono; 571 + 572 + beforeAll(async () => { 573 + const built = await makeApp(); 574 + app = built.app; 575 + await seedProvisionCommunity(built.db, ALICE, "correct-pw"); 576 + }); 577 + 578 + it("publishes a record under a provision-mode community", async () => { 579 + const before = pdsCalls.length; 580 + const res = await call(app, "POST", "/xrpc/test.comm.community.putRecord", ALICE, { 581 + communityDid: PROVISION_COMMUNITY_DID, 582 + collection: "app.event.message", 583 + record: { text: "hello from a provisioned community" }, 584 + }); 585 + expect(res.status).toBe(200); 586 + const body = (await res.json()) as any; 587 + expect(body.uri).toBe(`at://${PROVISION_COMMUNITY_DID}/app.event.message/fakerkey`); 588 + 589 + const newCalls = pdsCalls.slice(before); 590 + expect( 591 + newCalls.some( 592 + (c) => 593 + c.url.endsWith("/xrpc/com.atproto.server.createSession") && 594 + c.body.identifier === PROVISION_HANDLE 595 + ) 596 + ).toBe(true); 597 + expect( 598 + newCalls.some( 599 + (c) => 600 + c.url.endsWith("/xrpc/com.atproto.repo.createRecord") && 601 + c.body.repo === PROVISION_COMMUNITY_DID 602 + ) 603 + ).toBe(true); 604 + }); 605 + 606 + it("deletes a record under a provision-mode community", async () => { 607 + const before = pdsCalls.length; 608 + const res = await call(app, "POST", "/xrpc/test.comm.community.deleteRecord", ALICE, { 609 + communityDid: PROVISION_COMMUNITY_DID, 610 + collection: "app.event.message", 611 + rkey: "fakerkey", 612 + }); 613 + expect(res.status).toBe(200); 614 + expect(((await res.json()) as any).ok).toBe(true); 615 + 616 + const newCalls = pdsCalls.slice(before); 617 + expect( 618 + newCalls.some((c) => c.url.endsWith("/xrpc/com.atproto.repo.deleteRecord")) 619 + ).toBe(true); 620 + }); 621 + 622 + it("reports healthy for a provision-mode community", async () => { 623 + const res = await call( 624 + app, 625 + "GET", 626 + `/xrpc/test.comm.community.getHealth?communityDid=${PROVISION_COMMUNITY_DID}`, 627 + ALICE 628 + ); 629 + expect(res.status).toBe(200); 630 + expect(((await res.json()) as any).status).toBe("healthy"); 631 + }); 632 + });
+85
packages/contrail-community/tests/community-sessions-cache.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import { initCommunitySchema } from "../src/schema"; 3 + import { CommunityAdapter } from "../src/adapter"; 4 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 5 + 6 + describe("community_sessions cache", () => { 7 + let adapter: CommunityAdapter; 8 + 9 + beforeEach(async () => { 10 + const db = createSqliteDatabase(":memory:"); 11 + await initCommunitySchema(db); 12 + adapter = new CommunityAdapter(db); 13 + }); 14 + 15 + it("upserts and reads a cached session", async () => { 16 + await adapter.upsertSession("did:plc:x", { 17 + accessJwt: "atok", 18 + refreshJwt: "rtok", 19 + accessExp: 1234, 20 + }); 21 + const got = await adapter.getSession("did:plc:x"); 22 + expect(got).toEqual({ accessJwt: "atok", refreshJwt: "rtok", accessExp: 1234 }); 23 + }); 24 + 25 + it("returns null for missing did", async () => { 26 + const got = await adapter.getSession("did:plc:nope"); 27 + expect(got).toBeNull(); 28 + }); 29 + 30 + it("clears a session", async () => { 31 + await adapter.upsertSession("did:plc:x", { 32 + accessJwt: "a", 33 + refreshJwt: "r", 34 + accessExp: 1, 35 + }); 36 + await adapter.clearSession("did:plc:x"); 37 + expect(await adapter.getSession("did:plc:x")).toBeNull(); 38 + }); 39 + 40 + it("upsert overwrites existing session for the same did", async () => { 41 + await adapter.upsertSession("did:plc:x", { 42 + accessJwt: "old-a", 43 + refreshJwt: "old-r", 44 + accessExp: 100, 45 + }); 46 + await adapter.upsertSession("did:plc:x", { 47 + accessJwt: "new-a", 48 + refreshJwt: "new-r", 49 + accessExp: 200, 50 + }); 51 + const got = await adapter.getSession("did:plc:x"); 52 + expect(got).toEqual({ accessJwt: "new-a", refreshJwt: "new-r", accessExp: 200 }); 53 + }); 54 + 55 + it("isolates sessions across communities", async () => { 56 + await adapter.upsertSession("did:plc:a", { 57 + accessJwt: "a-tok", 58 + refreshJwt: "a-rtok", 59 + accessExp: 1, 60 + }); 61 + await adapter.upsertSession("did:plc:b", { 62 + accessJwt: "b-tok", 63 + refreshJwt: "b-rtok", 64 + accessExp: 2, 65 + }); 66 + expect(await adapter.getSession("did:plc:a")).toEqual({ 67 + accessJwt: "a-tok", 68 + refreshJwt: "a-rtok", 69 + accessExp: 1, 70 + }); 71 + expect(await adapter.getSession("did:plc:b")).toEqual({ 72 + accessJwt: "b-tok", 73 + refreshJwt: "b-rtok", 74 + accessExp: 2, 75 + }); 76 + await adapter.clearSession("did:plc:a"); 77 + expect(await adapter.getSession("did:plc:a")).toBeNull(); 78 + // Clearing one DID must not affect the other. 79 + expect(await adapter.getSession("did:plc:b")).toEqual({ 80 + accessJwt: "b-tok", 81 + refreshJwt: "b-rtok", 82 + accessExp: 2, 83 + }); 84 + }); 85 + });
+81
packages/contrail-community/tests/pds-account-ops.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + pdsGetRecommendedDidCredentials, 4 + pdsActivateAccount, 5 + } from "../src/pds"; 6 + 7 + describe("pdsGetRecommendedDidCredentials", () => { 8 + it("issues GET to the identity endpoint with bearer accessJwt and parses response", async () => { 9 + let received: { url: string; init: any } | null = null; 10 + const fetch = (async (url: string, init: any) => { 11 + received = { url, init }; 12 + return new Response( 13 + JSON.stringify({ 14 + rotationKeys: ["did:key:zRot"], 15 + verificationMethods: { atproto: "did:key:zSig" }, 16 + alsoKnownAs: ["at://h.test"], 17 + services: { 18 + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, 19 + }, 20 + }), 21 + { status: 200, headers: { "content-type": "application/json" } } 22 + ); 23 + }) as unknown as typeof globalThis.fetch; 24 + 25 + const result = await pdsGetRecommendedDidCredentials( 26 + "https://pds.test", 27 + "AT", 28 + { fetch } 29 + ); 30 + 31 + expect(received!.url).toBe( 32 + "https://pds.test/xrpc/com.atproto.identity.getRecommendedDidCredentials" 33 + ); 34 + // Default fetch method is GET when none specified. 35 + expect(received!.init?.method ?? "GET").toBe("GET"); 36 + // Bearer is the session accessJwt, NOT a service-auth JWT. 37 + expect(received!.init.headers.authorization).toBe("Bearer AT"); 38 + expect(result.rotationKeys).toEqual(["did:key:zRot"]); 39 + expect(result.verificationMethods).toEqual({ atproto: "did:key:zSig" }); 40 + expect(result.alsoKnownAs).toEqual(["at://h.test"]); 41 + expect(result.services).toEqual({ 42 + atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://pds.test" }, 43 + }); 44 + }); 45 + 46 + it("throws with status and body on non-2xx", async () => { 47 + const fetch = (async () => 48 + new Response("session expired", { status: 401 })) as any; 49 + await expect( 50 + pdsGetRecommendedDidCredentials("https://pds.test", "AT", { fetch }) 51 + ).rejects.toThrow(/getRecommendedDidCredentials failed.*401.*session expired/); 52 + }); 53 + }); 54 + 55 + describe("pdsActivateAccount", () => { 56 + it("issues POST to activateAccount with bearer accessJwt and resolves to undefined", async () => { 57 + let received: { url: string; init: any } | null = null; 58 + const fetch = (async (url: string, init: any) => { 59 + received = { url, init }; 60 + return new Response("", { status: 200 }); 61 + }) as unknown as typeof globalThis.fetch; 62 + 63 + const result = await pdsActivateAccount("https://pds.test", "AT", { fetch }); 64 + 65 + expect(received!.url).toBe( 66 + "https://pds.test/xrpc/com.atproto.server.activateAccount" 67 + ); 68 + expect(received!.init.method).toBe("POST"); 69 + // Bearer is the session accessJwt from pdsCreateAccount, NOT a service-auth JWT. 70 + expect(received!.init.headers.authorization).toBe("Bearer AT"); 71 + expect(result).toBeUndefined(); 72 + }); 73 + 74 + it("throws with status and body on non-2xx", async () => { 75 + const fetch = (async () => 76 + new Response("nope", { status: 400 })) as any; 77 + await expect( 78 + pdsActivateAccount("https://pds.test", "AT", { fetch }) 79 + ).rejects.toThrow(/activateAccount failed.*400.*nope/); 80 + }); 81 + });
+74
packages/contrail-community/tests/pds-create-account.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { pdsCreateAccount } from "../src/pds"; 3 + 4 + describe("pdsCreateAccount", () => { 5 + it("posts createAccount with bearer auth and returns session", async () => { 6 + let received: { url: string; init: any } | null = null; 7 + const fetch = (async (url: string, init: any) => { 8 + received = { url, init }; 9 + return new Response( 10 + JSON.stringify({ 11 + accessJwt: "AT", refreshJwt: "RT", handle: "h.test", did: "did:plc:x", 12 + }), 13 + { status: 200, headers: { "content-type": "application/json" } } 14 + ); 15 + }) as unknown as typeof globalThis.fetch; 16 + 17 + const result = await pdsCreateAccount( 18 + "https://pds.test", 19 + "JWT-VALUE", 20 + { 21 + handle: "h.test", 22 + did: "did:plc:x", 23 + email: "h@x.test", 24 + password: "p", 25 + inviteCode: "code", 26 + }, 27 + { fetch } 28 + ); 29 + 30 + expect(received!.url).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); 31 + expect(received!.init.method).toBe("POST"); 32 + expect(received!.init.headers.authorization).toBe("Bearer JWT-VALUE"); 33 + expect(JSON.parse(received!.init.body)).toEqual({ 34 + handle: "h.test", 35 + did: "did:plc:x", 36 + email: "h@x.test", 37 + password: "p", 38 + inviteCode: "code", 39 + }); 40 + expect(result.accessJwt).toBe("AT"); 41 + expect(result.did).toBe("did:plc:x"); 42 + }); 43 + 44 + it("strips trailing slash from pdsEndpoint", async () => { 45 + let receivedUrl = ""; 46 + const fetch = (async (url: string) => { 47 + receivedUrl = url; 48 + return new Response( 49 + JSON.stringify({ accessJwt: "AT", refreshJwt: "RT", handle: "h", did: "did:plc:x" }), 50 + { status: 200 } 51 + ); 52 + }) as any; 53 + await pdsCreateAccount( 54 + "https://pds.test/", 55 + "JWT", 56 + { handle: "h", did: "did:plc:x", email: "e", password: "p" }, 57 + { fetch } 58 + ); 59 + expect(receivedUrl).toBe("https://pds.test/xrpc/com.atproto.server.createAccount"); 60 + }); 61 + 62 + it("throws on non-2xx", async () => { 63 + const fetch = (async () => 64 + new Response(JSON.stringify({ error: "InvalidRequest", message: "bad" }), { status: 400 })) as any; 65 + await expect( 66 + pdsCreateAccount( 67 + "https://pds.test", 68 + "x", 69 + { handle: "h", did: "did:plc:x", email: "e", password: "p" }, 70 + { fetch } 71 + ) 72 + ).rejects.toThrow(/createAccount failed.*400.*InvalidRequest/); 73 + }); 74 + });
+81
packages/contrail-community/tests/plc-log-last.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + cidForOp, 4 + getLastOpCid, 5 + type SignedGenesisOp, 6 + type SignedTombstoneOp, 7 + } from "../src/plc"; 8 + 9 + const GENESIS_OP: SignedGenesisOp = { 10 + type: "plc_operation", 11 + prev: null, 12 + rotationKeys: ["did:key:zQ3shjNSBChNYuYsW41QDdm2D25zmQkdpfhgbaQBRG4ecg7sk"], 13 + verificationMethods: { 14 + atproto: "did:key:zQ3shmefuqey6KqP7M9cwFwywqTVuCZFXcCAGJ5JGktdAUdD2", 15 + }, 16 + alsoKnownAs: ["at://probe.devnet.test"], 17 + services: { 18 + atproto_pds: { 19 + type: "AtprotoPersonalDataServer", 20 + endpoint: "https://devnet.test", 21 + }, 22 + }, 23 + sig: "xEZ7BS7bXJ-7KqExTH158uJFNhcTi21khw-rCHjt70EwGVhftk29Xjf1IR9JGhSmDPE76Xqc01ydF9TmmPHr2w", 24 + }; 25 + 26 + const TOMBSTONE_OP: SignedTombstoneOp = { 27 + type: "plc_tombstone", 28 + prev: "bafyreiabmto3hekxoflemevicopvpud2k6ypf2fkp3v3g6iu36l4wxxfle", 29 + sig: "abc123", 30 + }; 31 + 32 + describe("getLastOpCid", () => { 33 + it("returns the CID computed locally from the PLC log/last op response", async () => { 34 + let calledUrl = ""; 35 + const fakeFetch: typeof fetch = async (input) => { 36 + calledUrl = String(input); 37 + return new Response(JSON.stringify(GENESIS_OP), { 38 + status: 200, 39 + headers: { "content-type": "application/json" }, 40 + }); 41 + }; 42 + const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { 43 + fetch: fakeFetch, 44 + }); 45 + expect(calledUrl).toBe("https://plc.test/did:plc:abc/log/last"); 46 + // PLC returns the bare op (no envelope). The function must compute the 47 + // CID with the same DAG-CBOR encoder cidForOp uses so the value matches 48 + // the CID PLC stored when it accepted the op. 49 + expect(cid).toBe(await cidForOp(GENESIS_OP)); 50 + }); 51 + 52 + it("computes the CID from a tombstone op response too", async () => { 53 + const fakeFetch: typeof fetch = async () => 54 + new Response(JSON.stringify(TOMBSTONE_OP), { 55 + status: 200, 56 + headers: { "content-type": "application/json" }, 57 + }); 58 + const cid = await getLastOpCid("https://plc.test", "did:plc:abc", { 59 + fetch: fakeFetch, 60 + }); 61 + expect(cid).toBe(await cidForOp(TOMBSTONE_OP)); 62 + }); 63 + 64 + it("strips a trailing slash from the directory base", async () => { 65 + let calledUrl = ""; 66 + const fakeFetch: typeof fetch = async (input) => { 67 + calledUrl = String(input); 68 + return new Response(JSON.stringify(GENESIS_OP), { status: 200 }); 69 + }; 70 + await getLastOpCid("https://plc.test/", "did:plc:xyz", { fetch: fakeFetch }); 71 + expect(calledUrl).toBe("https://plc.test/did:plc:xyz/log/last"); 72 + }); 73 + 74 + it("throws on a non-200 response, including the status and body", async () => { 75 + const fakeFetch: typeof fetch = async () => 76 + new Response("not found", { status: 404 }); 77 + await expect( 78 + getLastOpCid("https://plc.test", "did:plc:missing", { fetch: fakeFetch }) 79 + ).rejects.toThrow(/404.*not found/); 80 + }); 81 + });
+50
packages/contrail-community/tests/plc-update-op.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + generateKeyPair, 4 + buildGenesisOp, 5 + signGenesisOp, 6 + buildUpdateOp, 7 + signUpdateOp, 8 + cidForOp, 9 + } from "../src/plc"; 10 + 11 + describe("cidForOp", () => { 12 + it("produces a CIDv1 dag-cbor sha256 base32-lower CID starting with bafyrei", async () => { 13 + const kp = await generateKeyPair(); 14 + const unsigned = buildGenesisOp({ 15 + rotationKeys: [kp.publicDidKey], 16 + verificationMethodAtproto: kp.publicDidKey, 17 + alsoKnownAs: ["at://x.test"], 18 + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, 19 + }); 20 + const signed = await signGenesisOp(unsigned, kp.privateJwk); 21 + const cid = await cidForOp(signed); 22 + expect(cid).toMatch(/^bafyrei/); 23 + expect(cid.length).toBeGreaterThan(50); 24 + }); 25 + }); 26 + 27 + describe("buildUpdateOp + signUpdateOp", () => { 28 + it("produces a plc_operation with prev set and a sig segment", async () => { 29 + const kp = await generateKeyPair(); 30 + const genesis = buildGenesisOp({ 31 + rotationKeys: [kp.publicDidKey], 32 + verificationMethodAtproto: kp.publicDidKey, 33 + alsoKnownAs: ["at://x.test"], 34 + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, 35 + }); 36 + const signedGenesis = await signGenesisOp(genesis, kp.privateJwk); 37 + const prev = await cidForOp(signedGenesis); 38 + 39 + const update = buildUpdateOp({ 40 + prev, 41 + rotationKeys: [kp.publicDidKey, "did:key:zPdsRot"], 42 + verificationMethodAtproto: "did:key:zPdsSig", 43 + alsoKnownAs: ["at://x.test"], 44 + services: { atproto_pds: { type: "AtprotoPersonalDataServer", endpoint: "https://x.test" } }, 45 + }); 46 + 47 + const signedUpdate = await signUpdateOp(update, kp.privateJwk); 48 + expect(signedUpdate.sig).toMatch(/^[A-Za-z0-9_-]+$/); 49 + }); 50 + });
+259
packages/contrail-community/tests/provision-orchestrator.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import { initCommunitySchema } from "../src/schema"; 3 + import { CommunityAdapter } from "../src/adapter"; 4 + import { CredentialCipher } from "../src/credentials"; 5 + import { ProvisionOrchestrator } from "../src/provision"; 6 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 7 + 8 + const STUB_ROTATION_KEY = "did:key:zStubCallerRotationKeyForTests"; 9 + 10 + function mockPlc() { 11 + const ops: any[] = []; 12 + return { 13 + ops, 14 + async submit(did: string, op: any) { 15 + ops.push({ did, op }); 16 + return { ok: true }; 17 + }, 18 + }; 19 + } 20 + 21 + function mockPds() { 22 + return { 23 + async createAccount() { 24 + return { 25 + did: "did:plc:x", 26 + handle: "h.test", 27 + accessJwt: "AT", 28 + refreshJwt: "RT", 29 + }; 30 + }, 31 + async getRecommendedDidCredentials() { 32 + return { 33 + rotationKeys: ["did:key:zPdsRot"], 34 + verificationMethods: { atproto: "did:key:zPdsSig" }, 35 + alsoKnownAs: ["at://h.test"], 36 + services: { 37 + atproto_pds: { 38 + type: "AtprotoPersonalDataServer", 39 + endpoint: "https://pds.test", 40 + }, 41 + }, 42 + }; 43 + }, 44 + async activateAccount() { 45 + return; 46 + }, 47 + async createAppPassword() { 48 + return { password: "minted-app-pw" }; 49 + }, 50 + }; 51 + } 52 + 53 + describe("ProvisionOrchestrator", () => { 54 + let adapter: CommunityAdapter; 55 + let cipher: CredentialCipher; 56 + beforeEach(async () => { 57 + const db = createSqliteDatabase(":memory:"); 58 + await initCommunitySchema(db); 59 + cipher = new CredentialCipher(new Uint8Array(32).fill(99)); 60 + adapter = new CommunityAdapter(db); 61 + }); 62 + 63 + it("runs end-to-end and lands at status=activated", async () => { 64 + const orch = new ProvisionOrchestrator({ 65 + adapter, 66 + cipher, 67 + plc: mockPlc(), 68 + pds: mockPds(), 69 + pdsDid: "did:web:pds.test", 70 + }); 71 + 72 + const result = await orch.provision({ 73 + attemptId: "a1", 74 + pdsEndpoint: "https://pds.test", 75 + handle: "h.test", 76 + email: "h@x.test", 77 + password: "p", 78 + inviteCode: "code", 79 + rotationKey: STUB_ROTATION_KEY, 80 + }); 81 + 82 + expect(result.did).toBeTruthy(); 83 + expect(result.status).toBe("activated"); 84 + const row = await adapter.getProvisionAttempt("a1"); 85 + expect(row?.status).toBe("activated"); 86 + expect(row?.encryptedSigningKey).toBeTruthy(); 87 + expect(row?.encryptedRotationKey).toBeTruthy(); 88 + expect(row?.encryptedPassword).toBeTruthy(); 89 + }); 90 + 91 + it("seeds the community_sessions cache with the createAccount JWTs after activation", async () => { 92 + const orch = new ProvisionOrchestrator({ 93 + adapter, 94 + cipher, 95 + plc: mockPlc(), 96 + pds: mockPds(), 97 + pdsDid: "did:web:pds.test", 98 + }); 99 + 100 + const result = await orch.provision({ 101 + attemptId: "a1", 102 + pdsEndpoint: "https://pds.test", 103 + handle: "h.test", 104 + email: "h@x.test", 105 + password: "p", 106 + inviteCode: "code", 107 + rotationKey: STUB_ROTATION_KEY, 108 + }); 109 + 110 + const cached = await adapter.getSession(result.did); 111 + expect(cached).not.toBeNull(); 112 + expect(cached?.accessJwt).toBe("AT"); 113 + expect(cached?.refreshJwt).toBe("RT"); 114 + }); 115 + 116 + it("persists status=genesis_submitted before createAccount runs", async () => { 117 + let createCalled = false; 118 + const pds = { 119 + async createAccount() { 120 + // Inspect state at this exact moment. 121 + const row = await adapter.getProvisionAttempt("a1"); 122 + expect(row?.status).toBe("genesis_submitted"); 123 + createCalled = true; 124 + return { 125 + did: "did:plc:x", 126 + handle: "h.test", 127 + accessJwt: "AT", 128 + refreshJwt: "RT", 129 + }; 130 + }, 131 + async getRecommendedDidCredentials() { 132 + return { 133 + rotationKeys: [], 134 + verificationMethods: { atproto: "did:key:zSig" }, 135 + alsoKnownAs: ["at://h.test"], 136 + services: { 137 + atproto_pds: { type: "x", endpoint: "https://pds.test" }, 138 + }, 139 + }; 140 + }, 141 + async activateAccount() {}, 142 + async createAppPassword() { 143 + return { password: "minted" }; 144 + }, 145 + }; 146 + 147 + const orch = new ProvisionOrchestrator({ 148 + adapter, 149 + cipher, 150 + plc: mockPlc(), 151 + pds, 152 + pdsDid: "did:web:pds.test", 153 + }); 154 + await orch.provision({ 155 + attemptId: "a1", 156 + pdsEndpoint: "https://pds.test", 157 + handle: "h.test", 158 + email: "h@x.test", 159 + password: "p", 160 + rotationKey: STUB_ROTATION_KEY, 161 + }); 162 + expect(createCalled).toBe(true); 163 + }); 164 + 165 + it("marks last_error and rethrows when createAccount fails", async () => { 166 + const pds = { 167 + ...mockPds(), 168 + async createAccount() { 169 + throw new Error("createAccount 400: bad invite"); 170 + }, 171 + } as any; 172 + 173 + const orch = new ProvisionOrchestrator({ 174 + adapter, 175 + cipher, 176 + plc: mockPlc(), 177 + pds, 178 + pdsDid: "did:web:pds.test", 179 + }); 180 + 181 + await expect( 182 + orch.provision({ 183 + attemptId: "a1", 184 + pdsEndpoint: "https://pds.test", 185 + handle: "h.test", 186 + email: "h@x.test", 187 + password: "p", 188 + rotationKey: STUB_ROTATION_KEY, 189 + }) 190 + ).rejects.toThrow(/bad invite/); 191 + const row = await adapter.getProvisionAttempt("a1"); 192 + expect(row?.status).toBe("genesis_submitted"); // last successful step 193 + expect(row?.lastError).toMatch(/bad invite/); 194 + }); 195 + 196 + it("re-invoking with the same attemptId on a fully-completed row returns success without redoing PLC/PDS work", async () => { 197 + // Scenario: the orchestrator finished cleanly (status=activated + 198 + // encryptedPassword set), but a *downstream* step (router's 199 + // createFromProvisioned or bootstrapReservedSpaces) failed and the 200 + // caller retries with the same attemptId. The orchestrator must not 201 + // throw "already exists" — it should report success so the route can 202 + // resume the graduation steps. 203 + const plc = mockPlc(); 204 + const pds: any = mockPds(); 205 + const orch = new ProvisionOrchestrator({ 206 + adapter, 207 + cipher, 208 + plc, 209 + pds, 210 + pdsDid: "did:web:pds.test", 211 + }); 212 + 213 + const first = await orch.provision({ 214 + attemptId: "a1", 215 + pdsEndpoint: "https://pds.test", 216 + handle: "h.test", 217 + email: "h@x.test", 218 + password: "p", 219 + inviteCode: "code", 220 + rotationKey: STUB_ROTATION_KEY, 221 + }); 222 + expect(first.status).toBe("activated"); 223 + const opsAfterFirst = plc.ops.length; 224 + 225 + // Wire a fresh PDS mock whose every method throws — if the retry path 226 + // calls any of them, the test fails loudly. createSession is allowed 227 + // because the C3 retry path is wired for the not-yet-completed case; 228 + // a fully-completed row should NOT hit it either. 229 + const explodingPds: any = { 230 + createAccount: () => { throw new Error("createAccount should not be called on a completed retry"); }, 231 + getRecommendedDidCredentials: () => { throw new Error("getRecommendedDidCredentials should not be called"); }, 232 + activateAccount: () => { throw new Error("activateAccount should not be called"); }, 233 + createAppPassword: () => { throw new Error("createAppPassword should not be called on a completed retry"); }, 234 + createSession: () => { throw new Error("createSession should not be called on a completed retry"); }, 235 + }; 236 + const retryOrch = new ProvisionOrchestrator({ 237 + adapter, 238 + cipher, 239 + plc: { submit: () => { throw new Error("plc.submit should not be called on a completed retry"); } }, 240 + pds: explodingPds, 241 + pdsDid: "did:web:pds.test", 242 + }); 243 + 244 + const second = await retryOrch.provision({ 245 + attemptId: "a1", 246 + pdsEndpoint: "https://pds.test", 247 + handle: "h.test", 248 + email: "h@x.test", 249 + password: "p", 250 + inviteCode: "code", 251 + rotationKey: STUB_ROTATION_KEY, 252 + }); 253 + expect(second.status).toBe("activated"); 254 + expect(second.did).toBe(first.did); 255 + expect(second.attemptId).toBe("a1"); 256 + expect(plc.ops.length).toBe(opsAfterFirst); 257 + }); 258 + 259 + });
+353
packages/contrail-community/tests/provision-self-sovereign.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import { initCommunitySchema } from "../src/schema"; 3 + import { CommunityAdapter } from "../src/adapter"; 4 + import { CredentialCipher } from "../src/credentials"; 5 + import { ProvisionOrchestrator } from "../src/provision"; 6 + import { generateKeyPair } from "../src/plc"; 7 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 8 + 9 + /** Mock PLC client that records every submitted op so tests can inspect the 10 + * genesis op (in particular, its rotationKeys array). */ 11 + function mockPlc() { 12 + const ops: Array<{ did: string; op: any }> = []; 13 + return { 14 + ops, 15 + async submit(did: string, op: any) { 16 + ops.push({ did, op }); 17 + return { ok: true }; 18 + }, 19 + }; 20 + } 21 + 22 + /** Mock PDS client that records calls to createAppPassword so tests can assert 23 + * on its arguments (or its absence). The minted password is deterministic so 24 + * decryption assertions can compare. */ 25 + function mockPds(opts: { mintedPassword?: string } = {}) { 26 + const calls: { createAppPassword: Array<{ pdsUrl: string; accessJwt: string; name: string }> } = { 27 + createAppPassword: [], 28 + }; 29 + return { 30 + calls, 31 + async createAccount() { 32 + return { 33 + did: "did:plc:x", 34 + handle: "h.test", 35 + accessJwt: "AT", 36 + refreshJwt: "RT", 37 + }; 38 + }, 39 + async getRecommendedDidCredentials() { 40 + return { 41 + rotationKeys: ["did:key:zPdsRot"], 42 + verificationMethods: { atproto: "did:key:zPdsSig" }, 43 + alsoKnownAs: ["at://h.test"], 44 + services: { 45 + atproto_pds: { 46 + type: "AtprotoPersonalDataServer", 47 + endpoint: "https://pds.test", 48 + }, 49 + }, 50 + }; 51 + }, 52 + async activateAccount() { 53 + return; 54 + }, 55 + async createAppPassword(input: { pdsUrl: string; accessJwt: string; name: string }) { 56 + calls.createAppPassword.push(input); 57 + return { password: opts.mintedPassword ?? "minted-app-pass-XXXX" }; 58 + }, 59 + }; 60 + } 61 + 62 + describe("ProvisionOrchestrator — caller-supplied rotation key", () => { 63 + let adapter: CommunityAdapter; 64 + let cipher: CredentialCipher; 65 + beforeEach(async () => { 66 + const db = createSqliteDatabase(":memory:"); 67 + await initCommunitySchema(db); 68 + cipher = new CredentialCipher(new Uint8Array(32).fill(99)); 69 + adapter = new CommunityAdapter(db); 70 + }); 71 + 72 + it("genesis includes caller rotation key, mints app password, response carries rootCredentials", async () => { 73 + const callerKeyPair = await generateKeyPair(); 74 + const callerRotationDidKey = callerKeyPair.publicDidKey; 75 + const userPassword = "user-supplied-root-pw"; 76 + const mintedPassword = "minted-app-pw-1234"; 77 + 78 + const plc = mockPlc(); 79 + const pds = mockPds({ mintedPassword }); 80 + 81 + const orch = new ProvisionOrchestrator({ 82 + adapter, 83 + cipher, 84 + plc, 85 + pds, 86 + pdsDid: "did:web:pds.test", 87 + }); 88 + 89 + const result = await orch.provision({ 90 + attemptId: "ss1", 91 + pdsEndpoint: "https://pds.test", 92 + handle: "h.test", 93 + email: "h@x.test", 94 + password: userPassword, 95 + inviteCode: "code", 96 + rotationKey: callerRotationDidKey, 97 + }); 98 + 99 + // Status unchanged in shape. 100 + expect(result.status).toBe("activated"); 101 + expect(result.did).toBeTruthy(); 102 + 103 + // Response carries root credentials so the caller can keep their root password. 104 + expect(result.rootCredentials).toBeDefined(); 105 + expect(result.rootCredentials!.password).toBe(userPassword); 106 + expect(result.rootCredentials!.handle).toBe("h.test"); 107 + expect(typeof result.rootCredentials!.recoveryHint).toBe("string"); 108 + 109 + // Persisted attempt row reaches activated status. 110 + const row = await adapter.getProvisionAttempt("ss1"); 111 + expect(row).toBeTruthy(); 112 + expect(row!.status).toBe("activated"); 113 + 114 + // Genesis op submitted to PLC has BOTH rotation keys, with the caller's first. 115 + expect(plc.ops.length).toBeGreaterThanOrEqual(1); 116 + const genesis = plc.ops[0]!.op; 117 + expect(Array.isArray(genesis.rotationKeys)).toBe(true); 118 + expect(genesis.rotationKeys[0]).toBe(callerRotationDidKey); 119 + expect(genesis.rotationKeys.length).toBe(2); 120 + expect(genesis.rotationKeys[1]).toBeTruthy(); 121 + expect(genesis.rotationKeys[1]).not.toBe(callerRotationDidKey); 122 + 123 + // createAppPassword was invoked post-activation with the session's accessJwt. 124 + expect(pds.calls.createAppPassword.length).toBe(1); 125 + const apCall = pds.calls.createAppPassword[0]!; 126 + expect(apCall.pdsUrl).toBe("https://pds.test"); 127 + expect(apCall.accessJwt).toBe("AT"); 128 + expect(apCall.name).toContain("ss1"); 129 + 130 + // encrypted_password column re-decrypts to the MINTED app password, 131 + // not the user's supplied password. 132 + expect(row!.encryptedPassword).toBeTruthy(); 133 + const decryptedPw = await cipher.decryptString(row!.encryptedPassword!); 134 + expect(decryptedPw).toBe(mintedPassword); 135 + expect(decryptedPw).not.toBe(userPassword); 136 + 137 + // Subordinate rotation private JWK persisted in encrypted_rotation_key 138 + // must NOT decrypt to anything containing the caller's did:key fingerprint. 139 + expect(row!.encryptedRotationKey).toBeTruthy(); 140 + const decryptedRot = await cipher.decryptString(row!.encryptedRotationKey!); 141 + expect(decryptedRot).not.toContain(callerRotationDidKey); 142 + 143 + // Negative invariant: caller's did:key must not appear in any encrypted 144 + // column (after decryption). 145 + const encryptedSigning = row!.encryptedSigningKey; 146 + if (encryptedSigning) { 147 + const decryptedSig = await cipher.decryptString(encryptedSigning); 148 + expect(decryptedSig).not.toContain(callerRotationDidKey); 149 + } 150 + }); 151 + 152 + it("PLC update op preserves caller's rotation key at index 0", async () => { 153 + // H2 regression guard. The update op (plc.ops[1]) must keep the caller's 154 + // did:key as rotationKeys[0]. Without threading it through 155 + // runUpdateAndActivate, the caller's key is dropped and Contrail's 156 + // subordinate becomes the highest-priority rotation key — caller has 72h 157 + // to nullify before losing rotation authority on a DID they own. 158 + const callerKeyPair = await generateKeyPair(); 159 + const callerRotationDidKey = callerKeyPair.publicDidKey; 160 + 161 + const plc = mockPlc(); 162 + const pds = mockPds(); 163 + 164 + const orch = new ProvisionOrchestrator({ 165 + adapter, 166 + cipher, 167 + plc, 168 + pds, 169 + pdsDid: "did:web:pds.test", 170 + }); 171 + 172 + await orch.provision({ 173 + attemptId: "ss-update", 174 + pdsEndpoint: "https://pds.test", 175 + handle: "h.test", 176 + email: "h@x.test", 177 + password: "pw", 178 + inviteCode: "code", 179 + rotationKey: callerRotationDidKey, 180 + }); 181 + 182 + // Genesis op already asserted in the prior test; here we focus on the update op. 183 + expect(plc.ops.length).toBeGreaterThanOrEqual(2); 184 + const update = plc.ops[1]!.op; 185 + expect(Array.isArray(update.rotationKeys)).toBe(true); 186 + expect(update.rotationKeys[0]).toBe(callerRotationDidKey); 187 + // Contrail's subordinate must remain in the chain (we still need to sign 188 + // future update ops). 189 + expect(update.rotationKeys.length).toBeGreaterThanOrEqual(2); 190 + expect(update.rotationKeys.slice(1)).not.toContain(callerRotationDidKey); 191 + // PDS-recommended key is merged in after the contrail subordinate. 192 + expect(update.rotationKeys).toContain("did:key:zPdsRot"); 193 + }); 194 + 195 + it("createAppPassword failure persists last_error at status=activated and throws (no encryptedPassword)", async () => { 196 + const callerKeyPair = await generateKeyPair(); 197 + const plc = mockPlc(); 198 + const pds = { 199 + ...mockPds(), 200 + async createAppPassword(_: any) { 201 + throw new Error("PDS rejected: rate limited"); 202 + }, 203 + }; 204 + 205 + const orch = new ProvisionOrchestrator({ 206 + adapter, 207 + cipher, 208 + plc, 209 + pds: pds as any, 210 + pdsDid: "did:web:pds.test", 211 + }); 212 + 213 + await expect( 214 + orch.provision({ 215 + attemptId: "ss-fail", 216 + pdsEndpoint: "https://pds.test", 217 + handle: "h.test", 218 + email: "h@x.test", 219 + password: "pw", 220 + rotationKey: callerKeyPair.publicDidKey, 221 + }) 222 + ).rejects.toThrow(/createAppPassword/); 223 + 224 + const row = await adapter.getProvisionAttempt("ss-fail"); 225 + expect(row).toBeTruthy(); 226 + expect(row!.status).toBe("activated"); 227 + expect(row!.encryptedPassword).toBeFalsy(); 228 + expect(row!.lastError).toMatch(/createAppPassword/); 229 + }); 230 + 231 + it("retry with same attemptId after createAppPassword failure picks up at createAppPassword (no re-mint, no re-createAccount)", async () => { 232 + // Simulate the failure-then-retry shape: a first provision call got all 233 + // the way to createAppPassword and failed; the caller retries with the 234 + // same attemptId. The orchestrator must NOT re-submit PLC ops, NOT 235 + // re-call createAccount, only run createAppPassword. 236 + const callerKeyPair = await generateKeyPair(); 237 + const callerDidKey = callerKeyPair.publicDidKey; 238 + const mintedPassword = "minted-on-retry-99"; 239 + 240 + // First attempt: createAppPassword throws; everything else succeeds. 241 + const firstPds = { 242 + ...mockPds(), 243 + async createAppPassword(_: any) { 244 + throw new Error("PDS transient: 503"); 245 + }, 246 + }; 247 + const firstPlc = mockPlc(); 248 + const firstOrch = new ProvisionOrchestrator({ 249 + adapter, 250 + cipher, 251 + plc: firstPlc, 252 + pds: firstPlc && (firstPds as any), 253 + pdsDid: "did:web:pds.test", 254 + }); 255 + await expect( 256 + firstOrch.provision({ 257 + attemptId: "ss-retry", 258 + pdsEndpoint: "https://pds.test", 259 + handle: "h.test", 260 + email: "h@x.test", 261 + password: "user-root-pw", 262 + rotationKey: callerDidKey, 263 + }) 264 + ).rejects.toThrow(); 265 + 266 + // Sanity: row is in the failure state we expect. 267 + const failRow = await adapter.getProvisionAttempt("ss-retry"); 268 + expect(failRow!.status).toBe("activated"); 269 + expect(failRow!.encryptedPassword).toBeFalsy(); 270 + expect(firstPlc.ops.length).toBe(2); // genesis + update 271 + 272 + // Second attempt with the SAME attemptId. Use a fresh mock that would 273 + // EXPLODE if createAccount or any PLC op was re-issued. 274 + const retryPlc = { 275 + ops: [] as Array<{ did: string; op: any }>, 276 + async submit(_did: string, _op: any) { 277 + throw new Error("retry must not re-submit PLC ops"); 278 + }, 279 + }; 280 + const retryAppPasswordCalls: any[] = []; 281 + const retryPds = { 282 + async createAccount() { 283 + throw new Error("retry must not re-call createAccount"); 284 + }, 285 + async getRecommendedDidCredentials() { 286 + throw new Error("retry must not re-fetch recommended creds"); 287 + }, 288 + async activateAccount() { 289 + throw new Error("retry must not re-activate"); 290 + }, 291 + async createAppPassword(input: any) { 292 + retryAppPasswordCalls.push(input); 293 + return { password: mintedPassword }; 294 + }, 295 + async createSession(input: { pdsUrl: string; identifier: string; password: string }) { 296 + // Verify the retry uses the user's root password to obtain a fresh 297 + // accessJwt (the cached one from the failed attempt may have expired). 298 + expect(input.password).toBe("user-root-pw"); 299 + return { accessJwt: "AT-fresh", refreshJwt: "RT-fresh", did: "did:plc:x" }; 300 + }, 301 + }; 302 + const retryOrch = new ProvisionOrchestrator({ 303 + adapter, 304 + cipher, 305 + plc: retryPlc as any, 306 + pds: retryPds as any, 307 + pdsDid: "did:web:pds.test", 308 + }); 309 + 310 + const result = await retryOrch.provision({ 311 + attemptId: "ss-retry", 312 + pdsEndpoint: "https://pds.test", 313 + handle: "h.test", 314 + email: "h@x.test", 315 + password: "user-root-pw", 316 + rotationKey: callerDidKey, 317 + }); 318 + 319 + // Retry succeeded. 320 + expect(result.status).toBe("activated"); 321 + expect(result.did).toBe(failRow!.did); // SAME DID, not a new one 322 + expect(result.rootCredentials).toBeDefined(); 323 + expect(retryAppPasswordCalls.length).toBe(1); 324 + 325 + // Row now has the encrypted (minted) password. 326 + const finalRow = await adapter.getProvisionAttempt("ss-retry"); 327 + expect(finalRow!.status).toBe("activated"); 328 + expect(finalRow!.encryptedPassword).toBeTruthy(); 329 + const decrypted = await cipher.decryptString(finalRow!.encryptedPassword!); 330 + expect(decrypted).toBe(mintedPassword); 331 + }); 332 + 333 + it("rejects rotationKey that is not did:key:z…", async () => { 334 + const orch = new ProvisionOrchestrator({ 335 + adapter, 336 + cipher, 337 + plc: mockPlc(), 338 + pds: mockPds(), 339 + pdsDid: "did:web:pds.test", 340 + }); 341 + 342 + await expect( 343 + orch.provision({ 344 + attemptId: "bad1", 345 + pdsEndpoint: "https://pds.test", 346 + handle: "h.test", 347 + email: "h@x.test", 348 + password: "p", 349 + rotationKey: "not-a-did-key", 350 + }) 351 + ).rejects.toThrow(/rotationKey/); 352 + }); 353 + });
+18
packages/contrail-community/tests/schema.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { createSqliteDatabase } from "@atmo-dev/contrail/sqlite"; 3 + import { initCommunitySchema } from "../src/schema"; 4 + 5 + describe("provision_attempts schema", () => { 6 + it("enforces status enum", async () => { 7 + const db = createSqliteDatabase(":memory:"); 8 + await initCommunitySchema(db); 9 + await expect( 10 + db 11 + .prepare( 12 + "INSERT INTO provision_attempts (attempt_id, did, status, created_at, updated_at, pds_endpoint, handle, email) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" 13 + ) 14 + .bind("a1", "did:plc:x", "bogus", 1, 1, "https://pds", "h.test", "x@x") 15 + .run() 16 + ).rejects.toThrow(); 17 + }); 18 + });
+135
packages/contrail-community/tests/service-auth.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { mintServiceAuthJwt } from "../src/service-auth"; 3 + import { generateKeyPair } from "../src/plc"; 4 + 5 + function b64urlDecode(s: string): Uint8Array { 6 + const normal = s.replace(/-/g, "+").replace(/_/g, "/"); 7 + const padded = normal + "=".repeat((4 - (normal.length % 4)) % 4); 8 + const bin = atob(padded); 9 + const out = new Uint8Array(bin.length); 10 + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); 11 + return out; 12 + } 13 + 14 + function decodeJwtJson(seg: string): Record<string, unknown> { 15 + return JSON.parse(new TextDecoder().decode(b64urlDecode(seg))); 16 + } 17 + 18 + /** P-256 curve order; used for low-S threshold. */ 19 + const P256_N = BigInt( 20 + "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" 21 + ); 22 + const P256_N_HALF = P256_N >> 1n; 23 + 24 + function bytesToBigInt(b: Uint8Array): bigint { 25 + let v = 0n; 26 + for (const byte of b) v = (v << 8n) | BigInt(byte); 27 + return v; 28 + } 29 + 30 + describe("mintServiceAuthJwt", () => { 31 + it("round-trips: signature verifies against the keypair's public key (P1363, not DER)", async () => { 32 + // This is the canary: if the signer accidentally returns DER instead of raw r||s, 33 + // Web Crypto's verify with raw form will reject it — and so will atproto PDSes. 34 + const kp = await generateKeyPair(); 35 + const jwt = await mintServiceAuthJwt({ 36 + privateJwk: kp.privateJwk, 37 + iss: "did:plc:abc", 38 + aud: "did:web:pds.test", 39 + lxm: "com.atproto.server.createAccount", 40 + }); 41 + 42 + // Reconstruct the public JWK from the private JWK (drop d, key_ops, ext). 43 + const priv = kp.privateJwk as Record<string, unknown>; 44 + const publicJwk: JsonWebKey = { 45 + kty: priv.kty as string, 46 + crv: priv.crv as string, 47 + x: priv.x as string, 48 + y: priv.y as string, 49 + }; 50 + const publicKey = await crypto.subtle.importKey( 51 + "jwk", 52 + publicJwk, 53 + { name: "ECDSA", namedCurve: "P-256" }, 54 + false, 55 + ["verify"] 56 + ); 57 + 58 + const [h, p, s] = jwt.split("."); 59 + const signedBytes = new TextEncoder().encode(`${h}.${p}`); 60 + const sig = b64urlDecode(s!); 61 + 62 + // P1363 form is exactly 64 bytes for P-256. DER would be ~70-72 and start with 0x30. 63 + expect(sig.length).toBe(64); 64 + 65 + const ok = await crypto.subtle.verify( 66 + { name: "ECDSA", hash: "SHA-256" }, 67 + publicKey, 68 + sig as BufferSource, 69 + signedBytes as BufferSource 70 + ); 71 + expect(ok).toBe(true); 72 + }); 73 + 74 + it("emits low-S signatures across many independent signings", async () => { 75 + // Without normalization, ~50% of ECDSA signatures have s > n/2. 76 + // Across 12 fresh keypairs, the probability of *all* being naturally low-S is 77 + // ~1/4096. If the test ever sees a high-S signature, normalization is broken. 78 + const kp = await generateKeyPair(); 79 + for (let i = 0; i < 12; i++) { 80 + const jwt = await mintServiceAuthJwt({ 81 + privateJwk: kp.privateJwk, 82 + iss: "did:plc:abc", 83 + aud: "did:web:pds.test", 84 + lxm: "com.atproto.server.createAccount", 85 + // Force a fresh signature each iteration; ECDSA k is randomized per sign. 86 + }); 87 + const sig = b64urlDecode(jwt.split(".")[2]!); 88 + const s = bytesToBigInt(sig.slice(32)); 89 + expect(s).toBeLessThanOrEqual(P256_N_HALF); 90 + } 91 + }); 92 + 93 + it("encodes header and claims as base64url JSON with the expected shape", async () => { 94 + const kp = await generateKeyPair(); 95 + const fixedNow = 1_700_000_000_000; 96 + const jwt = await mintServiceAuthJwt({ 97 + privateJwk: kp.privateJwk, 98 + iss: "did:plc:abc", 99 + aud: "did:web:pds.test", 100 + lxm: "com.atproto.server.createAccount", 101 + ttlSec: 60, 102 + now: fixedNow, 103 + }); 104 + 105 + const [h, p] = jwt.split("."); 106 + const header = decodeJwtJson(h!); 107 + const payload = decodeJwtJson(p!); 108 + 109 + // Header MUST be exactly {alg,typ}; presence of kid would change the 110 + // signed bytes and break PDS verification (which doesn't use kid). 111 + expect(header).toEqual({ alg: "ES256", typ: "JWT" }); 112 + 113 + expect(payload.iss).toBe("did:plc:abc"); 114 + expect(payload.aud).toBe("did:web:pds.test"); 115 + expect(payload.lxm).toBe("com.atproto.server.createAccount"); 116 + expect(payload.iat).toBe(Math.floor(fixedNow / 1000)); 117 + expect(payload.exp).toBe(Math.floor(fixedNow / 1000) + 60); 118 + expect(typeof payload.jti).toBe("string"); 119 + expect((payload.jti as string).length).toBeGreaterThan(0); 120 + }); 121 + 122 + it("uses unique jti per call (replay-protection sanity)", async () => { 123 + const kp = await generateKeyPair(); 124 + const mk = () => 125 + mintServiceAuthJwt({ 126 + privateJwk: kp.privateJwk, 127 + iss: "did:plc:abc", 128 + aud: "did:web:pds.test", 129 + lxm: "com.atproto.server.createAccount", 130 + }); 131 + const a = decodeJwtJson((await mk()).split(".")[1]!); 132 + const b = decodeJwtJson((await mk()).split(".")[1]!); 133 + expect(a.jti).not.toBe(b.jti); 134 + }); 135 + });
+2 -2
packages/contrail-community/tsup.config.ts
··· 1 1 import { defineConfig } from "tsup"; 2 2 3 3 export default defineConfig({ 4 - entry: ["src/index.ts"], 4 + entry: ["src/index.ts", "src/cli/index.ts"], 5 5 format: ["esm"], 6 6 dts: true, 7 7 sourcemap: true, 8 8 clean: true, 9 9 tsconfig: "tsconfig.build.json", 10 - external: ["@atmo-dev/contrail"], 10 + external: ["@atmo-dev/contrail", "wrangler"], 11 11 });
+37
packages/contrail/src/cli.ts
··· 10 10 import { registerRefresh } from "./cli/commands/refresh.js"; 11 11 import { registerDev } from "./cli/commands/dev.js"; 12 12 import { registerAppendScheduled } from "./cli/commands/append-scheduled.js"; 13 + import { resolveAndLoadConfig } from "./cli/shared.js"; 13 14 14 15 const cli = cac("contrail"); 15 16 ··· 17 18 registerRefresh(cli); 18 19 registerDev(cli); 19 20 registerAppendScheduled(cli); 21 + 22 + // `reap` lives in @atmo-dev/contrail-community after the PR #30 package split. 23 + // Dynamically import so contrail has no compile-time edge into the community 24 + // package (contrail-community already depends on contrail; a static import 25 + // here would create a build cycle). contrail declares contrail-community as an 26 + // optional peer so a consumer that installs both gets `reap` wired up; when 27 + // it's genuinely absent the subcommand is simply omitted. 28 + try { 29 + const mod = await import("@atmo-dev/contrail-community" as string); 30 + if (typeof mod.registerReap === "function") { 31 + mod.registerReap(cli, { resolveAndLoadConfig }); 32 + } else { 33 + // Loaded, but the expected export is missing — a real packaging problem, 34 + // not "not installed". Surface it so a broken build is debuggable. 35 + console.warn( 36 + "[contrail] @atmo-dev/contrail-community loaded but does not export registerReap; `reap` unavailable." 37 + ); 38 + } 39 + } catch (err) { 40 + const code = (err as { code?: string })?.code; 41 + if (code === "ERR_MODULE_NOT_FOUND") { 42 + // Expected when contrail-community isn't installed alongside contrail. 43 + // Debug-level so it doesn't nag, but is visible under DEBUG diagnostics. 44 + if (process.env.DEBUG) { 45 + console.debug("[contrail] contrail-community not installed; `reap` unavailable."); 46 + } 47 + } else { 48 + // A different failure (broken export, missing transitive dep, syntax 49 + // error) — do NOT swallow it, or `reap` silently vanishes with no clue. 50 + console.warn( 51 + `[contrail] failed to load @atmo-dev/contrail-community for \`reap\`: ${ 52 + err instanceof Error ? err.message : String(err) 53 + }` 54 + ); 55 + } 56 + } 20 57 21 58 cli.help(); 22 59
+1 -1
packages/contrail/src/index.ts
··· 189 189 export { resolveLabelerEndpoint } from "./core/labels/resolve"; 190 190 191 191 // Community has moved to @atmo-dev/contrail-community. Import from there: 192 - // import { createCommunityIntegration, CommunityAdapter, ... } from "@atmo-dev/contrail-community"; 192 + // import { createCommunityIntegration, CommunityAdapter, ProvisionOrchestrator, ... } from "@atmo-dev/contrail-community"; 193 193 // const app = createApp(db, config, { community: createCommunityIntegration(...) });
+1
packages/contrail/tests/schema.test.ts
··· 55 55 await initSchema(db, TEST_CONFIG); 56 56 }); 57 57 }); 58 +
+15
pnpm-lock.yaml
··· 569 569 '@atmo-dev/contrail-base': 570 570 specifier: workspace:* 571 571 version: link:../contrail-base 572 + cac: 573 + specifier: ^7.0.0 574 + version: 7.0.0 572 575 hono: 573 576 specifier: ^4.12.8 574 577 version: 4.12.15 578 + wrangler: 579 + specifier: ^4.0.0 580 + version: 4.84.1(@cloudflare/workers-types@4.20260424.1) 575 581 devDependencies: 582 + '@types/node': 583 + specifier: ^25.5.0 584 + version: 25.6.0 585 + '@types/pg': 586 + specifier: ^8.20.0 587 + version: 8.20.0 588 + pg: 589 + specifier: ^8.20.0 590 + version: 8.20.0 576 591 tsup: 577 592 specifier: ^8.5.0 578 593 version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)