203 lines
6.3 KiB
TypeScript
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;
|
|
}
|