#!/usr/bin/env node import http, { createServer } from 'node:http'; import https from 'node:https'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { promisify } from 'node:util'; import { brotliCompress, gzipSync } from 'node:zlib'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const brotliCompressAsync = promisify(brotliCompress); // Monkey-patch globalThis.fetch to force IPv4 for HTTPS requests. // Node.js built-in fetch (undici) tries IPv6 first via Happy Eyeballs. // Government APIs (EIA, NASA FIRMS, FRED) publish AAAA records but their // IPv6 endpoints time out, causing ETIMEDOUT. This override ensures ALL // fetch() calls in dynamically-loaded handler modules (api/*.js) use IPv4. const _originalFetch = globalThis.fetch; function normalizeRequestBody(body) { if (body == null) return null; if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) return body; if (body instanceof URLSearchParams) return body.toString(); if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); if (body instanceof ArrayBuffer) return Buffer.from(body); return body; } function buildSafeResponse(statusCode, statusText, headers, bodyBuffer) { const status = Number.isInteger(statusCode) ? statusCode : 500; const body = (status === 204 || status === 205 || status === 304) ? null : bodyBuffer; return new Response(body, { status, statusText, headers }); } function isTransientVerificationError(error) { if (!(error instanceof Error)) return false; const code = typeof error.code === 'string' ? error.code : ''; if (code && ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_CONNECT_TIMEOUT'].includes(code)) { return true; } if (error.name === 'AbortError') return true; return /timed out|timeout|network|fetch failed|failed to fetch|socket hang up/i.test(error.message); } globalThis.fetch = function ipv4Fetch(input, init) { const isRequest = input && typeof input === 'object' && 'url' in input; let url; try { url = new URL(typeof input === 'string' ? input : input.url); } catch { return _originalFetch(input, init); } if (url.protocol !== 'https:' && url.protocol !== 'http:') return _originalFetch(input, init); const mod = url.protocol === 'https:' ? https : http; const method = init?.method || (isRequest ? input.method : 'GET'); const headers = {}; const rawHeaders = init?.headers || (isRequest ? input.headers : null); if (rawHeaders) { const h = rawHeaders instanceof Headers ? Object.fromEntries(rawHeaders.entries()) : Array.isArray(rawHeaders) ? Object.fromEntries(rawHeaders) : rawHeaders; Object.assign(headers, h); } return new Promise((resolve, reject) => { const req = mod.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method, headers, family: 4 }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const buf = Buffer.concat(chunks); const responseHeaders = new Headers(); for (const [k, v] of Object.entries(res.headers)) { if (v) responseHeaders.set(k, Array.isArray(v) ? v.join(', ') : v); } try { resolve(buildSafeResponse(res.statusCode, res.statusMessage, responseHeaders, buf)); } catch (error) { reject(error); } }); }); req.on('error', reject); if (init?.signal) { init.signal.addEventListener('abort', () => req.destroy()); } if (init?.body) { const body = normalizeRequestBody(init.body); if (body != null) req.write(body); } req.end(); }); }; const ALLOWED_ENV_KEYS = new Set([ 'GROQ_API_KEY', 'OPENROUTER_API_KEY', 'FRED_API_KEY', 'EIA_API_KEY', 'CLOUDFLARE_API_TOKEN', 'ACLED_ACCESS_TOKEN', 'URLHAUS_AUTH_KEY', 'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL', 'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET', 'AISSTREAM_API_KEY', 'VITE_WS_RELAY_URL', 'FINNHUB_API_KEY', 'NASA_FIRMS_API_KEY', 'OLLAMA_API_URL', 'OLLAMA_MODEL', ]); function json(data, status = 200, extraHeaders = {}) { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...extraHeaders }, }); } function canCompress(headers, body) { return body.length > 1024 && !headers['content-encoding']; } async function maybeCompressResponseBody(body, headers, acceptEncoding = '') { if (!canCompress(headers, body)) return body; const varyValue = headers['vary']; headers['vary'] = varyValue ? `${varyValue}, Accept-Encoding` : 'Accept-Encoding'; if (acceptEncoding.includes('br')) { headers['content-encoding'] = 'br'; return brotliCompressAsync(body); } if (acceptEncoding.includes('gzip')) { headers['content-encoding'] = 'gzip'; return gzipSync(body); } return body; } function isBracketSegment(segment) { return segment.startsWith('[') && segment.endsWith(']'); } function splitRoutePath(routePath) { return routePath.split('/').filter(Boolean); } function routePriority(routePath) { const parts = splitRoutePath(routePath); return parts.reduce((score, part) => { if (part.startsWith('[[...') && part.endsWith(']]')) return score + 0; if (part.startsWith('[...') && part.endsWith(']')) return score + 1; if (isBracketSegment(part)) return score + 2; return score + 10; }, 0); } function matchRoute(routePath, pathname) { const routeParts = splitRoutePath(routePath); const pathParts = splitRoutePath(pathname.replace(/^\/api/, '')); let i = 0; let j = 0; while (i < routeParts.length && j < pathParts.length) { const routePart = routeParts[i]; const pathPart = pathParts[j]; if (routePart.startsWith('[[...') && routePart.endsWith(']]')) { return true; } if (routePart.startsWith('[...') && routePart.endsWith(']')) { return true; } if (isBracketSegment(routePart)) { i += 1; j += 1; continue; } if (routePart !== pathPart) { return false; } i += 1; j += 1; } if (i === routeParts.length && j === pathParts.length) return true; if (i === routeParts.length - 1) { const tail = routeParts[i]; if (tail?.startsWith('[[...') && tail.endsWith(']]')) { return true; } if (tail?.startsWith('[...') && tail.endsWith(']')) { return j < pathParts.length; } } return false; } async function buildRouteTable(root) { if (!existsSync(root)) return []; const files = []; async function walk(dir) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const absolute = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(absolute); continue; } if (!entry.name.endsWith('.js')) continue; if (entry.name.startsWith('_')) continue; const relative = path.relative(root, absolute).replace(/\\/g, '/'); const routePath = relative.replace(/\.js$/, '').replace(/\/index$/, ''); files.push({ routePath, modulePath: absolute }); } } await walk(root); files.sort((a, b) => routePriority(b.routePath) - routePriority(a.routePath)); return files; } async function readBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); return chunks.length ? Buffer.concat(chunks) : undefined; } function toHeaders(nodeHeaders, options = {}) { const stripOrigin = options.stripOrigin === true; const headers = new Headers(); Object.entries(nodeHeaders).forEach(([key, value]) => { const lowerKey = key.toLowerCase(); if (lowerKey === 'host') return; if (stripOrigin && (lowerKey === 'origin' || lowerKey === 'referer' || lowerKey.startsWith('sec-fetch-'))) { return; } if (Array.isArray(value)) { value.forEach(v => headers.append(key, v)); } else if (typeof value === 'string') { headers.set(key, value); } }); return headers; } async function proxyToCloud(requestUrl, req, remoteBase) { const target = `${remoteBase}${requestUrl.pathname}${requestUrl.search}`; const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); return fetch(target, { method: req.method, // Strip browser-origin headers for server-to-server parity. headers: toHeaders(req.headers, { stripOrigin: true }), body, }); } function pickModule(pathname, routes) { const apiPath = pathname.startsWith('/api') ? pathname.slice(4) || '/' : pathname; for (const candidate of routes) { if (matchRoute(candidate.routePath, apiPath)) { return candidate.modulePath; } } return null; } const moduleCache = new Map(); const failedImports = new Set(); const fallbackCounts = new Map(); const cloudPreferred = new Set(); const TRAFFIC_LOG_MAX = 200; const trafficLog = []; let verboseMode = false; let _verboseStatePath = null; function loadVerboseState(resourceDir) { _verboseStatePath = path.join(resourceDir, 'verbose-mode.json'); try { const data = JSON.parse(readFileSync(_verboseStatePath, 'utf-8')); verboseMode = !!data.verboseMode; } catch { /* file missing or invalid — keep default false */ } } function saveVerboseState() { if (!_verboseStatePath) return; try { writeFileSync(_verboseStatePath, JSON.stringify({ verboseMode })); } catch { /* ignore */ } } function recordTraffic(entry) { trafficLog.push(entry); if (trafficLog.length > TRAFFIC_LOG_MAX) trafficLog.shift(); if (verboseMode) { const ts = entry.timestamp.split('T')[1].replace('Z', ''); console.log(`[traffic] ${ts} ${entry.method} ${entry.path} → ${entry.status} ${entry.durationMs}ms`); } } function logOnce(logger, route, message) { const key = `${route}:${message}`; const count = (fallbackCounts.get(key) || 0) + 1; fallbackCounts.set(key, count); if (count === 1) { logger.warn(`[local-api] ${route} → ${message}`); } else if (count === 5 || count % 100 === 0) { logger.warn(`[local-api] ${route} → ${message} (x${count})`); } } async function importHandler(modulePath) { if (failedImports.has(modulePath)) { throw new Error(`cached-failure:${path.basename(modulePath)}`); } const cached = moduleCache.get(modulePath); if (cached) return cached; try { const mod = await import(pathToFileURL(modulePath).href); moduleCache.set(modulePath, mod); return mod; } catch (error) { if (error.code === 'ERR_MODULE_NOT_FOUND') { failedImports.add(modulePath); } throw error; } } function resolveConfig(options = {}) { const port = Number(options.port ?? process.env.LOCAL_API_PORT ?? 46123); const remoteBase = String(options.remoteBase ?? process.env.LOCAL_API_REMOTE_BASE ?? 'https://worldmonitor.app').replace(/\/$/, ''); const resourceDir = String(options.resourceDir ?? process.env.LOCAL_API_RESOURCE_DIR ?? process.cwd()); const apiDir = options.apiDir ? String(options.apiDir) : [ path.join(resourceDir, 'api'), path.join(resourceDir, '_up_', 'api'), ].find((candidate) => existsSync(candidate)) ?? path.join(resourceDir, 'api'); const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar'); const cloudFallback = String(options.cloudFallback ?? process.env.LOCAL_API_CLOUD_FALLBACK ?? '') === 'true'; const logger = options.logger ?? console; return { port, remoteBase, resourceDir, apiDir, mode, cloudFallback, logger, }; } function isMainModule() { if (!process.argv[1]) return false; return pathToFileURL(process.argv[1]).href === import.meta.url; } async function handleLocalServiceStatus(context) { return json({ success: true, timestamp: new Date().toISOString(), summary: { operational: 2, degraded: 0, outage: 0, unknown: 0 }, services: [ { id: 'local-api', name: 'Local Desktop API', category: 'dev', status: 'operational', description: `Running on 127.0.0.1:${context.port}` }, { id: 'cloud-pass-through', name: 'Cloud pass-through', category: 'cloud', status: 'operational', description: `Fallback target ${context.remoteBase}` }, ], local: { enabled: true, mode: context.mode, port: context.port, remoteBase: context.remoteBase }, }); } async function tryCloudFallback(requestUrl, req, context, reason) { if (reason) { const route = requestUrl.pathname; const count = (fallbackCounts.get(route) || 0) + 1; fallbackCounts.set(route, count); if (count === 1) { const brief = reason instanceof Error ? (reason.code === 'ERR_MODULE_NOT_FOUND' ? 'missing npm dependency' : reason.message) : reason; context.logger.warn(`[local-api] ${route} → cloud (${brief})`); } else if (count === 5 || count % 100 === 0) { context.logger.warn(`[local-api] ${route} → cloud x${count}`); } } try { return await proxyToCloud(requestUrl, req, context.remoteBase); } catch (error) { context.logger.error('[local-api] cloud fallback failed', requestUrl.pathname, error); return null; } } const SIDECAR_ALLOWED_ORIGINS = [ /^tauri:\/\/localhost$/, /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, /^https:\/\/tauri\.localhost(:\d+)?$/, /^https:\/\/(.*\.)?worldmonitor\.app$/, ]; function getSidecarCorsOrigin(req) { const origin = req.headers?.origin || req.headers?.get?.('origin') || ''; if (origin && SIDECAR_ALLOWED_ORIGINS.some(p => p.test(origin))) return origin; return 'tauri://localhost'; } function makeCorsHeaders(req) { return { 'Access-Control-Allow-Origin': getSidecarCorsOrigin(req), 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400', 'Vary': 'Origin', }; } async function fetchWithTimeout(url, options = {}, timeoutMs = 12000) { // Use node:https with IPv4 forced — Node.js built-in fetch (undici) tries IPv6 // first and some servers (EIA, NASA FIRMS) have broken IPv6 causing ETIMEDOUT. const u = new URL(url); if (u.protocol === 'https:') { return new Promise((resolve, reject) => { const reqOpts = { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: options.method || 'GET', headers: options.headers || {}, family: 4, }; const req = https.request(reqOpts, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const body = Buffer.concat(chunks).toString(); resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, headers: { get: (k) => res.headers[k.toLowerCase()] || null }, text: () => Promise.resolve(body), json: () => Promise.resolve(JSON.parse(body)), }); }); }); req.on('error', reject); req.setTimeout(timeoutMs, () => { req.destroy(new Error('Request timed out')); }); if (options.body) { const body = normalizeRequestBody(options.body); if (body != null) req.write(body); } req.end(); }); } // HTTP fallback (localhost sidecar, etc.) const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } function relayToHttpUrl(rawUrl) { try { const parsed = new URL(rawUrl); if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; return parsed.toString().replace(/\/$/, ''); } catch { return null; } } function isAuthFailure(status, text = '') { if (status === 401 || status === 403) return true; return /unauthori[sz]ed|forbidden|invalid api key|invalid token|bad credentials/i.test(text); } async function validateSecretAgainstProvider(key, rawValue, context = {}) { const value = String(rawValue || '').trim(); if (!value) return { valid: false, message: 'Value is required' }; const fail = (message) => ({ valid: false, message }); const ok = (message) => ({ valid: true, message }); try { switch (key) { case 'GROQ_API_KEY': { const response = await fetchWithTimeout('https://api.groq.com/openai/v1/models', { headers: { Authorization: `Bearer ${value}` }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('Groq rejected this key'); if (!response.ok) return fail(`Groq probe failed (${response.status})`); return ok('Groq key verified'); } case 'OPENROUTER_API_KEY': { const response = await fetchWithTimeout('https://openrouter.ai/api/v1/models', { headers: { Authorization: `Bearer ${value}` }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('OpenRouter rejected this key'); if (!response.ok) return fail(`OpenRouter probe failed (${response.status})`); return ok('OpenRouter key verified'); } case 'FRED_API_KEY': { const response = await fetchWithTimeout( `https://api.stlouisfed.org/fred/series?series_id=GDP&api_key=${encodeURIComponent(value)}&file_type=json`, { headers: { Accept: 'application/json' } } ); const text = await response.text(); if (!response.ok) return fail(`FRED probe failed (${response.status})`); let payload = null; try { payload = JSON.parse(text); } catch { /* ignore */ } if (payload?.error_code || payload?.error_message) return fail('FRED rejected this key'); if (!Array.isArray(payload?.seriess)) return fail('Unexpected FRED response'); return ok('FRED key verified'); } case 'EIA_API_KEY': { const response = await fetchWithTimeout( `https://api.eia.gov/v2/?api_key=${encodeURIComponent(value)}`, { headers: { Accept: 'application/json' } } ); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('EIA rejected this key'); if (!response.ok) return fail(`EIA probe failed (${response.status})`); let payload = null; try { payload = JSON.parse(text); } catch { /* ignore */ } if (payload?.response?.id === undefined && !payload?.response?.routes) return fail('Unexpected EIA response'); return ok('EIA key verified'); } case 'CLOUDFLARE_API_TOKEN': { const response = await fetchWithTimeout( 'https://api.cloudflare.com/client/v4/radar/annotations/outages?dateRange=1d&limit=1', { headers: { Authorization: `Bearer ${value}` } } ); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('Cloudflare rejected this token'); if (!response.ok) return fail(`Cloudflare probe failed (${response.status})`); let payload = null; try { payload = JSON.parse(text); } catch { /* ignore */ } if (payload?.success !== true) return fail('Cloudflare Radar API did not return success'); return ok('Cloudflare token verified'); } case 'ACLED_ACCESS_TOKEN': { const response = await fetchWithTimeout('https://acleddata.com/api/acled/read?_format=json&limit=1', { headers: { Accept: 'application/json', Authorization: `Bearer ${value}`, }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('ACLED rejected this token'); if (!response.ok) return fail(`ACLED probe failed (${response.status})`); return ok('ACLED token verified'); } case 'URLHAUS_AUTH_KEY': { const response = await fetchWithTimeout('https://urlhaus-api.abuse.ch/v1/urls/recent/limit/1/', { headers: { Accept: 'application/json', 'Auth-Key': value, }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('URLhaus rejected this key'); if (!response.ok) return fail(`URLhaus probe failed (${response.status})`); return ok('URLhaus key verified'); } case 'OTX_API_KEY': { const response = await fetchWithTimeout('https://otx.alienvault.com/api/v1/user/me', { headers: { Accept: 'application/json', 'X-OTX-API-KEY': value, }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('OTX rejected this key'); if (!response.ok) return fail(`OTX probe failed (${response.status})`); return ok('OTX key verified'); } case 'ABUSEIPDB_API_KEY': { const response = await fetchWithTimeout('https://api.abuseipdb.com/api/v2/check?ipAddress=8.8.8.8&maxAgeInDays=90', { headers: { Accept: 'application/json', Key: value, }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('AbuseIPDB rejected this key'); if (!response.ok) return fail(`AbuseIPDB probe failed (${response.status})`); return ok('AbuseIPDB key verified'); } case 'WINGBITS_API_KEY': { const response = await fetchWithTimeout('https://customer-api.wingbits.com/v1/flights/details/3c6444', { headers: { Accept: 'application/json', 'x-api-key': value, }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('Wingbits rejected this key'); if (response.status >= 500) return fail(`Wingbits probe failed (${response.status})`); return ok('Wingbits key accepted'); } case 'FINNHUB_API_KEY': { const response = await fetchWithTimeout(`https://finnhub.io/api/v1/quote?symbol=AAPL&token=${encodeURIComponent(value)}`, { headers: { Accept: 'application/json' }, }); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('Finnhub rejected this key'); if (response.status === 429) return ok('Finnhub key accepted (rate limited)'); if (!response.ok) return fail(`Finnhub probe failed (${response.status})`); let payload = null; try { payload = JSON.parse(text); } catch { /* ignore */ } if (typeof payload?.error === 'string' && payload.error.toLowerCase().includes('invalid')) { return fail('Finnhub rejected this key'); } if (typeof payload?.c !== 'number') return fail('Unexpected Finnhub response'); return ok('Finnhub key verified'); } case 'NASA_FIRMS_API_KEY': { const response = await fetchWithTimeout( `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(value)}/VIIRS_SNPP_NRT/22,44,40,53/1`, { headers: { Accept: 'text/csv' } } ); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('NASA FIRMS rejected this key'); if (!response.ok) return fail(`NASA FIRMS probe failed (${response.status})`); if (/invalid api key|not authorized|forbidden/i.test(text)) return fail('NASA FIRMS rejected this key'); return ok('NASA FIRMS key verified'); } case 'OLLAMA_API_URL': { let probeUrl; try { const parsed = new URL(value); if (!['http:', 'https:'].includes(parsed.protocol)) return fail('Must be an http(s) URL'); // Probe the OpenAI-compatible models endpoint probeUrl = new URL('/v1/models', value).toString(); } catch { return fail('Invalid URL'); } const response = await fetchWithTimeout(probeUrl, { method: 'GET' }, 8000); if (!response.ok) { // Fall back to native Ollama /api/tags endpoint try { const tagsUrl = new URL('/api/tags', value).toString(); const tagsResponse = await fetchWithTimeout(tagsUrl, { method: 'GET' }, 8000); if (!tagsResponse.ok) return fail(`Ollama probe failed (${tagsResponse.status})`); return ok('Ollama endpoint verified (native API)'); } catch { return fail(`Ollama probe failed (${response.status})`); } } return ok('Ollama endpoint verified'); } case 'OLLAMA_MODEL': return ok('Model name stored'); case 'WS_RELAY_URL': case 'VITE_WS_RELAY_URL': case 'VITE_OPENSKY_RELAY_URL': { const probeUrl = relayToHttpUrl(value); if (!probeUrl) return fail('Relay URL is invalid'); const response = await fetchWithTimeout(probeUrl, { method: 'GET' }); if (response.status >= 500) return fail(`Relay probe failed (${response.status})`); return ok('Relay URL is reachable'); } case 'OPENSKY_CLIENT_ID': case 'OPENSKY_CLIENT_SECRET': { const contextClientId = typeof context.OPENSKY_CLIENT_ID === 'string' ? context.OPENSKY_CLIENT_ID.trim() : ''; const contextClientSecret = typeof context.OPENSKY_CLIENT_SECRET === 'string' ? context.OPENSKY_CLIENT_SECRET.trim() : ''; const clientId = key === 'OPENSKY_CLIENT_ID' ? value : (contextClientId || String(process.env.OPENSKY_CLIENT_ID || '').trim()); const clientSecret = key === 'OPENSKY_CLIENT_SECRET' ? value : (contextClientSecret || String(process.env.OPENSKY_CLIENT_SECRET || '').trim()); if (!clientId || !clientSecret) { return fail('Set both OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET before verification'); } const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, }); const response = await fetchWithTimeout( 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, } ); const text = await response.text(); if (isAuthFailure(response.status, text)) return fail('OpenSky rejected these credentials'); if (!response.ok) return fail(`OpenSky auth probe failed (${response.status})`); let payload = null; try { payload = JSON.parse(text); } catch { /* ignore */ } if (!payload?.access_token) return fail('OpenSky auth response did not include an access token'); return ok('OpenSky credentials verified'); } case 'AISSTREAM_API_KEY': return ok('AISSTREAM key stored (live verification not available in sidecar)'); default: return ok('Key stored'); } } catch (error) { const message = error instanceof Error ? error.message : 'provider probe failed'; if (isTransientVerificationError(error)) { return { valid: true, message: `Saved (could not verify: ${message})` }; } return fail(`Verification request failed: ${message}`); } } async function dispatch(requestUrl, req, routes, context) { if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: makeCorsHeaders(req) }); } if (requestUrl.pathname === '/api/service-status') { return handleLocalServiceStatus(context); } // Localhost-only diagnostics — no token required if (requestUrl.pathname === '/api/local-status') { return json({ success: true, mode: context.mode, port: context.port, apiDir: context.apiDir, remoteBase: context.remoteBase, cloudFallback: context.cloudFallback, routes: routes.length, }); } if (requestUrl.pathname === '/api/local-traffic-log') { if (req.method === 'DELETE') { trafficLog.length = 0; return json({ cleared: true }); } return json({ entries: [...trafficLog], verboseMode, maxEntries: TRAFFIC_LOG_MAX }); } if (requestUrl.pathname === '/api/local-debug-toggle') { if (req.method === 'POST') { verboseMode = !verboseMode; saveVerboseState(); context.logger.log(`[local-api] verbose logging ${verboseMode ? 'ON' : 'OFF'}`); } return json({ verboseMode }); } // Token auth — required for env mutations and all API handlers const expectedToken = process.env.LOCAL_API_TOKEN; if (expectedToken) { const authHeader = req.headers.authorization || ''; if (authHeader !== `Bearer ${expectedToken}`) { context.logger.warn(`[local-api] unauthorized request to ${requestUrl.pathname}`); return json({ error: 'Unauthorized' }, 401); } } if (requestUrl.pathname === '/api/local-env-update') { if (req.method === 'POST') { const body = await readBody(req); if (body) { try { const { key, value } = JSON.parse(body.toString()); if (typeof key === 'string' && key.length > 0 && ALLOWED_ENV_KEYS.has(key)) { if (value == null || value === '') { delete process.env[key]; context.logger.log(`[local-api] env unset: ${key}`); } else { process.env[key] = String(value); context.logger.log(`[local-api] env set: ${key}`); } moduleCache.clear(); failedImports.clear(); cloudPreferred.clear(); return json({ ok: true, key }); } return json({ error: 'key not in allowlist' }, 403); } catch { /* bad JSON */ } } return json({ error: 'expected { key, value }' }, 400); } return json({ error: 'POST required' }, 405); } if (requestUrl.pathname === '/api/local-validate-secret') { if (req.method !== 'POST') { return json({ error: 'POST required' }, 405); } const body = await readBody(req); if (!body) return json({ error: 'expected { key, value }' }, 400); try { const { key, value, context } = JSON.parse(body.toString()); if (typeof key !== 'string' || !ALLOWED_ENV_KEYS.has(key)) { return json({ error: 'key not in allowlist' }, 403); } const safeContext = (context && typeof context === 'object') ? context : {}; const result = await validateSecretAgainstProvider(key, value, safeContext); return json(result, result.valid ? 200 : 422); } catch { return json({ error: 'expected { key, value }' }, 400); } } if (context.cloudFallback && cloudPreferred.has(requestUrl.pathname)) { const cloudResponse = await tryCloudFallback(requestUrl, req, context); if (cloudResponse) return cloudResponse; } const modulePath = pickModule(requestUrl.pathname, routes); if (!modulePath || !existsSync(modulePath)) { if (context.cloudFallback) { const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler missing'); if (cloudResponse) return cloudResponse; } logOnce(context.logger, requestUrl.pathname, 'no local handler'); return json({ error: 'No local handler for this endpoint', endpoint: requestUrl.pathname }, 404); } try { const mod = await importHandler(modulePath); if (typeof mod.default !== 'function') { logOnce(context.logger, requestUrl.pathname, 'invalid handler module'); if (context.cloudFallback) { const cloudResponse = await tryCloudFallback(requestUrl, req, context, `invalid handler module`); if (cloudResponse) return cloudResponse; } return json({ error: 'Invalid handler module', endpoint: requestUrl.pathname }, 500); } const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await readBody(req); const request = new Request(requestUrl.toString(), { method: req.method, headers: toHeaders(req.headers, { stripOrigin: true }), body, }); const response = await mod.default(request); if (!(response instanceof Response)) { logOnce(context.logger, requestUrl.pathname, 'handler returned non-Response'); if (context.cloudFallback) { const cloudResponse = await tryCloudFallback(requestUrl, req, context, 'handler returned non-Response'); if (cloudResponse) return cloudResponse; } return json({ error: 'Handler returned invalid response', endpoint: requestUrl.pathname }, 500); } if (!response.ok && context.cloudFallback) { const cloudResponse = await tryCloudFallback(requestUrl, req, context, `local status ${response.status}`); if (cloudResponse) { cloudPreferred.add(requestUrl.pathname); return cloudResponse; } } return response; } catch (error) { const reason = error.code === 'ERR_MODULE_NOT_FOUND' ? 'missing dependency' : error.message; context.logger.error(`[local-api] ${requestUrl.pathname} → ${reason}`); if (context.cloudFallback) { const cloudResponse = await tryCloudFallback(requestUrl, req, context, error); if (cloudResponse) { cloudPreferred.add(requestUrl.pathname); return cloudResponse; } } return json({ error: 'Local handler error', reason, endpoint: requestUrl.pathname }, 502); } } export async function createLocalApiServer(options = {}) { const context = resolveConfig(options); loadVerboseState(context.resourceDir); const routes = await buildRouteTable(context.apiDir); const server = createServer(async (req, res) => { const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${context.port}`); if (!requestUrl.pathname.startsWith('/api/')) { res.writeHead(404, { 'content-type': 'application/json', ...makeCorsHeaders(req) }); res.end(JSON.stringify({ error: 'Not found' })); return; } const start = Date.now(); const skipRecord = req.method === 'OPTIONS' || requestUrl.pathname === '/api/local-traffic-log' || requestUrl.pathname === '/api/local-debug-toggle' || requestUrl.pathname === '/api/local-env-update' || requestUrl.pathname === '/api/local-validate-secret'; try { const response = await dispatch(requestUrl, req, routes, context); const durationMs = Date.now() - start; let body = Buffer.from(await response.arrayBuffer()); const headers = Object.fromEntries(response.headers.entries()); const corsOrigin = getSidecarCorsOrigin(req); headers['access-control-allow-origin'] = corsOrigin; headers['vary'] = headers['vary'] ? headers['vary'] + ', Origin' : 'Origin'; if (!skipRecord) { recordTraffic({ timestamp: new Date().toISOString(), method: req.method, path: requestUrl.pathname + (requestUrl.search || ''), status: response.status, durationMs, }); } const acceptEncoding = req.headers['accept-encoding'] || ''; body = await maybeCompressResponseBody(body, headers, acceptEncoding); if (headers['content-encoding']) { delete headers['content-length']; } res.writeHead(response.status, headers); res.end(body); } catch (error) { const durationMs = Date.now() - start; context.logger.error('[local-api] fatal', error); if (!skipRecord) { recordTraffic({ timestamp: new Date().toISOString(), method: req.method, path: requestUrl.pathname + (requestUrl.search || ''), status: 500, durationMs, error: error.message, }); } res.writeHead(500, { 'content-type': 'application/json', ...makeCorsHeaders(req) }); res.end(JSON.stringify({ error: 'Internal server error' })); } }); return { context, routes, server, async start() { await new Promise((resolve, reject) => { const onListening = () => { server.off('error', onError); resolve(); }; const onError = (error) => { server.off('listening', onListening); reject(error); }; server.once('listening', onListening); server.once('error', onError); server.listen(context.port, '127.0.0.1'); }); const address = server.address(); const boundPort = typeof address === 'object' && address?.port ? address.port : context.port; context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback})`); return { port: boundPort }; }, async close() { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }, }; } if (isMainModule()) { try { const app = await createLocalApiServer(); await app.start(); } catch (error) { console.error('[local-api] startup failed', error); process.exit(1); } }