import { readFile, writeFile } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildContentSecurityPolicy, createSecurityHeaders } from '../src/security/security-policy.ts'; import { parseSiteConfig } from '../src/content/schemas.ts'; const scriptPath = fileURLToPath(import.meta.url); const safeAssetPath = /^assets\/[A-Za-z0-9][A-Za-z0-9._-]*$/u; const sha256Pattern = /^[a-f0-9]{64}$/u; const safeHost = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; const exactKeys = (value, keys) => JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort()); export const parseHashedArtifactManifest = (input) => { if ( typeof input !== 'object' || input === null || Array.isArray(input) || !exactKeys(input, ['schema', 'files']) || input.schema !== 'portfolio-host-artifact-manifest/v1' || !Array.isArray(input.files) ) { throw new Error('Hashed artifact manifest has an invalid envelope.'); } const files = input.files.map((record) => { if ( typeof record !== 'object' || record === null || Array.isArray(record) || !exactKeys(record, ['path', 'sha256']) || typeof record.path !== 'string' || !safeAssetPath.test(record.path) || record.path.endsWith('.map') || typeof record.sha256 !== 'string' || !sha256Pattern.test(record.sha256) ) { throw new Error('Hashed artifact manifest contains an invalid file record.'); } return { path: record.path, sha256: record.sha256 }; }); const paths = files.map(({ path }) => path); if (JSON.stringify(paths) !== JSON.stringify([...new Set(paths)].sort())) { throw new Error('Hashed artifact manifest files must be sorted and unique.'); } return { schema: input.schema, files }; }; const hostManifestFromInput = (input) => { if (input?.schema === 'portfolio-build-authority/v1') { return parseHashedArtifactManifest({ schema: 'portfolio-host-artifact-manifest/v1', files: input.artifactContract?.files, }); } if (input?.schema === 'portfolio-artifact-contract/v1') { return parseHashedArtifactManifest({ schema: 'portfolio-host-artifact-manifest/v1', files: input.files, }); } return parseHashedArtifactManifest(input); }; const assertSafeAbsolutePath = (value, label) => { if (typeof value !== 'string' || !isAbsolute(value) || /[\s;$`{}]/u.test(value)) { throw new Error(`${label} must be an explicit safe absolute path.`); } }; const assertHost = (value, label) => { if (typeof value !== 'string' || !safeHost.test(value)) throw new Error(`${label} is invalid.`); }; const escapeNginxRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); const renderCacheMap = (site, files) => { if (site.environment === 'preview') { return ['map $uri $portfolio_cache_control {', ' default "private, no-store";', '}'].join('\n'); } return [ 'map $uri $portfolio_cache_control {', ' default "public, max-age=0, must-revalidate";', ...files.map(({ path }) => ` ~^/${escapeNginxRegex(path)}$ "public, max-age=31536000, immutable";`), '}', ].join('\n'); }; const renderAddHeaders = (site, jsonLdCspHash, indent = ' ') => { const headers = createSecurityHeaders(site, jsonLdCspHash); return [ `${indent}add_header Content-Security-Policy "${headers.csp}" always;`, `${indent}add_header X-Content-Type-Options "${headers.contentTypeOptions}" always;`, `${indent}add_header Referrer-Policy "${headers.referrerPolicy}" always;`, `${indent}add_header Permissions-Policy "${headers.permissionsPolicy}" always;`, `${indent}add_header X-Frame-Options "${headers.frameOptions}" always;`, `${indent}add_header Cache-Control $portfolio_cache_control always;`, ...(headers.hsts === undefined ? [] : [`${indent}add_header Strict-Transport-Security "${headers.hsts}" always;`]), ...(headers.xRobotsTag === undefined ? [] : [`${indent}add_header X-Robots-Tag "${headers.xRobotsTag}" always;`]), ].join('\n'); }; const renderTls = (certificatePath, keyPath, indent = ' ') => [ `${indent}ssl_certificate ${certificatePath};`, `${indent}ssl_certificate_key ${keyPath};`, `${indent}ssl_protocols TLSv1.2 TLSv1.3;`, ].join('\n'); export const renderNginxConfig = (options) => { const { site, jsonLdCspHash, documentRoot } = options; if ( typeof site !== 'object' || site === null || !['preview', 'production'].includes(site.environment) || typeof site.canonicalUrl !== 'string' ) { throw new Error('Site config is invalid.'); } const canonical = new URL(site.canonicalUrl); if ( canonical.protocol !== 'https:' || canonical.username !== '' || canonical.password !== '' || canonical.pathname !== '/' || canonical.search !== '' || canonical.hash !== '' || canonical.port !== '' ) { throw new Error('Canonical URL must be one root HTTPS origin.'); } assertHost(canonical.hostname, 'Canonical host'); assertSafeAbsolutePath(documentRoot, 'Document root'); const artifactManifest = parseHashedArtifactManifest(options.artifactManifest); const alternateHosts = options.alternateHosts ?? []; if (!Array.isArray(alternateHosts)) throw new Error('Alternate hosts must be an array.'); for (const host of alternateHosts) assertHost(host, 'Alternate host'); const sortedAlternateHosts = [...new Set(alternateHosts)].sort(); if (sortedAlternateHosts.length !== alternateHosts.length || sortedAlternateHosts.includes(canonical.hostname)) { throw new Error('Alternate hosts must be unique and distinct from the canonical host.'); } buildContentSecurityPolicy(jsonLdCspHash); const production = site.environment === 'production'; if (production) { assertSafeAbsolutePath(options.tlsCertificatePath, 'TLS certificate path'); assertSafeAbsolutePath(options.tlsKeyPath, 'TLS key path'); } else if (options.tlsCertificatePath !== undefined || options.tlsKeyPath !== undefined) { throw new Error('Preview host generation does not accept production TLS material.'); } const allHosts = [canonical.hostname, ...sortedAlternateHosts].join(' '); const redirectTarget = `https://${canonical.hostname}$request_uri`; const redirectServers = production ? [ 'server {', ' listen 80;', ` server_name ${allHosts};`, ` return 308 ${redirectTarget};`, '}', ...(sortedAlternateHosts.length === 0 ? [] : [ '', 'server {', ' listen 443 ssl;', ` server_name ${sortedAlternateHosts.join(' ')};`, renderTls(options.tlsCertificatePath, options.tlsKeyPath), ` return 308 ${redirectTarget};`, '}', ]), '', ].join('\n') : ''; const sitemapLocation = production ? [' location = /sitemap.xml {', ' try_files /sitemap.xml =404;', ' }'].join('\n') : ''; return [ '# Generated by scripts/generate-host-config.mjs. Do not edit.', '# Requires standard MIME mappings and static-file defaults in the parent Nginx http context.', 'max_ranges 1;', renderCacheMap(site, artifactManifest.files), '', redirectServers, 'server {', production ? ' listen 443 ssl;' : ' listen 8080;', ` server_name ${canonical.hostname};`, ...(production ? [renderTls(options.tlsCertificatePath, options.tlsKeyPath)] : []), ` root ${documentRoot};`, ' index index.html;', renderAddHeaders(site, jsonLdCspHash), '', ' location = / {', ' try_files /index.html =404;', ' }', ' location = /index.html {', ' try_files /index.html =404;', ' }', ' location = /robots.txt {', ' try_files /robots.txt =404;', ' }', ...(sitemapLocation === '' ? [] : [sitemapLocation]), ' location /assets/ {', ' try_files $uri =404;', ' }', ' location / {', ' return 404;', ' }', '}', '', ] .filter((line, index, all) => !(line === '' && all[index - 1] === '')) .join('\n'); }; const parseArguments = (arguments_) => { const values = new Map(); const alternateHosts = []; for (let index = 0; index < arguments_.length; index += 2) { const key = arguments_[index]; const value = arguments_[index + 1]; if (typeof key !== 'string' || !key.startsWith('--') || value === undefined) { throw new Error('Host config arguments must be exact --name value pairs.'); } if (key === '--alternate-host') alternateHosts.push(value); else if (values.has(key)) throw new Error(`Duplicate host config argument: ${key}`); else values.set(key, value); } const required = ['--config', '--json-ld-hash', '--artifact-manifest', '--document-root']; for (const key of required) if (!values.has(key)) throw new Error(`Missing host config argument: ${key}`); const allowed = new Set([...required, '--output', '--tls-certificate', '--tls-key']); for (const key of values.keys()) if (!allowed.has(key)) throw new Error(`Unknown host config argument: ${key}`); return { values, alternateHosts }; }; export const generateHostConfig = async (arguments_) => { const { values, alternateHosts } = parseArguments(arguments_); const readJson = async (path) => JSON.parse(await readFile(resolve(path), 'utf8')); const site = parseSiteConfig(await readJson(values.get('--config'))); const artifactManifest = hostManifestFromInput(await readJson(values.get('--artifact-manifest'))); const config = renderNginxConfig({ site, jsonLdCspHash: values.get('--json-ld-hash'), artifactManifest, documentRoot: values.get('--document-root'), alternateHosts, tlsCertificatePath: values.get('--tls-certificate'), tlsKeyPath: values.get('--tls-key'), }); const output = values.get('--output'); if (output === undefined) process.stdout.write(config); else await writeFile(resolve(output), config, { flag: 'wx' }); return config; }; if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { await generateHostConfig(process.argv.slice(2)); }