diff --git a/server/src/pdsClient.ts b/server/src/pdsClient.ts index e7a4bdd..230310d 100644 --- a/server/src/pdsClient.ts +++ b/server/src/pdsClient.ts @@ -322,6 +322,22 @@ export class PdsClient { return accessJwt; } + /** + * Reactivate the account as itself so its repo accepts reads/writes. Needed before + * purging a deactivated account, whose repo otherwise rejects everything with + * RepoDeactivated. No-op if already active. The purge re-applies the takedown after. + */ + async activateAccountAsUser(accessJwt: string): Promise { + const res = await fetch(`https://${this.hostname}/xrpc/com.atproto.server.activateAccount`, { + method: "POST", + headers: { Authorization: `Bearer ${accessJwt}`, "Content-Type": "application/json" }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`activateAccount failed: ${res.status} ${body}`); + } + } + /** * Delete up to `limit` records from the account's repo as the signed-in account, * paced and 429-aware. Returns how many were deleted and whether the repo is now diff --git a/server/src/purge.ts b/server/src/purge.ts index 544a01c..0e99d31 100644 --- a/server/src/purge.ts +++ b/server/src/purge.ts @@ -128,6 +128,8 @@ export class PurgeRunner { try { await this.pds.setAccountTakedown(did, false); const jwt = await this.pds.signInAsAccount(did); + // a deactivated repo rejects writes, so reactivate before deleting + await this.pds.activateAccountAsUser(jwt); round = await this.pds.deleteRecordsChunk(jwt, did, CHUNK); } catch (err) { roundErr = err; diff --git a/server/src/routes/accounts.ts b/server/src/routes/accounts.ts index e8187a4..eefbb01 100644 --- a/server/src/routes/accounts.ts +++ b/server/src/routes/accounts.ts @@ -154,16 +154,19 @@ export function registerAccountRoutes( app.post("/api/accounts/:did/purge-records", { preHandler: requireAuth }, async (req, reply) => { const { did } = req.params as { did: string }; - // purging is permanent, so only allowed on accounts already taken down: the - // takedown step is the reversible part of the flow, this one is not. The account - // itself survives so the handle stays taken. The work runs as a paced background - // job, so this only enqueues it and returns + // purging is permanent, so only allowed on accounts that are already hidden + // (taken down or deactivated): being hidden is the reversible part of the flow, + // this one is not. The account itself survives so the handle stays taken, and + // the purge always restores a takedown when done. Runs as a paced background job, + // so this only enqueues it and returns const row = db.prepare("SELECT status FROM accounts WHERE did = ?").get(did) as | { status: string } | undefined; if (!row) return reply.code(404).send({ error: "account not found" }); - if (row.status !== "takendown") { - return reply.code(409).send({ error: "account must be taken down before purging records" }); + if (row.status === "active") { + return reply + .code(409) + .send({ error: "account must be taken down or deactivated before purging records" }); } const job = purger.enqueue(did, req.session.operator!); return { ok: true, status: job.status, deleted: job.deleted }; diff --git a/server/test/purge.test.ts b/server/test/purge.test.ts index 6f912f3..c05e38c 100644 --- a/server/test/purge.test.ts +++ b/server/test/purge.test.ts @@ -21,6 +21,7 @@ function fakePds(total: number, chunk: number) { async signInAsAccount() { return "jwt"; }, + async activateAccountAsUser() {}, async deleteRecordsChunk() { const n = Math.min(chunk, remaining); remaining -= n; diff --git a/web/src/AccountsPanel.tsx b/web/src/AccountsPanel.tsx index 0f0c4bb..2148cdd 100644 --- a/web/src/AccountsPanel.tsx +++ b/web/src/AccountsPanel.tsx @@ -325,7 +325,7 @@ export function AccountsPanel({ > reset password - {a.status === "takendown" && !a.purge && ( + {a.status !== "active" && !a.purge && (