191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The Convex profile's half of `POST /api/v1/orgs/{org}/live` (live.ts): the CLP/1 stream is served by
|
|
// @conusai-kit/live-server in this process, over the deployment's own functions (convex/live.ts):
|
|
//
|
|
// - the log: `live:head` and `live:changes` (internal queries) read with the admin key, and the wake-up
|
|
// is a reactive subscription to `live:head` per organization a stream reads (`convexLiveLog`
|
|
// refcounts it), over one websocket client opened by the first stream
|
|
// - who may stream what: `live:open`, a public query run under the caller's identity (the same claims
|
|
// every procedure call carries, convex-caller.ts), decides with the kernel's tenancy over the
|
|
// deployment's rows (`decideLiveGrants`); its refusal comes back as the live problem the Postgres
|
|
// profile answers (401 signed out, 404 not reached), and each heartbeat asks `live:recheck`
|
|
//
|
|
// The admin key stays in this process; the browser holds no Convex credential.
|
|
|
|
import {
|
|
type ConvexLiveHead,
|
|
type ConvexLiveRow,
|
|
type ConvexLiveSource,
|
|
type LiveAuthorization,
|
|
type LiveRecheck,
|
|
type OpenRequest,
|
|
problems,
|
|
type ResolvedTopic,
|
|
} from "@conusai-kit/live-server"
|
|
import { ConvexClient } from "convex/browser"
|
|
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
|
|
import { ConvexError } from "convex/values"
|
|
import { asProblem, convexAdminClient } from "./convex-caller"
|
|
|
|
const OPEN = makeFunctionReference<"query">("live:open")
|
|
const RECHECK = makeFunctionReference<"query">("live:recheck")
|
|
const HEAD = makeFunctionReference<"query">("live:head")
|
|
const CHANGES = makeFunctionReference<"query">("live:changes")
|
|
|
|
/** What the source and the authorization ask of a one-shot client (`ConvexHttpClient`). */
|
|
export interface LiveQueryClient {
|
|
query(reference: never, args: never): Promise<unknown>
|
|
}
|
|
|
|
/** What the source asks of a subscribing client (`ConvexClient`, a websocket). */
|
|
export interface LiveWatchClient {
|
|
onUpdate(
|
|
reference: never,
|
|
args: never,
|
|
callback: (value: unknown) => unknown,
|
|
onError?: (error: Error) => unknown,
|
|
): () => void
|
|
close(): Promise<void>
|
|
}
|
|
|
|
type AdminAuthWatchClient = ConvexClient & {
|
|
/** Convex SDK 1.45.0 internal, as `ConvexHttpClient.setAdminAuth` (convex-caller.ts). */
|
|
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
|
|
}
|
|
|
|
/**
|
|
* The log source over two clients: `admin` reads (it holds the admin key and no identity, so it may call
|
|
* the internal `live:head` and `live:changes`), and `watcher` opens the subscribing client on the first
|
|
* `watch`; `close` closes it.
|
|
*/
|
|
export function convexLiveSource(clients: {
|
|
readonly admin: LiveQueryClient
|
|
readonly watcher: () => LiveWatchClient
|
|
}): ConvexLiveSource {
|
|
let watcher: LiveWatchClient | undefined
|
|
return {
|
|
head: async (orgId) => (await clients.admin.query(HEAD as never, { orgId } as never)) as ConvexLiveHead,
|
|
changes: async (orgId, after, limit) =>
|
|
(await clients.admin.query(
|
|
CHANGES as never,
|
|
{ orgId, after: { txid: after.txid, seq: after.seq }, limit } as never,
|
|
)) as {
|
|
rows: readonly ConvexLiveRow[]
|
|
head: ConvexLiveHead
|
|
more: boolean
|
|
},
|
|
watch(orgId, onChange, onError) {
|
|
watcher ??= clients.watcher()
|
|
// Convex answers the current head first, then every new one: each is a wake-up, and a wake-up
|
|
// with nothing new is one empty read.
|
|
return watcher.onUpdate(HEAD as never, { orgId } as never, () => onChange(), onError)
|
|
},
|
|
async close() {
|
|
const open = watcher
|
|
watcher = undefined
|
|
await open?.close()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** The log source over the linked deployment, with its admin key (env.convex). */
|
|
export function deploymentLiveSource(convex: { readonly url: string; readonly adminKey: string }): ConvexLiveSource {
|
|
return convexLiveSource({
|
|
admin: convexAdminClient(convex) as unknown as LiveQueryClient,
|
|
watcher: () => {
|
|
const client = new ConvexClient(convex.url, { unsavedChangesWarning: false })
|
|
;(client as AdminAuthWatchClient).setAdminAuth(convex.adminKey)
|
|
return client as unknown as LiveWatchClient
|
|
},
|
|
})
|
|
}
|
|
|
|
/** The verified caller of one stream, as live.ts resolves it from the request. */
|
|
export interface LiveCaller {
|
|
readonly userId: string
|
|
/** The session (or credential) key of the per-session stream limit. */
|
|
readonly session: string
|
|
/** The identity its Convex calls carry (convex-caller.ts `convexIdentity`). */
|
|
readonly identity: UserIdentityAttributes
|
|
}
|
|
|
|
export interface ConvexLiveAuthorizeOptions {
|
|
/** The request's caller: signed in (or the live 401) and its CSRF checked. */
|
|
caller(request: Request): Promise<LiveCaller>
|
|
/** A one-shot client that calls as `identity` (`convexAdminClient`). */
|
|
clientFor(identity: UserIdentityAttributes): LiveQueryClient
|
|
/** The org slug of `/api/v1/orgs/{org}/live`. */
|
|
orgSlugOf(request: Request): string
|
|
/** Where an unexpected re-check failure is reported (it does not end the stream). */
|
|
warn(message: string): void
|
|
}
|
|
|
|
/** The status of a refusal: a live problem's own, or the problem a Convex function threw. */
|
|
function statusOf(error: unknown): number | undefined {
|
|
if (error instanceof ConvexError) {
|
|
const status = (error.data as { status?: unknown } | null)?.status
|
|
return typeof status === "number" ? status : undefined
|
|
}
|
|
const status = (error as { status?: unknown } | null)?.status
|
|
return typeof status === "number" ? status : undefined
|
|
}
|
|
|
|
/** A refusal of `live:open` as the Postgres profile answers it; another Convex problem as the kernel's. */
|
|
function refusalOf(error: unknown): unknown {
|
|
if (!(error instanceof ConvexError)) return error
|
|
const status = statusOf(error)
|
|
if (status === 401) return problems.unauthenticated()
|
|
if (status === 404) return problems.notFound()
|
|
return asProblem(error)
|
|
}
|
|
|
|
interface OpenAnswer {
|
|
readonly org: string
|
|
readonly topics: readonly ResolvedTopic[]
|
|
readonly entities: readonly string[]
|
|
readonly denied: ReadonlyArray<{ readonly entity: string; readonly reason: string }>
|
|
}
|
|
|
|
/** The authorize function of `createLiveServer` on the Convex profile. */
|
|
export function convexLiveAuthorize(
|
|
options: ConvexLiveAuthorizeOptions,
|
|
): (request: Request, open: OpenRequest) => Promise<LiveAuthorization> {
|
|
return async (request, open) => {
|
|
const caller = await options.caller(request)
|
|
const slug = options.orgSlugOf(request)
|
|
// The validator of `live:open` takes these fields only.
|
|
const topics = open.topics.map((topic) => ({ ws: topic.ws, ...(topic.project ? { project: topic.project } : {}) }))
|
|
let answer: OpenAnswer
|
|
try {
|
|
answer = (await options
|
|
.clientFor(caller.identity)
|
|
.query(OPEN as never, { org: slug, topics, entities: [...open.entities] } as never)) as OpenAnswer
|
|
} catch (error) {
|
|
throw refusalOf(error)
|
|
}
|
|
return {
|
|
org: answer.org,
|
|
session: caller.session,
|
|
granted: { entities: answer.entities, topics: answer.topics },
|
|
denied: answer.denied,
|
|
// Every heartbeat and on a grant change: the credential is still valid, the caller still a member of
|
|
// every scope, and still holds the read permission of every entity the stream was granted.
|
|
recheck: async (): Promise<LiveRecheck> => {
|
|
try {
|
|
const again = await options.caller(new Request(request.url, { method: "GET", headers: request.headers }))
|
|
if (again.userId !== caller.userId) return "session"
|
|
return (await options
|
|
.clientFor(again.identity)
|
|
.query(RECHECK as never, { org: slug, topics, granted: [...answer.entities] } as never)) as LiveRecheck
|
|
} catch (error) {
|
|
const status = statusOf(error)
|
|
if (status === 401) return "session"
|
|
if (status === 403 || status === 404) return "membership"
|
|
options.warn(`live: the re-check failed: ${error instanceof Error ? error.name : "failed"}`)
|
|
return null
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|