/** * 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}`; }