Files
prop-ai-hr/mobile-uni/scripts/app-legal-config.mjs
T

117 lines
4.4 KiB
JavaScript

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