Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/account-deactivated-403.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 0 additions & 40 deletions packages/pds/src/cli/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}...`);

Expand All @@ -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 };
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -235,19 +219,13 @@ 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.");
p.outro("All good! 🦋");
return;
}

// ============================================
// Step 5: Fetch source stats
// ============================================
spinner.start(`Fetching your account details from ${sourceDomain}...`);

const sourceClient = new PDSClient(sourcePdsUrl);
Expand Down Expand Up @@ -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!");
Expand Down Expand Up @@ -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:"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
69 changes: 56 additions & 13 deletions packages/pds/src/xrpc/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <message>")
* @returns HTTP 403 Response with AccountDeactivated error type, or null if not a deactivation error
*/
function checkAccountDeactivatedError(
c: Context<AuthedAppEnv>,
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<AppEnv>,
accountDO: DurableObjectStub<AccountDurableObject>,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/pds/test/migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down