This commit is contained in:
@@ -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