This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user