Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/pds/src/account-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,20 @@ export class AccountDurableObject extends DurableObject<PDSEnv> {
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!,
Expand Down
6 changes: 6 additions & 0 deletions packages/pds/src/cli/commands/activate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
checkHandleResolution,
checkDidDocument,
checkRepoComplete,
checkRepoDidMatches,
type CheckResult,
} from "../utils/checks.js";

Expand Down Expand Up @@ -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;
}

Expand Down
31 changes: 31 additions & 0 deletions packages/pds/src/cli/utils/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hostname>` init default) and the account was later migrated to a
* `did:plc:…` identity. The relay rejects such a repo at `verifyRepoRoot`
* (`commit.did !== <account 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
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/pds/src/cli/utils/pds-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/pds/src/xrpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ export async function checkAccountStatus(
activated,
active,
validDid: true,
did: status.did,
repoCommit: status.head,
repoRev: status.rev,
repoBlocks,
Expand All @@ -390,6 +391,7 @@ export async function checkAccountStatus(
activated: false,
active: false,
validDid: true,
did: null,
repoCommit: null,
repoRev: null,
repoBlocks: 0,
Expand Down
56 changes: 56 additions & 0 deletions packages/pds/test/cli/checks.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
3 changes: 3 additions & 0 deletions packages/pds/test/migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
27 changes: 26 additions & 1 deletion packages/pds/test/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/);
});
});


Expand Down