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>) { 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, }); } 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, }); } 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 }); }