// "Delete my account" (joint contract §12, §14 "Lifecycle, CSP and revocation"). // // The order is the contract's, and every step can be repeated: // // 0. the person confirms (the account's e-mail address) from a session whose sign-in is recent; // the last owner of an organization others belong to hands it over first; // 1. the ledger row is written: the erasure reference (the app user id), the modules to erase and // the identity provider whose account ends with it, before anything of the account is removed, // so nothing that follows can lose the subject; // 2. `credentials`: every session and API key of the person is revoked -- nothing acts as them // from here on; // 3. `service-sessions`: so, server-side, is every service session a module minted for them (a // Support widget session), so none lives on to its own expiry while that module's erasure // waits. A module that cannot be reached leaves the step open, and does not hold the deletion up; // 4. `provider-account`: an external provider that owns the account (Better Auth) ends it there, // found by the identity mapping, so its sessions cannot map to a new, empty user afterwards. A // failure, or a process with no such provider bound, holds the deletion here: the next step // would remove the mapping the account is found by; // 5. `identity`: the identity mapping is removed (with the user row's provider identity, in one // transaction), and the sessions are revoked once more, so no sign-in reaches this account again // and no new service session is minted for its subject (a new sign-in makes a new, unrelated user); // 6. each installed module erases its share by the subject; a module that is unavailable stays // pending and the worker's sweep retries it (DELETION_SWEEP_JOB); // 7. only when every module confirmed: the memberships and the user row go, and the row is marked // completed. The ledger row stays, holding the opaque id, module and provider names only. // // The ledger records each of steps 2-5 when it completed (DeletionRecord.steps) and each module's // confirmed erasure, so a deletion interrupted anywhere -- a crash, a refused erasure, two requests // at once -- resumes from the ledger alone with exactly the steps still to do, and a step that // completed is not repeated. Each step is idempotent, so two runs at once are harmless. import { AppError, forbidden, notFound } from "@conusai-kit/core" import { RECENT_SIGN_IN_SECONDS } from "../contract/constants.js" import type { DeletionStep } from "../contract/entities.js" import type { DeletionStatus, DeletionView } from "../contract/procedures.js" import { refusalOf } from "./export.js" import type { AccountCredential, AccountDeps, AccountIdentityProvider, AccountMembership, DeletionChange, DeletionPatch, DeletionRecord, OrganizationRef, } from "./ports.js" /** A clock this far ahead of the session's is still "recent", not a refusal. */ const SKEW_MS = 60_000 const problem = (status: number, code: string, title: string, detail: string): AppError => new AppError({ type: `/problems/${code}`, title, status, code, detail }) export const reauthenticationRequired = (): AppError => problem(403, "reauthentication_required", "Sign in again", "sign in again to delete this account") const refOf = (membership: AccountMembership): OrganizationRef => ({ slug: membership.slug, name: membership.name }) /** Organizations that would be left without an owner while others are still members. */ export const lastOwnerOf = (memberships: readonly AccountMembership[]): OrganizationRef[] => memberships.filter((m) => m.role === "owner" && m.owners <= 1 && m.members > 1).map(refOf) /** Organizations that would be left without any member: a deletion suspends them. */ export const onlyMemberOf = (memberships: readonly AccountMembership[]): OrganizationRef[] => memberships.filter((m) => m.members <= 1).map(refOf) /** The modules a deletion erases, in module order. */ export const erasingModules = (deps: AccountDeps): string[] => deps.contributions.filter((contribution) => contribution.erase !== undefined).map((contribution) => contribution.key) const recent = (signedInAt: string | null, now: number): boolean => { if (signedInAt === null) return false const age = now - Date.parse(signedInAt) return age >= -SKEW_MS && age <= RECENT_SIGN_IN_SECONDS * 1000 } const normalized = (address: string): string => address.trim().toLowerCase() export const viewOf = (record: DeletionRecord): DeletionView => ({ state: record.state, erased: [...record.erased], pending: [...record.pending], abandoned: [...record.abandoned], requestedAt: record.requestedAt, completedAt: record.completedAt, }) /** * When the asking session signed in: its kit row's, or -- for an external provider's session, which * has none -- the time the provider reported with the session it validated for this request. */ const signedInAtOf = async (deps: AccountDeps, userId: string, credential: AccountCredential) => { if (!credential.interactive) return null if (credential.sessionId) return deps.store.sessionSignedInAt(userId, credential.sessionId) return credential.signedInAt ?? null } export async function deletionStatus( deps: AccountDeps, userId: string, credential: AccountCredential, ): Promise { const [deletion, memberships, signedInAt] = await Promise.all([ deps.store.deletion(userId), deps.store.memberships(userId), signedInAtOf(deps, userId, credential), ]) return { deletion: deletion ? viewOf(deletion) : null, interactive: credential.interactive, recentSignIn: recent(signedInAt, deps.now()), signedInAt, recentSignInSeconds: RECENT_SIGN_IN_SECONDS, lastOwnerOf: lastOwnerOf(memberships), onlyMemberOf: onlyMemberOf(memberships), modules: erasingModules(deps), } } export interface DeletionRequest { readonly userId: string readonly credential: AccountCredential readonly confirm: string } export async function requestDeletion(deps: AccountDeps, request: DeletionRequest): Promise { const { userId, credential } = request if (!credential.interactive) { throw forbidden("deleting an account needs the person signed in to this app, not an API credential") } // A deletion that has begun is resumed, whatever was typed: the person may be retrying it. const existing = await deps.store.deletion(userId) if (existing) return continueDeletion(deps, existing) const email = await deps.store.emailOf(userId) if (email === null) throw notFound("no such user") if (normalized(request.confirm) !== normalized(email)) { throw problem(400, "confirmation_mismatch", "Not confirmed", "type the account's e-mail address to confirm") } if (!recent(await signedInAtOf(deps, userId, credential), deps.now())) throw reauthenticationRequired() const blocking = lastOwnerOf(await deps.store.memberships(userId)) if (blocking.length > 0) { throw problem( 409, "last_owner", "Hand over your organizations first", `make another member an owner of ${blocking.map((org) => org.slug).join(", ")} first`, ) } return continueDeletion(deps, await startDeletion(deps, userId)) } /** The bound provider's id, or null: a provider that cannot be built still names a provider step. */ function providerIdOf(deps: AccountDeps): string | null { if (!deps.identityProvider) return null try { return deps.identityProvider().id } catch { return UNRESOLVED_PROVIDER } } /** What the ledger records when the bound provider could not be built: the step is held until it can. */ const UNRESOLVED_PROVIDER = "unresolved" const startDeletion = (deps: AccountDeps, userId: string): Promise => deps.store.startDeletion({ userId, modules: erasingModules(deps), provider: providerIdOf(deps), at: new Date(deps.now()).toISOString(), }) /** * The rule of every ledger change, on the row as it is now: null when the change is already made (a * concurrent run made it first), so the store writes nothing. Pure, so every store applies the same. */ export function applyDeletionChange(row: DeletionRecord, change: DeletionChange): DeletionPatch | null { switch (change.kind) { case "step": return row.steps.includes(change.step) ? null : { steps: [...row.steps, change.step] } case "erased": return row.pending.includes(change.module) ? { pending: row.pending.filter((key) => key !== change.module), erased: [...row.erased, change.module] } : null case "abandoned": return row.pending.includes(change.module) ? { pending: row.pending.filter((key) => key !== change.module), abandoned: [...row.abandoned, change.module] } : null case "attempt": return { attempts: row.attempts + 1, lastFailure: change.failure } case "completed": return row.state === "completed" ? null : { state: "completed", completedAt: change.at, lastFailure: null } } } /** Steps 2-7 for one ledger row. Safe to run again at any point, by the request or by the sweep. */ export async function continueDeletion(deps: AccountDeps, record: DeletionRecord): Promise { if (record.state === "completed") return viewOf(record) const { userId } = record const at = new Date(deps.now()).toISOString() let current = record const done = (step: DeletionStep) => current.steps.includes(step) const complete = async (step: DeletionStep) => { current = await deps.store.changeDeletion(userId, { kind: "step", step }) } const held = async (failure: string | null) => { current = await deps.store.changeDeletion(userId, { kind: "attempt", failure }) return viewOf(current) } if (!done("credentials")) { await deps.store.revokeCredentials(userId, at) await complete("credentials") } if (!done("service-sessions") && (await revokeModuleSessions(deps, userId))) await complete("service-sessions") // A row the ledger recorded no provider for (written before it did, or by a process that bound // none) still ends the account when this process binds one: an app that has an external provider // binds it in every process, and skipping the step would leave the account behind. const providerDue = current.provider !== null || deps.identityProvider !== undefined if (providerDue && !done("provider-account")) { const failure = await endProviderAccount(deps, current) if (failure !== null) return held(failure) await complete("provider-account") } if (!done("identity")) { // Sessions first, then the identity, then sessions again: a sign-in that completed between the // two opened a session this pass ends; after the detach no sign-in reaches the account. await deps.store.detachIdentity(userId) await deps.store.revokeCredentials(userId, at) await complete("identity") } let failure: string | null = null for (const key of current.pending) { const contribution = deps.contributions.find((candidate) => candidate.key === key) if (!contribution?.erase) { // Removed from the app after the deletion began: this app can no longer reach its service. The // ledger keeps the module and the subject, so an operator erases it there by `userId`. current = await deps.store.changeDeletion(userId, { kind: "abandoned", module: key }) continue } try { await contribution.erase({ subject: userId, container: deps.container, ...(deps.signal ? { signal: deps.signal } : {}), }) } catch (error) { failure = `${key}:${refusalOf(error).code}` continue } current = await deps.store.changeDeletion(userId, { kind: "erased", module: key }) } if (current.pending.length > 0) return held(failure) await deps.store.removeLocalData(userId) current = await deps.store.changeDeletion(userId, { kind: "completed", at }) return viewOf(current) } /** The problem code of a provider step that could not end the account, or null once it did. */ async function endProviderAccount(deps: AccountDeps, record: DeletionRecord): Promise { let provider: AccountIdentityProvider | undefined try { provider = deps.identityProvider?.() } catch { provider = undefined } // The process that resumes the deletion must reach the provider the deletion began with: going on // without it would detach the identity the account is found by, and leave the account behind. if (!provider) return "provider-account:provider_unavailable" if (record.provider !== null && record.provider !== UNRESOLVED_PROVIDER && record.provider !== provider.id) { return "provider-account:provider_unavailable" } try { const identities = await deps.store.identities(record.userId) const subjects = identities.filter((identity) => identity.issuer === provider.issuer).map(({ subject }) => subject) // Identities, but none under the provider's namespace: a namespace that differs between processes // would otherwise "succeed" and detach the identity the account is found by. Fail closed. if (subjects.length === 0 && identities.length > 0) return "provider-account:not_mapped" // Every account of the person at the provider (several aliases may map under one namespace). for (const subject of new Set(subjects)) await provider.endAccount(subject) return null } catch (error) { return `provider-account:${refusalOf(error).code}` } } /** * Every installed module's derived service sessions of the subject, revoked server-side; true when * every module confirmed. A module that cannot be reached does not hold the deletion up: its * erasure ends the same sessions (and is retried until it does), and a resumption repeats this step * until every module confirmed it. */ async function revokeModuleSessions(deps: AccountDeps, subject: string): Promise { const settled = await Promise.allSettled( deps.contributions .filter((contribution) => contribution.revokeSessions) .map((contribution) => contribution.revokeSessions?.({ subject, container: deps.container, ...(deps.signal ? { signal: deps.signal } : {}), }), ), ) return settled.every((result) => result.status === "fulfilled") } /** * The identity provider deleted the account itself (Better Auth's own deletion, delivered by its * revocation relay): the same erasure as `requestDeletion`, without the person's confirmation, the * recent sign-in or the last-owner hand-over -- the account is already gone at the provider, so * there is nothing left to confirm with and nothing to wait for. The ledger row comes first, as * always, so the worker's sweep finishes what a failing module holds up. Idempotent. */ export async function deleteForProvider(deps: AccountDeps, userId: string): Promise { const existing = await deps.store.deletion(userId) if (existing) return continueDeletion(deps, existing) if ((await deps.store.emailOf(userId)) === null) throw notFound("no such user") return continueDeletion(deps, await startDeletion(deps, userId)) } /** * The identity provider disabled the account (an administrator's ban): every session and API key of * the person is revoked here, and every service session a module minted for them, server-side. The * provider refuses the person's sign-in from now on; nothing of the account is erased. */ export async function disableForProvider(deps: AccountDeps, userId: string): Promise { await deps.store.revokeCredentials(userId, new Date(deps.now()).toISOString()) await revokeModuleSessions(deps, userId) } /** The worker's sweep: resumes every deletion still erasing. Fails, after trying all, when some still wait. */ export async function sweepDeletions(deps: AccountDeps, limit = 100): Promise<{ resumed: number; completed: number }> { const open = await deps.store.openDeletions(limit) let completed = 0 for (const record of open) { try { if ((await continueDeletion(deps, record)).state === "completed") completed += 1 } catch { // Counted below: one row's failure must not keep the others from being resumed. } } const waiting = open.length - completed if (waiting > 0) { // A failed run is what the worker reports and an operator is alerted on; the next run retries. throw new AppError({ type: "/problems/account_deletion_waiting", title: "Account deletions are waiting", status: 503, code: "account_deletion_waiting", detail: `${waiting} account deletion(s) wait for a module's erasure`, retryAfter: 300, }) } return { resumed: open.length, completed } }