255 lines
12 KiB
TypeScript
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)
|
|
}
|
|
}
|