Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f48712e42 |
97
scripts/verify-tenant-naming.mjs
Normal file
97
scripts/verify-tenant-naming.mjs
Normal file
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
132
src/lib/tenant-naming.ts
Normal file
132
src/lib/tenant-naming.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user