Files

177 lines
7.8 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The account module on the Convex profile (joint contract §12): export my data and delete my
// account, with the same ledger, ordering, idempotency and partial-failure export as on Postgres.
//
// `account.data.export` and `account.deletion.request` are generated actions (lib/effects.ts): their
// modules' shares are calls to platform services. Their store is bound as ACCOUNT_STORE, and every
// store method is one of the two internal functions below -- `createAccountStore` itself, the very
// store the Postgres profile runs, over this function's ctx.db -- so each step is one Convex
// transaction. A deletion is a ledger of idempotent steps the ledger records as they complete, so a
// deletion interrupted between two transactions resumes from the ledger as it does on Postgres.
//
// storeRead / storeWrite the store's methods (internal: only this deployment's actions call them)
// sweep the worker's DELETION_SWEEP_JOB, as a Convex cron (crons.ts)
// providerEvent the identity provider's account events (Better Auth's deletion or ban,
// from the web's revocation relay with the admin key)
// revocationRead / revocationWrite
// the sign-out revocation outbox's methods (internal), the Postgres
// profile's own store over ctx.db
// revokeSession a revoked app session (the web's sign-out, with the admin key): recorded
// in the outbox and delivered at once; never blocks the sign-out
// revocationSweep the worker's REVOCATION_JOB, as a one-minute Convex cron (crons.ts)
import { toProblem } from "@conusai-kit/core"
import { createConvexDataSource } from "@conusai-kit/data-convex"
import { executorFor } from "@conusai-kit/data-convex/runtime"
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx } from "convex/server"
import { ConvexError, type Value, v } from "convex/values"
import {
type AccountStore,
createAccountStore,
createRevocationStore,
DELETION_SWEEP_JOB,
type RevocationStore,
recordProviderAccountEvent,
} from "../modules/dist/account/index.js"
import { internalAction, internalMutation, internalQuery } from "./_generated/server"
import {
ACCOUNT_STORE_READS,
ACCOUNT_STORE_WRITES,
type EffectActionCtx,
effectContainer,
REVOCATION_STORE_READS,
REVOCATION_STORE_WRITES,
revokeSessionInBackend,
sweepRevocationsInBackend,
} from "./lib/effects"
import { convexEntities, registry } from "./lib/runtime"
type FunctionContext<DataModel extends GenericDataModel> = {
db: GenericQueryCtx<DataModel>["db"] | GenericMutationCtx<DataModel>["db"]
}
const storeOver = <DataModel extends GenericDataModel>(ctx: FunctionContext<DataModel>): AccountStore => {
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) })
return createAccountStore({ data, system: data })
}
const revocationsOver = <DataModel extends GenericDataModel>(ctx: FunctionContext<DataModel>): RevocationStore =>
createRevocationStore(createConvexDataSource({ entities: convexEntities, executor: executorFor(ctx) }))
/** One store method, its answer as plain JSON (no `undefined` crosses into Convex), or the kernel's problem. */
async function run(store: AccountStore | RevocationStore, method: string, args: unknown[]): Promise<Value> {
try {
const call = (store as unknown as Record<string, (...input: unknown[]) => Promise<unknown>>)[method]
if (!call) throw new Error(`no store method ${method}`)
return JSON.parse(JSON.stringify((await call.apply(store, args)) ?? null)) as Value
} catch (error) {
throw new ConvexError(toProblem(error) as unknown as Value)
}
}
const literals = <T extends string>(names: readonly T[]) =>
v.union(
...(names.map((name) => v.literal(name)) as [
ReturnType<typeof v.literal<T>>,
...ReturnType<typeof v.literal<T>>[],
]),
)
export const storeRead = internalQuery({
args: { method: literals(ACCOUNT_STORE_READS), args: v.array(v.any()) },
handler: (ctx, { method, args }) => run(storeOver(ctx), method, args),
})
export const storeWrite = internalMutation({
args: { method: literals(ACCOUNT_STORE_WRITES), args: v.array(v.any()) },
handler: (ctx, { method, args }) => run(storeOver(ctx), method, args),
})
export const revocationRead = internalQuery({
args: { method: literals(REVOCATION_STORE_READS), args: v.array(v.any()) },
handler: (ctx, { method, args }) => run(revocationsOver(ctx), method, args),
})
export const revocationWrite = internalMutation({
args: { method: literals(REVOCATION_STORE_WRITES), args: v.array(v.any()) },
handler: (ctx, { method, args }) => run(revocationsOver(ctx), method, args),
})
/**
* A revoked app session (joint contract §14), from the web's sign-out with the admin key: the
* revocation is written to the outbox and every module that minted derived service sessions for it
* is asked at once, bounded. What a platform did not confirm `revocationSweep` retries. Fails only
* when nothing was recorded; the web then asks the modules itself.
*/
export const revokeSession = internalAction({
args: { session: v.string() },
handler: async (ctx, { session }) => {
try {
await revokeSessionInBackend(ctx as unknown as EffectActionCtx, session)
} catch (error) {
throw new ConvexError(toProblem(error) as unknown as Value)
}
return null
},
})
/**
* Delivers every due revocation, as the Postgres worker's revocation job does every minute: each row
* is claimed with a versioned write and delivered once per module, with the row's backoff (30 s
* doubling, at most 5 min) between attempts, until 65 minutes after the sign-out, when no widget
* session it minted can still be alive and the row lapses. A run that leaves rows waiting fails with
* the kernel's `account_revocations_waiting` problem, which the backend logs.
*/
export const revocationSweep = internalAction({
args: {},
handler: async (ctx) => {
try {
await sweepRevocationsInBackend(ctx as unknown as EffectActionCtx)
} catch (error) {
throw new ConvexError(toProblem(error) as unknown as Value)
}
return null
},
})
/**
* Resumes every deletion a module's erasure (or the provider's account end) held up, as the Postgres
* worker's sweep does every five minutes: the module's own job handler, in this action's container.
* A run that leaves deletions waiting fails with the kernel's `account_deletion_waiting` problem,
* which the backend logs: that is the line an operator is alerted on.
*/
export const sweep = internalAction({
args: {},
handler: async (ctx) => {
const job = registry.jobs[DELETION_SWEEP_JOB]
if (!job) throw new Error("the account module declares no deletion sweep")
try {
await job.def.handler(null, {
container: effectContainer(ctx as unknown as EffectActionCtx),
correlationId: globalThis.crypto.randomUUID(),
attempt: 1,
})
} catch (error) {
throw new ConvexError(toProblem(error) as unknown as Value)
}
return null
},
})
/**
* An account event of the app's identity provider, delivered by the web's revocation relay with the
* admin key: `account-deleted` erases the account through the ledger, `user-disabled` revokes every
* credential and derived service session of it. Idempotent, so a replayed event is harmless.
*/
export const providerEvent = internalAction({
args: { reason: v.union(v.literal("account-deleted"), v.literal("user-disabled")), userId: v.string() },
handler: async (ctx, event) => {
try {
await recordProviderAccountEvent(effectContainer(ctx as unknown as EffectActionCtx), event)
} catch (error) {
throw new ConvexError(toProblem(error) as unknown as Value)
}
return null
},
})