import { ConfigValidationError } from './errors.js' import { isIP } from 'node:net' export function normalizeBaseUrl(rawUrl) { let url try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') } if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https') if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials') const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase() const ipv4 = hostname.split('.').map(Number) const isIpv4 = ipv4.length === 4 && ipv4.every(part => Number.isInteger(part) && part >= 0 && part <= 255) const prohibitedIpv4 = isIpv4 && ( ipv4[0] === 127 || ipv4[0] === 0 || (ipv4[0] === 169 && ipv4[1] === 254) || (ipv4[0] >= 224) ) const prohibitedIpv6 = hostname === '::' || hostname === '::1' || /^fe[89ab][0-9a-f]:/.test(hostname) || hostname.startsWith('ff') || hostname.startsWith('::ffff:') || hostname.startsWith('64:ff9b:') const prohibitedMetadata = hostname === '100.100.100.200' || hostname === '168.63.129.16' const prohibitedTransition = hostname.startsWith('2002:') const isIpLiteral = isIP(hostname) !== 0 if (!isIpLiteral || prohibitedIpv4 || prohibitedIpv6 || prohibitedMetadata || prohibitedTransition) { throw new ConfigValidationError('instance target must be a permitted IP literal; hostnames and local/metadata addresses are prohibited') } url.hash = '' url.search = '' return url.toString().replace(/\/$/, '') } export function normalizeInstance(instance, index = 0) { if (!instance || typeof instance !== 'object') throw new ConfigValidationError(`instances[${index}] must be an object`) const id = String(instance.id || '').trim() const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim() const rawUrl = String(instance.url || '').trim() if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new ConfigValidationError(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`) if (!rawUrl) throw new ConfigValidationError(`instances[${index}].url is required`) const password = instance.auth?.password ?? instance.password ?? '' const mode = password ? 'password' : String(instance.auth?.mode || 'none') if (!['none', 'password'].includes(mode)) throw new ConfigValidationError(`instances[${index}].auth.mode must be none or password`) return { id, name, url: normalizeBaseUrl(rawUrl), description: String(instance.description || '').trim(), tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [], auth: { mode, password: password ? String(password) : '' }, capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [], } } export function normalizeConfig(raw = {}, env = process.env) { const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : [] const ids = new Set() for (const item of instances) { if (ids.has(item.id)) throw new ConfigValidationError(`duplicate instance id: ${item.id}`) ids.add(item.id) } const port = Number(env.PORT ?? raw.server?.port ?? 8788) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535') const host = String(env.HOST ?? raw.server?.host ?? '127.0.0.1').trim() const bareHost = host.replace(/^\[|\]$/g, '').toLowerCase() const octets = bareHost.split('.') const ipv4Loopback = octets.length === 4 && octets[0] === '127' && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) if (!(bareHost === 'localhost' || bareHost === '::1' || bareHost === '0:0:0:0:0:0:0:1' || ipv4Loopback)) { throw new ConfigValidationError('server.host must be a loopback host (127/8, ::1, or localhost)') } return { server: { host, port }, instances, raw: { ...raw, instances }, } } export function serializeConfig(snapshot) { return { ...(snapshot.raw || {}), server: snapshot.raw?.server || snapshot.server, instances: snapshot.instances.map(instance => ({ id: instance.id, name: instance.name, url: instance.url, description: instance.description || '', tags: instance.tags || [], auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' }, capabilities: instance.capabilities || [], })), } } export function immutableSnapshot(value) { const clone = structuredClone(value) const freeze = object => { if (object && typeof object === 'object' && !Object.isFrozen(object)) { Object.freeze(object) for (const item of Object.values(object)) freeze(item) } return object } return freeze(clone) }