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