371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
// 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<import("convex/server").UserIdentity | null> }
|
|
runQuery(reference: never, args: never): Promise<unknown>
|
|
runMutation(reference: never, args: never): Promise<unknown>
|
|
}
|
|
|
|
/** 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<string, unknown>
|
|
if (typeof value.status === "number" && typeof value.code === "string" && typeof value.title === "string") {
|
|
return new AppError(value as unknown as ConstructorParameters<typeof AppError>[0])
|
|
}
|
|
}
|
|
return error
|
|
}
|
|
|
|
async function backend<T>(run: () => Promise<unknown>): Promise<T> {
|
|
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<unknown>
|
|
}
|
|
|
|
// 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<NestedProcedures>("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<ScopeIds>
|
|
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 <L extends TenantLevel>(level: L, input: unknown, actor: ScopeOf<L>["actor"]) => {
|
|
const ids = await backend<ScopeIds>(() =>
|
|
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<L>
|
|
},
|
|
authorize: (scope, permission, procedure) =>
|
|
backend<boolean>(() =>
|
|
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<void> {
|
|
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<void> {
|
|
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<unknown> {
|
|
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)
|
|
}
|
|
}
|