Files
template-app/convex/lib/runtime.ts
T

255 lines
12 KiB
TypeScript

// 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)
}
}