diff --git a/scripts/verify-tenant-naming.mjs b/scripts/verify-tenant-naming.mjs new file mode 100644 index 0000000..5cacec3 --- /dev/null +++ b/scripts/verify-tenant-naming.mjs @@ -0,0 +1,97 @@ +// Standalone JS port of deriveTenantName for offline verification. +// Mirror lib/tenant-naming.ts byte-for-byte logic. + +const MAX_NAMESPACE_LEN = 63; +const NAMESPACE_PREFIX = "tenant-"; +const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; +const SUFFIX_HEX_LEN = 8; +const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; +const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; + +function slugify(input) { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function requestIdSuffix(requestId) { + const hex = requestId.replace(/-/g, "").toLowerCase(); + if (!/^[0-9a-f]{8}/.test(hex)) { + throw new Error(`Invalid request id: ${requestId}`); + } + return hex.slice(0, SUFFIX_HEX_LEN); +} + +function deriveTenantName(kind, companyName, requestId) { + const suffix = requestIdSuffix(requestId); + if (kind === "personal") return `p-${suffix}`; + const rawSlug = slugify(companyName); + const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, ""); + if (!slug) return `t-${suffix}`; + return `${slug}-${suffix}`; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const cases = [ + // [kind, companyName, requestId, expected, note] + ["company", "Acme GmbH", "abc12345-1234-1234-1234-123456789abc", "acme-gmbh-abc12345", "basic company"], + ["company", "Müller AG", "abc12345-aaaa", "m-ller-ag-abc12345", "umlaut → '-'"], + ["company", "!!!", "abc12345-aaaa", "t-abc12345", "no alnum → 't-' fallback"], + ["personal", "irrelevant", "abc12345-aaaa", "p-abc12345", "personal ignores companyName"], + ["personal", "", "abc12345-aaaa", "p-abc12345", "personal with empty companyName"], + ["company", " Trim Me ", "abc12345-aaaa", "trim-me-abc12345", "leading/trailing whitespace"], + ["company", "Foo---Bar", "abc12345-aaaa", "foo-bar-abc12345", "consecutive hyphens collapse"], + ["company", "A very long company name that absolutely will exceed the slug limit easily", "abc12345-aaaa", null, "must be <= 56 chars"], + ["company", "----", "abc12345-aaaa", "t-abc12345", "all-hyphen → fallback"], + ["company", "ACME", "ABCDEF12-...", "acme-abcdef12", "uppercase UUID is lowercased"], +]; + +let pass = 0, fail = 0; +for (const [kind, name, id, expected, note] of cases) { + let got; + let err = null; + try { + got = deriveTenantName(kind, name, id); + } catch (e) { + err = e.message; + } + + // Special length-only cases + if (expected === null) { + const ok = got && got.length <= 56; + console.log(`${ok ? "PASS" : "FAIL"} len(${got}) = ${got?.length} [${note}]`); + if (ok) pass++; else fail++; + continue; + } + + if (err) { + console.log(`THROW ${err} [${note}]`); + if (expected === "throw") pass++; else fail++; + continue; + } + + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`); + if (ok) pass++; else fail++; +} + +// Should-throw cases +console.log("\nThrow cases:"); +const throwCases = [ + ["company", "Acme", "", "empty requestId"], + ["company", "Acme", "xyz", "non-hex requestId"], + ["company", "Acme", "1234567", "too short (7 chars)"], +]; +for (const [kind, name, id, note] of throwCases) { + let threw = false; + try { deriveTenantName(kind, name, id); } catch { threw = true; } + console.log(`${threw ? "PASS" : "FAIL"} threw=${threw} [${note}]`); + if (threw) pass++; else fail++; +} + +console.log(`\n${pass} pass, ${fail} fail`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 1d842c6..dfef446 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -14,6 +14,7 @@ import { getDefaultAgentsMd, generateToolsMd, } from "@/lib/workspace-defaults"; +import { deriveTenantName } from "@/lib/tenant-naming"; import { safeError } from "@/lib/errors"; /** @@ -61,13 +62,14 @@ export async function POST( const isReApproval = tenantRequest.status === "rejected"; - // Derive tenant name from company name: lowercase, alphanumeric + hyphens - const tenantName = - tenantRequest.companyName - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`; + // Build the CR name: see `lib/tenant-naming.ts` for the format spec. + // For now all approvals are kind="company" — the personal branch is + // wired but unused until Slice 4 introduces the `is_personal` column. + const tenantName = deriveTenantName( + "company", + tenantRequest.companyName, + tenantRequest.id + ); try { // Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard) diff --git a/src/lib/tenant-naming.ts b/src/lib/tenant-naming.ts new file mode 100644 index 0000000..8bf6292 --- /dev/null +++ b/src/lib/tenant-naming.ts @@ -0,0 +1,132 @@ +/** + * Deterministic tenant-name derivation for PiecedTenant CRs. + * + * Background + * ---------- + * Every PiecedTenant CR's `metadata.name` becomes part of the tenant + * namespace, which the operator builds as `tenant-{name}` (see + * `pieced-operator/api/v1alpha1/piecedtenant_types.go::NamespaceName`). + * Kubernetes namespace names follow the RFC 1123 DNS *label* spec: + * lowercased alphanumeric + hyphens, must start and end with alnum, + * and **max 63 characters**. + * + * That gives us 63 - len("tenant-") = 56 chars to play with for the CR + * name itself. Anything longer is rejected at apply time, so we cap + * here. + * + * Format + * ------ + * kind=company → {slug}-{requestIdHex8} e.g. "acme-gmbh-abc12345" + * kind=personal → p-{requestIdHex8} e.g. "p-abc12345" + * + * The 8-hex-char suffix is taken from `tenant_requests.id` (a Postgres + * `gen_random_uuid()` value, set at row insert). Two motivations: + * + * 1. Uniqueness — multiple requests for the same company name no longer + * collide (this is what unblocks Slice 3, multi-tenant per org). + * 2. Stability — the suffix is known at approval time and never changes, + * so the operator and portal agree without coordination. We use the + * request UUID rather than the eventual LiteLLM virtual-key UUID + * because the latter doesn't exist until the operator runs. + * + * 8 hex chars = 32 bits of entropy. Collision probability with 100 active + * tenants per company prefix is ~1e-6; for our pilot scale that's fine. + * + * Limits + * ------ + * Suffix is always 8 + 1 (hyphen) = 9 chars. Slug therefore caps at + * 56 - 9 = 47 chars, then we strip any trailing hyphens left by the cut. + * + * Examples + * -------- + * deriveTenantName("company", "Acme GmbH", "abc12345-...") = "acme-gmbh-abc12345" + * deriveTenantName("company", "Müller AG", "abc12345-...") = "m-ller-ag-abc12345" (umlaut → "-") + * deriveTenantName("company", "!!!", "abc12345-...") = "t-abc12345" (slug empty → "t-") + * deriveTenantName("personal", "", "abc12345-...") = "p-abc12345" + */ + +export type TenantKind = "company" | "personal"; + +const MAX_NAMESPACE_LEN = 63; +const NAMESPACE_PREFIX = "tenant-"; +const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; // 56 +const SUFFIX_HEX_LEN = 8; +const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; // including the joining "-" +const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; // 47 + +export class InvalidRequestIdError extends Error { + constructor(requestId: string) { + super( + `Cannot derive tenant name: requestId "${requestId}" does not contain ${SUFFIX_HEX_LEN} hex characters` + ); + this.name = "InvalidRequestIdError"; + } +} + +/** + * Reduce an arbitrary string to a DNS-label-safe slug. Non-alnum runs + * collapse to a single "-"; leading/trailing hyphens are stripped. + * + * Note this does not transliterate Unicode — "Müller" becomes "m-ller", + * not "mueller". That's deliberate: transliteration introduces locale + * dependencies (de-DE vs de-CH vs sv-SE all disagree on ä→a/ä→ae) and + * we'd rather have a stable, ugly slug than a pretty one that changes + * if we touch the locale config later. Customers see the human-readable + * `displayName`, not the slug. + */ +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * Extract the first 8 hex chars of a UUID string. Strips hyphens and + * lowercases first so callers can pass either "abc12345-..." or + * "ABC12345..." form. Postgres `gen_random_uuid()` already emits the + * canonical lowercase-hyphenated form, so this is just defense in depth + * against any hand-inserted IDs. + */ +function requestIdSuffix(requestId: string): string { + const hex = requestId.replace(/-/g, "").toLowerCase(); + if (!/^[0-9a-f]{8}/.test(hex)) { + throw new InvalidRequestIdError(requestId); + } + return hex.slice(0, SUFFIX_HEX_LEN); +} + +/** + * Build the PiecedTenant CR `metadata.name` for an approved tenant request. + * + * @param kind "company" for normal customer accounts; "personal" + * for individual accounts (Slice 4 — `is_personal=true`). + * @param companyName Raw display name from the registration. Ignored when + * kind="personal". + * @param requestId `tenant_requests.id` (Postgres UUID). + * @returns A K8s-safe CR name, ≤ 56 chars, with an 8-hex suffix. + */ +export function deriveTenantName( + kind: TenantKind, + companyName: string, + requestId: string +): string { + const suffix = requestIdSuffix(requestId); + + if (kind === "personal") { + return `p-${suffix}`; + } + + // Company branch: slug-{suffix}, with empty-slug fallback. + const rawSlug = slugify(companyName); + + // Cap then re-trim — slicing might leave a dangling hyphen if a non-alnum + // run sat right at the boundary (e.g. "acme-foo-bar-..." cut to "acme-foo-"). + const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, ""); + + if (!slug) { + return `t-${suffix}`; + } + + return `${slug}-${suffix}`; +}