Files
pieced-portal/src/lib/domain-check.ts
admin 3521a0ff4f
All checks were successful
Build and Push / build (push) Successful in 1m30s
Personal accounts
2026-04-26 22:26:33 +02:00

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 };
}