import { isIP } from 'node:net'; const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i; const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i; const isPrivateIpv4 = (hostname) => { const parts = hostname.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return false; } return parts[0] === 0 || parts[0] === 10 || parts[0] === 127 || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || (parts[0] === 169 && parts[1] === 254) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2)) || (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) || (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || parts[0] >= 224; }; const isPrivateHostname = (hostname) => { const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); const ipVersion = isIP(normalized); return normalized === 'localhost' || normalized.endsWith('.localhost') || (ipVersion === 6 && ( normalized === '::1' || /^f[cd]/.test(normalized) || normalized.startsWith('fe80:') )) || (ipVersion === 4 && isPrivateIpv4(normalized)); }; export const normalizeLegalUrl = (rawValue, label = 'legal URL') => { const value = String(rawValue || '').trim(); if (!value) { throw new Error(`${label} is required`); } if (PLACEHOLDER_PATTERN.test(value)) { throw new Error(`${label} must not use a placeholder or example domain`); } let parsed; try { parsed = new URL(value); } catch { throw new Error(`${label} must be a valid absolute URL`); } if (parsed.protocol !== 'https:') { throw new Error(`${label} must use HTTPS`); } if (parsed.username || parsed.password) { throw new Error(`${label} must not contain embedded credentials`); } if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) { throw new Error(`${label} must not use a reserved hostname`); } if (isPrivateHostname(parsed.hostname)) { throw new Error(`${label} must use a public hostname`); } return parsed.toString(); }; export const validateLegalUrlPair = (termsValue, privacyValue) => { const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL'); const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL'); if (termsUrl === privacyUrl) { throw new Error('service agreement URL and privacy policy URL must be different'); } return { termsUrl, privacyUrl }; }; const extractLinks = (message) => [...String(message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]); export const validateAndroidPrivacyDocument = (privacy) => { if (!privacy || typeof privacy !== 'object') { throw new Error('androidPrivacy.json must contain a JSON object'); } if (privacy.prompt !== 'template') { throw new Error('androidPrivacy.json must use prompt=template'); } if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) { throw new Error('androidPrivacy.json version must be a positive numeric policy version'); } if (privacy.hrefLoader !== 'system') { throw new Error('androidPrivacy.json must open legal links with hrefLoader=system'); } const primaryLinks = extractLinks(privacy.message); const secondaryLinks = extractLinks(privacy.second?.message); if (primaryLinks.length !== 2 || secondaryLinks.length !== 2) { throw new Error('androidPrivacy.json must contain two legal links in both prompts'); } const primary = validateLegalUrlPair(primaryLinks[0], primaryLinks[1]); const secondary = validateLegalUrlPair(secondaryLinks[0], secondaryLinks[1]); if (primary.termsUrl !== secondary.termsUrl || primary.privacyUrl !== secondary.privacyUrl) { throw new Error('androidPrivacy.json primary and secondary prompts must use the same legal links'); } return primary; }; export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => { const { termsUrl, privacyUrl } = validateLegalUrlPair(termsValue, privacyValue); const rendered = String(templateText) .replaceAll('__TERMS_URL__', termsUrl) .replaceAll('__PRIVACY_URL__', privacyUrl); const privacy = JSON.parse(rendered); validateAndroidPrivacyDocument(privacy); return `${JSON.stringify(privacy, null, 2)}\n`; };