From 8c12d10688fbe1c56ec31ed767224ad6a4561b92 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 1 Jan 2026 18:02:34 +0000 Subject: [PATCH 1/2] fix: return HTTP 403 AccountDeactivated error for write operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When write operations (createRecord, putRecord, deleteRecord, applyWrites) are attempted on a deactivated account, return a proper HTTP 403 with error type "AccountDeactivated" instead of a generic 500 error. This gives clients clear feedback that the account needs to be activated. Also removes redundant step number comments from migrate.ts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/pds/src/cli/commands/migrate.ts | 40 -------------- packages/pds/src/xrpc/repo.ts | 69 +++++++++++++++++++----- packages/pds/test/migration.test.ts | 4 +- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/packages/pds/src/cli/commands/migrate.ts b/packages/pds/src/cli/commands/migrate.ts index 09cbf8a5..af1d0bcf 100644 --- a/packages/pds/src/cli/commands/migrate.ts +++ b/packages/pds/src/cli/commands/migrate.ts @@ -79,9 +79,6 @@ export const migrateCommand = defineCommand({ p.intro("🦋 PDS Migration"); - // ============================================ - // Step 1: Healthcheck - // ============================================ const spinner = p.spinner(); spinner.start(`Checking PDS at ${targetDomain}...`); @@ -103,9 +100,6 @@ export const migrateCommand = defineCommand({ } spinner.stop(`Connected to ${targetDomain}`); - // ============================================ - // Step 2: Load config - // ============================================ const wranglerVars = getVars(); const devVars = readDevVars(); const config = { ...devVars, ...wranglerVars }; @@ -126,12 +120,8 @@ export const migrateCommand = defineCommand({ process.exit(1); } - // Set auth token for target PDS targetClient.setAuthToken(authToken); - // ============================================ - // Step 3: Resolve source PDS from DID - // ============================================ spinner.start(`Looking up @${handle}...`); const didResolver = new DidResolver(); @@ -155,9 +145,6 @@ export const migrateCommand = defineCommand({ const sourceDomain = getDomain(sourcePdsUrl); spinner.stop(`Found your account at ${sourceDomain}`); - // ============================================ - // Step 4: Check target state - // ============================================ spinner.start("Checking account status..."); let status; @@ -174,9 +161,6 @@ export const migrateCommand = defineCommand({ spinner.stop("Account status retrieved"); - // ============================================ - // Handle --clean flag - // ============================================ if (args.clean) { if (status.active) { p.log.error("Cannot reset: account is active"); @@ -235,9 +219,6 @@ export const migrateCommand = defineCommand({ status = await targetClient.getAccountStatus(); } - // ============================================ - // Check if already active - // ============================================ if (status.active) { p.log.warn("Your account is already active in the Atmosphere!"); p.log.info("No migration needed - your PDS is live."); @@ -245,9 +226,6 @@ export const migrateCommand = defineCommand({ return; } - // ============================================ - // Step 5: Fetch source stats - // ============================================ spinner.start(`Fetching your account details from ${sourceDomain}...`); const sourceClient = new PDSClient(sourcePdsUrl); @@ -276,9 +254,6 @@ export const migrateCommand = defineCommand({ const needsBlobSync = missingBlobs > 0 || needsRepoImport; const isResuming = !needsRepoImport && needsBlobSync; - // ============================================ - // Show migration preview - // ============================================ if (isResuming) { // Resume flow p.log.info("Welcome back!"); @@ -354,9 +329,6 @@ export const migrateCommand = defineCommand({ return; } - // ============================================ - // Step 6: Authenticate to source PDS - // ============================================ const isBlueskyPds = sourceDomain.endsWith(".bsky.network"); const passwordPrompt = isBlueskyPds ? "Your current Bluesky password:" @@ -390,9 +362,6 @@ export const migrateCommand = defineCommand({ process.exit(1); } - // ============================================ - // Step 7: Export and import repo - // ============================================ if (needsRepoImport) { spinner.start("Packing your repository..."); let carBytes: Uint8Array; @@ -427,9 +396,6 @@ export const migrateCommand = defineCommand({ status = await targetClient.getAccountStatus(); } - // ============================================ - // Step 8: Migrate preferences - // ============================================ spinner.start("Migrating your preferences..."); try { const preferences = await sourceClient.getPreferences(); @@ -444,9 +410,6 @@ export const migrateCommand = defineCommand({ spinner.stop("Skipped preferences (not available)"); } - // ============================================ - // Step 9: Sync blobs - // ============================================ const expectedBlobs = status.expectedBlobs; const alreadyImported = status.importedBlobs; const blobsToSync = expectedBlobs - alreadyImported; @@ -511,9 +474,6 @@ export const migrateCommand = defineCommand({ } } - // ============================================ - // Step 10: Verify and show next steps - // ============================================ spinner.start("Verifying migration..."); const finalStatus = await targetClient.getAccountStatus(); spinner.stop("Verification complete"); diff --git a/packages/pds/src/xrpc/repo.ts b/packages/pds/src/xrpc/repo.ts index 6478b053..b2ba235f 100644 --- a/packages/pds/src/xrpc/repo.ts +++ b/packages/pds/src/xrpc/repo.ts @@ -19,6 +19,30 @@ function invalidRecordError( ); } +/** + * Check if an error is an AccountDeactivated error and return appropriate HTTP 403 response. + * @param c - Hono context for creating the response + * @param err - The error to check (expected format: "AccountDeactivated: ") + * @returns HTTP 403 Response with AccountDeactivated error type, or null if not a deactivation error + */ +function checkAccountDeactivatedError( + c: Context, + err: unknown, +): Response | null { + const message = err instanceof Error ? err.message : String(err); + if (message.startsWith("AccountDeactivated:")) { + return c.json( + { + error: "AccountDeactivated", + message: + "Account is deactivated. Call activateAccount to enable writes.", + }, + 403, + ); + } + return null; +} + export async function describeRepo( c: Context, accountDO: DurableObjectStub, @@ -230,9 +254,15 @@ export async function createRecord( return invalidRecordError(c, err); } - const result = await accountDO.rpcCreateRecord(collection, rkey, record); + try { + const result = await accountDO.rpcCreateRecord(collection, rkey, record); + return c.json(result); + } catch (err) { + const deactivatedError = checkAccountDeactivatedError(c, err); + if (deactivatedError) return deactivatedError; - return c.json(result); + throw err; + } } export async function deleteRecord( @@ -262,19 +292,26 @@ export async function deleteRecord( ); } - const result = await accountDO.rpcDeleteRecord(collection, rkey); + try { + const result = await accountDO.rpcDeleteRecord(collection, rkey); - if (!result) { - return c.json( - { - error: "RecordNotFound", - message: `Record not found: ${collection}/${rkey}`, - }, - 404, - ); - } + if (!result) { + return c.json( + { + error: "RecordNotFound", + message: `Record not found: ${collection}/${rkey}`, + }, + 404, + ); + } - return c.json(result); + return c.json(result); + } catch (err) { + const deactivatedError = checkAccountDeactivatedError(c, err); + if (deactivatedError) return deactivatedError; + + throw err; + } } export async function putRecord( @@ -315,6 +352,9 @@ export async function putRecord( const result = await accountDO.rpcPutRecord(collection, rkey, record); return c.json(result); } catch (err) { + const deactivatedError = checkAccountDeactivatedError(c, err); + if (deactivatedError) return deactivatedError; + return c.json( { error: "InvalidRequest", @@ -381,6 +421,9 @@ export async function applyWrites( const result = await accountDO.rpcApplyWrites(writes); return c.json(result); } catch (err) { + const deactivatedError = checkAccountDeactivatedError(c, err); + if (deactivatedError) return deactivatedError; + return c.json( { error: "InvalidRequest", diff --git a/packages/pds/test/migration.test.ts b/packages/pds/test/migration.test.ts index 991e8666..31144a91 100644 --- a/packages/pds/test/migration.test.ts +++ b/packages/pds/test/migration.test.ts @@ -660,7 +660,9 @@ describe("Account Migration", () => { ); expect(createResponse.ok).toBe(false); - expect(createResponse.status).toBe(500); + expect(createResponse.status).toBe(403); + const error = (await createResponse.json()) as { error: string }; + expect(error.error).toBe("AccountDeactivated"); }); }); From 6eec27a3dfc6a4be4cef8922bd4f9c75fa8aff63 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 1 Jan 2026 18:04:06 +0000 Subject: [PATCH 2/2] chore: add changeset for AccountDeactivated 403 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/account-deactivated-403.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/account-deactivated-403.md diff --git a/.changeset/account-deactivated-403.md b/.changeset/account-deactivated-403.md new file mode 100644 index 00000000..5f1acd3e --- /dev/null +++ b/.changeset/account-deactivated-403.md @@ -0,0 +1,7 @@ +--- +"@ascorbic/pds": patch +--- + +Return HTTP 403 with AccountDeactivated error for write operations on deactivated accounts + +Previously, attempting write operations on a deactivated account returned a generic 500 error. Now returns a proper 403 Forbidden with error type "AccountDeactivated", giving clients clear feedback that the account needs to be activated.