@@ -0,0 +1,287 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user