237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
// 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")
|