// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // Effect procedures on the Convex profile (spec §5.4: effect → action). An action has no ctx.db, // so it cannot hold a transaction; the modules of CONVEX_ACTION_EFFECT_MODULES (lib/convexModules) // need none, because each effect is one call to a platform service through the module's own port // and owns no app row. The kernel runs here exactly as it does on every profile -- input // validation, scope, permission, the kind rule, the handler, output validation -- with three // bindings that reach the backend instead of a local database: // // tenancy resolveScope and authorize run as internal queries (effectScope.ts) with the same // tenancy hooks over ctx.db, under the action's own verified identity; // nested a read or write the handler calls (`tenancy.me.get` for a trait, say) runs as its // generated query or mutation, so it is a real Convex transaction with its own checks; // ports GUIDES_PLATFORM and SUPPORT_PLATFORM are bound from the deployment variables the // deploy job installs in the backend (profiles/convex/deploy.mjs), like the Blob // client of filesActions.ts. // account the account module's export and deletion do own app rows -- the deletion ledger and // the person's own -- so their store is bound as ACCOUNT_STORE, each method one // internal query or mutation of convex/account.ts (one transaction per step: the // deletion is a resumable ledger of idempotent steps by design), with the caller's // ACCOUNT_CREDENTIAL from the BFF's identity and, with Better Auth, the provider whose // account a deletion ends. The sign-out revocation outbox is bound the same way // (REVOCATION_STORE): `account:revokeSession` records a revoked app session and delivers // it from this container, and the one-minute cron `account:revocationSweep` retries it. // // The platform token never leaves the backend, and the browser still reaches these procedures only // through the BFF, which calls the generated action with the admin key and the member's identity. import { betterAuthAccountEndFromEnv } from "@conusai-kit/auth-convex-better-auth/account" // kit:convex-better-auth import { type AnyProcedure, type AnyScope, AppError, buildRegistry, type CallOptions, Container, createCaller, implement, internal, type ModuleManifest, type ProcedureKind, type ScopeOf, type TenancyHooks, type TenantLevel, token, toProblem, } from "@conusai-kit/core" import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides import { SUPPORT_PLATFORM, supportPlatform } from "@conusai-kit/support" // kit:support import { REQUEST_SESSION } from "@conusai-kit/tenancy" import { makeFunctionReference } from "convex/server" import { ConvexError, type Value } from "convex/values" import { ACCOUNT_CREDENTIAL, ACCOUNT_IDENTITY_PROVIDER, // kit:convex-better-auth ACCOUNT_REGISTRY, ACCOUNT_STORE, type AccountCredential, type AccountStore, REVOCATION_JOB, REVOCATION_STORE, type RevocationStore, recordSessionRevocation, } from "../../modules/dist/account/index.js" import { CONVEX_ACTION_EFFECT_MODULES } from "./convexModules.mjs" import { callContextOf, deployableModules, registry } from "./runtime" /** What an action handler gets from Convex that this file uses. */ export interface EffectActionCtx { auth: { getUserIdentity(): Promise } runQuery(reference: never, args: never): Promise runMutation(reference: never, args: never): Promise } /** The tenant ids of a scope, which is what crosses into the internal query and back. */ export interface ScopeIds { orgId: string workspaceId?: string projectId?: string } const functionName = (name: string): string => name.replace(/[^A-Za-z0-9_$]/g, "_") /** A backend refusal, back as the kernel's own problem; anything else stays opaque. */ const asAppError = (error: unknown): unknown => { if (error instanceof ConvexError && typeof error.data === "object" && error.data !== null) { const value = error.data as Record if (typeof value.status === "number" && typeof value.code === "string" && typeof value.title === "string") { return new AppError(value as unknown as ConstructorParameters[0]) } } return error } async function backend(run: () => Promise): Promise { try { return (await run()) as T } catch (error) { throw asAppError(error) } } /** Runs a nested read or write procedure as its generated Convex function. */ interface NestedProcedures { run(name: string, kind: ProcedureKind, input: unknown, idempotencyKey: string | undefined): Promise } // A read port: a nested call is made from a read, write or effect handler alike, and the kernel has // already enforced the kind rule for it before this handler runs. const NESTED = token("convex.nested-procedures", { access: "read" }) const forwarding = (def: AnyProcedure): AnyProcedure => implement(def.contract, async ({ input, ctx }) => ctx.container.resolve(NESTED).run(def.name, def.kind, input, ctx.idempotencyKey), ) as AnyProcedure /** * The registry of an effect action: the same modules, contracts, permissions and extension points, * with every read and write procedure forwarding to its generated function. Effect procedures keep * their own handlers, and only those of CONVEX_ACTION_EFFECT_MODULES are ever called here. */ const actionRegistry = buildRegistry( deployableModules.map( (module): ModuleManifest => ({ ...module, procedures: (module.procedures ?? []).map((def) => (def.kind === "effect" ? def : forwarding(def))), }), ), ) const referenceOf = new Map( [...registry.procedures.values()].map(({ module, def }) => [ def.name, `generated/${module}:${functionName(def.name)}`, ]), ) function nestedProcedures(ctx: EffectActionCtx): NestedProcedures { return { run: (name, kind, input, idempotencyKey) => { const reference = referenceOf.get(name) if (!reference) throw internal(`no generated Convex function for ${name}`) if (kind === "read") { return backend(() => ctx.runQuery(makeFunctionReference<"query">(reference) as never, { input } as never)) } if (kind === "write") { return backend(() => ctx.runMutation( makeFunctionReference<"mutation">(reference) as never, { input, ...(idempotencyKey === undefined ? {} : { idempotencyKey }) } as never, ), ) } throw internal(`a nested effect procedure ${name} runs in this action, never as another function`) }, } } const idsOf = (scope: AnyScope): ScopeIds => { const value = scope as AnyScope & Partial if (typeof value.orgId !== "string") throw internal("an effect action authorizes tenant scopes only") return { orgId: value.orgId, ...(value.workspaceId === undefined ? {} : { workspaceId: value.workspaceId }), ...(value.projectId === undefined ? {} : { projectId: value.projectId }), } } /** The tenancy hooks, run as internal queries against ctx.db under the action's identity. */ function backendTenancy(ctx: EffectActionCtx): TenancyHooks { return { resolveScope: async (level: L, input: unknown, actor: ScopeOf["actor"]) => { const ids = await backend(() => ctx.runQuery(makeFunctionReference<"query">("effectScope:resolve") as never, { level, input } as never), ) // The kernel's own actor object: it checks that the resolver did not swap it. return { level, actor, ...ids } as unknown as ScopeOf }, authorize: (scope, permission, procedure) => backend(() => ctx.runQuery( makeFunctionReference<"query">("effectScope:authorize") as never, { level: scope.level, scope: idsOf(scope), permission, procedure } as never, ), ), } } /** * The platform deployment variables the deploy job installs in the backend: the URL and the app's * token, required unless the backend runs against the fake platform (development and tests). */ export function platformEnv(): { apiUrl: string; token: string; fake: boolean } { const fake = ["1", "true"].includes(process.env.CONUSAI_FAKE_PLATFORM?.trim().toLowerCase() ?? "") const read = (name: string): string => { const value = process.env[name]?.trim() ?? "" if (!value && !fake) throw new Error(`Convex deployment is missing ${name}`) return value } return { apiUrl: read("CONUSAI_API_URL"), token: read("CONUSAI_API_TOKEN"), fake } } /** The account store's methods by the transaction they need: a read is a query, a change a mutation. */ export const ACCOUNT_STORE_READS = [ "emailOf", "sessionSignedInAt", "memberships", "localExport", "deletion", "openDeletions", "identities", ] as const export const ACCOUNT_STORE_WRITES = [ "startDeletion", "changeDeletion", "revokeCredentials", "detachIdentity", "removeLocalData", ] as const type StoreMethod = (typeof ACCOUNT_STORE_READS)[number] | (typeof ACCOUNT_STORE_WRITES)[number] /** * The account store of an action: each method is one internal query or mutation of convex/account.ts * over ctx.db, so every step of an export or a deletion is its own Convex transaction. A refusal comes * back as the kernel's own problem (a conflict, a not-found), as from any other backend function. */ export function convexAccountStore(ctx: EffectActionCtx): AccountStore { const read = (method: StoreMethod) => (...args: unknown[]) => backend(() => ctx.runQuery(makeFunctionReference<"query">("account:storeRead") as never, { method, args } as never), ) const write = (method: StoreMethod) => (...args: unknown[]) => backend(() => ctx.runMutation(makeFunctionReference<"mutation">("account:storeWrite") as never, { method, args } as never), ) return Object.fromEntries([ ...ACCOUNT_STORE_READS.map((method) => [method, read(method)]), ...ACCOUNT_STORE_WRITES.map((method) => [method, write(method)]), ]) as unknown as AccountStore } /** The revocation outbox's methods by the transaction they need, like the account store's. */ export const REVOCATION_STORE_READS = ["due"] as const export const REVOCATION_STORE_WRITES = ["record", "change", "purge"] as const type RevocationMethod = (typeof REVOCATION_STORE_READS)[number] | (typeof REVOCATION_STORE_WRITES)[number] /** * The revocation outbox of an action: each method is one internal query or mutation of * convex/account.ts over ctx.db, the Postgres profile's own store (adapters/revocations.ts). A claim * or a settlement is one versioned write, so two actions never deliver one row at once. */ export function convexRevocationStore(ctx: EffectActionCtx): RevocationStore { const read = (method: RevocationMethod) => (...args: unknown[]) => backend(() => ctx.runQuery(makeFunctionReference<"query">("account:revocationRead") as never, { method, args } as never), ) const write = (method: RevocationMethod) => (...args: unknown[]) => backend(() => ctx.runMutation( makeFunctionReference<"mutation">("account:revocationWrite") as never, { method, args } as never, ), ) return Object.fromEntries([ ...REVOCATION_STORE_READS.map((method) => [method, read(method)]), ...REVOCATION_STORE_WRITES.map((method) => [method, write(method)]), ]) as unknown as RevocationStore } const backendLog = { warn: (message: string) => console.warn(message) } /** * A revoked app session, recorded in the outbox and delivered at once to every module that minted * derived service sessions for it (`account:revokeSession`, called by the web's sign-out). Answers * once the row is written and the first bounded attempt settled; throws only when nothing was * recorded, so the web asks the modules itself. */ export function revokeSessionInBackend(ctx: EffectActionCtx, session: string): Promise { return recordSessionRevocation(effectContainer(ctx), session, { log: backendLog }) } /** * The account module's revocation job (the Postgres worker's REVOCATION_JOB) in this action: every * due row is claimed and delivered, a row older than 65 minutes lapses, finished rows are purged * after a day. A run that leaves rows waiting fails with `account_revocations_waiting`. */ export async function sweepRevocationsInBackend(ctx: EffectActionCtx): Promise { const job = registry.jobs[REVOCATION_JOB] if (!job) throw internal("the account module declares no revocation job") await job.def.handler(null, { container: effectContainer(ctx), correlationId: globalThis.crypto.randomUUID(), attempt: 1, }) } /** * The container of one effect action: the module ports from the deployment variables, and the * account module's store, registry and the caller's credential and device session (none for a * scheduled action). */ export function effectContainer( ctx: EffectActionCtx, credential: AccountCredential = { interactive: false }, requestSession: string | null = null, ): Container { const container = new Container() // The device session a derived service session (a Support widget session) is minted for, so that // device's sign-out ends it (the revocation outbox asks by it). container.value(REQUEST_SESSION, requestSession) container.value(NESTED, nestedProcedures(ctx)) container.value(ACCOUNT_REGISTRY, registry) container.value(ACCOUNT_STORE, convexAccountStore(ctx)) container.value(REVOCATION_STORE, convexRevocationStore(ctx)) container.value(ACCOUNT_CREDENTIAL, credential) // kit:convex-better-auth:begin -- a deletion ends the person's Better Auth account (the deploy job // installs the Better Auth deployment's URL, admin key and namespace in this backend). if (process.env.AUTH_PROVIDER?.trim() === "convex-better-auth") { container.register(ACCOUNT_IDENTITY_PROVIDER, () => betterAuthAccountEndFromEnv(process.env)) } // kit:convex-better-auth:end // kit:guides:begin container.value(GUIDES_REGISTRY, registry) container.value(GUIDES_PLATFORM, guidesPlatform(platformEnv())) // kit:guides:end // kit:support:begin container.value(SUPPORT_PLATFORM, supportPlatform({ ...platformEnv(), channel: process.env.CONUSAI_SUPPORT_CHANNEL })) // kit:support:end return container } /** * Runs one effect procedure in this action (the generated wrapper's ctx.run). Only an effect of a * module listed in CONVEX_ACTION_EFFECT_MODULES runs; anything else fails closed. */ export async function callEffectProcedure( ctx: EffectActionCtx, name: string, input: unknown, options?: CallOptions, ): Promise { try { const entry = actionRegistry.procedures.get(name) if (entry?.def.kind !== "effect" || !CONVEX_ACTION_EFFECT_MODULES.has(entry.module)) { throw internal(`${name} is not an effect procedure this profile runs as an action`) } const callContext = await callContextOf(ctx.auth) return await createCaller( { registry: actionRegistry, container: effectContainer(ctx, callContext.accountCredential, callContext.requestSession), tenancy: backendTenancy(ctx), }, callContext, ).callByName(name, input, options) } catch (error) { // An unexpected failure stays opaque to the caller but not to the operator: the backend log // names it (an AppError is the kernel's own answer and needs no log line). if (!(error instanceof AppError)) console.error(`effect ${name} failed`, error) // The same problem contract as every other Convex function (lib/runtime.ts). throw new ConvexError(toProblem(error) as unknown as Value) } }