// admin-guard.mjs // // Pure, testable network-guard helpers for the memind_adm consoles. // // Each back-office console (platform super-admin, plaza operations) can be // pinned to an allowlist of request hosts and/or client IPs, independently. // This lets the super-admin surface be locked down harder than moderation // while both still live in one process — and the same knobs keep working // unchanged once the consoles are split into separate processes. export function parseList(value) { return String(value ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); } export function hostAllowed(reqHost, allowedHosts) { if (!allowedHosts.length) return true; const hostname = String(reqHost ?? '').split(':')[0].toLowerCase(); return allowedHosts.some((h) => h.toLowerCase() === hostname); } function ipv4ToInt(ip) { const parts = ip.split('.'); if (parts.length !== 4) return null; let acc = 0; for (const part of parts) { if (!/^\d{1,3}$/.test(part)) return null; const n = Number(part); if (n < 0 || n > 255) return null; acc = (acc * 256 + n) >>> 0; } return acc >>> 0; } // Strip an IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.0.0.1". function normalizeIp(ip) { return String(ip ?? '').replace(/^::ffff:/i, ''); } export function ipMatches(clientIp, rule) { const ip = normalizeIp(clientIp); if (rule.includes('/')) { const [base, bitsStr] = rule.split('/'); const bits = Number(bitsStr); const ipInt = ipv4ToInt(ip); const baseInt = ipv4ToInt(base); if (ipInt === null || baseInt === null) return false; if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; if (bits === 0) return true; const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0; return (ipInt & mask) === (baseInt & mask); } return ip === normalizeIp(rule); } export function ipAllowed(clientIp, ipAllowlist) { if (!ipAllowlist.length) return true; return ipAllowlist.some((rule) => ipMatches(clientIp, rule)); } /** * Build an Express middleware enforcing host + IP allowlists for one console. * Returns null when neither list is configured (no-op — common in dev). * @param {object} opts * @param {string[]} [opts.allowedHosts] * @param {string[]} [opts.ipAllowlist] * @param {(res, req, reason: 'host'|'ip') => unknown} opts.onDeny */ export function buildNetworkGuard({ allowedHosts = [], ipAllowlist = [], onDeny }) { if (!allowedHosts.length && !ipAllowlist.length) return null; return (req, res, next) => { if (!hostAllowed(req.get('host'), allowedHosts)) return onDeny(res, req, 'host'); if (!ipAllowed(req.ip, ipAllowlist)) return onDeny(res, req, 'ip'); return next(); }; }