// 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> /** * 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 /** 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 memberships(userId: string): Promise /** The rows of this app the person can take away: profile, identities, memberships, sessions, API keys. */ localExport(userId: string): Promise | null> deletion(userId: string): Promise /** Deletions still erasing, oldest first. */ openDeletions(limit: number): Promise /** 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 /** 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 /** * 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 /** Revokes every session (every device) and every API key of the person. Idempotent. */ revokeCredentials(userId: string, at: string): Promise /** * 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 /** Leaves every organization (suspending one left with no member) and deletes the user row. Idempotent. */ removeLocalData(userId: string): Promise } /** 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("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 } export const ACCOUNT_IDENTITY_PROVIDER = token("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("account.store") /** The registry the installed modules' `tenancy.account` contributions are read from. */ export const ACCOUNT_REGISTRY = token("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("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 /** Pending rows whose next attempt is due at `now` and that no live claim holds, oldest first. */ due(now: string, limit: number): Promise /** The row with `patch` applied if it is still at `record.version`, else null (another process changed it). */ change(record: RevocationRecord, patch: RevocationPatch): Promise /** Removes delivered and lapsed rows that finished before `before`; answers how many. */ purge(before: string): Promise } /** * 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 downloadExport(requestId: string): Promise> } export const ACCOUNT_PRIVACY = token("account.privacy")