This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
export interface ConvexModuleLike {
|
||||
readonly id: string
|
||||
readonly procedures?: readonly { readonly name: string; readonly kind: string }[]
|
||||
}
|
||||
|
||||
export const CONVEX_EXCLUDED_MODULES: Map<string, string>
|
||||
export const CONVEX_EXCLUDED_PROCEDURES: Map<string, string>
|
||||
export const CONVEX_ACTION_EFFECT_MODULES: Map<string, string>
|
||||
|
||||
export function convexModules<T extends ConvexModuleLike>(appModules: readonly T[]): T[]
|
||||
export function convexBffModules<T extends ConvexModuleLike>(appModules: readonly T[]): T[]
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The modules that deploy to the Convex backend on this profile, derived from the app's one
|
||||
// module list (modules/index.ts) -- named exclusions, not a second list.
|
||||
//
|
||||
// Why a module is excluded: its callable procedure graph needs a Node-only runtime or a workflow
|
||||
// that has not been composed here. These are explicit gaps, not inferred from the module
|
||||
// importing a package which has unrelated Node-only exports. Auth is included: its procedure
|
||||
// handlers use Web Crypto; the OIDC redirects and sealed session storage remain in the trusted
|
||||
// Next BFF, which invokes Convex functions with server-only admin auth and a verified identity.
|
||||
//
|
||||
// Convex actions have no ctx.db, so the runtime cannot run an effect procedure that changes the
|
||||
// app's own rows in an action. File upload and delete have explicit action→mutation compositions
|
||||
// in files.ts; other effects remain omitted until they have an equivalent state-machine workflow.
|
||||
export const CONVEX_EXCLUDED_MODULES = new Map([])
|
||||
|
||||
// The modules whose effect procedures are generated as Convex actions (convex/lib/effects.ts):
|
||||
// each effect is one call to a platform service through the module's own port and owns no app
|
||||
// row, so it needs no transaction -- except the account module's, whose rows are reached through
|
||||
// its bound store, one transaction per resumable step. The kernel still resolves the tenant scope and checks the
|
||||
// permission, against ctx.db in an internal query (convex/effectScope.ts), and the handler's
|
||||
// nested reads and writes run as the generated queries and mutations they always are.
|
||||
export const CONVEX_ACTION_EFFECT_MODULES = new Map()
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("guides", "Guides runtime calls through GUIDES_PLATFORM; no app rows") // kit:guides
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("support", "Support session/contact calls through SUPPORT_PLATFORM") // kit:support
|
||||
// The account module's export and deletion compose every module's share (platform calls) and own the
|
||||
// deletion ledger, which they reach through ACCOUNT_STORE: one internal query or mutation per step.
|
||||
CONVEX_ACTION_EFFECT_MODULES.set("account", "account export/deletion; ledger steps through convex/account.ts")
|
||||
|
||||
export const CONVEX_EXCLUDED_PROCEDURES = new Map([
|
||||
["branding.logo.set", "logo decoding and Blob writes require an audited action workflow"],
|
||||
["branding.logo.clear", "logo record updates and concurrent Blob cleanup require an action workflow"],
|
||||
["branding.logo.get", "logo bytes require an authorized Blob action workflow"],
|
||||
["files.file.delete", "the BFF composes the object delete action with internal mutations"],
|
||||
])
|
||||
// The account page's platform analytics erasure and export (ADR-049) call the platform with the
|
||||
// deployment token through ACCOUNT_PRIVACY, which only the web's composition root binds; no Convex
|
||||
// action binds it yet, so on this profile the panel answers "unavailable".
|
||||
for (const name of ["account.analytics.request", "account.analytics.status", "account.analytics.export"]) {
|
||||
CONVEX_EXCLUDED_PROCEDURES.set(name, "ACCOUNT_PRIVACY is bound by the web's composition root only")
|
||||
}
|
||||
// kit:guides:begin -- a survey file's bytes are the streamed body of a PUT, which cannot be a Convex
|
||||
// action's argument, and no BFF composition streams it to the platform on this profile yet.
|
||||
CONVEX_EXCLUDED_PROCEDURES.set(
|
||||
"guides.runtime.uploadFile",
|
||||
"a streamed request body cannot be a Convex action argument",
|
||||
)
|
||||
// kit:guides:end
|
||||
|
||||
const modulesFor = (appModules, includeBffFileEffects) =>
|
||||
appModules
|
||||
.filter((module) => !CONVEX_EXCLUDED_MODULES.has(module.id))
|
||||
.map((module) => ({
|
||||
...module,
|
||||
procedures: (module.procedures ?? []).filter((procedure) => {
|
||||
const composedBffFileProcedure =
|
||||
includeBffFileEffects && module.id === "files" && procedure.name.startsWith("files.file.")
|
||||
const runnable = procedure.kind !== "effect" || CONVEX_ACTION_EFFECT_MODULES.has(module.id)
|
||||
return composedBffFileProcedure || (runnable && !CONVEX_EXCLUDED_PROCEDURES.has(procedure.name))
|
||||
}),
|
||||
}))
|
||||
|
||||
// The backend generator and deployment registry omit procedures that are composed as actions by
|
||||
// the BFF. The web HTTP registry keeps those file contracts so their routes still validate input,
|
||||
// tenancy, permissions, rate limits and CSRF before the caller invokes the action workflow.
|
||||
export const convexModules = (appModules) => modulesFor(appModules, false)
|
||||
export const convexBffModules = (appModules) => modulesFor(appModules, true)
|
||||
@@ -0,0 +1,370 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The tenant function builders the generated wrappers are built with (spec §5.4: convex-helpers
|
||||
// custom functions + the repository predicate are the tenant wall). Each adds `ctx.run`, which
|
||||
// runs a procedure of the registry inside this function's transaction (lib/runtime.ts) or, in an
|
||||
// action, an effect procedure through the action composition (lib/effects.ts).
|
||||
|
||||
import { makeTenantFunctions } from "@conusai-kit/data-convex/runtime"
|
||||
import { action, mutation, query } from "../_generated/server"
|
||||
import { callEffectProcedure, type EffectActionCtx } from "./effects"
|
||||
import { callProcedure } from "./runtime"
|
||||
|
||||
export const { tenantQuery, tenantMutation, tenantAction } = makeTenantFunctions(
|
||||
{ query, mutation, action },
|
||||
(ctx) => (name, input, options) =>
|
||||
"db" in ctx
|
||||
? callProcedure(ctx, name, input, options)
|
||||
: callEffectProcedure(ctx as unknown as EffectActionCtx, name, input, options),
|
||||
)
|
||||
@@ -0,0 +1,254 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex composition for this app (spec §5.4): one registry, the data source bound to the
|
||||
// calling function's own ctx.db (the whole invocation is one Convex transaction), the tenancy
|
||||
// hooks every profile shares, and the caller every procedure goes through. `callProcedure` is
|
||||
// what the generated wrappers' ctx.run invokes; the jobs dispatcher and the outbox relay build
|
||||
// on the same pieces.
|
||||
|
||||
import { authModule } from "@conusai-kit/auth/module"
|
||||
import {
|
||||
buildRegistry,
|
||||
type CallOptions,
|
||||
Container,
|
||||
createCaller,
|
||||
type ModuleManifest,
|
||||
type Registry,
|
||||
toProblem,
|
||||
unauthenticated,
|
||||
} from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, entitiesOf, JOB_QUEUE, OBJECT_STORE, type ObjectStore, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import type { ConvexWriter } from "@conusai-kit/data-convex"
|
||||
import { createConvexDataSource, createConvexJobQueue } from "@conusai-kit/data-convex"
|
||||
import { actorFromIdentity, executorFor } from "@conusai-kit/data-convex/runtime"
|
||||
import { FILES_REGISTRY, filesModule } from "@conusai-kit/files"
|
||||
import { guidesModule } from "@conusai-kit/guides" // kit:guides
|
||||
import { PROPOSALS_REGISTRY, proposalsModule } from "@conusai-kit/proposals"
|
||||
import { supportModule } from "@conusai-kit/support" // kit:support
|
||||
import { createTenancyHooks, TENANCY, tenancyModule } from "@conusai-kit/tenancy"
|
||||
import type { GenericDataModel, GenericMutationCtx, GenericQueryCtx, UserIdentity } from "convex/server"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { ConvexError, type Value } from "convex/values"
|
||||
import {
|
||||
ACCOUNT_CREDENTIAL,
|
||||
ACCOUNT_REGISTRY,
|
||||
type AccountCredential,
|
||||
accountModule,
|
||||
} from "../../modules/dist/account/index.js"
|
||||
import { convexBrandingModule } from "../../modules/dist/branding/module.convex.js"
|
||||
import { convexDocumentsModule } from "../../modules/dist/documents/module.convex.js"
|
||||
import { NOTES_FLAGS } from "../../modules/dist/notes/core/ports.js"
|
||||
import { notesModule } from "../../modules/dist/notes/module.js"
|
||||
import { CONVEX_EXCLUDED_MODULES, convexModules } from "./convexModules.mjs"
|
||||
|
||||
// One import per deployable module: the bundle may not contain the excluded ones AT ALL (their
|
||||
// handlers import Node builtins, and queries/mutations never run under "use node"), so this
|
||||
// cannot be `appModules.filter(...)` -- importing the module list would import them. The
|
||||
// exclusion map is the shared record; test/convex-profile.test.ts proves this list is exactly
|
||||
// convexModules(appModules), so the generator and the runtime never disagree.
|
||||
export const deployableModules: readonly ModuleManifest[] = convexModules([
|
||||
tenancyModule,
|
||||
authModule,
|
||||
filesModule,
|
||||
proposalsModule,
|
||||
guidesModule, // kit:guides -- its effect procedures run as Convex actions (lib/effects.ts)
|
||||
supportModule, // kit:support -- its effect procedures run as Convex actions (lib/effects.ts)
|
||||
convexBrandingModule,
|
||||
convexDocumentsModule,
|
||||
notesModule,
|
||||
accountModule, // export and deletion run as Convex actions over internal store functions (convex/account.ts)
|
||||
])
|
||||
for (const module of deployableModules) {
|
||||
if (CONVEX_EXCLUDED_MODULES.has(module.id)) {
|
||||
throw new Error(
|
||||
`convex profile: ${module.id} is excluded (${CONVEX_EXCLUDED_MODULES.get(module.id)}) but still imported`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one registry, built from the manifests of the modules this profile deploys. */
|
||||
export const registry: Registry = buildRegistry([...deployableModules])
|
||||
|
||||
/** The Convex data source over every convex-bound entity of the registry. */
|
||||
export const convexEntities = entitiesOf(registry, "convex")
|
||||
|
||||
/** The expected server contract exposed by system.ready and checked by the web readiness probe. */
|
||||
export const convexCapabilities = {
|
||||
modules: deployableModules.map(({ id }) => id).sort(),
|
||||
procedures: [...registry.procedures.keys()].sort(),
|
||||
actions: [
|
||||
"documentsActions:process",
|
||||
"filesActions:begin",
|
||||
"filesActions:complete",
|
||||
"filesActions:remove",
|
||||
"filesActions:cleanupDelete",
|
||||
"filesActions:recoverUpload",
|
||||
"filesActions:readiness",
|
||||
],
|
||||
tables: convexEntities.map(({ name }) => name).sort(),
|
||||
schema: convexEntities
|
||||
.map((entity) => ({
|
||||
name: entity.name,
|
||||
table: entity.table,
|
||||
tenancy: entity.tenancy,
|
||||
columns: [...entity.columns].sort(),
|
||||
unique: entity.unique.map((fields) => [...fields]),
|
||||
indexes: entity.indexes.map((fields) => [...fields]),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
}
|
||||
|
||||
/**
|
||||
* The only identity accepted by public Convex functions is one the Next BFF attaches while it
|
||||
* holds the linked service's admin key. auth.config.ts intentionally configures no external JWT
|
||||
* provider for this issuer: a browser or a caller who knows a user id cannot forge this identity.
|
||||
*/
|
||||
export const CONVEX_BFF_ISSUER = "https://conusai.internal/bff"
|
||||
|
||||
// `kit-files.filesDeps` resolves this port eagerly even for notes/file metadata operations that
|
||||
// never touch bytes. External object-store calls cannot run in a Convex mutation; the file upload
|
||||
// and delete procedures therefore use the explicit action/mutation workflows in filesActions.ts
|
||||
// and files.ts, rather than running through this generic procedure dispatcher.
|
||||
export const unavailableObjectStore = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, operation) => async () => {
|
||||
throw new Error(`Convex object-store operation ${String(operation)} requires its action workflow`)
|
||||
},
|
||||
},
|
||||
) as ObjectStore
|
||||
|
||||
interface FunctionCtx<DataModel extends GenericDataModel> {
|
||||
db?: GenericQueryCtx<DataModel>["db"] | GenericMutationCtx<DataModel>["db"]
|
||||
auth: { getUserIdentity(): Promise<UserIdentity | null> }
|
||||
scheduler?: GenericMutationCtx<DataModel>["scheduler"]
|
||||
}
|
||||
|
||||
export interface CallContext {
|
||||
actor?: ReturnType<typeof actorFromIdentity>
|
||||
pinnedOrgId?: string
|
||||
/** What the account module asks of the caller's credential (ACCOUNT_CREDENTIAL), from the BFF's identity. */
|
||||
accountCredential: AccountCredential
|
||||
/**
|
||||
* The device's app session (kit-auth `sessionRefKey`) a derived service session is minted for
|
||||
* (REQUEST_SESSION), from the BFF's identity: that device's sign-out then ends it.
|
||||
*/
|
||||
requestSession: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential claims the BFF attaches to the identity (apps/web/src/server/convex-caller.ts):
|
||||
* whether a person signed in to this app is behind the call, the kit session row it is bound to, or
|
||||
* the sign-in time an external provider reported for a session with no row. Only an identity the
|
||||
* admin key holder attached reaches here, so these are the BFF's facts, never a browser's.
|
||||
*/
|
||||
export function accountCredentialOf(identity: UserIdentity | null): AccountCredential {
|
||||
const claims = (identity ?? {}) as UserIdentity & {
|
||||
conusaiCredential?: unknown
|
||||
conusaiSessionId?: unknown
|
||||
conusaiSignedInAt?: unknown
|
||||
}
|
||||
if (claims.conusaiCredential !== "interactive") return { interactive: false }
|
||||
return {
|
||||
interactive: true,
|
||||
...(typeof claims.conusaiSessionId === "string" ? { sessionId: claims.conusaiSessionId } : {}),
|
||||
...(typeof claims.conusaiSignedInAt === "string" ? { signedInAt: claims.conusaiSignedInAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The device session the BFF names for a signed-in person's call (`conusaiIdentitySession`, the
|
||||
* kit-auth `sessionRefKey` of the request's app session), or null. Like the credential claims, only
|
||||
* an identity the admin key holder attached reaches here.
|
||||
*/
|
||||
export function requestSessionOf(identity: UserIdentity | null): string | null {
|
||||
const claim = (identity as (UserIdentity & { conusaiIdentitySession?: unknown }) | null)?.conusaiIdentitySession
|
||||
return typeof claim === "string" && claim.length > 0 ? claim : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The actor and the organization pin of a call, from the verified identity: only a BFF identity
|
||||
* attached with the self-hosted admin key is accepted (an absent identity is an anonymous call).
|
||||
*/
|
||||
export async function callContextOf(auth: FunctionCtx<GenericDataModel>["auth"]): Promise<CallContext> {
|
||||
const identity = await auth.getUserIdentity()
|
||||
if (identity && identity.issuer !== CONVEX_BFF_ISSUER) {
|
||||
throw unauthenticated("this Convex function accepts calls only from the authenticated app BFF")
|
||||
}
|
||||
const callContext: CallContext = {
|
||||
accountCredential: accountCredentialOf(identity),
|
||||
requestSession: requestSessionOf(identity),
|
||||
}
|
||||
if (identity) {
|
||||
callContext.actor = actorFromIdentity(identity)
|
||||
const pinnedOrgId = (identity as UserIdentity & { conusaiOrgId?: string }).conusaiOrgId
|
||||
if (typeof pinnedOrgId === "string") callContext.pinnedOrgId = pinnedOrgId
|
||||
}
|
||||
return callContext
|
||||
}
|
||||
|
||||
/** The tenancy hooks every procedure uses, over a function's own ctx.db. */
|
||||
export function tenancyOver<DataModel extends GenericDataModel>(
|
||||
db: NonNullable<FunctionCtx<DataModel>["db"]>,
|
||||
): ReturnType<typeof createTenancyHooks> {
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor({ db }) })
|
||||
return createTenancyHooks({ data, registry })
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one procedure inside this function's transaction: the actor from the verified identity
|
||||
* (a BFF identity attached with the self-hosted admin key), the scope resolved by the
|
||||
* tenancy hooks from the input's slugs, the permission checked, the handler run with the
|
||||
* capability container of its kind -- the same path the HTTP mount takes on the other profiles.
|
||||
*/
|
||||
export async function callProcedure<DataModel extends GenericDataModel>(
|
||||
ctx: FunctionCtx<DataModel>,
|
||||
name: string,
|
||||
input: unknown,
|
||||
options?: CallOptions,
|
||||
): Promise<unknown> {
|
||||
if (!ctx.db) {
|
||||
throw new Error(
|
||||
"Convex effect procedures run through lib/effects.ts (callEffectProcedure); actions cannot share a database transaction",
|
||||
)
|
||||
}
|
||||
const data = createConvexDataSource({ entities: convexEntities, executor: executorFor({ db: ctx.db }) })
|
||||
const callContext = await callContextOf(ctx.auth)
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
container.value(OBJECT_STORE, unavailableObjectStore)
|
||||
container.value(FILES_REGISTRY, registry)
|
||||
container.value(PROPOSALS_REGISTRY, registry)
|
||||
container.value(ACCOUNT_REGISTRY, registry)
|
||||
container.value(ACCOUNT_CREDENTIAL, callContext.accountCredential)
|
||||
// File actions use the same transactional row model as notes, so the profile can expose the
|
||||
// module's default attachment flag after its full upload round-trip gate passes.
|
||||
container.value(NOTES_FLAGS, { enabled: () => true })
|
||||
// Only mutations have a scheduler. The queue writes its job row and schedules the dispatcher
|
||||
// in this same ctx.db transaction, so rollback cannot leave either half behind.
|
||||
if (ctx.scheduler) {
|
||||
const jobDb = ctx.db as unknown as ConvexWriter
|
||||
container.value(
|
||||
JOB_QUEUE,
|
||||
createConvexJobQueue({
|
||||
db: jobDb,
|
||||
schedule: ({ delayMs, jobId, inline }) =>
|
||||
ctx.scheduler
|
||||
?.runAfter(delayMs, makeFunctionReference<"mutation">("conusaiJobs:run"), {
|
||||
jobId,
|
||||
...(inline ? { inline } : {}),
|
||||
})
|
||||
.then(() => undefined) ?? Promise.reject(new Error("Convex scheduler is unavailable in this function")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
container.value(TENANCY, tenancy)
|
||||
try {
|
||||
return await createCaller({ registry, container, tenancy }, callContext).callByName(name, input, options)
|
||||
} catch (error) {
|
||||
// Convex serializes ConvexError.data across the HTTP boundary. Keep the kernel's problem
|
||||
// contract intact for the trusted BFF, while unknown backend failures stay opaque.
|
||||
throw new ConvexError(toProblem(error) as unknown as Value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user