// The handlers of the module's procedures, its sweep job and its own share of an account: each // resolves the ports from the container it runs with and calls one use case. No rule lives here. import { AppError, type Container, forbidden, implement, type JobDef } from "@conusai-kit/core" import { DATA_SOURCE, SYSTEM_DATA } from "@conusai-kit/data" import { type AccountDataContribution, accountDataOf } from "@conusai-kit/tenancy" import { z } from "zod" import { ACCOUNT_SECTION, DELETION_SWEEP_JOB, REVOCATION_JOB } from "../contract/constants.js" import { accountAnalyticsExport, accountAnalyticsRequest, accountAnalyticsStatus, accountDataExport, accountDeletionRequest, accountDeletionStatus, } from "../contract/procedures.js" import { analyticsExport, analyticsStatus, requestAnalytics } from "../core/analytics.js" import { deleteForProvider, deletionStatus, disableForProvider, requestDeletion, sweepDeletions, } from "../core/deletion.js" import { exportAccount } from "../core/export.js" import { ACCOUNT_CREDENTIAL, ACCOUNT_IDENTITY_PROVIDER, ACCOUNT_PRIVACY, ACCOUNT_REGISTRY, ACCOUNT_STORE, type AccountCredential, type AccountDeps, type AccountPrivacy, type AccountStore, REVOCATION_STORE, } from "../core/ports.js" import { type RevocationDeps, revokeOnSignOut, sweepSessionRevocations } from "../core/revocation.js" import { createRevocationStore } from "./revocations.js" import { createAccountStore } from "./store.js" /** The bound store (the Convex profile's actions), or the one over the container's data source. */ const storeOf = (container: Container): AccountStore => container.has(ACCOUNT_STORE) ? container.resolve(ACCOUNT_STORE) : createAccountStore({ data: container.resolve(DATA_SOURCE), system: container.resolve(SYSTEM_DATA) }) const depsOf = (container: Container, signal?: AbortSignal): AccountDeps => ({ store: storeOf(container), contributions: accountDataOf(container.resolve(ACCOUNT_REGISTRY)), container, now: Date.now, ...(signal ? { signal } : {}), ...(container.has(ACCOUNT_IDENTITY_PROVIDER) ? { identityProvider: () => container.resolve(ACCOUNT_IDENTITY_PROVIDER) } : {}), }) /** A process or a test that bound no credential is a caller with no interactive session. */ const credentialOf = (container: Container): AccountCredential => container.has(ACCOUNT_CREDENTIAL) ? container.resolve(ACCOUNT_CREDENTIAL) : { interactive: false } /** * This app's own rows about the person, as a section of the export. It has no erasure: those rows go * last, in the deletion's own final step, after every other module confirmed its erasure. */ export const accountOwnData: AccountDataContribution = { key: ACCOUNT_SECTION, export: ({ subject, container }) => storeOf(container).localExport(subject), } export const accountDataExportImpl = implement(accountDataExport, async ({ ctx }) => { const deps = depsOf(ctx.container, ctx.signal) // Only the person, never a bearer acting as them: an export is their data, in full. if (!credentialOf(ctx.container).interactive) { throw forbidden("exporting an account needs the person signed in to this app, not an API credential") } return exportAccount(deps, ctx.scope.actor.userId) }) export const accountDeletionStatusImpl = implement(accountDeletionStatus, async ({ ctx }) => deletionStatus(depsOf(ctx.container, ctx.signal), ctx.scope.actor.userId, credentialOf(ctx.container)), ) export const accountDeletionRequestImpl = implement(accountDeletionRequest, async ({ input, ctx }) => requestDeletion(depsOf(ctx.container, ctx.signal), { userId: ctx.scope.actor.userId, credential: credentialOf(ctx.container), confirm: input.confirm, }), ) const privacyOf = (container: Container): AccountPrivacy | undefined => container.has(ACCOUNT_PRIVACY) ? container.resolve(ACCOUNT_PRIVACY) : undefined /** The person's platform data is theirs alone: a bearer acting as them may neither erase nor take it. */ const requireInteractive = (container: Container, what: string): void => { if (!credentialOf(container).interactive) { throw forbidden(`${what} needs the person signed in to this app, not an API credential`) } } export const accountAnalyticsRequestImpl = implement(accountAnalyticsRequest, async ({ input, ctx }) => { requireInteractive(ctx.container, input.kind === "erase" ? "erasing analytics data" : "exporting analytics data") return requestAnalytics(privacyOf(ctx.container), ctx.scope.actor.userId, input.kind, Date.now) }) export const accountAnalyticsStatusImpl = implement(accountAnalyticsStatus, async ({ input, ctx }) => { requireInteractive(ctx.container, "reading an analytics request") return analyticsStatus(privacyOf(ctx.container), ctx.scope.actor.userId, input.ticket, Date.now) }) export const accountAnalyticsExportImpl = implement(accountAnalyticsExport, async ({ input, ctx }) => { requireInteractive(ctx.container, "downloading analytics data") return analyticsExport(privacyOf(ctx.container), ctx.scope.actor.userId, input.ticket, Date.now) }) export const ACCOUNT_PROCEDURES = [ accountDataExportImpl, accountDeletionStatusImpl, accountDeletionRequestImpl, accountAnalyticsRequestImpl, accountAnalyticsStatusImpl, accountAnalyticsExportImpl, ] const revocationDepsOf = (container: Container, log: RevocationDeps["log"], callMs?: number): RevocationDeps => ({ // The bound outbox (the Convex profile's actions), or the one over the container's data source. store: container.has(REVOCATION_STORE) ? container.resolve(REVOCATION_STORE) : createRevocationStore(container.resolve(DATA_SOURCE)), contributions: accountDataOf(container.resolve(ACCOUNT_REGISTRY)), container, now: Date.now, log, ...(callMs === undefined ? {} : { callMs }), }) /** * kit-auth's `onSessionRevoked` hook for this app (apps/web): records the revocation of an app * session in the revocation outbox and delivers it to every module that minted derived sessions, * bounded; what is not delivered the worker's REVOCATION_JOB retries (on the Convex profile, the * backend's `account:revokeSession` action runs it and a one-minute cron the job). Throws only when the row could * not be written. `container` binds the data source, the registry and the modules' platform ports. */ export function recordSessionRevocation( container: Container, session: string, options: { log: RevocationDeps["log"]; callMs?: number }, ): Promise { return revokeOnSignOut(revocationDepsOf(container, options.log, options.callMs), session) } /** * An account event of the app's external identity provider, delivered server-side (the Better Auth * revocation relay): `account-deleted` erases the account through the same ledger as a deletion the * person asks for, `user-disabled` revokes every credential and derived service session of it. The * user is the mapped app user; an event for a subject that never signed in to this app has none * and is not delivered here. Idempotent, so a replayed event is harmless. */ export async function recordProviderAccountEvent( container: Container, event: { readonly reason: "account-deleted" | "user-disabled"; readonly userId: string }, ): Promise { const deps = depsOf(container) if (event.reason === "account-deleted") { await deleteForProvider(deps, event.userId) return } await disableForProvider(deps, event.userId) } /** The jobs: scopeless (an account and a session belong to no organization), scheduled by the worker. */ export const ACCOUNT_JOBS: Record> = { [DELETION_SWEEP_JOB]: { payload: z.unknown(), handler: async (_payload, ctx) => { await sweepDeletions(depsOf(ctx.container, ctx.signal)) }, }, [REVOCATION_JOB]: { payload: z.unknown(), handler: async (_payload, ctx) => { // Each row carries its own backoff, so the run is not what is retried; a run that left rows // waiting fails, which is the line the worker logs and an operator is alerted on. const sweep = await sweepSessionRevocations(revocationDepsOf(ctx.container, { warn: () => undefined })) if (sweep.waiting > 0) { throw new AppError({ type: "/problems/account_revocations_waiting", title: "Session revocations are waiting", status: 503, code: "account_revocations_waiting", detail: `${sweep.waiting} session revocation(s) wait for a module's platform`, retryAfter: 60, }) } }, }, }