Timestamp and registration checking
This commit is contained in:
266
src/lib/domain-check.ts
Normal file
266
src/lib/domain-check.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 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').
|
||||
*
|
||||
* 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')`,
|
||||
[`%@${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 };
|
||||
}
|
||||
Reference in New Issue
Block a user