// 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 { db?: GenericQueryCtx["db"] | GenericMutationCtx["db"] auth: { getUserIdentity(): Promise } scheduler?: GenericMutationCtx["scheduler"] } export interface CallContext { actor?: ReturnType 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["auth"]): Promise { 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( db: NonNullable["db"]>, ): ReturnType { 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( ctx: FunctionCtx, name: string, input: unknown, options?: CallOptions, ): Promise { 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) } }