import type { ConsoleDataClient, ConsoleQuery, FunctionInvokeOptions, RealtimeChannel } from '../../supabase/functions/_backend/utils/console_contract' import type { Database } from '~/types/database.types' import { createConsoleAuthClient, resolveConsoleApiBase } from '~/services/consoleAuth' export type { AuthChangeEvent, AuthError, Factor, RealtimeChannel, Session, User } from '../../supabase/functions/_backend/utils/console_contract' /** * Console transport to the Capgo API (VITE_API_HOST). * * Data (`from`/`rpc`), storage, realtime and function calls are sent to * `/private/console/*` and executed server-side with the caller's session; * auth goes through `/private/console/auth/*` (see consoleAuth.ts). The * browser never holds an auth-provider host, key or SDK. */ export type ConsoleInvokeOptions = FunctionInvokeOptions & { signal?: AbortSignal } export type ConsoleInvokeResult = { data: T | null, error: null } | { data: null, error: FunctionsHttpError | FunctionsFetchError } export class FunctionsHttpError extends Error { code?: string constructor(public context: Response) { super('Capgo API returned an error') this.name = 'FunctionsHttpError' } } export class FunctionsFetchError extends Error { code?: string constructor(public context: unknown) { super('Failed to send a request to the Capgo API', { cause: context }) this.name = 'FunctionsFetchError' } } function resolveBaseURL() { return resolveConsoleApiBase(import.meta.env.VITE_API_HOST as string | undefined) } const EVENTS_MAX_BACKOFF_MS = 30_000 const QUERY_RETRIES = 2 const MUTATIONS = new Set(['insert', 'update', 'upsert', 'delete']) // RPCs are replayed on transient failures only when their name says they read. const READ_RPC = /^(?:get|is|check|count|find|verify|rbac_check)_/ // A request that never reached the API (network drop) or hit a booting/overloaded // edge (503) can be replayed safely for reads; mutations are never replayed. function isTransientQueryFailure(error: Error): boolean { if (error instanceof FunctionsFetchError) return true return error instanceof FunctionsHttpError && error.context instanceof Response && error.context.status === 503 } function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } async function readEventStream(body: ReadableStream, onEvent: (payload: unknown) => void) { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' try { for (;;) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) for (let end = buffer.indexOf('\n\n'); end >= 0; end = buffer.indexOf('\n\n')) { const event = buffer.slice(0, end) buffer = buffer.slice(end + 2) if (event.startsWith('data: ')) onEvent(JSON.parse(event.slice(6))) } } } finally { reader.releaseLock() } } export function createConsoleClient(options: { persistSession?: boolean } = {}) { const baseURL = resolveBaseURL() const auth = createConsoleAuthClient({ baseURL, persistSession: options.persistSession }) async function bearer(): Promise { const { data } = await auth.getSession() return data.session?.access_token ?? null } async function invoke(path: string, opts: ConsoleInvokeOptions = {}): Promise> { try { const headers = new Headers(opts.headers) const token = await bearer() if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) const binary = opts.body instanceof Blob || opts.body instanceof FormData || opts.body instanceof ArrayBuffer let body: BodyInit | undefined if (opts.body != null) body = binary || typeof opts.body === 'string' ? opts.body as BodyInit : JSON.stringify(opts.body) if (opts.body != null && !binary && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json') const response = await fetch(`${baseURL}/${path.replace(/^\//, '')}`, { method: opts.method ?? 'POST', headers, signal: opts.signal, body, }) if (!response.ok) return { data: null, error: new FunctionsHttpError(response) } const contentType = response.headers.get('content-type') ?? '' const data = contentType.includes('application/json') ? await response.json() : await response.text() return { data: data as T, error: null } } catch (error) { return { data: null, error: new FunctionsFetchError(error) } } } // Records the PostgREST builder chain and sends it in one request when awaited. function query(kind: ConsoleQuery['kind'], name: string, args: unknown[]) { const request: ConsoleQuery = { kind, name, args, operations: [] } let execution: Promise | undefined let signal: AbortSignal | undefined const builder: any = new Proxy({}, { get(_target, method: string) { if (method === 'then') { execution ??= (async () => { const mutates = request.kind === 'rpc' ? !READ_RPC.test(request.name) : request.operations.some(operation => MUTATIONS.has(operation.method)) const retries = mutates ? 0 : QUERY_RETRIES let result = await invoke('private/console/query', { body: request, signal }) for (let attempt = 0; attempt < retries; attempt++) { if (!result.error || !isTransientQueryFailure(result.error) || signal?.aborted) break await sleep(300 * 2 ** attempt) result = await invoke('private/console/query', { body: request, signal }) } return result })().then((result) => { const throws = request.operations.some(operation => operation.method === 'throwOnError') if (result.error) { if (throws) throw result.error return { data: null, error: result.error, count: null, status: result.error instanceof FunctionsHttpError ? result.error.context.status : 0 } } const value = result.data as { data: unknown, error: { message: string } | null, count: number | null, status: number } const error = value.error ? Object.assign(new Error(value.error.message), value.error) : null if (error && throws) throw error return { ...value, error } }) return execution.then.bind(execution) } if (method === 'abortSignal') { return (value: AbortSignal) => { signal = value return builder } } return (...operationArgs: unknown[]) => { // JSON turns undefined into null, which would defeat SDK default parameters on the server. const args = [...operationArgs] // Index access on purpose: older native WebViews lack Array.prototype.at. while (args.length && args[args.length - 1] === undefined) args.pop() request.operations.push({ method, args }) return builder } }, }) return builder } // One subscription attempt: 'ok' when the server closed a healthy stream, // 'retry' on a transient failure, 'stop' when no events backend exists. async function connectEvents(orgId: string, signal: AbortSignal, callback: (status: string) => void, onEvent: (payload: unknown) => void): Promise<'ok' | 'retry' | 'stop'> { try { const token = await bearer() const response = await fetch(`${baseURL}/private/console/events?org_id=${encodeURIComponent(orgId)}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal, }) if (response.status === 503) { // No events backend (local edge runtime): report once and stop. callback('CHANNEL_ERROR') return 'stop' } if (!response.ok || !response.body) throw new Error('Cannot subscribe to console events') callback('SUBSCRIBED') await readEventStream(response.body, onEvent) return 'ok' } catch { if (!signal.aborted) callback('CHANNEL_ERROR') return 'retry' } } async function pumpEvents(orgId: string, controller: AbortController, callback: (status: string) => void, onEvent: (payload: unknown) => void) { let failures = 0 while (!controller.signal.aborted) { const outcome = await connectEvents(orgId, controller.signal, callback, onEvent) if (outcome === 'stop') return failures = outcome === 'ok' ? 0 : failures + 1 if (!controller.signal.aborted) await sleep(Math.min(EVENTS_MAX_BACKOFF_MS, 1000 * 2 ** failures)) } callback('CLOSED') } // Org-scoped CLI activity as server-sent events, exposing the realtime channel shape the console uses. function channel(name: string): RealtimeChannel { const orgId = name.replace(/^cli-events:org:/, '') let handler: (message: { payload: unknown }) => void = () => {} const controller = new AbortController() const subscription = { on(_kind: string, _filter: unknown, callback: (message: { payload: unknown }) => void) { handler = callback return subscription }, subscribe(callback: (status: string) => void) { void pumpEvents(orgId, controller, callback, payload => handler({ payload })) return subscription }, track: async (_presence: unknown) => 'ok', unsubscribe: async () => { controller.abort() return 'ok' }, } return subscription as unknown as RealtimeChannel } async function storageResult(path: string, options: ConsoleInvokeOptions): Promise<{ data: T | null, error: Error | null }> { const result = await invoke<{ data: T | null, error: Error | null }>(path, options) if (result.error) return { data: null, error: result.error } return result.data ?? { data: null, error: new Error('Empty storage response') } } return { auth, from: ((name: string) => query('table', name, [])) as ConsoleDataClient['from'], rpc: ((name: string, ...args: unknown[]) => query('rpc', name, args)) as ConsoleDataClient['rpc'], functions: { invoke }, channel, removeChannel: async (subscription: RealtimeChannel) => subscription.unsubscribe(), storage: { from(bucket: string) { if (bucket !== 'images') throw new Error('Unsupported console storage bucket') return { createSignedUrl: (path: string, expiresIn: number) => storageResult<{ signedUrl: string }>('private/console/images/sign', { body: { path, expiresIn } }), remove: (paths: string[]) => storageResult('private/console/images/remove', { body: { paths } }), upload: (path: string, file: Blob | ArrayBuffer, options?: { contentType?: string, upsert?: boolean }) => storageResult<{ path: string }>('private/console/images/upload', { body: file, headers: { 'x-image-path': path, 'x-image-upsert': String(options?.upsert ?? false), 'Content-Type': options?.contentType ?? 'application/octet-stream' }, }), } }, }, } } export type ConsoleClient = ConsoleDataClient & Pick, 'auth' | 'functions' | 'storage' | 'channel' | 'removeChannel'>