177 lines
7.8 KiB
TypeScript
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
|
|
},
|
|
})
|