This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user