Files
template-app/modules/account/core/ports.ts
T

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")