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> = {
|
||||
telegram: "telegramIdHelp",
|
||||
discord: "discordIdHelp",
|
||||
threema: "threemaIdHelp",
|
||||
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
|
||||
// the `mail` skill (category=skill, not channel), so it never appears
|
||||
// in `enabledChannels`. If a future channel is added to the catalog,
|
||||
// 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 {
|
||||
tenantName: string;
|
||||
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
|
||||
@@ -63,6 +77,70 @@ export function ChannelUsers({
|
||||
[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(
|
||||
(channel: string) => {
|
||||
const userId = inputValues[channel]?.trim();
|
||||
@@ -74,18 +152,27 @@ export function ChannelUsers({
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
setInputValues((prev) => ({ ...prev, [channel]: "" }));
|
||||
updateChannelUsers(updated);
|
||||
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void addToRelayChannel(channel, userId);
|
||||
} else {
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
[channel]: [...current, userId],
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
}
|
||||
},
|
||||
[channelUsers, inputValues, updateChannelUsers, t]
|
||||
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(channel: string, userId: string) => {
|
||||
if (RELAY_MANAGED_CHANNELS.has(channel)) {
|
||||
void removeFromRelayChannel(channel, userId);
|
||||
return;
|
||||
}
|
||||
const current = channelUsers[channel] || [];
|
||||
const updated = {
|
||||
...channelUsers,
|
||||
@@ -93,7 +180,7 @@ export function ChannelUsers({
|
||||
};
|
||||
updateChannelUsers(updated);
|
||||
},
|
||||
[channelUsers, updateChannelUsers]
|
||||
[channelUsers, updateChannelUsers, removeFromRelayChannel]
|
||||
);
|
||||
|
||||
if (enabledChannels.length === 0) return null;
|
||||
|
||||
@@ -30,6 +30,26 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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) {
|
||||
setShowModal(true);
|
||||
setSecrets({});
|
||||
@@ -40,6 +60,34 @@ export function PackageCard({
|
||||
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) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -124,7 +172,7 @@ export function PackageCard({
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
|
||||
@@ -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