This commit is contained in:
70
deploy/README-threema.md
Normal file
70
deploy/README-threema.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Wiring Threema relay into the portal
|
||||||
|
|
||||||
|
Drop-in files in this archive:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
|
||||||
|
src/lib/threema-relay.ts # new — admin API client
|
||||||
|
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
|
||||||
|
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
|
||||||
|
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
|
||||||
|
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
|
||||||
|
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual steps after dropping in
|
||||||
|
|
||||||
|
1. `.env` (and `.env.example`) — add:
|
||||||
|
```
|
||||||
|
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
|
||||||
|
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
|
||||||
|
```
|
||||||
|
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
|
||||||
|
|
||||||
|
2. Patch the message files (one-time):
|
||||||
|
```bash
|
||||||
|
node deploy/patch-i18n-threema.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
|
||||||
|
`tenants/[name]/page.tsx` still derives the enabled-channels list
|
||||||
|
from it — it should now include `threema` automatically once a
|
||||||
|
tenant has it in `spec.packages`.
|
||||||
|
|
||||||
|
4. Type-check:
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow summary
|
||||||
|
|
||||||
|
### Enabling Threema for a tenant
|
||||||
|
1. Customer toggles the threema package card.
|
||||||
|
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
|
||||||
|
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
|
||||||
|
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||||
|
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
|
||||||
|
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
|
||||||
|
|
||||||
|
### Customer adds a Threema ID
|
||||||
|
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
|
||||||
|
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
|
||||||
|
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
|
||||||
|
4. On 409-from-other-tenant: 409 to client with explanation.
|
||||||
|
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
|
||||||
|
|
||||||
|
### Customer removes a Threema ID
|
||||||
|
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
|
||||||
|
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
|
||||||
|
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
|
||||||
|
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
|
||||||
|
|
||||||
|
### Disabling Threema for a tenant
|
||||||
|
1. Customer disables the threema card.
|
||||||
|
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
|
||||||
|
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
|
||||||
|
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
|
||||||
|
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
|
||||||
|
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
|
||||||
|
|
||||||
|
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.
|
||||||
80
deploy/patch-i18n-threema.mjs
Normal file
80
deploy/patch-i18n-threema.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Run: node deploy/patch-i18n-threema.mjs
|
||||||
|
*
|
||||||
|
* Idempotently injects:
|
||||||
|
* - packages.threema.{description, instructions, disclaimer}
|
||||||
|
* - channelUsers.threemaIdHelp
|
||||||
|
*
|
||||||
|
* into all four message files. Run from the pieced-portal repo root.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const i18n = {
|
||||||
|
en: {
|
||||||
|
pkg: {
|
||||||
|
description:
|
||||||
|
"Threema messaging routed through the PieCed central gateway. No Gateway account of your own required — PieCed mints credentials when you enable this package.",
|
||||||
|
instructions:
|
||||||
|
"1. Enable this package — PieCed provisions a central-gateway slot for your tenant.\n2. Add the Threema IDs you want to talk to under Authorized Users → threema.\n3. Each Threema ID can only belong to one PieCed tenant; if a registration fails, that ID is already in use elsewhere.",
|
||||||
|
disclaimer:
|
||||||
|
"Messages are end-to-end encrypted at the Threema boundary by the PieCed central gateway. Inbound and outbound message counts are logged per tenant for billing.",
|
||||||
|
},
|
||||||
|
channelHelp:
|
||||||
|
"Enter the 8-character Threema ID (uppercase letters and digits, no asterisk) of the person you want to talk to. The * prefix is for Gateway accounts, which PieCed manages on your behalf.",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
pkg: {
|
||||||
|
description:
|
||||||
|
"Threema-Messaging über das zentrale PieCed-Gateway. Sie benötigen kein eigenes Gateway-Konto — PieCed stellt die Anmeldedaten beim Aktivieren dieses Pakets bereit.",
|
||||||
|
instructions:
|
||||||
|
"1. Aktivieren Sie dieses Paket — PieCed richtet einen zentralen Gateway-Slot für Ihren Tenant ein.\n2. Fügen Sie die Threema-IDs, mit denen Sie kommunizieren wollen, unter Autorisierte Benutzer → threema hinzu.\n3. Jede Threema-ID kann nur einem PieCed-Tenant zugeordnet sein; wenn die Registrierung fehlschlägt, ist die ID bereits anderweitig vergeben.",
|
||||||
|
disclaimer:
|
||||||
|
"Die Nachrichten werden am Threema-Übergang vom zentralen PieCed-Gateway Ende-zu-Ende verschlüsselt. Eingehende und ausgehende Nachrichten werden pro Tenant für die Abrechnung gezählt.",
|
||||||
|
},
|
||||||
|
channelHelp:
|
||||||
|
"Geben Sie die 8-stellige Threema-ID (Großbuchstaben und Ziffern, ohne Sternchen) der Person ein, mit der Sie kommunizieren möchten. Das *-Präfix gehört zu Gateway-Konten, die PieCed für Sie verwaltet.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
pkg: {
|
||||||
|
description:
|
||||||
|
"Messagerie Threema via la passerelle centrale PieCed. Aucun compte Gateway personnel requis — PieCed génère les identifiants à l'activation du package.",
|
||||||
|
instructions:
|
||||||
|
"1. Activez ce package — PieCed approvisionne un slot de passerelle centrale pour votre tenant.\n2. Ajoutez les identifiants Threema avec lesquels vous souhaitez échanger sous Utilisateurs autorisés → threema.\n3. Chaque identifiant Threema ne peut appartenir qu'à un seul tenant PieCed ; si l'enregistrement échoue, l'identifiant est déjà utilisé ailleurs.",
|
||||||
|
disclaimer:
|
||||||
|
"Les messages sont chiffrés de bout en bout côté Threema par la passerelle centrale PieCed. Les volumes entrant et sortant sont consignés par tenant pour la facturation.",
|
||||||
|
},
|
||||||
|
channelHelp:
|
||||||
|
"Saisissez l'identifiant Threema à 8 caractères (lettres majuscules et chiffres, sans astérisque) de la personne avec qui vous souhaitez communiquer. Le préfixe * concerne les comptes Gateway, gérés par PieCed pour vous.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
pkg: {
|
||||||
|
description:
|
||||||
|
"Messaggistica Threema instradata tramite il gateway centrale PieCed. Non è necessario un account Gateway proprio — PieCed crea le credenziali quando attivi il pacchetto.",
|
||||||
|
instructions:
|
||||||
|
"1. Attiva questo pacchetto — PieCed predispone uno slot del gateway centrale per il tuo tenant.\n2. Aggiungi gli ID Threema con cui vuoi comunicare sotto Utenti autorizzati → threema.\n3. Ogni ID Threema può appartenere a un solo tenant PieCed; se la registrazione fallisce, l'ID è già usato altrove.",
|
||||||
|
disclaimer:
|
||||||
|
"I messaggi sono cifrati end-to-end al confine con Threema dal gateway centrale PieCed. I conteggi di messaggi in ingresso e uscita vengono registrati per tenant ai fini della fatturazione.",
|
||||||
|
},
|
||||||
|
channelHelp:
|
||||||
|
"Inserisci l'ID Threema di 8 caratteri (lettere maiuscole e cifre, senza asterisco) della persona con cui vuoi comunicare. Il prefisso * appartiene agli account Gateway, gestiti da PieCed per te.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [lang, entries] of Object.entries(i18n)) {
|
||||||
|
const path = `src/messages/${lang}.json`;
|
||||||
|
const json = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
|
||||||
|
json.packages = json.packages ?? {};
|
||||||
|
json.packages.threema = {
|
||||||
|
description: entries.pkg.description,
|
||||||
|
instructions: entries.pkg.instructions,
|
||||||
|
disclaimer: entries.pkg.disclaimer,
|
||||||
|
};
|
||||||
|
|
||||||
|
json.channelUsers = json.channelUsers ?? {};
|
||||||
|
json.channelUsers.threemaIdHelp = entries.channelHelp;
|
||||||
|
|
||||||
|
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
|
||||||
|
console.log(`Patched ${path} — added packages.threema and channelUsers.threemaIdHelp`);
|
||||||
|
}
|
||||||
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
|
import { getTenant } from "@/lib/k8s";
|
||||||
|
import {
|
||||||
|
writePackageSecrets,
|
||||||
|
deletePackageSecrets,
|
||||||
|
} from "@/lib/openbao";
|
||||||
|
import { mintToken, revokeToken } from "@/lib/threema-relay";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threema package provisioning — special-cased because the credentials
|
||||||
|
* are platform-issued (relay mints them), not customer-supplied.
|
||||||
|
*
|
||||||
|
* POST /api/tenants/:name/threema
|
||||||
|
* - Mints a per-tenant bearer + HMAC secret from the central relay.
|
||||||
|
* - Writes both to OpenBao under
|
||||||
|
* secret/data/tenants/<tenant-{name}>/threema-relay so the
|
||||||
|
* operator's ExternalSecret can sync them into the tenant
|
||||||
|
* namespace alongside other channel secrets.
|
||||||
|
* - Returns 200 on success. The caller (PackageCard) then PATCHes
|
||||||
|
* tenant.spec.packages to add "threema".
|
||||||
|
*
|
||||||
|
* DELETE /api/tenants/:name/threema
|
||||||
|
* - Revokes the per-tenant token at the relay (cascades to all
|
||||||
|
* routes — the relay's tokens.deleteToken also deletes routes).
|
||||||
|
* - Deletes the OpenBao secret so the ExternalSecret/operator can
|
||||||
|
* converge cleanly.
|
||||||
|
* - Returns 200 on success even if no token existed (idempotent).
|
||||||
|
*
|
||||||
|
* Failure semantics
|
||||||
|
* -----------------
|
||||||
|
* On POST: if minting succeeds but the OpenBao write fails, we attempt
|
||||||
|
* to revoke the just-minted token before returning the error. That way
|
||||||
|
* the relay doesn't keep an orphan token row that nothing can use.
|
||||||
|
* Best-effort cleanup; if the revoke also fails, the relay admin can
|
||||||
|
* use DELETE /admin/tokens/<name> manually.
|
||||||
|
*
|
||||||
|
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
|
||||||
|
* return the error and stop — leaving OpenBao alone means the pod's
|
||||||
|
* still-mounted secret keeps working in the brief window between
|
||||||
|
* "customer hits disable" and "operator reconciles spec without threema",
|
||||||
|
* which is more graceful than yanking the secret out from under a
|
||||||
|
* running pod.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VAULT_SUFFIX = "threema-relay";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!canMutate(user))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant(name);
|
||||||
|
if (!tenant)
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (
|
||||||
|
!user.isPlatform &&
|
||||||
|
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const minted = await mintToken(name);
|
||||||
|
if (!minted.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Relay mint failed: ${minted.message}` },
|
||||||
|
{ status: minted.kind === "http" ? 502 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
|
||||||
|
token: minted.token,
|
||||||
|
"hmac-secret": minted.hmacSecret,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Compensate: revoke the just-minted token so the relay doesn't
|
||||||
|
// hold an orphan. Best-effort — log and continue surfacing the
|
||||||
|
// original error.
|
||||||
|
const revoke = await revokeToken(name);
|
||||||
|
if (!revoke.ok) {
|
||||||
|
console.error(
|
||||||
|
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[threema/provision]", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Provisioning failed") },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!canMutate(user))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant(name);
|
||||||
|
if (!tenant)
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (
|
||||||
|
!user.isPlatform &&
|
||||||
|
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const revoke = await revokeToken(name);
|
||||||
|
// 404 from relay = nothing to revoke = idempotent success.
|
||||||
|
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Relay revoke failed: ${revoke.message}` },
|
||||||
|
{ status: revoke.kind === "http" ? 502 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
|
||||||
|
// tolerates 404.
|
||||||
|
try {
|
||||||
|
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
|
||||||
|
} catch (e) {
|
||||||
|
// Already revoked at the relay — surface the openbao failure
|
||||||
|
// but keep the partial-success state visible.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
|
||||||
|
partial: true,
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[threema/deprovision]", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Deprovisioning failed") },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
import {
|
||||||
|
createRoute,
|
||||||
|
deleteRoute,
|
||||||
|
isRouteConflictForOtherTenant,
|
||||||
|
isRouteConflictForSameTenant,
|
||||||
|
listRoutes,
|
||||||
|
} from "@/lib/threema-relay";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threema route management — keeps three places in sync:
|
||||||
|
*
|
||||||
|
* 1. Relay DB (`routes` table) — source of truth for uniqueness
|
||||||
|
* 2. K8s spec.channelUsers.threema — what the operator sees
|
||||||
|
* 3. Customer UI — derived from (2)
|
||||||
|
*
|
||||||
|
* Add (POST) order: relay first (to claim uniqueness atomically), then
|
||||||
|
* K8s. On K8s failure we compensate by deleting the relay route.
|
||||||
|
*
|
||||||
|
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
|
||||||
|
* is the customer-facing semantic that matters), then relay. On relay
|
||||||
|
* failure we DO NOT rollback K8s — the customer wanted it gone, and
|
||||||
|
* the relay's stale route will be cleaned up on the next retry (deletes
|
||||||
|
* are idempotent at the relay).
|
||||||
|
*
|
||||||
|
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
|
||||||
|
* spec.channelUsers.threema, which REPLACES the entire array. We GET
|
||||||
|
* the latest array, mutate it, then PATCH. Two concurrent adds from the
|
||||||
|
* same customer's tabs can lose one of them. Acceptable at pilot scale
|
||||||
|
* (single-digit customers, low concurrency); revisit with SSA + field
|
||||||
|
* managers if it ever bites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROUTE_BODY = z.object({
|
||||||
|
threemaId: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
|
||||||
|
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||||
|
if (!canMutate(user))
|
||||||
|
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||||
|
|
||||||
|
const tenant = await getTenant(name);
|
||||||
|
if (!tenant)
|
||||||
|
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
|
||||||
|
if (
|
||||||
|
!user.isPlatform &&
|
||||||
|
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||||
|
) {
|
||||||
|
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { tenant };
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentThreemaIds(tenantSpec: any): string[] {
|
||||||
|
const cu = tenantSpec?.channelUsers ?? {};
|
||||||
|
const ids = cu.threema;
|
||||||
|
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GET ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
const { name } = await params;
|
||||||
|
const loaded = await loadTenantOrError(name, user);
|
||||||
|
if (loaded.error) return loaded.error;
|
||||||
|
|
||||||
|
const res = await listRoutes(name);
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Relay list failed: ${res.message}` },
|
||||||
|
{ status: res.kind === "http" ? 502 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ routes: res.routes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- POST -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
const { name } = await params;
|
||||||
|
const loaded = await loadTenantOrError(name, user);
|
||||||
|
if (loaded.error) return loaded.error;
|
||||||
|
const tenant = loaded.tenant;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
const parsed = ROUTE_BODY.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const threemaId = parsed.data.threemaId;
|
||||||
|
|
||||||
|
// Step 1: claim at the relay. Uniqueness is enforced here.
|
||||||
|
const claim = await createRoute(name, threemaId);
|
||||||
|
if (!claim.ok) {
|
||||||
|
if (isRouteConflictForOtherTenant(claim, name)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "This Threema ID is already registered to another tenant" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRouteConflictForSameTenant(claim, name)) {
|
||||||
|
// Genuine non-409 failure
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Relay create failed: ${claim.message}` },
|
||||||
|
{ status: claim.kind === "http" ? claim.status : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
|
||||||
|
const existing = currentThreemaIds(tenant!.spec);
|
||||||
|
if (!existing.includes(threemaId)) {
|
||||||
|
const next = [...existing, threemaId];
|
||||||
|
try {
|
||||||
|
await patchTenantSpec(name, {
|
||||||
|
channelUsers: {
|
||||||
|
...(tenant!.spec?.channelUsers ?? {}),
|
||||||
|
threema: next,
|
||||||
|
} as Record<string, string[]>,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Compensate: drop the relay route so we don't leave an orphan.
|
||||||
|
const compensate = await deleteRoute(name, threemaId);
|
||||||
|
if (!compensate.ok) {
|
||||||
|
console.error(
|
||||||
|
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, threemaId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DELETE ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
const { name } = await params;
|
||||||
|
const loaded = await loadTenantOrError(name, user);
|
||||||
|
if (loaded.error) return loaded.error;
|
||||||
|
const tenant = loaded.tenant;
|
||||||
|
|
||||||
|
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
|
||||||
|
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
|
||||||
|
const parsed = ROUTE_BODY.safeParse({ threemaId });
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: drop from K8s (idempotent).
|
||||||
|
const existing = currentThreemaIds(tenant!.spec);
|
||||||
|
if (existing.includes(parsed.data.threemaId)) {
|
||||||
|
const next = existing.filter((id) => id !== parsed.data.threemaId);
|
||||||
|
try {
|
||||||
|
await patchTenantSpec(name, {
|
||||||
|
channelUsers: {
|
||||||
|
...(tenant!.spec?.channelUsers ?? {}),
|
||||||
|
threema: next,
|
||||||
|
} as Record<string, string[]>,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: drop at relay (also idempotent — 404 is fine).
|
||||||
|
const dropped = await deleteRoute(name, parsed.data.threemaId);
|
||||||
|
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
|
||||||
|
// K8s is already updated; surface but don't rollback. Next time the
|
||||||
|
// user toggles, both will converge.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
threemaId: parsed.data.threemaId,
|
||||||
|
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
|
||||||
|
}
|
||||||
@@ -8,12 +8,26 @@ import { useRouter } from "next/navigation";
|
|||||||
const CHANNEL_ID_HELP: Record<string, string> = {
|
const CHANNEL_ID_HELP: Record<string, string> = {
|
||||||
telegram: "telegramIdHelp",
|
telegram: "telegramIdHelp",
|
||||||
discord: "discordIdHelp",
|
discord: "discordIdHelp",
|
||||||
|
threema: "threemaIdHelp",
|
||||||
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
|
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
|
||||||
// the `mail` skill (category=skill, not channel), so it never appears
|
// the `mail` skill (category=skill, not channel), so it never appears
|
||||||
// in `enabledChannels`. If a future channel is added to the catalog,
|
// in `enabledChannels`. If a future channel is added to the catalog,
|
||||||
// give it an entry here so the help blurb renders.
|
// give it an entry here so the help blurb renders.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels whose user list is managed through a dedicated endpoint
|
||||||
|
* instead of the generic PATCH /api/tenants/:name flow.
|
||||||
|
*
|
||||||
|
* Threema is the only one today — adding/removing a Threema ID has to
|
||||||
|
* synchronise with the central pieced-threema-gateway relay's `routes`
|
||||||
|
* table (uniqueness enforced there, not in K8s). The
|
||||||
|
* /api/tenants/:name/threema/routes endpoint owns that two-step
|
||||||
|
* coordination (relay first, then K8s, with compensation on K8s
|
||||||
|
* failure). Other channels just patch the K8s spec directly.
|
||||||
|
*/
|
||||||
|
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
|
||||||
|
|
||||||
interface ChannelUsersProps {
|
interface ChannelUsersProps {
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
|
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
|
||||||
@@ -63,6 +77,70 @@ export function ChannelUsers({
|
|||||||
[tenantName, router]
|
[tenantName, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threema (and any future relay-managed channel) uses a dedicated
|
||||||
|
* endpoint that synchronises the central relay's routes table with
|
||||||
|
* the K8s spec atomically. We call it per-ID rather than sending the
|
||||||
|
* whole array because uniqueness is enforced ID-by-ID at the relay,
|
||||||
|
* and the error UX of "this ID is taken" is per-add.
|
||||||
|
*/
|
||||||
|
const addToRelayChannel = useCallback(
|
||||||
|
async (channel: string, threemaId: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/tenants/${tenantName}/${channel}/routes`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ threemaId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
setChannelUsers((prev) => {
|
||||||
|
const current = prev[channel] ?? [];
|
||||||
|
if (current.includes(threemaId)) return prev;
|
||||||
|
return { ...prev, [channel]: [...current, threemaId] };
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tenantName, router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeFromRelayChannel = useCallback(
|
||||||
|
async (channel: string, threemaId: string) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
setChannelUsers((prev) => {
|
||||||
|
const current = prev[channel] ?? [];
|
||||||
|
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tenantName, router]
|
||||||
|
);
|
||||||
|
|
||||||
const handleAdd = useCallback(
|
const handleAdd = useCallback(
|
||||||
(channel: string) => {
|
(channel: string) => {
|
||||||
const userId = inputValues[channel]?.trim();
|
const userId = inputValues[channel]?.trim();
|
||||||
@@ -74,18 +152,27 @@ export function ChannelUsers({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInputValues((prev) => ({ ...prev, [channel]: "" }));
|
||||||
|
|
||||||
|
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||||
|
void addToRelayChannel(channel, userId);
|
||||||
|
} else {
|
||||||
const updated = {
|
const updated = {
|
||||||
...channelUsers,
|
...channelUsers,
|
||||||
[channel]: [...current, userId],
|
[channel]: [...current, userId],
|
||||||
};
|
};
|
||||||
setInputValues((prev) => ({ ...prev, [channel]: "" }));
|
|
||||||
updateChannelUsers(updated);
|
updateChannelUsers(updated);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[channelUsers, inputValues, updateChannelUsers, t]
|
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemove = useCallback(
|
const handleRemove = useCallback(
|
||||||
(channel: string, userId: string) => {
|
(channel: string, userId: string) => {
|
||||||
|
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||||
|
void removeFromRelayChannel(channel, userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const current = channelUsers[channel] || [];
|
const current = channelUsers[channel] || [];
|
||||||
const updated = {
|
const updated = {
|
||||||
...channelUsers,
|
...channelUsers,
|
||||||
@@ -93,7 +180,7 @@ export function ChannelUsers({
|
|||||||
};
|
};
|
||||||
updateChannelUsers(updated);
|
updateChannelUsers(updated);
|
||||||
},
|
},
|
||||||
[channelUsers, updateChannelUsers]
|
[channelUsers, updateChannelUsers, removeFromRelayChannel]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (enabledChannels.length === 0) return null;
|
if (enabledChannels.length === 0) return null;
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ export function PackageCard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleEnable() {
|
async function handleEnable() {
|
||||||
|
if (pkg.customProvisioning) {
|
||||||
|
// Platform-side provisioning, then add to packages list.
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!provRes.ok) {
|
||||||
|
const err = await provRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||||
|
}
|
||||||
|
await togglePackage(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pkg.requiresSecrets) {
|
if (pkg.requiresSecrets) {
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
setSecrets({});
|
setSecrets({});
|
||||||
@@ -40,6 +60,34 @@ export function PackageCard({
|
|||||||
await togglePackage(true);
|
await togglePackage(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (pkg.customProvisioning) {
|
||||||
|
// Revoke platform-side credentials FIRST so the relay drops
|
||||||
|
// routes before the operator removes the channel from the
|
||||||
|
// OpenClaw config. Partial-success (token revoked, OpenBao
|
||||||
|
// delete failed) returns 503 with partial=true and we surface
|
||||||
|
// the error rather than continuing — the secret may still be
|
||||||
|
// valid in OpenBao and rolling back the relay revoke isn't
|
||||||
|
// possible (it cascaded to routes).
|
||||||
|
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!deprovRes.ok) {
|
||||||
|
const err = await deprovRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await togglePackage(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePackage(enable: boolean) {
|
async function togglePackage(enable: boolean) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +172,7 @@ export function PackageCard({
|
|||||||
)}
|
)}
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<button
|
<button
|
||||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
onClick={enabled ? handleDisable : handleEnable}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||||
enabled
|
enabled
|
||||||
|
|||||||
@@ -19,6 +19,17 @@
|
|||||||
* pieced-tts-talk).
|
* pieced-tts-talk).
|
||||||
* - channel — messaging integration.
|
* - channel — messaging integration.
|
||||||
* - skill — ClawHub skill install.
|
* - 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 {
|
export interface PackageSecretField {
|
||||||
@@ -38,6 +49,14 @@ export interface PackageDef {
|
|||||||
instructionsKey?: string;
|
instructionsKey?: string;
|
||||||
disclaimerKey?: string;
|
disclaimerKey?: string;
|
||||||
category: PackageCategory;
|
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[] = [
|
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||||
@@ -121,6 +140,21 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
disclaimerKey: "packages.discord.disclaimer",
|
disclaimerKey: "packages.discord.disclaimer",
|
||||||
category: "channel",
|
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
|
// 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