export type TenantHostFields = { domain?: string | null; domains?: unknown; adminUrl?: string | null; appUrl?: string | null; nodeServer?: string | null; }; export function normalizeTenantHost(value?: string | null): string { if (!value) return ''; return String(value) .trim() .toLowerCase() .replace(/^https?:\/\//, '') .split('/')[0] .split(':')[0] .replace(/\.$/, ''); } /** aliases JSON array, object map, CSV, or a single host/URL */ export function readHostList(raw: unknown): string[] { if (!raw) return []; if (Array.isArray(raw)) return raw.flatMap((item) => readHostList(item)); if (typeof raw === 'object') { return Object.values(raw as Record).flatMap(readHostList); } const text = String(raw).trim(); if (!text) return []; if ( (text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}')) ) { try { return readHostList(JSON.parse(text)); } catch { /* fall through */ } } return text .split(/[\s,;]+/) .map((item) => item.trim()) .filter(Boolean); } function looksLikeHostname(value: string): boolean { if (!value.includes('.')) return false; if (/^09\d{9}$/.test(value)) return false; return true; } function hostsEqual(left: string, right: string): boolean { if (!left || !right) return false; if (left === right) return true; return left.replace(/^www\./, '') === right.replace(/^www\./, ''); } /** new.gholaktala.ir belongs to tenant domain gholaktala.ir — not via shared nodeServer. */ function isSubdomainOf(host: string, parent: string): boolean { const child = normalizeTenantHost(host); const root = normalizeTenantHost(parent).replace(/^www\./, ''); if (!child || !root || !root.includes('.')) return false; return child === root || child.endsWith('.' + root); } export type TenantHostMatchMode = 'full' | 'hmac'; /** * full: get_user — دامنه، alias، app/admin/node. * hmac: فقط دامنهٔ اصلی نسخه (و ساب‌دامنهٔ همان). adminUrl مشترک * (k.montzar.ir روی نسخهٔ app.gholaktala.ir) هویت نیست. */ export function scoreTenantHost( tenant: TenantHostFields, hostRaw: string, mode: TenantHostMatchMode = 'full', ): number { const host = normalizeTenantHost(hostRaw); if (!host) return 0; const eq = (raw?: string | null) => hostsEqual(normalizeTenantHost(raw), host); if (eq(tenant.domain)) return 100; if (mode === 'hmac') { if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75; return 0; } for (const alias of readHostList(tenant.domains)) { if (eq(alias)) return 90; } for (const value of readHostList(tenant.appUrl)) { if (eq(value)) return 80; } if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75; for (const alias of readHostList(tenant.domains)) { if (isSubdomainOf(host, alias)) return 72; } for (const value of readHostList(tenant.adminUrl)) { const normalized = normalizeTenantHost(value); if (looksLikeHostname(normalized) && hostsEqual(normalized, host)) { return 50; } } for (const value of readHostList(tenant.nodeServer)) { if (eq(value)) return 10; } return 0; } export function findTenantForHost( tenants: T[], host: string, mode: TenantHostMatchMode = 'full', ): T | undefined { const scored = tenants .map((row) => ({ row, score: scoreTenantHost(row, host, mode) })) .filter((item) => item.score > 0) .sort((a, b) => b.score - a.score); if (!scored.length) return undefined; const best = scored[0].score; const tied = scored.filter((item) => item.score === best); if (tied.length !== 1) return undefined; return tied[0].row; }