/** * Domain-uniqueness check for company registration. * * Goal: prevent two people from the same company creating two separate * ZITADEL orgs. If alice@acme.ch registers Acme GmbH, then later * bob@acme.ch tries to register Acme Holding AG, we should block bob and * tell him to ask alice for an invite. * * Strategy: * 1. Extract the domain from the submitted email address. * 2. If the domain is in PUBLIC_EMAIL_DOMAINS, skip the check entirely * (gmail/outlook/etc. are not company identifiers — many independent * personal/sole-proprietor registrations may share gmail.com). * 3. Otherwise, look up tenant_requests with status NOT IN * ('rejected', 'deleted'). A domain is "in use" if any active row's * contact_email shares that domain. * 4. As a secondary check, query ZITADEL for orgs whose primary verified * domain matches. This catches orgs created outside the portal flow * (manually in ZITADEL console, or by an earlier bootstrap script). * The primary-domain check is BEST-EFFORT — if ZITADEL is unreachable * or returns an unexpected shape, we log and skip. The DB check is * authoritative for portal-created orgs and that's what matters most. * * Returns the matching domain (lowercased) if a duplicate is found, else * null. The caller turns that into a 409 response with a localized error. */ import { Pool } from "pg"; // --------------------------------------------------------------------------- // Public email-provider blocklist // --------------------------------------------------------------------------- /** * Domains where personal accounts dominate. Registrations from these are * allowed to coexist independently — we don't treat "two gmail.com users" * as the same company. * * Conservative list focused on Switzerland + major international providers. * Adding to this list reduces false positives; removing increases them. * Anything not on this list is treated as a corporate domain. */ export const PUBLIC_EMAIL_DOMAINS: ReadonlySet = new Set([ // Global "gmail.com", "googlemail.com", "outlook.com", "outlook.de", "hotmail.com", "hotmail.de", "hotmail.fr", "hotmail.it", "live.com", "msn.com", "yahoo.com", "yahoo.de", "yahoo.fr", "yahoo.it", "icloud.com", "me.com", "mac.com", "proton.me", "protonmail.com", "pm.me", "tutanota.com", "tutanota.de", "tuta.io", "fastmail.com", "zoho.com", "aol.com", // Switzerland "bluewin.ch", "gmx.ch", "gmx.com", "gmx.net", "gmx.de", "gmx.at", "hispeed.ch", "sunrise.ch", "swissonline.ch", "vtxnet.ch", "vtx.ch", "tele2.ch", "freesurf.ch", "bluemail.ch", "hotmail.ch", "yahoo.ch", "mail.ch", // Germany / Austria (common in DACH region) "web.de", "t-online.de", "freenet.de", "1und1.de", "aon.at", // France / Italy "orange.fr", "free.fr", "laposte.net", "wanadoo.fr", "sfr.fr", "libero.it", "tiscali.it", "alice.it", "virgilio.it", ]); // --------------------------------------------------------------------------- // Domain extraction // --------------------------------------------------------------------------- /** * Extract the lowercased domain from an email address. Returns null if the * input is not a well-formed email (defense in depth — Zod already validates * the format upstream). */ export function extractEmailDomain(email: string): string | null { const at = email.lastIndexOf("@"); if (at === -1 || at === email.length - 1) return null; const domain = email.slice(at + 1).trim().toLowerCase(); if (!domain || !domain.includes(".")) return null; return domain; } /** * True if the domain belongs to a public email provider where multiple * independent registrations should be allowed. */ export function isPublicEmailDomain(domain: string): boolean { return PUBLIC_EMAIL_DOMAINS.has(domain.toLowerCase()); } // --------------------------------------------------------------------------- // Database check // --------------------------------------------------------------------------- /** * Look up active tenant_requests whose contact_email shares the given domain. * Active = status NOT IN ('rejected', 'deleted'). * * Slice 4: personal-account rows (is_personal = TRUE) are excluded. A * person's personal account doesn't claim the domain on behalf of a * company — alice@acme.ch registering as a personal account must not * block the actual Acme GmbH from registering later. The personal flag * lives on the row itself, set by /api/register at creation time. * * Uses LOWER() on both sides to handle any historical case inconsistency in * stored emails. The pattern '%@' is anchored so 'acme.ch' does not * match 'notacme.ch' or 'acme.ch.evil.com'. */ async function findDuplicateInDb( pool: Pool, domain: string ): Promise { const result = await pool.query<{ count: string }>( `SELECT COUNT(*) AS count FROM tenant_requests WHERE LOWER(contact_email) LIKE $1 AND status NOT IN ('rejected', 'deleted') AND is_personal = FALSE`, [`%@${domain.toLowerCase()}`] ); return Number(result.rows[0]?.count ?? 0) > 0; } // --------------------------------------------------------------------------- // ZITADEL check (secondary, best-effort) // --------------------------------------------------------------------------- /** * Search ZITADEL orgs by primary verified domain. * * Uses the v2 OrganizationService.ListOrganizations API: * POST {ZITADEL_URL}/v2/organizations/_search * * Filter shape (per ZITADEL v2 API): an `organizationDomain` query that * matches against verified domain. Method is EQUALS and case-insensitive. * * Returns true if at least one org matches. Returns false on any error * (network, auth, schema mismatch) — we log and let the DB check be * authoritative. The portal must not block legitimate registrations because * ZITADEL had a hiccup. */ async function findDuplicateInZitadel(domain: string): Promise { const ZITADEL_URL = process.env.ZITADEL_ISSUER; const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT; if (!ZITADEL_URL || !ZITADEL_SA_PAT) { console.warn("ZITADEL env not configured, skipping org-domain check"); return false; } try { const res = await fetch(`${ZITADEL_URL}/v2/organizations/_search`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${ZITADEL_SA_PAT}`, }, body: JSON.stringify({ queries: [ { organizationDomain: { domain, method: "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE", }, }, ], // Limit + sort: we only need to know whether ANY org has this domain pagination: { limit: 1 }, }), // Belt: hard timeout so a hung ZITADEL doesn't stall registration signal: AbortSignal.timeout(5000), }); if (!res.ok) { console.warn( `ZITADEL org-domain search returned ${res.status}, skipping check` ); return false; } const data = (await res.json()) as { result?: Array<{ id?: string; name?: string }>; }; return Array.isArray(data.result) && data.result.length > 0; } catch (err) { console.warn("ZITADEL org-domain search failed, skipping check:", err); return false; } } // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- export interface DuplicateCheckResult { /** True if registration must be blocked. */ blocked: boolean; /** The domain that was matched (lowercased). Set when blocked = true. */ domain?: string; } /** * Run the full duplicate-domain check for a registration request. * * Order: * - Parse domain. Invalid → not blocked (Zod already failed if so; * this is just defensive). * - Public domain → not blocked. * - DB hit → blocked. * - ZITADEL hit → blocked. * - Otherwise → not blocked. */ export async function checkRegistrationDomain( pool: Pool, email: string ): Promise { const domain = extractEmailDomain(email); if (!domain) return { blocked: false }; if (isPublicEmailDomain(domain)) return { blocked: false }; if (await findDuplicateInDb(pool, domain)) { return { blocked: true, domain }; } if (await findDuplicateInZitadel(domain)) { return { blocked: true, domain }; } return { blocked: false }; }