/** * Personal-account helpers. * * Two ZITADEL org-name formats may identify a personal account: * * 1. Legacy (Slice 4 .. 7-pre-Bug9): * "{givenName} {familyName} (Personal)" * Embedded the user's name in the org name. Hit a uniqueness * collision on common Swiss names (Bug 9: two people named "Eva * Müller" can't both register). Suffix is detected via * `PERSONAL_ORG_SUFFIX`. * * 2. Current (Slice 7+): * "personal-{8 hex chars}" * Opaque, structurally collision-free, no PII. The user's display * name lives only in the per-user fields (`session.user.name`), * which is what the GUI shows wherever it would otherwise have * shown the org name. See `displayOrgNameFor()` below. * * Both formats are recognised as personal by `isPersonalOrgName()`. * Existing legacy orgs continue to work; new orgs are created in the * opaque format. * * Why a name pattern and not ZITADEL org metadata? * ------------------------------------------------ * - Visible in ZITADEL Console, JWT claims, admin tools — useful debug * signal at zero cost. * - Customers cannot rename their own org (requires IAM_OWNER, which * only the SA holds), so the marker is stable for the life of the * org. * - No extra ZITADEL API calls at onboarding time. * - No extra portal DB tables. * * Trade-off: an admin who manually renames a personal org via Console * could remove the marker. That's a deliberate destructive action; the * worst outcome is a misnamed K8s CR. Nothing breaks. */ /** Suffix used by the legacy " (Personal)" naming scheme. */ export const PERSONAL_ORG_SUFFIX = " (Personal)"; /** * Pattern for the current opaque-id naming scheme. The hex chunk is * generated from `crypto.randomUUID()` — eight hex digits give 4 billion * distinct values, far more than the pilot will ever need, while * keeping the org name short and copy-pasteable. */ const PERSONAL_ORG_OPAQUE_RE = /^personal-[0-9a-f]{8}$/; /** * Generate a fresh opaque org name for a personal account. * * The result is uniformly random in the form "personal-XXXXXXXX". Caller * doesn't need a duplicate check — at 4e9 cardinality the birthday * collision probability is negligible at pilot scale, and ZITADEL would * reject a duplicate creation with a clean error which we let surface. * * `crypto.randomUUID()` is used because it's available natively in * Node 20+ and edge runtimes. We slice the hex digits we need from * the UUID rather than calling a separate randomBytes API; the result * is the same. */ export function generatePersonalOrgName(): string { const uuid = crypto.randomUUID(); // 8-4-4-4-12 hex digits const hex = uuid.replace(/-/g, "").slice(0, 8); return `personal-${hex}`; } /** * Returns true when the given ZITADEL org name marks a personal account. * * Recognises both the legacy " (Personal)" suffix and the current * "personal-{8hex}" opaque form. Whitespace inside the legacy suffix is * significant — `" (personal)"` lowercase or `"(Personal)"` without the * leading space are NOT matches and are treated as company orgs. * * Pass `session.orgName` from the SessionUser at the call site. */ export function isPersonalOrgName( orgName: string | null | undefined ): boolean { if (!orgName) return false; const trimmed = orgName.trimEnd(); if (PERSONAL_ORG_OPAQUE_RE.test(trimmed)) return true; if (trimmed.endsWith(PERSONAL_ORG_SUFFIX)) return true; return false; } /** * The label to show wherever the GUI would otherwise show the user's * org name. For company accounts this is the org name; for personal * accounts the org name itself is opaque (or a synthetic legacy * "Name (Personal)" string), so we substitute the user's display name. * * Use this anywhere a customer-facing string would render the * organisation: nav header, billing forms, SOUL.md interpolation, etc. */ export function displayOrgNameFor(user: { name?: string | null; email?: string | null; orgName?: string | null; isPersonal?: boolean; }): string { const orgName = user.orgName ?? ""; // Defensive: if `isPersonal` wasn't set on the session (older sessions // pre-Slice-7-Bug-9), fall back to detecting from the name itself. const personal = user.isPersonal ?? isPersonalOrgName(orgName); if (!personal) return orgName; // Legacy legacy "Name (Personal)" — strip the suffix and use what's // left as a sensible display, since it's already the user's name. if (orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX)) { return orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim(); } // New opaque form — show the user's display name. Fall back to email // local-part if no display name is available, which is rare but // possible during the brief window between user creation and the // user setting their profile. if (user.name && user.name.trim().length > 0) return user.name.trim(); if (user.email) return user.email.split("@")[0]; return orgName; } /** * One-instance-per-account rule for personal accounts (Bug 5). * * Personal accounts are 1-instance by design: a single user, a single * tenant. After the first tenant or in-flight request exists, the * customer is over quota and any further onboarding submission must * be blocked. Company accounts are unaffected. * * `tenantCount` and `requestCount` are measured against the customer's * own org — caller is responsible for filtering before passing them * in. Both values are non-negative integers; the predicate is true * iff at least one of them is > 0. * * Used by the dashboard (hide the "+ Create new instance" button), * /dashboard/new (server-redirect), and /api/onboarding (return 403). * Keeping the rule in one place avoids three separate copies of the * same boolean drifting apart. */ export function personalAccountAtCapacity( isPersonal: boolean, tenantCount: number, requestCount: number ): boolean { return isPersonal && (tenantCount > 0 || requestCount > 0); }