200 lines
8.4 KiB
TypeScript
200 lines
8.4 KiB
TypeScript
// 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<void> {
|
|
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<void> {
|
|
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<string, JobDef<unknown>> = {
|
|
[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,
|
|
})
|
|
}
|
|
},
|
|
},
|
|
}
|