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

113 lines
3.8 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 };
};
export const validateAndroidPrivacyDocument = (privacy) => {
if (!privacy || typeof privacy !== 'object') {
throw new Error('androidPrivacy.json must contain a JSON object');
}
if (privacy.prompt !== 'none') {
throw new Error('androidPrivacy.json must use prompt=none for the controlled internal App');
}
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
throw new Error('androidPrivacy.json version must be a positive numeric configuration version');
}
for (const templateOnlyField of [
'title',
'message',
'buttonAccept',
'buttonRefuse',
'hrefLoader',
'second',
'disagreeMode',
'styles'
]) {
if (Object.hasOwn(privacy, templateOnlyField)) {
throw new Error(`androidPrivacy.json prompt=none must not contain ${templateOnlyField}`);
}
}
return {
prompt: privacy.prompt,
version: String(privacy.version)
};
};
export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => {
validateLegalUrlPair(termsValue, privacyValue);
const privacy = JSON.parse(String(templateText));
validateAndroidPrivacyDocument(privacy);
return `${JSON.stringify(privacy, null, 2)}\n`;
};