274 lines
8.4 KiB
TypeScript
274 lines
8.4 KiB
TypeScript
/**
|
|
* 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<string> = 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 '%@<domain>' is anchored so 'acme.ch' does not
|
|
* match 'notacme.ch' or 'acme.ch.evil.com'.
|
|
*/
|
|
async function findDuplicateInDb(
|
|
pool: Pool,
|
|
domain: string
|
|
): Promise<boolean> {
|
|
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<boolean> {
|
|
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<DuplicateCheckResult> {
|
|
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 };
|
|
}
|