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
+188
View File
@@ -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)
}
}
+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 }
}
+46
View File
@@ -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),
}
}
+236
View File
@@ -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")
+183
View File
@@ -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 }
}