Files
template-app/apps/web/src/server/convex-caller.ts
T
2026-11-14 09:56:45 +00:00

288 lines
12 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Server-only procedure transport for the Convex profile. The browser still uses the same Hono
// BFF and session cookie as SQL profiles; the linked admin key never crosses that boundary.
//
// A write (a generated tenant mutation) is called with `LIVE_TXID_ARG`, so it answers its live log
// position with its value; the position is recorded with the router's capture and answered as
// `ConusAI-Txid`, as a Postgres transaction id is (umbrella TS6.3).
import type { AccountCredential } from "@app/modules/account"
import type { Principal } from "@conusai-kit/auth"
import type { CallOptions, TransportCaller } from "@conusai-kit/core"
import { AppError, internal } from "@conusai-kit/core"
import { LIVE_TXID_ARG, type LiveTxidEnvelope } from "@conusai-kit/data-convex/runtime"
import { recordTxid } from "@conusai-kit/data-kysely"
import { type FileScope, PartGuard, platformObjectStore } from "@conusai-kit/files"
import type { ObjectStore as PlatformObjectStore } from "@conusai-kit/platform"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
import { ConvexError } from "convex/values"
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures"
import { CONVEX_BFF_ISSUER } from "../../../../convex/lib/runtime"
const unavailableProcedure = (name: string): AppError =>
new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: `no procedure ${name}`,
})
type AdminAuthClient = ConvexHttpClient & {
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
}
/** A Convex refusal as the kernel's problem; anything else is an opaque internal error. */
export const asProblem = (error: unknown): AppError => {
if (error instanceof AppError) return error
if (error instanceof ConvexError && typeof error.data === "object" && error.data !== null) {
const value = error.data as Record<string, unknown>
if (
typeof value.type === "string" &&
typeof value.title === "string" &&
typeof value.status === "number" &&
typeof value.code === "string" &&
(value.detail === undefined || typeof value.detail === "string")
) {
return new AppError(value as unknown as ConstructorParameters<typeof AppError>[0])
}
}
return internal()
}
/** The account credential as identity claims: only for a person signed in to this app. */
function credentialClaims(credential: AccountCredential | undefined): Record<string, string> {
if (!credential?.interactive) return {}
return {
conusaiCredential: "interactive",
...(credential.sessionId ? { conusaiSessionId: credential.sessionId } : {}),
...(credential.signedInAt ? { conusaiSignedInAt: credential.signedInAt } : {}),
}
}
/**
* An account event of the app's identity provider (Better Auth's deletion or ban, from its revocation
* relay) on the Convex profile: the account module's flow runs in the backend (convex/account.ts
* `providerEvent`), called with the admin key and no identity. A failure is thrown, so the relay keeps
* the event and delivers it again.
*/
export async function deliverConvexAccountEvent(
convex: { url: string; adminKey: string },
event: { readonly reason: "account-deleted" | "user-disabled"; readonly userId: string },
): Promise<void> {
const client = convexAdminClient(convex)
try {
await client.action(makeFunctionReference<"action">("account:providerEvent") as never, event as never)
} catch (error) {
throw asProblem(error)
}
}
/** Who a request's Convex calls are made as (request.ts): the facts the BFF verified. */
export interface ConvexIdentityOptions {
readonly principal: Principal | null
readonly user?: { readonly email: string; readonly displayName: string }
/** What the account module asks of the credential (request.ts), carried as identity claims (convex/lib/runtime.ts). */
readonly credential?: AccountCredential
/** The request's app session (kit-auth `sessionRefKey`): a derived service session is minted for it. */
readonly identitySession?: string
}
/**
* The identity a request's Convex calls carry (convex/lib/runtime.ts `callContextOf` reads it), or
* undefined for a request with no principal: its calls are anonymous.
*/
export function convexIdentity(options: ConvexIdentityOptions): UserIdentityAttributes | undefined {
if (!options.principal) return undefined
const subject = options.principal.actor.userId
return {
issuer: CONVEX_BFF_ISSUER,
subject,
tokenIdentifier: `${CONVEX_BFF_ISSUER}|${subject}`,
...(options.user?.email ? { email: options.user.email } : {}),
...(options.user?.displayName ? { name: options.user.displayName } : {}),
...(options.principal.orgId ? { conusaiOrgId: options.principal.orgId } : {}),
...credentialClaims(options.credential),
...(options.identitySession ? { conusaiIdentitySession: options.identitySession } : {}),
}
}
/**
* A one-shot client holding the deployment's admin key, server-side only: with an identity it calls
* as that person; without one it may call the deployment's internal functions too.
*/
export function convexAdminClient(
convex: { readonly url: string; readonly adminKey: string },
identity?: UserIdentityAttributes,
): ConvexHttpClient {
const client = new ConvexHttpClient(convex.url)
;(client as AdminAuthClient).setAdminAuth(convex.adminKey, identity)
return client
}
/**
* A tenant mutation's answer when it was called with `LIVE_TXID_ARG`: the procedure's value, with
* the write's live log position recorded for the router's `ConusAI-Txid` (none when it wrote no
* collection row).
*/
export function unwrapLiveTxid(answer: unknown): unknown {
const envelope = answer as Partial<LiveTxidEnvelope> | null
if (typeof envelope !== "object" || envelope === null || !("value" in envelope) || !("txid" in envelope)) {
throw internal("a tenant mutation did not answer its live txid envelope")
}
if (typeof envelope.txid === "string") recordTxid(envelope.txid)
return envelope.value
}
export function createConvexCaller(options: {
url: string
adminKey: string
principal: Principal | null
objectStore: PlatformObjectStore
/** The request's identity (`convexIdentity`); absent for a request with no principal. */
identity?: UserIdentityAttributes
}): TransportCaller {
const client = convexAdminClient(options, options.identity)
const callByName = async (name: string, input: unknown, callOptions?: CallOptions): Promise<unknown> => {
if (name === "files.file.download") {
if (!options.principal) throw unavailableProcedure(name)
const value = input as { org: string; workspace: string; project: string; fileId: string }
try {
const info = (await client.query(
makeFunctionReference("files:downloadInfo") as never,
{
input: value,
} as never,
)) as {
storageKey: string
filename: string
contentType: string
size: number
scope: { orgId: string; workspaceId: string; projectId: string }
}
const scope: FileScope = { level: "project", actor: options.principal.actor, ...info.scope }
const object = await platformObjectStore({ blob: options.objectStore }).get(scope, info.storageKey)
if (!object) {
throw new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: "no such file",
})
}
const fallback = info.filename.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "download"
return new Response(object.body, {
status: 200,
headers: {
"content-type": info.contentType,
"content-length": String(info.size),
"content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(info.filename)}`,
"x-content-type-options": "nosniff",
"cache-control": "private, no-store",
},
})
} catch (error) {
throw asProblem(error)
}
}
if (name === "files.file.putPart") {
if (!options.principal) throw unavailableProcedure(name)
const value = input as {
org: string
workspace: string
project: string
fileId: string
part: number
body: ReadableStream<Uint8Array> | Uint8Array
}
try {
const info = (await client.query(
makeFunctionReference("files:partInfo") as never,
{
input: {
org: value.org,
workspace: value.workspace,
project: value.project,
fileId: value.fileId,
part: value.part,
},
} as never,
)) as {
uploadId: string
storageKey: string
part: number
size: number
contentType?: string
scope: { orgId: string; workspaceId: string; projectId: string }
}
const scope: FileScope = {
level: "project",
actor: options.principal.actor,
...info.scope,
}
const guard = new PartGuard({
expectedBytes: info.size,
...(info.contentType ? { contentType: info.contentType } : {}),
})
const store = platformObjectStore({ blob: options.objectStore })
try {
const sent = await store.putUploadPart(scope, {
key: info.storageKey,
uploadId: info.uploadId,
number: info.part,
size: info.size,
body: guard.wrap(value.body),
})
return { part: info.part, etag: sent.etag }
} catch (error) {
throw guard.violation ?? error
}
} catch (error) {
throw asProblem(error)
}
}
const fileAction = {
"files.file.beginUpload": "filesActions:begin",
"files.file.completeUpload": "filesActions:complete",
"files.file.delete": "filesActions:remove",
}[name]
if (fileAction) {
const fn = makeFunctionReference(fileAction) as never
try {
return await client.action(fn, { input } as never)
} catch (error) {
throw asProblem(error)
}
}
const definition = CONVEX_PROCEDURES[name as keyof typeof CONVEX_PROCEDURES]
if (!definition) throw unavailableProcedure(name)
const fn = makeFunctionReference(definition.reference) as never
const args = { input, ...(callOptions?.idempotencyKey ? { idempotencyKey: callOptions.idempotencyKey } : {}) }
try {
// Widened: an app without guides and support generates no effect procedure at all.
switch (definition.kind as "read" | "write" | "effect") {
case "read":
return await client.query(fn, args as never)
// Every write is a generated tenant mutation, the one kind of function that takes LIVE_TXID_ARG.
case "write":
return unwrapLiveTxid(await client.mutation(fn, { ...args, [LIVE_TXID_ARG]: true } as never))
// An effect of CONVEX_ACTION_EFFECT_MODULES (guides, support): a generated action that runs
// the kernel with the module's platform port (convex/lib/effects.ts).
case "effect":
return await client.action(fn, args as never)
}
} catch (error) {
throw asProblem(error)
}
}
return {
call: (contract, input, options) => callByName(contract.name, input, options),
callByName,
} as TransportCaller
}