This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
// "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<DeletionStatus> {
|
||||
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<DeletionView> {
|
||||
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<DeletionRecord> =>
|
||||
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<DeletionView> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<DeletionView> {
|
||||
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<void> {
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user