ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+360
View File
@@ -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 }
}