Group B fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-29 15:43:12 +02:00
parent c7df5c83a4
commit eeef108f7e
18 changed files with 387 additions and 82 deletions

View File

@@ -1,40 +1,147 @@
/**
* Personal-account helpers.
*
* Slice 4 establishes the convention that ZITADEL org names for personal
* accounts end with the literal " (Personal)" suffix. This file
* centralises the suffix and the predicate so both registration (which
* sets the suffix) and onboarding (which reads it from the session) use
* the same canonical form.
* Two ZITADEL org-name formats may identify a personal account:
*
* Why a name suffix and not ZITADEL org metadata?
* -----------------------------------------------
* 1. The suffix is visible in ZITADEL Console, admin tools, JWT claims,
* etc. — useful debugging signal at zero cost.
* 2. Customers cannot rename their own org (requires IAM_OWNER, which
* only the SA holds), so the suffix is stable for the lifetime of
* the org.
* 3. No extra ZITADEL API calls at onboarding time to fetch metadata.
* 4. No extra portal DB tables.
* 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`.
*
* The trade-off: an admin who manually renames a personal org via
* ZITADEL Console could remove the suffix, after which onboarding
* would treat that org as a company. That's a deliberate destructive
* action and the worst outcome is a misnamed K8s CR; nothing breaks.
* 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.
*
* The check is exact-suffix match (after trimming). Whitespace inside
* the suffix is significant — `" (personal)"` lowercase or `"(Personal)"`
* without the leading space are not matches and not personal orgs.
* 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 {
export function isPersonalOrgName(
orgName: string | null | undefined
): boolean {
if (!orgName) return false;
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
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);
}