This commit is contained in:
@@ -19,6 +19,17 @@
|
||||
* pieced-tts-talk).
|
||||
* - channel — messaging integration.
|
||||
* - skill — ClawHub skill install.
|
||||
*
|
||||
* Custom provisioning (Threema):
|
||||
* The `threema` channel sets `requiresSecrets: false` because its
|
||||
* credentials are platform-issued, not customer-entered. Enabling
|
||||
* threema goes through a dedicated endpoint
|
||||
* (/api/tenants/:name/threema) that mints token + HMAC secret from
|
||||
* the central pieced-threema-gateway relay and writes them to OpenBao
|
||||
* at secret/data/tenants/<name>/threema-relay before the package is
|
||||
* added to spec.packages. Disabling reverses both steps. The
|
||||
* `customProvisioning` flag here tells the package-card UI to use
|
||||
* that endpoint instead of the standard /secrets+PATCH dance.
|
||||
*/
|
||||
|
||||
export interface PackageSecretField {
|
||||
@@ -38,6 +49,14 @@ export interface PackageDef {
|
||||
instructionsKey?: string;
|
||||
disclaimerKey?: string;
|
||||
category: PackageCategory;
|
||||
/**
|
||||
* When true, enabling/disabling this package goes through
|
||||
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
|
||||
* /secrets+PATCH flow. The handler at that path does platform-side
|
||||
* provisioning (mint credentials, register with sibling services, etc.)
|
||||
* that the customer is not aware of.
|
||||
*/
|
||||
customProvisioning?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
@@ -121,6 +140,21 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
{
|
||||
id: "threema",
|
||||
name: "Threema",
|
||||
descriptionKey: "packages.threema.description",
|
||||
// No customer-entered secrets. The token + hmac secret are minted
|
||||
// server-side by the relay's /admin/tokens endpoint when the
|
||||
// package is enabled, and stored in OpenBao by the portal. The
|
||||
// `customProvisioning` flag steers the PackageCard UI through the
|
||||
// dedicated /api/tenants/:name/threema endpoint instead.
|
||||
requiresSecrets: false,
|
||||
customProvisioning: true,
|
||||
instructionsKey: "packages.threema.instructions",
|
||||
disclaimerKey: "packages.threema.disclaimer",
|
||||
category: "channel",
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SKILLS
|
||||
|
||||
202
src/lib/threema-relay.ts
Normal file
202
src/lib/threema-relay.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user