This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// The person's platform analytics and observability data (ADR-049): start its erasure or export,
|
||||
// follow the job, take the export away. Pure TypeScript over the ACCOUNT_PRIVACY port.
|
||||
//
|
||||
// The platform's GET routes answer every request made by this project's deployment tokens, so a
|
||||
// request id from the browser would let one person read another's job or export. The browser
|
||||
// therefore only ever holds a ticket, signed here:
|
||||
//
|
||||
// `v1.<request id>.<expires at, ms since the epoch>.<HMAC-SHA256(K, payload)>`
|
||||
// payload = `v1|<expires at>|<len(user id)>:<user id>|<request id>`
|
||||
//
|
||||
// K is not the deployment token itself: it is HKDF-SHA256 over `ticketKey` (the token) with the info
|
||||
// label TICKET_KEY_INFO, so the bearer credential never doubles as a MAC key and the ticket MAC is
|
||||
// useless for anything else. The version and the expiry are inside the signed payload, so neither
|
||||
// can be edited. A ticket is accepted only for the user it was signed for and only until it
|
||||
// expires (TICKET_LIFETIME_MS); a ticket of somebody else, an expired one or a forged one is the
|
||||
// same NotFound.
|
||||
//
|
||||
// Rotation: rotating the deployment token changes K, so every outstanding ticket stops verifying
|
||||
// (NotFound). That is deliberate and harmless: nothing is stored, a ticket only lives in the open
|
||||
// page, and asking again answers the open job (the platform's own idempotency) with a fresh ticket,
|
||||
// or starts a new export once the last one finished. A reload forgets the ticket the same way.
|
||||
|
||||
import { AppError, conflict, notFound } from "@conusai-kit/core"
|
||||
import type { AnalyticsView } from "../contract/procedures.js"
|
||||
import type { AccountPrivacy, AccountPrivacyRequest } from "./ports.js"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
/** The ticket format's version; part of both the ticket and its signed payload. */
|
||||
export const TICKET_VERSION = "v1"
|
||||
/** The HKDF info label that makes the ticket MAC key purpose-specific. */
|
||||
export const TICKET_KEY_INFO = "conusai-template/account-privacy-ticket/v1"
|
||||
/** How long a ticket names its request: a day, longer than any page stays open on a job. */
|
||||
export const TICKET_LIFETIME_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const unavailable = (detail: string) =>
|
||||
new AppError({
|
||||
type: "/problems/unavailable",
|
||||
title: "Service unavailable",
|
||||
status: 503,
|
||||
code: "unavailable",
|
||||
detail,
|
||||
})
|
||||
|
||||
function hex(bytes: ArrayBuffer): string {
|
||||
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
/** HKDF-SHA256(secret, salt = empty, info = TICKET_KEY_INFO) as a non-extractable HMAC key. */
|
||||
async function ticketMacKey(secret: string): Promise<CryptoKey> {
|
||||
const ikm = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(TICKET_KEY_INFO) },
|
||||
ikm,
|
||||
{ name: "HMAC", hash: "SHA-256", length: 256 },
|
||||
false,
|
||||
["sign"],
|
||||
)
|
||||
}
|
||||
|
||||
async function signature(secret: string, userId: string, requestId: string, expiresAt: number): Promise<string> {
|
||||
// A length prefix, so ("ab", "c") and ("a", "bc") never sign alike; the request id is last and the
|
||||
// other fields are fixed-form, so the payload parses one way only.
|
||||
const payload = `${TICKET_VERSION}|${expiresAt}|${userId.length}:${userId}|${requestId}`
|
||||
return hex(await crypto.subtle.sign("HMAC", await ticketMacKey(secret), encoder.encode(payload)))
|
||||
}
|
||||
|
||||
/** Constant-time comparison of two hex strings. */
|
||||
function sameHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
||||
return diff === 0
|
||||
}
|
||||
|
||||
/** A ticket naming `requestId` for `userId`, valid for TICKET_LIFETIME_MS from `now`. */
|
||||
export async function ticketFor(secret: string, userId: string, requestId: string, now: number): Promise<string> {
|
||||
const expiresAt = now + TICKET_LIFETIME_MS
|
||||
return `${TICKET_VERSION}.${requestId}.${expiresAt}.${await signature(secret, userId, requestId, expiresAt)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The request id a ticket names, when it was signed for this user under this secret and has not
|
||||
* expired at `now`; NotFound otherwise (one answer for forged, foreign and expired tickets).
|
||||
*/
|
||||
export async function requestIdOf(secret: string, userId: string, ticket: string, now: number): Promise<string> {
|
||||
const missing = () => notFound("no such request for this account")
|
||||
const parts = ticket.split(".")
|
||||
// The request id may in principle hold dots: version first, expiry and MAC last.
|
||||
if (parts.length < 4 || parts[0] !== TICKET_VERSION) throw missing()
|
||||
const given = parts.at(-1)
|
||||
const expiresText = parts.at(-2)
|
||||
const requestId = parts.slice(1, -2).join(".")
|
||||
if (!given || !expiresText || !requestId || !/^[0-9]{1,16}$/.test(expiresText)) throw missing()
|
||||
const expiresAt = Number(expiresText)
|
||||
const expected = await signature(secret, userId, requestId, expiresAt)
|
||||
// The MAC first and always, then the clock: an expired ticket answers exactly like a forged one.
|
||||
const authentic = sameHex(given, expected)
|
||||
if (!authentic || !(now < expiresAt)) throw missing()
|
||||
return requestId
|
||||
}
|
||||
|
||||
/** The platform's refusals as the app's problems; the deployment token never shows in one. */
|
||||
function platformFailure(error: unknown): AppError {
|
||||
const status = typeof (error as { status?: unknown })?.status === "number" ? (error as { status: number }).status : 0
|
||||
if (status === 404) return notFound("no such request for this account")
|
||||
if (status === 409) return conflict("the export is not ready, has expired, or failed")
|
||||
if (status === 403) return unavailable("this app's deployment token does not hold privacy:subjects")
|
||||
return unavailable("the platform's privacy service did not answer")
|
||||
}
|
||||
|
||||
const view = async (
|
||||
privacy: AccountPrivacy,
|
||||
userId: string,
|
||||
request: AccountPrivacyRequest,
|
||||
created: boolean,
|
||||
now: number,
|
||||
): Promise<AnalyticsView> => ({
|
||||
ticket: await ticketFor(privacy.ticketKey, userId, request.id, now),
|
||||
kind: request.kind,
|
||||
status: request.status,
|
||||
created,
|
||||
stores: Object.fromEntries(
|
||||
Object.entries(request.stores).map(([store, outcome]) => [
|
||||
store,
|
||||
{
|
||||
status: outcome.status === "not_keyed" || outcome.status === "failed" ? outcome.status : "completed",
|
||||
deleted: outcome.deleted ?? 0,
|
||||
anonymized: outcome.anonymized ?? 0,
|
||||
exported: outcome.exported ?? 0,
|
||||
},
|
||||
]),
|
||||
) as AnalyticsView["stores"],
|
||||
requestedAt: request.created_at,
|
||||
finishedAt: request.finished_at ?? null,
|
||||
expiresAt: request.export_expires_at ?? null,
|
||||
})
|
||||
|
||||
const required = (privacy: AccountPrivacy | undefined): AccountPrivacy => {
|
||||
if (!privacy) throw unavailable("this deployment has no platform privacy service")
|
||||
return privacy
|
||||
}
|
||||
|
||||
export async function requestAnalytics(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
kind: AnalyticsView["kind"],
|
||||
now: () => number,
|
||||
): Promise<AnalyticsView> {
|
||||
const port = required(privacy)
|
||||
try {
|
||||
const { request, created } =
|
||||
kind === "erase" ? await port.eraseSubject({ userId }) : await port.exportSubject({ userId })
|
||||
return await view(port, userId, request, created, now())
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyticsStatus(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
ticket: string,
|
||||
now: () => number,
|
||||
): Promise<AnalyticsView> {
|
||||
const port = required(privacy)
|
||||
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
|
||||
try {
|
||||
return await view(port, userId, await port.getRequest(requestId), false, now())
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyticsExport(
|
||||
privacy: AccountPrivacy | undefined,
|
||||
userId: string,
|
||||
ticket: string,
|
||||
now: () => number,
|
||||
): Promise<{ exportedAt: string; document: Record<string, unknown> }> {
|
||||
const port = required(privacy)
|
||||
const requestId = await requestIdOf(port.ticketKey, userId, ticket, now())
|
||||
try {
|
||||
return { exportedAt: new Date(now()).toISOString(), document: await port.downloadExport(requestId) }
|
||||
} catch (error) {
|
||||
throw error instanceof AppError ? error : platformFailure(error)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// "Export my data": every installed module's share of the account, composed into one document.
|
||||
//
|
||||
// Each module that keeps something about the person contributes an export to kit-tenancy's
|
||||
// `tenancy.account` point -- this module's own rows among them -- and every one of them is a section.
|
||||
// A module that fails is a `failed` section and the export is `complete: false`, naming it; a module
|
||||
// that can erase but not export is `not_exported`. Nothing is left out without saying so (joint
|
||||
// contract §12).
|
||||
|
||||
import { AppError, notFound } from "@conusai-kit/core"
|
||||
import { ACCOUNT_EXPORT_FORMAT } from "../contract/constants.js"
|
||||
import type { AccountExport, ExportSection } from "../contract/procedures.js"
|
||||
import type { AccountDeps } from "./ports.js"
|
||||
|
||||
/** The problem code of a refusal, and whether trying again later may help. Never its detail. */
|
||||
export function refusalOf(error: unknown): { code: string; retryable: boolean } {
|
||||
if (error instanceof AppError) return { code: error.code, retryable: error.status >= 500 || error.status === 429 }
|
||||
return { code: "unavailable", retryable: true }
|
||||
}
|
||||
|
||||
export async function exportAccount(deps: AccountDeps, userId: string): Promise<AccountExport> {
|
||||
if ((await deps.store.emailOf(userId)) === null) throw notFound("no such user")
|
||||
const sections = await Promise.all(
|
||||
deps.contributions.map(async (contribution): Promise<[string, ExportSection]> => {
|
||||
if (!contribution.export) return [contribution.key, { status: "not_exported" }]
|
||||
try {
|
||||
const data = await contribution.export({
|
||||
subject: userId,
|
||||
container: deps.container,
|
||||
...(deps.signal ? { signal: deps.signal } : {}),
|
||||
})
|
||||
return [contribution.key, { status: "ok", data }]
|
||||
} catch (error) {
|
||||
return [contribution.key, { status: "failed", ...refusalOf(error) }]
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failed = sections.filter(([, section]) => section.status === "failed").map(([key]) => key)
|
||||
return {
|
||||
format: ACCOUNT_EXPORT_FORMAT,
|
||||
exportedAt: new Date(deps.now()).toISOString(),
|
||||
subject: userId,
|
||||
complete: sections.every(([, section]) => section.status === "ok"),
|
||||
failed,
|
||||
sections: Object.fromEntries(sections),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// The ports of the account module. The use cases (export.ts, deletion.ts) are pure TypeScript over
|
||||
// these; adapters/store.ts implements the store over kit-data, and the composition roots bind the
|
||||
// two tokens.
|
||||
|
||||
import { type Container, type Registry, token } from "@conusai-kit/core"
|
||||
import type { AccountDataContribution, OrgRole } from "@conusai-kit/tenancy"
|
||||
import type { DeletionState, DeletionStep, RevocationState } from "../contract/entities.js"
|
||||
|
||||
export interface OrganizationRef {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** One organization the person belongs to, with what a deletion has to know about it. */
|
||||
export interface AccountMembership extends OrganizationRef {
|
||||
readonly orgId: string
|
||||
readonly role: OrgRole
|
||||
/** Owners of the organization, the person included. */
|
||||
readonly owners: number
|
||||
/** Members of the organization, the person included. */
|
||||
readonly members: number
|
||||
}
|
||||
|
||||
export interface DeletionRecord {
|
||||
readonly userId: string
|
||||
readonly state: DeletionState
|
||||
readonly pending: readonly string[]
|
||||
readonly erased: readonly string[]
|
||||
readonly abandoned: readonly string[]
|
||||
readonly attempts: number
|
||||
readonly lastFailure: string | null
|
||||
readonly requestedAt: string
|
||||
readonly completedAt: string | null
|
||||
/** The steps before the erasures that completed (DELETION_STEPS), so a resumption skips them. */
|
||||
readonly steps: readonly DeletionStep[]
|
||||
/** The identity provider whose account the deletion ends (AccountIdentityProvider.id), or null. */
|
||||
readonly provider: string | null
|
||||
}
|
||||
|
||||
export type DeletionPatch = Partial<Omit<DeletionRecord, "userId" | "requestedAt" | "provider">>
|
||||
|
||||
/**
|
||||
* One change of a ledger row, as data: what a use case asks of the store, which applies it to the
|
||||
* row's current version (`applyDeletionChange`, core/deletion.ts) and retries a concurrent change.
|
||||
* Data, not a callback, so a store in another process (the Convex profile's internal mutations)
|
||||
* applies exactly the same rule.
|
||||
*/
|
||||
export type DeletionChange =
|
||||
| { readonly kind: "step"; readonly step: DeletionStep }
|
||||
| { readonly kind: "erased"; readonly module: string }
|
||||
| { readonly kind: "abandoned"; readonly module: string }
|
||||
| { readonly kind: "attempt"; readonly failure: string | null }
|
||||
| { readonly kind: "completed"; readonly at: string }
|
||||
|
||||
/** This app's own rows about a person, and the deletion ledger. */
|
||||
export interface AccountStore {
|
||||
/** The account's e-mail address, which the person types to confirm a deletion; null for no such user. */
|
||||
emailOf(userId: string): Promise<string | null>
|
||||
/** When the person signed in to the session that asks, or null for a session of somebody else, revoked or gone. */
|
||||
sessionSignedInAt(userId: string, sessionId: string): Promise<string | null>
|
||||
memberships(userId: string): Promise<readonly AccountMembership[]>
|
||||
/** The rows of this app the person can take away: profile, identities, memberships, sessions, API keys. */
|
||||
localExport(userId: string): Promise<Record<string, unknown> | null>
|
||||
|
||||
deletion(userId: string): Promise<DeletionRecord | null>
|
||||
/** Deletions still erasing, oldest first. */
|
||||
openDeletions(limit: number): Promise<readonly DeletionRecord[]>
|
||||
/** Writes the ledger row, or answers the one a concurrent request wrote first. */
|
||||
startDeletion(input: {
|
||||
userId: string
|
||||
modules: readonly string[]
|
||||
provider: string | null
|
||||
at: string
|
||||
}): Promise<DeletionRecord>
|
||||
/** Applies one change to the row (a read-modify-write that retries a concurrent change); a change already made writes nothing. */
|
||||
changeDeletion(userId: string, change: DeletionChange): Promise<DeletionRecord>
|
||||
/**
|
||||
* The person's identities that still map to the account: every identity-mapping row, and the user
|
||||
* row's own provider identity unless it was detached. Empty once the identity step ran.
|
||||
*/
|
||||
identities(userId: string): Promise<readonly { readonly issuer: string; readonly subject: string }[]>
|
||||
|
||||
/** Revokes every session (every device) and every API key of the person. Idempotent. */
|
||||
revokeCredentials(userId: string, at: string): Promise<void>
|
||||
/**
|
||||
* Removes the identity-provider mapping and the profile from the user row, so no sign-in reaches
|
||||
* the account again (a new sign-in with the same provider identity makes a new, unrelated user).
|
||||
* The row itself stays, under the same id, until every module's erasure is confirmed. Idempotent.
|
||||
*/
|
||||
detachIdentity(userId: string): Promise<void>
|
||||
/** Leaves every organization (suspending one left with no member) and deletes the user row. Idempotent. */
|
||||
removeLocalData(userId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** What the use cases run with: the store, the installed modules' shares and the container they resolve ports from. */
|
||||
export interface AccountDeps {
|
||||
readonly store: AccountStore
|
||||
readonly contributions: readonly AccountDataContribution[]
|
||||
readonly container: Container
|
||||
readonly now: () => number
|
||||
readonly signal?: AbortSignal
|
||||
/**
|
||||
* The external provider whose account a deletion ends (ACCOUNT_IDENTITY_PROVIDER), when one is bound:
|
||||
* resolved only when the step runs, so a provider that cannot be built (a missing variable) holds a
|
||||
* deletion at that step instead of failing every export and deletion.
|
||||
*/
|
||||
readonly identityProvider?: () => AccountIdentityProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential of the request that asks, as the composition root knows it: whether a person signed
|
||||
* in to this app is behind it, and the kit session row it is bound to. A process that binds nothing
|
||||
* (the worker, a test) is a caller with no interactive session.
|
||||
*/
|
||||
export interface AccountCredential {
|
||||
readonly interactive: boolean
|
||||
readonly sessionId?: string
|
||||
/**
|
||||
* When the person signed in to the session that asks, for a session with no kit row (an external
|
||||
* provider's, such as Better Auth's): the provider reports it with the session it validated.
|
||||
*/
|
||||
readonly signedInAt?: string
|
||||
}
|
||||
|
||||
export const ACCOUNT_CREDENTIAL = token<AccountCredential>("account.credential")
|
||||
|
||||
/**
|
||||
* The app's external sign-in provider, when it owns its own accounts (Better Auth): a deletion ends
|
||||
* the person's account there before it detaches the identity, so no live provider session maps to a
|
||||
* new, empty user afterwards. The deletion records the provider's `id` when it begins, and every
|
||||
* process that may resume one (the web, the worker's sweep, the Convex profile's actions) binds the
|
||||
* same provider: a resumption without it waits at that step. The account is found by the identity
|
||||
* mapping under `issuer`, which is why the step runs before the identity is detached. `endAccount`
|
||||
* is idempotent: an account already gone is not an error. An app that binds none (OIDC, whose
|
||||
* provider account is the identity service's own) has no such step.
|
||||
*/
|
||||
export interface AccountIdentityProvider {
|
||||
/** The provider's name, as the ledger records it (`convex-better-auth`). */
|
||||
readonly id: string
|
||||
/** The issuer the identity mapping holds the provider's subjects under (AUTH_IDENTITY_NAMESPACE). */
|
||||
readonly issuer: string
|
||||
/** Ends the account of this provider subject, sessions first. */
|
||||
endAccount(providerSubject: string): Promise<void>
|
||||
}
|
||||
|
||||
export const ACCOUNT_IDENTITY_PROVIDER = token<AccountIdentityProvider>("account.identityProvider")
|
||||
|
||||
/**
|
||||
* The store a use case runs with, when the composition root binds one: the Convex profile's actions
|
||||
* bind a store whose every method is one internal query or mutation (convex/account.ts). Unbound,
|
||||
* the store is built over the container's data source (adapters/store.ts).
|
||||
*/
|
||||
export const ACCOUNT_STORE = token<AccountStore>("account.store")
|
||||
|
||||
/** The registry the installed modules' `tenancy.account` contributions are read from. */
|
||||
export const ACCOUNT_REGISTRY = token<Registry>("account.registry")
|
||||
|
||||
/** One revoked app session whose derived service sessions are still to be ended (the revocation outbox). */
|
||||
export interface RevocationRecord {
|
||||
readonly id: string
|
||||
readonly version: number
|
||||
readonly session: string
|
||||
readonly state: RevocationState
|
||||
readonly pending: readonly string[]
|
||||
readonly delivered: readonly string[]
|
||||
readonly attempts: number
|
||||
readonly nextAttemptAt: string
|
||||
readonly claimedUntil: string | null
|
||||
readonly lastFailure: string | null
|
||||
readonly requestedAt: string
|
||||
readonly completedAt: string | null
|
||||
}
|
||||
|
||||
export type RevocationPatch = Partial<
|
||||
Pick<
|
||||
RevocationRecord,
|
||||
"state" | "pending" | "delivered" | "attempts" | "nextAttemptAt" | "claimedUntil" | "lastFailure" | "completedAt"
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* The revocation outbox a delivery runs with, when the composition root binds one: the Convex
|
||||
* profile's actions bind a store whose every method is one internal query or mutation
|
||||
* (convex/account.ts). Unbound, it is built over the container's data source (adapters/revocations.ts).
|
||||
*/
|
||||
export const REVOCATION_STORE = token<RevocationStore>("account.revocations")
|
||||
|
||||
/** The revocation outbox. Every change is written against the row's version: a lost race writes nothing. */
|
||||
export interface RevocationStore {
|
||||
/** Writes the row, claimed by the caller until `claimedUntil`; null when the session is already recorded. */
|
||||
record(input: {
|
||||
session: string
|
||||
modules: readonly string[]
|
||||
at: string
|
||||
claimedUntil: string
|
||||
}): Promise<RevocationRecord | null>
|
||||
/** Pending rows whose next attempt is due at `now` and that no live claim holds, oldest first. */
|
||||
due(now: string, limit: number): Promise<readonly RevocationRecord[]>
|
||||
/** The row with `patch` applied if it is still at `record.version`, else null (another process changed it). */
|
||||
change(record: RevocationRecord, patch: RevocationPatch): Promise<RevocationRecord | null>
|
||||
/** Removes delivered and lapsed rows that finished before `before`; answers how many. */
|
||||
purge(before: string): Promise<number>
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's per-subject erasure and export (ADR-049), as the account module needs it: a
|
||||
* structural subset of `@conusai-kit/platform`'s `Privacy` (createPrivacy), bound by the web's
|
||||
* composition root over the deployment token (`privacy:subjects` in conusai.json). `ticketKey` is the
|
||||
* server-side secret (the deployment token) the module derives its ticket MAC key from with HKDF
|
||||
* (core/analytics.ts): the token itself never keys a MAC, and rotating it invalidates open tickets.
|
||||
* Unbound (the fake platform, a test), the analytics panel answers "unavailable".
|
||||
*/
|
||||
export interface AccountPrivacyRequest {
|
||||
readonly id: string
|
||||
readonly kind: "erase" | "export"
|
||||
readonly status: "pending" | "running" | "completed" | "failed" | "expired"
|
||||
readonly stores: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{ readonly status: string; readonly deleted?: number; readonly anonymized?: number; readonly exported?: number }
|
||||
>
|
||||
>
|
||||
readonly created_at: string
|
||||
readonly finished_at?: string | null
|
||||
readonly export_expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface AccountPrivacy {
|
||||
readonly ticketKey: string
|
||||
eraseSubject(subject: { userId: string }): Promise<{ request: AccountPrivacyRequest; created: boolean }>
|
||||
exportSubject(subject: { userId: string }): Promise<{ request: AccountPrivacyRequest; created: boolean }>
|
||||
getRequest(requestId: string): Promise<AccountPrivacyRequest>
|
||||
downloadExport(requestId: string): Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export const ACCOUNT_PRIVACY = token<AccountPrivacy>("account.privacy")
|
||||
@@ -0,0 +1,183 @@
|
||||
// The revocation outbox (joint contract §14 "Lifecycle, CSP and revocation"): when kit-auth revokes an
|
||||
// app session -- a sign-out, a refused refresh, an expiry -- every installed module that mints derived
|
||||
// service sessions (a Support widget session records its parent app session) ends them on its
|
||||
// platform. A platform that is down at that moment must not leave them alive until their own expiry,
|
||||
// and must not hold the sign-out up either:
|
||||
//
|
||||
// 1. the revocation is written as a row (the modules still to tell), claimed by this process;
|
||||
// 2. each module is asked at once, bounded (REVOCATION_CALL_MS); the ones that confirmed leave
|
||||
// `pending`, and a row with nothing pending is `delivered`;
|
||||
// 3. a row left pending is retried by the worker's revocation job (REVOCATION_JOB), with backoff,
|
||||
// until every module confirmed -- or until no session it minted can still be alive (`lapsed`);
|
||||
// 4. a delivery pass claims its row with a versioned write first, so two processes (the web's
|
||||
// first attempt and the worker, or two workers) never deliver one row at once, and a module that
|
||||
// confirmed is never asked again.
|
||||
//
|
||||
// Nothing here throws into a sign-out: `revokeOnSignOut` answers once the row is written and the first
|
||||
// attempt has settled or timed out. Only a refused write of the row itself is reported (thrown), so
|
||||
// the caller can fall back to asking the modules directly.
|
||||
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import type { AccountDataContribution } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
DERIVED_SESSION_LIFETIME_MS,
|
||||
REVOCATION_BACKOFF_MAX_MS,
|
||||
REVOCATION_BACKOFF_MS,
|
||||
REVOCATION_CALL_MS,
|
||||
REVOCATION_CLAIM_MS,
|
||||
REVOCATION_RETENTION_MS,
|
||||
} from "../contract/constants.js"
|
||||
import { refusalOf } from "./export.js"
|
||||
import type { RevocationRecord, RevocationStore } from "./ports.js"
|
||||
|
||||
export interface RevocationDeps {
|
||||
readonly store: RevocationStore
|
||||
readonly contributions: readonly AccountDataContribution[]
|
||||
/** The container each module resolves its platform port from. */
|
||||
readonly container: Container
|
||||
readonly now: () => number
|
||||
readonly log: { warn(message: string): void }
|
||||
/** How long one module's call may take; REVOCATION_CALL_MS by default. */
|
||||
readonly callMs?: number
|
||||
}
|
||||
|
||||
const iso = (ms: number): string => new Date(ms).toISOString()
|
||||
|
||||
/** The modules that mint derived service sessions, in module order. */
|
||||
export const revokingModules = (contributions: readonly AccountDataContribution[]): string[] =>
|
||||
contributions
|
||||
.filter((contribution) => contribution.revokeSessions !== undefined)
|
||||
.map((contribution) => contribution.key)
|
||||
|
||||
/** When the retry after `attempts` failed passes is due: REVOCATION_BACKOFF_MS doubling, capped. */
|
||||
export function revocationBackoffMs(attempts: number): number {
|
||||
return Math.min(REVOCATION_BACKOFF_MAX_MS, REVOCATION_BACKOFF_MS * 2 ** Math.max(0, Math.min(attempts - 1, 20)))
|
||||
}
|
||||
|
||||
async function bounded(run: () => Promise<void> | undefined, ms: number): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timed out")), ms)
|
||||
})
|
||||
try {
|
||||
await Promise.race([run(), timeout])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One delivery pass over a row this process has claimed: every pending module is asked, bounded; the
|
||||
* row is then written against the claimed version -- the confirmed modules leave `pending`, a row with
|
||||
* none left is `delivered`, otherwise the next attempt is scheduled with backoff and the claim released.
|
||||
*/
|
||||
async function deliver(deps: RevocationDeps, claimed: RevocationRecord): Promise<RevocationRecord | null> {
|
||||
const callMs = deps.callMs ?? REVOCATION_CALL_MS
|
||||
const outcomes = await Promise.all(
|
||||
claimed.pending.map(async (key): Promise<{ key: string; failure: string | null }> => {
|
||||
const contribution = deps.contributions.find((candidate) => candidate.key === key)
|
||||
// Removed from the app since the revocation was recorded: nothing here can reach its platform,
|
||||
// and its sessions end at their own expiry.
|
||||
if (!contribution?.revokeSessions) return { key, failure: null }
|
||||
try {
|
||||
await bounded(
|
||||
() => contribution.revokeSessions?.({ session: claimed.session, container: deps.container.child() }),
|
||||
callMs,
|
||||
)
|
||||
return { key, failure: null }
|
||||
} catch (error) {
|
||||
return { key, failure: `${key}:${refusalOf(error).code}` }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failed = outcomes.filter((outcome) => outcome.failure !== null)
|
||||
const confirmed = outcomes.filter((outcome) => outcome.failure === null).map((outcome) => outcome.key)
|
||||
const now = deps.now()
|
||||
if (failed.length === 0) {
|
||||
return deps.store.change(claimed, {
|
||||
state: "delivered",
|
||||
pending: [],
|
||||
delivered: [...claimed.delivered, ...confirmed],
|
||||
claimedUntil: null,
|
||||
lastFailure: null,
|
||||
completedAt: iso(now),
|
||||
})
|
||||
}
|
||||
for (const { key } of failed)
|
||||
deps.log.warn(`auth: ${key} did not revoke the sessions of a revoked app session; retrying`)
|
||||
const attempts = claimed.attempts + 1
|
||||
return deps.store.change(claimed, {
|
||||
pending: failed.map((outcome) => outcome.key),
|
||||
delivered: [...claimed.delivered, ...confirmed],
|
||||
attempts,
|
||||
nextAttemptAt: iso(now + revocationBackoffMs(attempts)),
|
||||
claimedUntil: null,
|
||||
lastFailure: failed[0]?.failure ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* kit-auth's revocation hook, durable: records the revocation, then delivers it at once. Answers
|
||||
* within about one bounded call; a module that could not be reached is retried by the worker. Throws
|
||||
* only when the row could not be written (nothing was recorded).
|
||||
*/
|
||||
export async function revokeOnSignOut(deps: RevocationDeps, session: string): Promise<void> {
|
||||
const modules = revokingModules(deps.contributions)
|
||||
if (modules.length === 0) return
|
||||
const now = deps.now()
|
||||
const recorded = await deps.store.record({
|
||||
session,
|
||||
modules,
|
||||
at: iso(now),
|
||||
claimedUntil: iso(now + REVOCATION_CLAIM_MS),
|
||||
})
|
||||
// Already recorded (kit-auth told twice about one session): the row that exists is delivered by
|
||||
// whoever holds it, or by the worker.
|
||||
if (recorded === null) return
|
||||
try {
|
||||
await deliver(deps, recorded)
|
||||
} catch {
|
||||
// The row stays claimed until its claim expires; the worker then delivers it.
|
||||
deps.log.warn("auth: a revocation was recorded but not yet delivered; the worker retries it")
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevocationSweep {
|
||||
/** Rows this pass claimed. */
|
||||
readonly claimed: number
|
||||
/** Rows every module has now confirmed. */
|
||||
readonly delivered: number
|
||||
/** Rows still pending, scheduled again. */
|
||||
readonly waiting: number
|
||||
/** Rows given up: no session they name can still be alive. */
|
||||
readonly lapsed: number
|
||||
/** Finished rows removed. */
|
||||
readonly purged: number
|
||||
}
|
||||
|
||||
/** The worker's revocation job: every due row, claimed one by one and delivered. */
|
||||
export async function sweepSessionRevocations(deps: RevocationDeps, limit = 100): Promise<RevocationSweep> {
|
||||
const start = deps.now()
|
||||
const due = await deps.store.due(iso(start), limit)
|
||||
let claimed = 0
|
||||
let delivered = 0
|
||||
let waiting = 0
|
||||
let lapsed = 0
|
||||
for (const row of due) {
|
||||
const now = deps.now()
|
||||
if (now - Date.parse(row.requestedAt) >= DERIVED_SESSION_LIFETIME_MS) {
|
||||
// Every service session minted for that app session has expired by now: nothing is left to end.
|
||||
if (await deps.store.change(row, { state: "lapsed", claimedUntil: null, completedAt: iso(now) })) lapsed += 1
|
||||
continue
|
||||
}
|
||||
const mine = await deps.store.change(row, { claimedUntil: iso(now + REVOCATION_CLAIM_MS) })
|
||||
// Another process claimed or settled it first: that one delivers it.
|
||||
if (mine === null) continue
|
||||
claimed += 1
|
||||
const settled = await deliver(deps, mine).catch(() => null)
|
||||
if (settled?.state === "delivered") delivered += 1
|
||||
else waiting += 1
|
||||
}
|
||||
const purged = await deps.store.purge(iso(start - REVOCATION_RETENTION_MS))
|
||||
return { claimed, delivered, waiting, lapsed, purged }
|
||||
}
|
||||
Reference in New Issue
Block a user