/** * Admin client for the central pieced-threema-gateway relay. * * All calls authenticate with a static admin bearer (THREEMA_RELAY_ADMIN_TOKEN * from env, sourced from OpenBao at secret/data/threema-gateway/admin). The * portal is the only caller; never expose these functions through a public * HTTP surface. * * Resilience: every method returns a strongly-typed result rather than * throwing. Network errors surface as `{ ok: false, kind: 'network' }`, * 4xx/5xx as `{ ok: false, kind: 'http', status, ... }`. Callers that need * to compensate (e.g. delete a route after a K8s patch failure) can * inspect `kind` to decide whether retry is sensible. */ const RELAY_URL = (process.env.THREEMA_RELAY_URL ?? "").replace(/\/+$/, ""); const ADMIN_TOKEN = process.env.THREEMA_RELAY_ADMIN_TOKEN ?? ""; function assertConfigured(): void { if (!RELAY_URL) throw new Error("THREEMA_RELAY_URL not set"); if (!ADMIN_TOKEN) throw new Error("THREEMA_RELAY_ADMIN_TOKEN not set"); } type RelayError = | { ok: false; kind: "network"; message: string } | { ok: false; kind: "http"; status: number; message: string; body?: unknown }; export type RelayResult = ({ ok: true } & T) | RelayError; async function call( path: string, init: RequestInit, ): Promise> { assertConfigured(); try { const res = await fetch(`${RELAY_URL}${path}`, { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${ADMIN_TOKEN}`, "Content-Type": "application/json", }, }); let body: unknown = null; const text = await res.text().catch(() => ""); if (text) { try { body = JSON.parse(text); } catch { body = text; } } if (!res.ok) { const message = (body && typeof body === "object" && "error" in body && typeof (body as Record).error === "string" ? ((body as Record).error as string) : `HTTP ${res.status}`); return { ok: false, kind: "http", status: res.status, message, body }; } return { ok: true, ...(body as Record) } as RelayResult; } catch (e) { return { ok: false, kind: "network", message: (e as Error).message }; } } // --------------------------------------------------------------------------- // Tokens // --------------------------------------------------------------------------- /** Mint (or rotate) per-tenant bearer + HMAC secret. */ export function mintToken( tenantName: string, ): Promise> { return call("/admin/tokens", { method: "POST", body: JSON.stringify({ tenantName }), }); } /** * Revoke per-tenant token. Cascades — relay also deletes all routes * owned by this tenant. */ export function revokeToken( tenantName: string, ): Promise> { return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, { method: "DELETE", }); } export function tokenExists( tenantName: string, ): Promise> { return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, { method: "GET", }); } // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- export interface RouteRow { threema_id: string; tenant_name: string; created_at: string; created_by: string; } export function listRoutes( tenantName: string, ): Promise> { return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, { method: "GET", }); } /** * Create a route. The relay enforces uniqueness — on conflict returns * status 409 with body `{ ownedBy: | null }`. Callers * should inspect `kind === 'http'` + status 409 + body.ownedBy to * distinguish idempotent self-claim from real conflict. */ export function createRoute( tenantName: string, threemaId: string, ): Promise> { return call("/admin/routes", { method: "POST", body: JSON.stringify({ tenantName, threemaId }), }); } export function deleteRoute( tenantName: string, threemaId: string, ): Promise> { return call( `/admin/routes/${encodeURIComponent(threemaId)}?tenant=${encodeURIComponent(tenantName)}`, { method: "DELETE" }, ); } // --------------------------------------------------------------------------- // Usage // --------------------------------------------------------------------------- export interface UsageBreakdown { tenant: string; from: string; to: string; totals: { in: number; out: number }; daily: Array<{ day: string; direction: "in" | "out"; count: number }>; } export function getUsage( tenantName: string, from?: Date, to?: Date, ): Promise> { const qs = new URLSearchParams({ tenant: tenantName }); if (from) qs.set("from", from.toISOString()); if (to) qs.set("to", to.toISOString()); return call(`/admin/usage?${qs.toString()}`, { method: "GET" }); } // --------------------------------------------------------------------------- // Health // --------------------------------------------------------------------------- export function health(): Promise> { return call("/admin/health", { method: "GET" }); } // --------------------------------------------------------------------------- // Helpers for caller code // --------------------------------------------------------------------------- /** * Type guard: did this route-create fail because the ID is owned by * someone else? Distinguishes from "owned by SAME tenant" (idempotent). */ export function isRouteConflictForOtherTenant( result: RelayResult<{ ok: true }>, tenantName: string, ): boolean { if (result.ok) return false; if (result.kind !== "http" || result.status !== 409) return false; const body = result.body as { ownedBy?: string | null } | undefined; return !!body?.ownedBy && body.ownedBy !== tenantName; } export function isRouteConflictForSameTenant( result: RelayResult<{ ok: true }>, tenantName: string, ): boolean { if (result.ok) return false; if (result.kind !== "http" || result.status !== 409) return false; const body = result.body as { ownedBy?: string | null } | undefined; return body?.ownedBy === tenantName; }