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