diff --git a/packages/pds/src/account-do.ts b/packages/pds/src/account-do.ts index 8d92d48a..d0efdc24 100644 --- a/packages/pds/src/account-do.ts +++ b/packages/pds/src/account-do.ts @@ -143,6 +143,20 @@ export class AccountDurableObject extends DurableObject { const root = await this.storage!.getRoot(); if (root) { this.repo = await Repo.load(this.storage!, root); + // Guard: the stored repo must be keyed to the configured DID. A + // mismatch happens when the repo was created under an earlier DID + // (e.g. the did:web init default) and DID was later changed to a + // migrated did:plc. Serving such a repo produces commits whose + // commit.did the relay rejects at verifyRepoRoot, silently leaving + // the account un-federatable. Fail loudly instead. Do NOT destroy + // storage here: this is read context and runs on every request. + if (this.repo.did !== this.env.DID) { + throw new Error( + `Repo DID mismatch: stored repo is ${this.repo.did} but configured DID is ${this.env.DID}. ` + + `The repo was created before DID was changed (e.g. did:web default → migrated did:plc). ` + + `Deactivate the account and call gg.mk.experimental.resetMigration to rebuild the repo under the correct DID.`, + ); + } } else { this.repo = await Repo.create( this.storage!, diff --git a/packages/pds/src/cli/commands/activate.ts b/packages/pds/src/cli/commands/activate.ts index 01447afd..f6f38ade 100644 --- a/packages/pds/src/cli/commands/activate.ts +++ b/packages/pds/src/cli/commands/activate.ts @@ -19,6 +19,7 @@ import { checkHandleResolution, checkDidDocument, checkRepoComplete, + checkRepoDidMatches, type CheckResult, } from "../utils/checks.js"; @@ -55,6 +56,11 @@ async function runChecks( const repoResult = checkRepoComplete(status); checks.push({ name: "Repo", ...repoResult }); + // Check 4: Repo DID matches the configured account DID. Catches a stale + // did:web repo left over from `pds init` when migrating to a did:plc. + const repoDidResult = checkRepoDidMatches(status, did); + checks.push({ name: "Repo DID", ...repoDidResult }); + return checks; } diff --git a/packages/pds/src/cli/utils/checks.ts b/packages/pds/src/cli/utils/checks.ts index e5b3775b..92952458 100644 --- a/packages/pds/src/cli/utils/checks.ts +++ b/packages/pds/src/cli/utils/checks.ts @@ -221,6 +221,37 @@ export function checkRepoComplete(status: MigrationStatus): CheckResult { }; } +/** + * Check that the stored repo is keyed to the configured account DID. + * + * A mismatch happens when the repo was created under an earlier DID (e.g. the + * `did:web:` init default) and the account was later migrated to a + * `did:plc:…` identity. The relay rejects such a repo at `verifyRepoRoot` + * (`commit.did !== `) and never onboards the account, even though + * every other check passes. Catch it before the user believes migration worked. + */ +export function checkRepoDidMatches( + status: MigrationStatus, + expectedDid: string, +): CheckResult { + if (!status.did) { + // No repo yet — nothing to mismatch; other repo checks report emptiness. + return { ok: true, message: "no repo yet" }; + } + if (status.did !== expectedDid) { + return { + ok: false, + message: `Repo DID (${status.did}) does not match account DID (${expectedDid})`, + detail: + "The repo was created under a different DID (e.g. the did:web init default) " + + "before migrating to did:plc. Deactivate the account and run " + + "gg.mk.experimental.resetMigration to rebuild the repo under the correct DID, " + + "then re-run migrate.", + }; + } + return { ok: true, message: "matches account DID" }; +} + /** * Check if profile is indexed by AppView */ diff --git a/packages/pds/src/cli/utils/pds-client.ts b/packages/pds/src/cli/utils/pds-client.ts index cd3c0a61..3b126e58 100644 --- a/packages/pds/src/cli/utils/pds-client.ts +++ b/packages/pds/src/cli/utils/pds-client.ts @@ -42,6 +42,8 @@ export interface MigrationStatus { activated: boolean; active: boolean; validDid: boolean; + /** The DID the stored repo is actually keyed to (`null` if no repo exists yet). */ + did: string | null; repoCommit: string | null; repoRev: string | null; repoBlocks: number; diff --git a/packages/pds/src/xrpc/server.ts b/packages/pds/src/xrpc/server.ts index 92c7b562..a05f796b 100644 --- a/packages/pds/src/xrpc/server.ts +++ b/packages/pds/src/xrpc/server.ts @@ -376,6 +376,7 @@ export async function checkAccountStatus( activated, active, validDid: true, + did: status.did, repoCommit: status.head, repoRev: status.rev, repoBlocks, @@ -390,6 +391,7 @@ export async function checkAccountStatus( activated: false, active: false, validDid: true, + did: null, repoCommit: null, repoRev: null, repoBlocks: 0, diff --git a/packages/pds/test/cli/checks.test.ts b/packages/pds/test/cli/checks.test.ts new file mode 100644 index 00000000..9740943a --- /dev/null +++ b/packages/pds/test/cli/checks.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for CLI preflight check helpers. + * + * `checkRepoDidMatches` is a pure function; we exercise its three branches + * (matching DID, mismatched DID, and no-repo-yet) directly. + */ +import { describe, it, expect } from "vitest"; +import { checkRepoDidMatches } from "../../src/cli/utils/checks.js"; +import type { MigrationStatus } from "../../src/cli/utils/pds-client.js"; + +function makeStatus(overrides: Partial = {}): MigrationStatus { + return { + activated: true, + active: true, + validDid: true, + did: "did:plc:account", + repoCommit: "bafyreigh2akiscaildc7ypw7e6tqocp3vy3uwgyq37e6kz3sm6f5l3hbjm", + repoRev: "3kabc", + repoBlocks: 10, + indexedRecords: 5, + expectedBlobs: 0, + importedBlobs: 0, + ...overrides, + }; +} + +describe("checkRepoDidMatches", () => { + it("passes when the repo DID matches the account DID", () => { + const result = checkRepoDidMatches( + makeStatus({ did: "did:plc:account" }), + "did:plc:account", + ); + expect(result.ok).toBe(true); + expect(result.message).toContain("matches account DID"); + }); + + it("fails with reset guidance when the repo DID differs", () => { + const result = checkRepoDidMatches( + makeStatus({ did: "did:web:pds.example.com" }), + "did:plc:account", + ); + expect(result.ok).toBe(false); + expect(result.message).toContain("did:web:pds.example.com"); + expect(result.message).toContain("did:plc:account"); + expect(result.detail).toContain("resetMigration"); + }); + + it("passes (skips) when no repo exists yet", () => { + const result = checkRepoDidMatches( + makeStatus({ did: null }), + "did:plc:account", + ); + expect(result.ok).toBe(true); + expect(result.message).toContain("no repo yet"); + }); +}); diff --git a/packages/pds/test/migration.test.ts b/packages/pds/test/migration.test.ts index 7d5ecf9e..889696e5 100644 --- a/packages/pds/test/migration.test.ts +++ b/packages/pds/test/migration.test.ts @@ -70,6 +70,9 @@ describe("Account Migration", () => { expect(body.validDid).toBe(true); expect(body.repoRev).toBeDefined(); expect(body.repoRev).not.toBeNull(); + // The repo DID must be surfaced so the CLI can detect a did:web/did:plc + // mismatch during migration preflight. + expect(body.did).toBe(env.DID); }); it("returns account status info", async () => { diff --git a/packages/pds/test/storage.test.ts b/packages/pds/test/storage.test.ts index aaa389ad..26bc9601 100644 --- a/packages/pds/test/storage.test.ts +++ b/packages/pds/test/storage.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest"; import { env, runInDurableObject } from "./helpers"; import { CID } from "@atproto/lex-data"; import { encode, cidForCbor, type LexValue } from "@atproto/lex-cbor"; -import { BlockMap, CidSet } from "@atproto/repo"; +import { BlockMap, CidSet, Repo } from "@atproto/repo"; +import { Secp256k1Keypair } from "@atproto/crypto"; import { AccountDurableObject } from "../src/account-do"; import { SqliteRepoStorage } from "../src/storage"; import { SqliteOAuthStorage } from "../src/oauth-storage"; @@ -522,6 +523,30 @@ describe("AccountDurableObject", () => { expect(repo.did).toBe(env.DID); }); }); + + it("throws when the stored repo DID does not match the configured DID", async () => { + const id = env.ACCOUNT.newUniqueId(); + const stub = env.ACCOUNT.get(id); + const mismatchedDid = "did:plc:exampletestmismatch"; + expect(mismatchedDid).not.toBe(env.DID); + + // Setup: simulate a repo created under a different DID (e.g. the did:web + // init default) before the account was migrated to a did:plc identity. + await runInDurableObject(stub, async (instance: AccountDurableObject) => { + const storage = await instance.getStorage(); + const keypair = await Secp256k1Keypair.import(env.SIGNING_KEY); + await Repo.create(storage, mismatchedDid, keypair); + }); + + // Loading the repo must fail loudly rather than silently serve a repo + // whose commits the relay rejects at verifyRepoRoot. The guard throws + // inside blockConcurrencyWhile, so the error surfaces from getRepo(). + await expect( + runInDurableObject(stub, async (instance: AccountDurableObject) => { + await instance.getRepo(); + }), + ).rejects.toThrow(/Repo DID mismatch/); + }); });