Files
pieced-portal/src/lib/threema-relay.ts
admin 85c4302f7a
All checks were successful
Build and Push / build (push) Successful in 1m30s
Threema Gateway
2026-05-16 22:00:27 +02:00

203 lines
6.3 KiB
TypeScript

/**
* 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<T> = ({ ok: true } & T) | RelayError;
async function call<T>(
path: string,
init: RequestInit,
): Promise<RelayResult<T>> {
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<string, unknown>).error === "string"
? ((body as Record<string, unknown>).error as string)
: `HTTP ${res.status}`);
return { ok: false, kind: "http", status: res.status, message, body };
}
return { ok: true, ...(body as Record<string, unknown>) } as RelayResult<T>;
} 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<RelayResult<{ token: string; hmacSecret: string }>> {
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<RelayResult<{ deletedRoutes: number }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "DELETE",
});
}
export function tokenExists(
tenantName: string,
): Promise<RelayResult<{ exists: true }>> {
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<RelayResult<{ routes: RouteRow[] }>> {
return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
/**
* Create a route. The relay enforces uniqueness — on conflict returns
* status 409 with body `{ ownedBy: <tenantName> | 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<RelayResult<{ ok: true }>> {
return call("/admin/routes", {
method: "POST",
body: JSON.stringify({ tenantName, threemaId }),
});
}
export function deleteRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
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<RelayResult<UsageBreakdown>> {
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<RelayResult<{ credits: number }>> {
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;
}