diff --git a/lexicons/com/atproto/admin/deleteAccount.json b/lexicons/com/atproto/admin/deleteAccount.json new file mode 100644 index 00000000000..bd7532cef61 --- /dev/null +++ b/lexicons/com/atproto/admin/deleteAccount.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.admin.deleteAccount", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a user account as an administrator.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 3fd82222639..fe64ab61f30 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -8,6 +8,7 @@ import { import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -145,6 +146,7 @@ import * as AppBskyUnspeccedSearchActorsSkeleton from './types/app/bsky/unspecce import * as AppBskyUnspeccedSearchPostsSkeleton from './types/app/bsky/unspecced/searchPostsSkeleton' export * as ComAtprotoAdminDefs from './types/com/atproto/admin/defs' +export * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' export * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' export * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' export * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -369,6 +371,17 @@ export class AdminNS { this._service = service } + deleteAccount( + data?: ComAtprotoAdminDeleteAccount.InputSchema, + opts?: ComAtprotoAdminDeleteAccount.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.admin.deleteAccount', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoAdminDeleteAccount.toKnownErr(e) + }) + } + disableAccountInvites( data?: ComAtprotoAdminDisableAccountInvites.InputSchema, opts?: ComAtprotoAdminDisableAccountInvites.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts b/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..b8b5aa511b8 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + did: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index bf69ebafa68..bf2b35cb678 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -9,6 +9,7 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -195,6 +196,17 @@ export class AdminNS { this._server = server } + deleteAccount( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteAccount.Handler>, + ComAtprotoAdminDeleteAccount.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteAccount' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..13e68eb5c7d --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + did: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index e9f9e669c96..415fbc7b26b 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -2,7 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { UserPreference } from '../../../../services/account' import { InvalidRequestError } from '@atproto/xrpc-server' -import { authPassthru, proxy } from '../../../proxy' +import { authPassthru, ensureThisPds, proxy } from '../../../proxy' export default function (server: Server, ctx: AppContext) { server.app.bsky.actor.putPreferences({ @@ -22,6 +22,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const { preferences } = input.body const requester = auth.credentials.did const { services, db } = ctx diff --git a/packages/pds/src/api/com/atproto/moderation/createReport.ts b/packages/pds/src/api/com/atproto/moderation/createReport.ts index f2cb05a95e1..79abd286a71 100644 --- a/packages/pds/src/api/com/atproto/moderation/createReport.ts +++ b/packages/pds/src/api/com/atproto/moderation/createReport.ts @@ -2,6 +2,7 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { authPassthru, + ensureThisPds, proxy, proxyAppView, resultPassthru, @@ -26,6 +27,8 @@ export default function (server: Server, ctx: AppContext) { return proxied } + ensureThisPds(ctx, auth.credentials.pdsDid) + const requester = auth.credentials.did const { data: result } = await proxyAppView(ctx, async (agent) => agent.com.atproto.moderation.createReport(input.body, { diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index c57d197c295..9141cea6f2d 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -1,11 +1,16 @@ -import { AuthRequiredError } from '@atproto/xrpc-server' +import { MINUTE } from '@atproto/common' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { MINUTE } from '@atproto/common' +import { isThisPds } from '../../../proxy' +import { retryHttp } from '../../../../util/retry' const REASON_ACCT_DELETION = 'account_deletion' -// @TODO negotiate account deletions between pds and entryway export default function (server: Server, ctx: AppContext) { server.com.atproto.server.deleteAccount({ rateLimit: { @@ -14,19 +19,24 @@ export default function (server: Server, ctx: AppContext) { }, handler: async ({ input, req }) => { const { did, password, token } = input.body - const validPass = await ctx.services - .account(ctx.db) - .verifyAccountPassword(did, password) + const accountService = ctx.services.account(ctx.db) + const validPass = await accountService.verifyAccountPassword( + did, + password, + ) if (!validPass) { throw new AuthRequiredError('Invalid did or password') } - await ctx.services - .account(ctx.db) - .assertValidToken(did, 'delete_account', token) + const account = await accountService.getAccount(did, true) + if (!account) { + throw new InvalidRequestError('account not found', 'AccountNotFound') + } + + await accountService.assertValidToken(did, 'delete_account', token) await ctx.db.transaction(async (dbTxn) => { - const accountService = ctx.services.account(dbTxn) + const accountTxn = ctx.services.account(dbTxn) const moderationTxn = ctx.services.moderation(dbTxn) const currState = await moderationTxn.getRepoTakedownState(did) // Do not disturb an existing takedown, continue with account deletion @@ -36,15 +46,46 @@ export default function (server: Server, ctx: AppContext) { ref: REASON_ACCT_DELETION, }) } - await accountService.deleteEmailToken(did, 'delete_account') + await accountTxn.deleteEmailToken(did, 'delete_account') }) + const { pdsDid } = account + if (ctx.cfg.service.isEntryway && pdsDid && !isThisPds(ctx, pdsDid)) { + try { + const pds = await accountService.getPds(pdsDid, { cached: true }) + if (!pds) { + throw new UpstreamFailureError('unknown pds') + } + // both entryway and pds behind it need to clean-up account state, then pds sequences tombstone. + // the long flow is: pds(server.deleteAccount) -> entryway(server.deleteAccount) -> pds(admin.deleteAccount) + const agent = ctx.pdsAgents.get(pds.host) + await retryHttp(() => + agent.com.atproto.admin.deleteAccount( + { did }, + { + encoding: 'application/json', + headers: ctx.authVerifier.createAdminRoleHeaders(), + }, + ), + ) + } catch (err) { + req.log.error( + { did, pdsDid, err }, + 'account deletion failed on pds behind entryway', + ) + } + } + ctx.backgroundQueue.add(async (db) => { + // in the background perform the hard account deletion work try { - // In the background perform the hard account deletion work await ctx.services.record(db).deleteForActor(did) await ctx.services.repo(db).deleteRepo(did) await ctx.services.account(db).deleteAccount(did) + if (!ctx.cfg.service.isEntryway || isThisPds(ctx, pdsDid)) { + // if this is the user's pds sequence the tombstone, otherwise taken care of by their pds behind the entryway. + await ctx.services.account(db).sequenceTombstone(did) + } } catch (err) { req.log.error({ did, err }, 'account deletion failed') } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index bf69ebafa68..bf2b35cb678 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -9,6 +9,7 @@ import { StreamAuthVerifier, } from '@atproto/xrpc-server' import { schemas } from './lexicons' +import * as ComAtprotoAdminDeleteAccount from './types/com/atproto/admin/deleteAccount' import * as ComAtprotoAdminDisableAccountInvites from './types/com/atproto/admin/disableAccountInvites' import * as ComAtprotoAdminDisableInviteCodes from './types/com/atproto/admin/disableInviteCodes' import * as ComAtprotoAdminEnableAccountInvites from './types/com/atproto/admin/enableAccountInvites' @@ -195,6 +196,17 @@ export class AdminNS { this._server = server } + deleteAccount( + cfg: ConfigOf< + AV, + ComAtprotoAdminDeleteAccount.Handler>, + ComAtprotoAdminDeleteAccount.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.admin.deleteAccount' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + disableAccountInvites( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index f17885819a0..d3e78cff850 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -710,6 +710,29 @@ export const schemaDict = { }, }, }, + ComAtprotoAdminDeleteAccount: { + lexicon: 1, + id: 'com.atproto.admin.deleteAccount', + defs: { + main: { + type: 'procedure', + description: 'Delete a user account as an administrator.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['did'], + properties: { + did: { + type: 'string', + format: 'did', + }, + }, + }, + }, + }, + }, + }, ComAtprotoAdminDisableAccountInvites: { lexicon: 1, id: 'com.atproto.admin.disableAccountInvites', @@ -7574,6 +7597,7 @@ export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[] export const lexicons: Lexicons = new Lexicons(schemas) export const ids = { ComAtprotoAdminDefs: 'com.atproto.admin.defs', + ComAtprotoAdminDeleteAccount: 'com.atproto.admin.deleteAccount', ComAtprotoAdminDisableAccountInvites: 'com.atproto.admin.disableAccountInvites', ComAtprotoAdminDisableInviteCodes: 'com.atproto.admin.disableInviteCodes', diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts b/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts new file mode 100644 index 00000000000..13e68eb5c7d --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/admin/deleteAccount.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + did: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 749435a0ed1..e5479e3af65 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -385,6 +385,9 @@ export class AccountService { .deleteFrom('did_handle') .where('did_handle.did', '=', did) .execute() + } + + async sequenceTombstone(did: string): Promise { const seqEvt = await sequencer.formatSeqTombstone(did) await this.db.transaction(async (txn) => { await sequencer.sequenceEvt(txn, seqEvt) diff --git a/packages/pds/src/util/retry.ts b/packages/pds/src/util/retry.ts new file mode 100644 index 00000000000..ad3c44ee08f --- /dev/null +++ b/packages/pds/src/util/retry.ts @@ -0,0 +1,65 @@ +// @NOTE nabbed from @atproto/bsky utils. not DRYing it up now to avoid big fork between main and entryway. +import { wait } from '@atproto/common' +import { ResponseType, XRPCError } from '@atproto/xrpc' + +export async function retry( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + const { max = 3, retryable = () => true } = opts + let retries = 0 + let doneError: unknown + while (!doneError) { + try { + if (retries) await backoff(retries) + return await fn() + } catch (err) { + const willRetry = retries < max && retryable(err) + if (!willRetry) doneError = err + retries += 1 + } + } + throw doneError +} + +export async function retryHttp( + fn: () => Promise, + opts: RetryOptions = {}, +): Promise { + return retry(fn, { retryable: retryableHttp, ...opts }) +} + +export function retryableHttp(err: unknown) { + if (err instanceof XRPCError) { + if (err.status === ResponseType.Unknown) return true + return retryableHttpStatusCodes.has(err.status) + } + return false +} + +const retryableHttpStatusCodes = new Set([ + 408, 425, 429, 500, 502, 503, 504, 522, 524, +]) + +type RetryOptions = { + max?: number + retryable?: (err: unknown) => boolean +} + +// Waits exponential backoff with max and jitter: ~50, ~100, ~200, ~400, ~800, ~1000, ~1000, ... +async function backoff(n: number, multiplier = 50, max = 1000) { + const exponentialMs = Math.pow(2, n) * multiplier + const ms = Math.min(exponentialMs, max) + await wait(jitter(ms)) +} + +// Adds randomness +/-15% of value +function jitter(value: number) { + const delta = value * 0.15 + return value + randomRange(-delta, delta) +} + +function randomRange(from: number, to: number) { + const rand = Math.random() * (to - from) + return rand + from +} diff --git a/packages/pds/tests/sync/subscribe-repos.test.ts b/packages/pds/tests/sync/subscribe-repos.test.ts index 58745b7fe1e..5aa14024ff2 100644 --- a/packages/pds/tests/sync/subscribe-repos.test.ts +++ b/packages/pds/tests/sync/subscribe-repos.test.ts @@ -351,6 +351,7 @@ describe('repo subscribe repos', () => { await ctx.services.record(db).deleteForActor(did) await ctx.services.repo(db).deleteRepo(did) await ctx.services.account(db).deleteAccount(did) + await ctx.services.account(db).sequenceTombstone(did) } const ws = new WebSocket( @@ -381,6 +382,7 @@ describe('repo subscribe repos', () => { await ctx.services.record(db).deleteForActor(baddie3) await ctx.services.repo(db).deleteRepo(baddie3) await ctx.services.account(db).deleteAccount(baddie3) + await ctx.services.account(db).sequenceTombstone(baddie3) const ws = new WebSocket( `ws://${serverHost}/xrpc/com.atproto.sync.subscribeRepos?cursor=${-1}`,