218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
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 });
|
|
}
|