361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
// "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 }
|
|
}
|