style: format extracted local services

This commit is contained in:
Sameh Khamis committed 2026-09-12 13:01:49 -07:00
1 parent 233285fe12
commit 5223b6df5b
33 files changed
+1423 -468

No files matched your search

+32 -1
View File
@@ -83,5 +83,36 @@
"server/providers/radio/transport.js", "server/providers/radio/transport.js",
"src/tooling/mediaProviders.test.mjs", "src/tooling/mediaProviders.test.mjs",
"server/standalone/api-not-found.js", "server/standalone/api-not-found.js",
"src/tooling/previewServing.test.mjs" "src/tooling/previewServing.test.mjs",
"server/providers/common/request.js",
"server/providers/common/source-root.js",
"server/providers/local.js",
"server/providers/military-installations.js",
"server/providers/military-installations/cache.js",
"server/providers/military-installations/constants.js",
"server/providers/military-installations/query.js",
"server/providers/openai.js",
"server/providers/openai/constants.js",
"server/providers/openai/debug-log.js",
"server/providers/openai/hud-summary.js",
"server/providers/openai/instructions.js",
"server/providers/openai/rate-limit.js",
"server/providers/openai/realtime.js",
"server/providers/openai/tools.js",
"server/providers/overpass.js",
"server/providers/overpass/cache.js",
"server/providers/overpass/constants.js",
"server/providers/overpass/geometry.js",
"server/providers/overpass/query.js",
"server/providers/overpass/transport.js",
"server/providers/regional.js",
"server/providers/regional/briefing.js",
"server/providers/regional/http.js",
"server/providers/regional/news.js",
"server/providers/regional/place.js",
"server/providers/regional/query.js",
"server/providers/regional/weather-effects.js",
"server/providers/regional/weather.js",
"server/standalone/key-setup.js",
"src/tooling/localServices.test.mjs"
] ]
+39 -120
View File
@@ -10,47 +10,27 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./build/vite"],
"./build/vite" "modules": ["build/vite.js"],
], "external": ["vite-plugin-cesium"]
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
}, },
"live-providers": { "live-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/live"],
"./server/providers/live"
],
"modules": [ "modules": [
"server/providers/live.js", "server/providers/live.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -65,24 +45,16 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js" "src/data/aisWatchdog.js"
], ],
"external": [ "external": ["ws"]
"ws"
]
}, },
"adsb-lol-source": { "adsb-lol-source": {
"exports": [ "exports": ["./sources/adsb-lol"],
"./sources/adsb-lol" "modules": ["src/data/adsbLolFallback.js"],
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": [] "external": []
}, },
"place-providers": { "place-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/places"],
"./server/providers/places"
],
"modules": [ "modules": [
"server/providers/places.js", "server/providers/places.js",
"server/providers/places/google.js", "server/providers/places/google.js",
@@ -97,19 +69,13 @@
"external": [] "external": []
}, },
"place-payloads": { "place-payloads": {
"exports": [ "exports": ["./sources/places"],
"./sources/places" "modules": ["src/data/placeProviderPayloads.js"],
],
"modules": [
"src/data/placeProviderPayloads.js"
],
"external": [] "external": []
}, },
"space-providers": { "space-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/space"],
"./server/providers/space"
],
"modules": [ "modules": [
"server/providers/space.js", "server/providers/space.js",
"server/providers/space/celestrak.js", "server/providers/space/celestrak.js",
@@ -120,19 +86,13 @@
"external": [] "external": []
}, },
"space-requests": { "space-requests": {
"exports": [ "exports": ["./sources/space"],
"./sources/space" "modules": ["src/data/spaceProviderRequests.js"],
],
"modules": [
"src/data/spaceProviderRequests.js"
],
"external": [] "external": []
}, },
"terrain-provider": { "terrain-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/terrain"],
"./server/providers/terrain"
],
"modules": [ "modules": [
"server/providers/terrain.js", "server/providers/terrain.js",
"src/data/terrainHeightsProxy.js" "src/data/terrainHeightsProxy.js"
@@ -140,70 +100,41 @@
"external": [] "external": []
}, },
"terrain-source": { "terrain-source": {
"exports": [ "exports": ["./sources/terrain"],
"./sources/terrain" "modules": ["src/data/terrainHeightsProxy.js"],
],
"modules": [
"src/data/terrainHeightsProxy.js"
],
"external": [] "external": []
}, },
"traffic-provider": { "traffic-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/traffic"],
"./server/providers/traffic" "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
],
"modules": [
"server/providers/traffic.js",
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"traffic-source": { "traffic-source": {
"exports": [ "exports": ["./sources/traffic"],
"./sources/traffic" "modules": ["src/data/tomtomTiles.js"],
],
"modules": [
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"firms-provider": { "firms-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/firms"],
"./server/providers/firms" "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
],
"modules": [
"server/providers/firms.js",
"src/data/firmsCsv.js"
],
"external": [] "external": []
}, },
"gbfs-provider": { "gbfs-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/gbfs"],
"./server/providers/gbfs" "modules": ["server/providers/gbfs.js", "src/data/gbfsSource.js"],
],
"modules": [
"server/providers/gbfs.js",
"src/data/gbfsSource.js"
],
"external": [] "external": []
}, },
"gbfs-source": { "gbfs-source": {
"exports": [ "exports": ["./sources/gbfs"],
"./sources/gbfs" "modules": ["src/data/gbfsSource.js"],
],
"modules": [
"src/data/gbfsSource.js"
],
"external": [] "external": []
}, },
"cctv-provider": { "cctv-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/cctv"],
"./server/providers/cctv"
],
"modules": [ "modules": [
"server/providers/cctv.js", "server/providers/cctv.js",
"server/providers/cctv/catalog.js", "server/providers/cctv/catalog.js",
@@ -220,9 +151,7 @@
}, },
"radio-provider": { "radio-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/radio"],
"./server/providers/radio"
],
"modules": [ "modules": [
"server/providers/radio.js", "server/providers/radio.js",
"server/providers/radio/catalog.js", "server/providers/radio/catalog.js",
@@ -236,9 +165,7 @@
}, },
"overpass-provider": { "overpass-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/overpass"],
"./server/providers/overpass"
],
"modules": [ "modules": [
"server/providers/common/geo.js", "server/providers/common/geo.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -257,9 +184,7 @@
}, },
"military-installations-provider": { "military-installations-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/military-installations"],
"./server/providers/military-installations"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -276,9 +201,7 @@
}, },
"regional-provider": { "regional-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/regional"],
"./server/providers/regional"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -297,9 +220,7 @@
}, },
"openai-provider": { "openai-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/openai"],
"./server/providers/openai"
],
"modules": [ "modules": [
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"server/providers/common/request.js", "server/providers/common/request.js",
@@ -319,9 +240,7 @@
}, },
"standalone-key-setup": { "standalone-key-setup": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/standalone/key-setup"],
"./server/standalone/key-setup"
],
"modules": [ "modules": [
"scripts/pinokio-environment.mjs", "scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js", "server/providers/common/source-root.js",
-1
View File
@@ -1,4 +1,3 @@
/** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */ /** Read a request body with a hard byte cap; throws { code:'BODY_TOO_LARGE' } past the cap. */
async function readRequestBodyCapped(req, maxBytes) { async function readRequestBodyCapped(req, maxBytes) {
const chunks = []; const chunks = [];
+4 -1
View File
@@ -2,6 +2,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
/** Resolve defaultSourceRoot for ESM context. */ /** Resolve defaultSourceRoot for ESM context. */
const defaultSourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); const defaultSourceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../..',
);
export { defaultSourceRoot }; export { defaultSourceRoot };
+11 -2
View File
@@ -47,8 +47,17 @@ function localProviderPlugins() {
export { localProviderPlugins }; export { localProviderPlugins };
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream } from './cctv.js'; export {
export { createRadioProxyMiddleware, isPublicRadioAddress, normalizeRadioBrowserStation, publicRadioStation, publicRadioHttpsUrl } from './radio.js'; CCTV_FRAME_FETCH_TIMEOUT_MS,
fetchCctvImageFromUpstream,
} from './cctv.js';
export {
createRadioProxyMiddleware,
isPublicRadioAddress,
normalizeRadioBrowserStation,
publicRadioStation,
publicRadioHttpsUrl,
} from './radio.js';
export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js';
export { googlePlacesContextProxy } from './places.js'; export { googlePlacesContextProxy } from './places.js';
export { googleServerApiKey } from './places.js'; export { googleServerApiKey } from './places.js';
+83 -20
View File
@@ -1,11 +1,31 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS } from './military-installations/constants.js'; import {
MILITARY_INSTALLATION_ELEMENT_CAP,
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
MILITARY_INSTALLATION_DISK_TTL_MS,
MILITARY_INSTALLATION_STALE_MS,
} from './military-installations/constants.js';
import { fetchOverpassPayload } from './overpass/transport.js'; import { fetchOverpassPayload } from './overpass/transport.js';
import { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk } from './military-installations/cache.js'; import {
import { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason } from './military-installations/query.js'; _militaryInstallationCache,
trimMilitaryInstallationCache,
writeMilitaryInstallationDisk,
resolveMilitaryInstallationTier,
readMilitaryInstallationDisk,
} from './military-installations/cache.js';
import {
validMilitaryInstallationBox,
quantizeMilitaryInstallationBox,
militaryInstallationCacheKey,
militaryInstallationFailureReason,
} from './military-installations/query.js';
import { coalesceProxyRequest } from './common/http.js'; import { coalesceProxyRequest } from './common/http.js';
const _militaryInstallationsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 }); const _militaryInstallationsRateLimiter = makeRateLimiter({
windowMs: 60_000,
max: 90,
globalMax: 300,
});
const _militaryInstallationInFlight = new Map(); const _militaryInstallationInFlight = new Map();
@@ -17,12 +37,23 @@ function militaryInstallationsProxy() {
`data=${encodeURIComponent(ql)}`, `data=${encodeURIComponent(ql)}`,
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
); );
if (upstream.status >= 400 || upstream.rateLimited || upstream.runtimeError) { if (
throw Object.assign(new Error('Mapped installation upstream unavailable'), { upstream.status >= 400 ||
installationReason: upstream.rateLimited ? 'rate_limited' upstream.rateLimited ||
: upstream.status === 504 ? 'timeout' upstream.runtimeError
: upstream.runtimeError ? 'query_failed' : 'unavailable', ) {
}); throw Object.assign(
new Error('Mapped installation upstream unavailable'),
{
installationReason: upstream.rateLimited
? 'rate_limited'
: upstream.status === 504
? 'timeout'
: upstream.runtimeError
? 'query_failed'
: 'unavailable',
},
);
} }
const parsed = JSON.parse(upstream.body); const parsed = JSON.parse(upstream.body);
const elements = Array.isArray(parsed?.elements) const elements = Array.isArray(parsed?.elements)
@@ -53,7 +84,10 @@ function militaryInstallationsProxy() {
return; return;
} }
if (!_militaryInstallationsRateLimiter(clientKey(req))) { if (!_militaryInstallationsRateLimiter(clientKey(req))) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '5',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' })); res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return; return;
} }
@@ -61,7 +95,11 @@ function militaryInstallationsProxy() {
const requested = validMilitaryInstallationBox(url.searchParams); const requested = validMilitaryInstallationBox(url.searchParams);
if (!requested) { if (!requested) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'A non-dateline bbox no larger than 10 degrees is required' })); res.end(
JSON.stringify({
error: 'A non-dateline bbox no larger than 10 degrees is required',
}),
);
return; return;
} }
// Query the SNAPPED box, not the raw viewport: neighbouring views then // Query the SNAPPED box, not the raw viewport: neighbouring views then
@@ -70,7 +108,9 @@ function militaryInstallationsProxy() {
// response, so a truncated tile can never starve the actual viewport. It // response, so a truncated tile can never starve the actual viewport. It
// is keyed separately so exact and snapped answers never collide. // is keyed separately so exact and snapped answers never collide.
const exact = url.searchParams.get('exact') === '1'; const exact = url.searchParams.get('exact') === '1';
const box = exact ? requested : quantizeMilitaryInstallationBox(requested); const box = exact
? requested
: quantizeMilitaryInstallationBox(requested);
// Key at the precision the query actually uses (see militaryInstallationCacheKey). // Key at the precision the query actually uses (see militaryInstallationCacheKey).
const key = exact const key = exact
? `exact:${militaryInstallationCacheKey(box, 5)}` ? `exact:${militaryInstallationCacheKey(box, 5)}`
@@ -81,7 +121,8 @@ function militaryInstallationsProxy() {
cacheKey: key, cacheKey: key,
memoryCache: _militaryInstallationCache, memoryCache: _militaryInstallationCache,
inFlight: _militaryInstallationInFlight, inFlight: _militaryInstallationInFlight,
readDisk: () => readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS), readDisk: () =>
readMilitaryInstallationDisk(key, MILITARY_INSTALLATION_DISK_TTL_MS),
now, now,
}); });
if (preflight.source !== 'UPSTREAM') { if (preflight.source !== 'UPSTREAM') {
@@ -89,8 +130,14 @@ function militaryInstallationsProxy() {
_militaryInstallationCache.set(key, preflight.entry); _militaryInstallationCache.set(key, preflight.entry);
trimMilitaryInstallationCache(); trimMilitaryInstallationCache();
} }
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Military-Installations': preflight.source }); res.writeHead(200, {
res.end(JSON.stringify({ ...preflight.entry.payload, status: 'cached' })); 'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60',
'X-Military-Installations': preflight.source,
});
res.end(
JSON.stringify({ ...preflight.entry.payload, status: 'cached' }),
);
return; return;
} }
const request = coalesceProxyRequest( const request = coalesceProxyRequest(
@@ -108,7 +155,11 @@ function militaryInstallationsProxy() {
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
} catch (error) { } catch (error) {
if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) { if (cached && now - cached.cachedAt <= MILITARY_INSTALLATION_STALE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE' }); res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Military-Installations': 'STALE',
});
res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
return; return;
} }
@@ -116,12 +167,24 @@ function militaryInstallationsProxy() {
// layer (the same serve-stale rule the Overpass proxy applies). // layer (the same serve-stale rule the Overpass proxy applies).
const stale = await readMilitaryInstallationDisk(key, Infinity); const stale = await readMilitaryInstallationDisk(key, Infinity);
if (stale) { if (stale) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Military-Installations': 'STALE-DISK' }); res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Military-Installations': 'STALE-DISK',
});
res.end(JSON.stringify({ ...stale.payload, status: 'stale' })); res.end(JSON.stringify({ ...stale.payload, status: 'stale' }));
return; return;
} }
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(503, {
res.end(JSON.stringify({ error: 'Mapped installation context is temporarily unavailable', reason: militaryInstallationFailureReason(error) })); 'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'Mapped installation context is temporarily unavailable',
reason: militaryInstallationFailureReason(error),
}),
);
} }
}); });
} }
@@ -1,4 +1,10 @@
import { MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE } from './constants.js'; import {
MILITARY_INSTALLATION_CACHE_MS,
MILITARY_INSTALLATION_ELEMENT_CAP,
MILITARY_INSTALLATION_DISK_TTL_MS,
MILITARY_INSTALLATION_DISK_DIR,
MILITARY_INSTALLATION_MAX_CACHE,
} from './constants.js';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs'; import { promises as fsp } from 'node:fs';
@@ -32,10 +38,13 @@ async function resolveMilitaryInstallationTier({
cacheMs = MILITARY_INSTALLATION_CACHE_MS, cacheMs = MILITARY_INSTALLATION_CACHE_MS,
}) { }) {
const cached = memoryCache.get(cacheKey); const cached = memoryCache.get(cacheKey);
if (cached && now - cached.cachedAt <= cacheMs) return { source: 'HIT', entry: cached }; if (cached && now - cached.cachedAt <= cacheMs)
return { source: 'HIT', entry: cached };
if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null }; if (inFlight.has(cacheKey)) return { source: 'UPSTREAM', entry: null };
const disk = await readDisk(); const disk = await readDisk();
return disk ? { source: 'DISK', entry: disk } : { source: 'UPSTREAM', entry: null }; return disk
? { source: 'DISK', entry: disk }
: { source: 'UPSTREAM', entry: null };
} }
/** /**
@@ -51,8 +60,11 @@ async function resolveMilitaryInstallationTier({
* @returns {?{payload: object, cachedAt: number}} * @returns {?{payload: object, cachedAt: number}}
*/ */
function migrateMilitaryInstallationEntry(entry) { function migrateMilitaryInstallationEntry(entry) {
if (!entry?.payload || typeof entry.payload.saturated === 'boolean') return entry; if (!entry?.payload || typeof entry.payload.saturated === 'boolean')
const elements = Array.isArray(entry.payload.elements) ? entry.payload.elements : []; return entry;
const elements = Array.isArray(entry.payload.elements)
? entry.payload.elements
: [];
return { return {
...entry, ...entry,
payload: { payload: {
@@ -68,13 +80,24 @@ function militaryInstallationDiskFresh(
maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS, maxAgeMs = MILITARY_INSTALLATION_DISK_TTL_MS,
now = Date.now(), now = Date.now(),
) { ) {
if (!entry || !Number.isFinite(entry.cachedAt) || !Array.isArray(entry.payload?.elements)) return false; if (
!entry ||
!Number.isFinite(entry.cachedAt) ||
!Array.isArray(entry.payload?.elements)
)
return false;
return now - entry.cachedAt <= maxAgeMs; return now - entry.cachedAt <= maxAgeMs;
} }
/** Cache key -> stable disk-cache file path. */ /** Cache key -> stable disk-cache file path. */
function militaryInstallationDiskPath(cacheKey, dir = MILITARY_INSTALLATION_DISK_DIR) { function militaryInstallationDiskPath(
return path.join(dir, `${createHash('sha1').update(cacheKey).digest('hex')}.json`); cacheKey,
dir = MILITARY_INSTALLATION_DISK_DIR,
) {
return path.join(
dir,
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
);
} }
/** /**
@@ -88,7 +111,9 @@ async function readMilitaryInstallationDisk(
dir = MILITARY_INSTALLATION_DISK_DIR, dir = MILITARY_INSTALLATION_DISK_DIR,
) { ) {
try { try {
const entry = JSON.parse(await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8')); const entry = JSON.parse(
await fsp.readFile(militaryInstallationDiskPath(cacheKey, dir), 'utf8'),
);
if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null; if (!militaryInstallationDiskFresh(entry, maxAgeMs)) return null;
return migrateMilitaryInstallationEntry(entry); return migrateMilitaryInstallationEntry(entry);
} catch { } catch {
@@ -117,7 +142,10 @@ async function writeMilitaryInstallationDisk(
await fsp.rename(temp, target); await fsp.rename(temp, target);
return true; return true;
} catch (err) { } catch (err) {
console.warn('[Installations Proxy] disk cache write failed:', err?.message || err); console.warn(
'[Installations Proxy] disk cache write failed:',
err?.message || err,
);
await fsp.rm(temp, { force: true }).catch(() => {}); await fsp.rm(temp, { force: true }).catch(() => {});
return false; return false;
} }
@@ -131,4 +159,13 @@ function trimMilitaryInstallationCache() {
} }
} }
export { _militaryInstallationCache, trimMilitaryInstallationCache, writeMilitaryInstallationDisk, resolveMilitaryInstallationTier, readMilitaryInstallationDisk, migrateMilitaryInstallationEntry, militaryInstallationDiskFresh, militaryInstallationDiskPath }; export {
_militaryInstallationCache,
trimMilitaryInstallationCache,
writeMilitaryInstallationDisk,
resolveMilitaryInstallationTier,
readMilitaryInstallationDisk,
migrateMilitaryInstallationEntry,
militaryInstallationDiskFresh,
militaryInstallationDiskPath,
};
@@ -33,7 +33,11 @@ const MILITARY_INSTALLATION_ELEMENT_CAP = 700;
const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000; const MILITARY_INSTALLATION_DISK_TTL_MS = 30 * 86_400_000;
/** Disk-cache directory for mapped installation payloads. */ /** Disk-cache directory for mapped installation payloads. */
const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'military-installations'); const MILITARY_INSTALLATION_DISK_DIR = path.join(
process.cwd(),
'.gev-cache',
'military-installations',
);
/** /**
* Cache-key grid step in degrees (~5.5 km). * Cache-key grid step in degrees (~5.5 km).
@@ -45,4 +49,13 @@ const MILITARY_INSTALLATION_DISK_DIR = path.join(process.cwd(), '.gev-cache', 'm
*/ */
const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05; const MILITARY_INSTALLATION_BBOX_STEP_DEG = 0.05;
export { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_DISK_TTL_MS, MILITARY_INSTALLATION_STALE_MS, MILITARY_INSTALLATION_CACHE_MS, MILITARY_INSTALLATION_DISK_DIR, MILITARY_INSTALLATION_MAX_CACHE, MILITARY_INSTALLATION_BBOX_STEP_DEG }; export {
MILITARY_INSTALLATION_ELEMENT_CAP,
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
MILITARY_INSTALLATION_DISK_TTL_MS,
MILITARY_INSTALLATION_STALE_MS,
MILITARY_INSTALLATION_CACHE_MS,
MILITARY_INSTALLATION_DISK_DIR,
MILITARY_INSTALLATION_MAX_CACHE,
MILITARY_INSTALLATION_BBOX_STEP_DEG,
};
@@ -7,12 +7,17 @@ import { requiredFiniteQueryNumber } from '../common/query.js';
* @param {number} [stepDeg] * @param {number} [stepDeg]
* @returns {{south:number, west:number, north:number, east:number}} * @returns {{south:number, west:number, north:number, east:number}}
*/ */
function quantizeMilitaryInstallationBox(box, stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG) { function quantizeMilitaryInstallationBox(
box,
stepDeg = MILITARY_INSTALLATION_BBOX_STEP_DEG,
) {
// Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line // Round the ratio first: 29.9999/0.05 lands a hair under an exact grid line
// in binary floating point, which would otherwise snap a whole cell too far. // in binary floating point, which would otherwise snap a whole cell too far.
const snap = (value, grow) => { const snap = (value, grow) => {
const cells = Number((value / stepDeg).toFixed(9)); const cells = Number((value / stepDeg).toFixed(9));
return Number(((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6)); return Number(
((grow > 0 ? Math.ceil(cells) : Math.floor(cells)) * stepDeg).toFixed(6),
);
}; };
return { return {
south: Math.max(-90, snap(box.south, -1)), south: Math.max(-90, snap(box.south, -1)),
@@ -46,15 +51,35 @@ function validMilitaryInstallationBox(params) {
const north = requiredFiniteQueryNumber(params, 'north'); const north = requiredFiniteQueryNumber(params, 'north');
const east = requiredFiniteQueryNumber(params, 'east'); const east = requiredFiniteQueryNumber(params, 'east');
if (![south, west, north, east].every(Number.isFinite)) return null; if (![south, west, north, east].every(Number.isFinite)) return null;
if (south < -90 || north > 90 || west < -180 || east > 180 || south >= north || west >= east) return null; if (
south < -90 ||
north > 90 ||
west < -180 ||
east > 180 ||
south >= north ||
west >= east
)
return null;
if (north - south > 10 || east - west > 10) return null; if (north - south > 10 || east - west > 10) return null;
return { south, west, north, east }; return { south, west, north, east };
} }
/** Safe, evidence-based reason for an installation upstream failure. */ /** Safe, evidence-based reason for an installation upstream failure. */
function militaryInstallationFailureReason(error) { function militaryInstallationFailureReason(error) {
if (['rate_limited', 'timeout', 'query_failed'].includes(error?.installationReason)) return error.installationReason; if (
return ['AbortError', 'TimeoutError'].includes(error?.name) ? 'timeout' : 'unavailable'; ['rate_limited', 'timeout', 'query_failed'].includes(
error?.installationReason,
)
)
return error.installationReason;
return ['AbortError', 'TimeoutError'].includes(error?.name)
? 'timeout'
: 'unavailable';
} }
export { validMilitaryInstallationBox, quantizeMilitaryInstallationBox, militaryInstallationCacheKey, militaryInstallationFailureReason }; export {
validMilitaryInstallationBox,
quantizeMilitaryInstallationBox,
militaryInstallationCacheKey,
militaryInstallationFailureReason,
};
+12 -3
View File
@@ -9,13 +9,22 @@ import { createRealtimeTokenHandler } from './openai/realtime.js';
* Keeps OPENAI_API_KEY server-side while the browser connects to the * Keeps OPENAI_API_KEY server-side while the browser connects to the
* Realtime API over WebRTC with a short-lived secret. * Realtime API over WebRTC with a short-lived secret.
*/ */
function openAiRealtimeProxy({ sourceRoot = defaultSourceRoot, annotationGuidance } = {}) { function openAiRealtimeProxy({
sourceRoot = defaultSourceRoot,
annotationGuidance,
} = {}) {
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/openai/hud-summary', handleHudSummary); middlewares.use('/api/openai/hud-summary', handleHudSummary);
middlewares.use('/api/realtime/debug-log', createDebugLogHandler({ sourceRoot })); middlewares.use(
'/api/realtime/debug-log',
createDebugLogHandler({ sourceRoot }),
);
middlewares.use('/api/realtime/token', createRealtimeTokenHandler({ annotationGuidance })); middlewares.use(
'/api/realtime/token',
createRealtimeTokenHandler({ annotationGuidance }),
);
} }
return { return {
+9 -1
View File
@@ -16,4 +16,12 @@ const OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT = 0.5;
const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano'; const OPENAI_HUD_SUMMARY_MODEL_DEFAULT = 'gpt-5-nano';
export { OPENAI_HUD_SUMMARY_MODEL_DEFAULT, OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT }; export {
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
OPENAI_REALTIME_MODEL_DEFAULT,
OPENAI_REALTIME_VOICE_DEFAULT,
OPENAI_REALTIME_REASONING_DEFAULT,
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
};
+16 -6
View File
@@ -6,9 +6,12 @@ import fs from 'node:fs';
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024; const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs'); const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
const REALTIME_DEBUG_LOG_FILE = path.join(REALTIME_DEBUG_LOG_DIR, 'realtime-conversations.jsonl'); const REALTIME_DEBUG_LOG_FILE = path.join(
return async (req, res) => { REALTIME_DEBUG_LOG_DIR,
'realtime-conversations.jsonl',
);
return async (req, res) => {
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.statusCode = 405; res.statusCode = 405;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
@@ -20,16 +23,23 @@ return async (req, res) => {
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES); const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
const record = JSON.parse(body || '{}'); const record = JSON.parse(body || '{}');
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true }); fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
fs.appendFileSync(REALTIME_DEBUG_LOG_FILE, `${JSON.stringify({ fs.appendFileSync(
REALTIME_DEBUG_LOG_FILE,
`${JSON.stringify({
loggedAt: new Date().toISOString(), loggedAt: new Date().toISOString(),
...record, ...record,
})}\n`); })}\n`,
);
res.statusCode = 204; res.statusCode = 204;
res.end(); res.end();
} catch (error) { } catch (error) {
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: error?.message || 'Failed to write Realtime debug log' })); res.end(
JSON.stringify({
error: error?.message || 'Failed to write Realtime debug log',
}),
);
} }
}; };
} }
+17 -7
View File
@@ -9,7 +9,7 @@ function extractOpenAiResponseText(data) {
} }
if (!Array.isArray(data?.output)) return ''; if (!Array.isArray(data?.output)) return '';
return data.output return data.output
.flatMap((item) => Array.isArray(item?.content) ? item.content : []) .flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
.map((part) => part?.text || part?.output_text || '') .map((part) => part?.text || part?.output_text || '')
.join(' ') .join(' ')
.trim(); .trim();
@@ -58,7 +58,9 @@ async function handleHudSummary(req, res) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: process.env.OPENAI_HUD_SUMMARY_MODEL || OPENAI_HUD_SUMMARY_MODEL_DEFAULT, model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: [ instructions: [
"Write one concise intelligence-HUD summary for God's Eye View.", "Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.', 'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
@@ -76,15 +78,23 @@ async function handleHudSummary(req, res) {
res.statusCode = response.ok && summary ? 200 : response.status || 502; res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ res.end(
JSON.stringify({
summary: summary || null, summary: summary || null,
error: response.ok ? null : data.error?.message || 'OpenAI HUD summary request failed', error: response.ok
})); ? null
: data.error?.message || 'OpenAI HUD summary request failed',
}),
);
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: error?.message || 'OpenAI HUD summary request failed' })); res.end(
} JSON.stringify({
error: error?.message || 'OpenAI HUD summary request failed',
}),
);
} }
}
export { handleHudSummary }; export { handleHudSummary };
+6 -4
View File
@@ -1,8 +1,10 @@
const DEFAULT_ANNOTATION_GUIDANCE =
'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
const DEFAULT_ANNOTATION_GUIDANCE = 'Use a single annotate_map call with several annotations when you are describing multiple related places at once. Set flyTo true only when the user is not already looking at the place; if every mark in a call lands off-screen the app auto-frames them, so when unsure leave flyTo false. Do NOT say out loud that you are drawing, highlighting, or annotating — just speak naturally about the places while the marks appear. ANNOTATIONS ACCUMULATE AND PERSIST — keep adding marks as you explore; you can fly around, change topic, and jump between far-apart places and the marks STAY, so the user can build up the map and show people things. Do NOT clear on your own initiative: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.'; function realtimeInstructions(
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
function realtimeInstructions(annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE) { ) {
return [ return [
"You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.", "You are GEV Voice Control, a concise voice controller for a Cesium geospatial app called God's Eye View.",
'Have a natural spoken conversation with the user while the mic session is active.', 'Have a natural spoken conversation with the user while the mic session is active.',
'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.',
+4 -1
View File
@@ -9,7 +9,10 @@ let _openAiRateLimiter;
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */ /** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
function openAiRateLimiter() { function openAiRateLimiter() {
if (_openAiRateLimiter === undefined) _openAiRateLimiter = makeOptInRateLimiter(process.env.GEV_RATELIMIT_OPENAI_PER_MIN); if (_openAiRateLimiter === undefined)
_openAiRateLimiter = makeOptInRateLimiter(
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
);
return _openAiRateLimiter; return _openAiRateLimiter;
} }
+51 -15
View File
@@ -1,11 +1,21 @@
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js'; import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { resolveVoiceModel, isKnownVoiceTier } from '../../../src/voice/voiceCost.js'; import {
import { OPENAI_REALTIME_MODEL_MINI_DEFAULT, OPENAI_REALTIME_MODEL_DEFAULT, OPENAI_REALTIME_VOICE_DEFAULT, OPENAI_REALTIME_REASONING_DEFAULT, OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT, OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT } from './constants.js'; resolveVoiceModel,
isKnownVoiceTier,
} from '../../../src/voice/voiceCost.js';
import {
OPENAI_REALTIME_MODEL_MINI_DEFAULT,
OPENAI_REALTIME_MODEL_DEFAULT,
OPENAI_REALTIME_VOICE_DEFAULT,
OPENAI_REALTIME_REASONING_DEFAULT,
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
} from './constants.js';
import { realtimeInstructions } from './instructions.js'; import { realtimeInstructions } from './instructions.js';
import { GEV_REALTIME_TOOLS } from './tools.js'; import { GEV_REALTIME_TOOLS } from './tools.js';
function createRealtimeTokenHandler({ annotationGuidance } = {}) { function createRealtimeTokenHandler({ annotationGuidance } = {}) {
return async (req, res) => { return async (req, res) => {
if (req.method !== 'GET' && req.method !== 'POST') { if (req.method !== 'GET' && req.method !== 'POST') {
res.statusCode = 405; res.statusCode = 405;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
@@ -32,7 +42,9 @@ return async (req, res) => {
// a wrong upstream model id is then a config fix, not a code change. // a wrong upstream model id is then a config fix, not a code change.
const requestedTier = (() => { const requestedTier = (() => {
try { try {
return new URL(req.url || '', 'http://localhost').searchParams.get('tier'); return new URL(req.url || '', 'http://localhost').searchParams.get(
'tier',
);
} catch { } catch {
return null; return null;
} }
@@ -40,17 +52,31 @@ return async (req, res) => {
const tier = resolveVoiceModel(requestedTier).tier; const tier = resolveVoiceModel(requestedTier).tier;
const model = const model =
tier === 'mini' tier === 'mini'
? process.env.OPENAI_REALTIME_MODEL_MINI || OPENAI_REALTIME_MODEL_MINI_DEFAULT ? process.env.OPENAI_REALTIME_MODEL_MINI ||
OPENAI_REALTIME_MODEL_MINI_DEFAULT
: process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT; : process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT;
const voice = process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT; const voice =
const effort = process.env.OPENAI_REALTIME_REASONING_EFFORT || OPENAI_REALTIME_REASONING_DEFAULT; process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
const contextTokenLimit = Math.round(Math.max( const effort =
process.env.OPENAI_REALTIME_REASONING_EFFORT ||
OPENAI_REALTIME_REASONING_DEFAULT;
const contextTokenLimit = Math.round(
Math.max(
1000, 1000,
Math.min(12000, Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) || OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT) Math.min(
)); 12000,
Number(process.env.OPENAI_REALTIME_CONTEXT_TOKENS) ||
OPENAI_REALTIME_CONTEXT_TOKENS_DEFAULT,
),
),
);
const contextRetentionRatio = Math.max( const contextRetentionRatio = Math.max(
0.1, 0.1,
Math.min(1, Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) || OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT) Math.min(
1,
Number(process.env.OPENAI_REALTIME_CONTEXT_RETENTION) ||
OPENAI_REALTIME_CONTEXT_RETENTION_DEFAULT,
),
); );
const sessionConfig = { const sessionConfig = {
session: { session: {
@@ -83,7 +109,9 @@ return async (req, res) => {
}; };
try { try {
const response = await fetch('https://api.openai.com/v1/realtime/client_secrets', { const response = await fetch(
'https://api.openai.com/v1/realtime/client_secrets',
{
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@@ -91,10 +119,14 @@ return async (req, res) => {
'OpenAI-Safety-Identifier': 'gev-local-dev', 'OpenAI-Safety-Identifier': 'gev-local-dev',
}, },
body: JSON.stringify(sessionConfig), body: JSON.stringify(sessionConfig),
}); },
);
const body = await response.text(); const body = await response.text();
res.statusCode = response.status; res.statusCode = response.status;
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json'); res.setHeader(
'Content-Type',
response.headers.get('content-type') || 'application/json',
);
// Which tier/model this secret was actually minted for. The upstream // Which tier/model this secret was actually minted for. The upstream
// body is passed through untouched (the client parses it verbatim), so // body is passed through untouched (the client parses it verbatim), so
// these headers are the authoritative echo — including the case where a // these headers are the authoritative echo — including the case where a
@@ -108,7 +140,11 @@ return async (req, res) => {
} catch (error) { } catch (error) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: error?.message || 'Failed to create Realtime token' })); res.end(
JSON.stringify({
error: error?.message || 'Failed to create Realtime token',
}),
);
} }
}; };
} }
+455 -105
View File
@@ -1,38 +1,52 @@
const GEV_REALTIME_TOOLS = [ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'fly_to_location', name: 'fly_to_location',
description: "Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.", description:
"Fly the God's Eye View camera to a known city, geocoded country/region/city/landmark, or explicit WGS84 coordinate. Countries/cities frame the whole place; landmarks/buildings use close framing.",
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
locationId: { locationId: {
type: 'string', type: 'string',
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'], enum: [
description: 'Known city preset ID. Use when the requested place matches one of these cities.', 'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
description:
'Known city preset ID. Use when the requested place matches one of these cities.',
}, },
query: { query: {
type: 'string', type: 'string',
description: 'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".', description:
'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
}, },
latitude: { type: 'number', minimum: -90, maximum: 90 }, latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 }, longitude: { type: 'number', minimum: -180, maximum: 180 },
viewMode: { viewMode: {
type: 'string', type: 'string',
enum: ['close', 'overview'], enum: ['close', 'overview'],
description: 'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.', description:
'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
}, },
rangeM: { rangeM: {
type: 'number', type: 'number',
minimum: 100, minimum: 100,
maximum: 20000000, maximum: 20000000,
description: 'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.', description:
'Optional camera range from the target in meters. Omit it for automatic whole-country/whole-city or close-landmark framing; provide it only when the user explicitly requests a numeric height or distance.',
}, },
waitForArrival: { waitForArrival: {
type: 'boolean', type: 'boolean',
description: 'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.', description:
'Set true when a later tool depends on the destination viewport. The result then waits for the camera flight and returns arrived=true; cancellation returns ok=false.',
}, },
}, },
}, },
@@ -40,7 +54,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'select_nearest_aircraft', name: 'select_nearest_aircraft',
description: 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', description:
'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -48,12 +63,23 @@ const GEV_REALTIME_TOOLS = [
layerId: { layerId: {
type: 'string', type: 'string',
enum: ['flights', 'military'], enum: ['flights', 'military'],
description: 'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.', description:
'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
}, },
locationId: { locationId: {
type: 'string', type: 'string',
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'], enum: [
description: 'Known city preset ID when the place matches one of these cities.', 'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
description:
'Known city preset ID when the place matches one of these cities.',
}, },
locationQuery: { locationQuery: {
type: 'string', type: 'string',
@@ -69,7 +95,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'adjust_camera_zoom', name: 'adjust_camera_zoom',
description: 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', description:
'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -81,7 +108,8 @@ const GEV_REALTIME_TOOLS = [
amount: { amount: {
type: 'string', type: 'string',
enum: ['little', 'medium', 'lot'], enum: ['little', 'medium', 'lot'],
description: 'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".', description:
'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
}, },
}, },
required: ['direction', 'amount'], required: ['direction', 'amount'],
@@ -90,7 +118,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'zoom_to_globe', name: 'zoom_to_globe',
description: 'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.', description:
'Pull the camera out to an ABSOLUTE full-Earth globe view (~18,000 km altitude, the whole planet in frame), keeping the current region centered. Use for "globe view", "whole earth", "see the planet", "zoom all the way out". Never use adjust_camera_zoom for these — its relative steps cannot reach the globe.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -134,7 +163,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'show_data_layers_menu', name: 'show_data_layers_menu',
description: 'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.', description:
'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -171,7 +201,16 @@ const GEV_REALTIME_TOOLS = [
properties: { properties: {
panelId: { panelId: {
type: 'string', type: 'string',
enum: ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel'], enum: [
'data-panel',
'location-bar',
'control-panel',
'cctv-panel',
'radio-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
],
}, },
open: { type: 'boolean' }, open: { type: 'boolean' },
}, },
@@ -181,7 +220,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'set_context_mode', name: 'set_context_mode',
description: 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', description:
'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -198,7 +238,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'control_cockpit', name: 'control_cockpit',
description: 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', description:
'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -206,16 +247,24 @@ const GEV_REALTIME_TOOLS = [
action: { action: {
type: 'string', type: 'string',
enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'], enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'],
description: 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', description:
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
}, },
targetLayer: { targetLayer: {
type: 'string', type: 'string',
enum: ['flights', 'military', 'ais-live-vessels', 'military-installations'], enum: [
description: 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', 'flights',
'military',
'ais-live-vessels',
'military-installations',
],
description:
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
}, },
aircraftClass: { aircraftClass: {
type: 'string', type: 'string',
description: 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', description:
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
}, },
}, },
required: ['action'], required: ['action'],
@@ -231,7 +280,15 @@ const GEV_REALTIME_TOOLS = [
properties: { properties: {
style: { style: {
type: 'string', type: 'string',
enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'], enum: [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
'snow',
],
}, },
}, },
required: ['style'], required: ['style'],
@@ -240,7 +297,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'get_entity_context', name: 'get_entity_context',
description: 'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.', description:
'Get current GEV scene context, including basemap/3D-tile target context, selected entity metadata if active, and entities currently visible in the camera view.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -248,7 +306,8 @@ const GEV_REALTIME_TOOLS = [
scope: { scope: {
type: 'string', type: 'string',
enum: ['auto', 'selected', 'in_view'], enum: ['auto', 'selected', 'in_view'],
description: 'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.', description:
'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
}, },
layerId: { layerId: {
type: 'string', type: 'string',
@@ -271,7 +330,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'get_current_view_state', name: 'get_current_view_state',
description: 'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.', description:
'Read the current camera, style, Context, Cockpit, HUD, detection, map stack, post-processing, scene-playback, tracked-entity, and layer state before choosing another action.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -281,12 +341,17 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'set_hud', name: 'set_hud',
description: 'Control the intelligence HUD overlay: visibility and/or layout variant.', description:
'Control the intelligence HUD overlay: visibility and/or layout variant.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
visible: { type: 'string', enum: ['on', 'off', 'auto'], description: 'auto restores style-driven show/hide.' }, visible: {
type: 'string',
enum: ['on', 'off', 'auto'],
description: 'auto restores style-driven show/hide.',
},
layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] }, layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] },
}, },
}, },
@@ -294,30 +359,52 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'set_detection', name: 'set_detection',
description: 'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.', description:
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
enabled: { type: 'boolean', description: 'false turns detection OFF; true restores the current density-derived profile.' }, enabled: {
type: 'boolean',
description:
'false turns detection OFF; true restores the current density-derived profile.',
},
mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] }, mode: { type: 'string', enum: ['sparse', 'balanced', 'dense'] },
densityPct: { type: 'number', description: 'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.' }, densityPct: {
allocationStrategy: { type: 'string', enum: ['elastic', 'weighted'], description: 'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.' }, type: 'number',
description:
'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.',
},
allocationStrategy: {
type: 'string',
enum: ['elastic', 'weighted'],
description:
'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.',
},
}, },
}, },
}, },
{ {
type: 'function', type: 'function',
name: 'set_map_stack', name: 'set_map_stack',
description: 'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).', description:
'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
stack: { stack: {
type: 'string', type: 'string',
enum: ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'], enum: [
description: 'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.', 'photoreal',
'bing-aerial',
'bing-labels',
'esri-imagery',
'osm',
],
description:
'photoreal = Google 3D. Use bing-aerial only when the user explicitly says "Bing aerial" — "satellite(s)" never means a basemap; only the explicit phrase "Esri" / "Esri imagery" means esri-imagery.',
}, },
}, },
required: ['stack'], required: ['stack'],
@@ -326,7 +413,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'set_post_processing', name: 'set_post_processing',
description: 'Control bloom and sharpen post-processing toggles and intensities.', description:
'Control bloom and sharpen post-processing toggles and intensities.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -336,7 +424,10 @@ const GEV_REALTIME_TOOLS = [
additionalProperties: false, additionalProperties: false,
properties: { properties: {
enabled: { type: 'boolean' }, enabled: { type: 'boolean' },
intensityPct: { type: 'number', description: '0-200 (UI percent).' }, intensityPct: {
type: 'number',
description: '0-200 (UI percent).',
},
}, },
}, },
sharpen: { sharpen: {
@@ -344,7 +435,10 @@ const GEV_REALTIME_TOOLS = [
additionalProperties: false, additionalProperties: false,
properties: { properties: {
enabled: { type: 'boolean' }, enabled: { type: 'boolean' },
intensityPct: { type: 'number', description: '0-100 (UI percent).' }, intensityPct: {
type: 'number',
description: '0-100 (UI percent).',
},
}, },
}, },
}, },
@@ -353,13 +447,20 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'control_scene', name: 'control_scene',
description: 'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.', description:
'Cinematic scene playback: list scenes, play one scene by name, stop, advance, or read status. Play starts a single named scene and returns immediately.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
action: { type: 'string', enum: ['list', 'play', 'stop', 'next', 'status'] }, action: {
sceneId: { type: 'string', description: 'Scene id or (partial) title for play.' }, type: 'string',
enum: ['list', 'play', 'stop', 'next', 'status'],
},
sceneId: {
type: 'string',
description: 'Scene id or (partial) title for play.',
},
}, },
required: ['action'], required: ['action'],
}, },
@@ -367,14 +468,38 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'control_cctv', name: 'control_cctv',
description: 'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.', description:
'CCTV camera operations: enable/disable the layer, select a camera by name, next/prev/nearest/focus, toggle coverage wedges / projection overlay / auto-hop, "viewshed" for color-coded per-camera coverage volumes, and "adjust" for the on-camera calibration gizmo.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
action: { type: 'string', enum: ['enable', 'disable', 'select', 'next', 'prev', 'nearest', 'focus', 'coverage', 'viewshed', 'adjust', 'projection', 'autohop'] }, action: {
cameraQuery: { type: 'string', description: 'Camera name or id for select.' }, type: 'string',
enabled: { type: 'boolean', description: 'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.' }, enum: [
'enable',
'disable',
'select',
'next',
'prev',
'nearest',
'focus',
'coverage',
'viewshed',
'adjust',
'projection',
'autohop',
],
},
cameraQuery: {
type: 'string',
description: 'Camera name or id for select.',
},
enabled: {
type: 'boolean',
description:
'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.',
},
}, },
required: ['action'], required: ['action'],
}, },
@@ -382,32 +507,85 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'control_radio', name: 'control_radio',
description: 'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.', description:
'Control Internet Radio playback without moving the map. Use select whenever the request includes a station category, name, country, coordinates, or nearby place—even when the user says play. Use play only for an unqualified "turn on/start the radio" request so the current or nearest station begins. Enable only reveals the Radio layer/markers without audio. Also supports disable, resume, pause, stop, next/previous, volume, and status.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
action: { action: {
type: 'string', type: 'string',
enum: ['enable', 'disable', 'play', 'resume', 'pause', 'stop', 'next', 'previous', 'volume', 'select', 'status'], enum: [
description: 'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.', 'enable',
'disable',
'play',
'resume',
'pause',
'stop',
'next',
'previous',
'volume',
'select',
'status',
],
description:
'Use select for any request qualified by category, station, country, coordinates, or place. Use play only for an unqualified turn on/start/listen request. Use enable only when the user explicitly asks to show or enable the Radio layer or its markers without requesting audio.',
},
volumePct: {
type: 'number',
minimum: 0,
maximum: 100,
description:
'Required for volume; sets the persistent Radio playback volume.',
}, },
volumePct: { type: 'number', minimum: 0, maximum: 100, description: 'Required for volume; sets the persistent Radio playback volume.' },
category: { category: {
type: 'string', type: 'string',
enum: ['all', 'news', 'talk', 'weather', 'public-safety', 'aviation-marine', 'traffic-transit', 'music'], enum: [
description: 'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.', 'all',
'news',
'talk',
'weather',
'public-safety',
'aviation-marine',
'traffic-transit',
'music',
],
description:
'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
}, },
locationId: { locationId: {
type: 'string', type: 'string',
enum: ['austin', 'sf', 'nyc', 'tokyo', 'london', 'paris', 'dubai', 'dc'], enum: [
'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
description: 'Known nearby-city anchor for select.', description: 'Known nearby-city anchor for select.',
}, },
locationQuery: { type: 'string', maxLength: 120, description: 'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.' }, locationQuery: {
type: 'string',
maxLength: 120,
description:
'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.',
},
latitude: { type: 'number', minimum: -90, maximum: 90 }, latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 }, longitude: { type: 'number', minimum: -180, maximum: 180 },
country: { type: 'string', maxLength: 80, description: 'Country code or name filter, for example US or United States.' }, country: {
stationQuery: { type: 'string', maxLength: 120, description: 'Optional station name/tag substring.' }, type: 'string',
maxLength: 80,
description:
'Country code or name filter, for example US or United States.',
},
stationQuery: {
type: 'string',
maxLength: 120,
description: 'Optional station name/tag substring.',
},
}, },
required: ['action'], required: ['action'],
}, },
@@ -415,13 +593,22 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'track_entity', name: 'track_entity',
description: 'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.', description:
'Find and follow a specific aircraft (callsign/ICAO hex), ship (name/MMSI), or satellite (name/NORAD id) on enabled layers. Camera follows the entity.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
query: { type: 'string', description: 'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.' }, query: {
layerId: { type: 'string', description: 'Optional layer hint: flights | military | ais-live-vessels | satellites.' }, type: 'string',
description:
'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.',
},
layerId: {
type: 'string',
description:
'Optional layer hint: flights | military | ais-live-vessels | satellites.',
},
}, },
required: ['query'], required: ['query'],
}, },
@@ -429,7 +616,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'stop_tracking', name: 'stop_tracking',
description: 'Stop following the tracked aircraft/satellite and clear any selected vessel.', description:
'Stop following the tracked aircraft/satellite and clear any selected vessel.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -439,13 +627,21 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'frame_overhead', name: 'frame_overhead',
description: 'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.', description:
'Cinematically frame entities near the current view: pulls the camera back and angles it so nearby aircraft, ships, or satellites are visible together.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
target: { type: 'string', enum: ['flights', 'military', 'satellites', 'vessels'] }, target: {
radiusKm: { type: 'number', description: 'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.' }, type: 'string',
enum: ['flights', 'military', 'satellites', 'vessels'],
},
radiusKm: {
type: 'number',
description:
'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.',
},
}, },
required: ['target'], required: ['target'],
}, },
@@ -453,14 +649,16 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'annotate_map', name: 'annotate_map',
description: "Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.", description:
'Draw annotations on the 3D map to visually point out what you are talking about — like sketching on a whiteboard over the world. Use this whenever you mention a specific place, building, campus, boundary, district, or a relationship between two places, so the user can SEE what you mean. Give place NAMES (preferred) or explicit lat/lng; the app resolves them to real-world positions and real building/area outlines — never guess pixel positions. Call this as you begin describing something, and you may mark several places in one call.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
annotations: { annotations: {
type: 'array', type: 'array',
description: 'One or more things to mark. Mark multiple related places together when describing them as a group.', description:
'One or more things to mark. Mark multiple related places together when describing them as a group.',
minItems: 1, minItems: 1,
maxItems: 24, maxItems: 24,
items: { items: {
@@ -470,19 +668,30 @@ const GEV_REALTIME_TOOLS = [
type: { type: {
type: 'string', type: 'string',
enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'], enum: ['pin', 'highlight', 'area', 'arrow', 'route', 'label'],
description: 'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.', description:
'pin = planted marker at a spot; highlight = pulsing ring drawing the eye to a point; area = trace the outline of a building/campus/compound/district; arrow = a connector from one place to another (use target as the origin and toTarget as the destination); route = a path through several waypoints (use the points array); label = a floating text callout.',
},
target: {
type: 'string',
maxLength: 200,
description:
'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.',
}, },
target: { type: 'string', maxLength: 200, description: 'Place name to resolve, e.g. "Palace of Fine Arts, San Francisco", "the Pentagon", "Presidio of San Francisco". Preferred over coordinates. For a specific monument/statue/feature that sits within a larger landmark, use its OWN name + city ("Tejano Monument, Austin", "Texas African American History Memorial, Austin") — do NOT phrase it as "X at the Texas State Capitol", which makes the geocoder collapse several of them onto the same centroid so they stack on one spot.' },
points: { points: {
type: 'array', type: 'array',
description: 'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).', description:
'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
minItems: 2, minItems: 2,
maxItems: 12, maxItems: 12,
items: { items: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
target: { type: 'string', maxLength: 200, description: 'Waypoint place name.' }, target: {
type: 'string',
maxLength: 200,
description: 'Waypoint place name.',
},
latitude: { type: 'number', minimum: -90, maximum: 90 }, latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 }, longitude: { type: 'number', minimum: -180, maximum: 180 },
screenX: { type: 'number', minimum: 0, maximum: 1 }, screenX: { type: 'number', minimum: 0, maximum: 1 },
@@ -493,32 +702,101 @@ const GEV_REALTIME_TOOLS = [
mode: { mode: {
type: 'string', type: 'string',
enum: ['walking', 'driving', 'cycling'], enum: ['walking', 'driving', 'cycling'],
description: 'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.', description:
'For type=route: travel mode for a real street-following route (the app returns distance + time). Pick from the verb the user used ("walk" → walking, "drive" → driving). Defaults to walking.',
},
latitude: {
type: 'number',
minimum: -90,
maximum: 90,
description:
'Explicit latitude (use only if no good place name exists).',
}, },
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Explicit latitude (use only if no good place name exists).' },
longitude: { type: 'number', minimum: -180, maximum: 180 }, longitude: { type: 'number', minimum: -180, maximum: 180 },
toTarget: { type: 'string', maxLength: 200, description: 'For type=arrow: the destination place name.' }, toTarget: {
type: 'string',
maxLength: 200,
description: 'For type=arrow: the destination place name.',
},
toLatitude: { type: 'number', minimum: -90, maximum: 90 }, toLatitude: { type: 'number', minimum: -90, maximum: 90 },
toLongitude: { type: 'number', minimum: -180, maximum: 180 }, toLongitude: { type: 'number', minimum: -180, maximum: 180 },
label: { type: 'string', maxLength: 120, description: 'Short caption shown on the map (a few words). Optional.' }, label: {
type: 'string',
maxLength: 120,
description:
'Short caption shown on the map (a few words). Optional.',
},
color: { color: {
type: 'string', type: 'string',
enum: ['primary', 'amber', 'cyan', 'green', 'red'], enum: ['primary', 'amber', 'cyan', 'green', 'red'],
description: 'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.', description:
'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
},
footprint: {
type: 'boolean',
description:
'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.',
},
intent: {
type: 'string',
enum: ['the_thing', 'around_the_thing'],
description:
'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.',
},
entityKind: {
type: 'string',
enum: [
'building',
'compound',
'district',
'street',
'point_feature',
],
description:
'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).',
},
screenX: {
type: 'number',
minimum: 0,
maximum: 1,
description:
'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.',
},
screenY: {
type: 'number',
minimum: 0,
maximum: 1,
description:
'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.',
},
toScreenX: {
type: 'number',
minimum: 0,
maximum: 1,
description:
'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).',
},
toScreenY: {
type: 'number',
minimum: 0,
maximum: 1,
description:
'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).',
}, },
footprint: { type: 'boolean', description: 'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.' },
intent: { type: 'string', enum: ['the_thing', 'around_the_thing'], description: 'For type=area: "the_thing" (default) outlines the place itself (its footprint/boundary); "around_the_thing" highlights a surrounding zone (a buffered radius around it). Infer from phrasing: "the Capitol"/"show me X" → the_thing; "around/near/by X" or "the area around X" → around_the_thing.' },
entityKind: { type: 'string', enum: ['building', 'compound', 'district', 'street', 'point_feature'], description: 'What KIND of thing the target IS — a fact, not a style choice: building = one structure; compound = campus/grounds/mall/park; district = neighborhood or area of a city; street = a named road/corridor; point_feature = monument/statue/memorial/plaque/fountain or other small point landmark. Set it whenever you know it — it routes the resolver to the right footprint source (point_feature anchors monuments as precise points instead of adopting a nearby building outline).' },
screenX: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: when you cannot name/geocode the place but can SEE it in the latest viewport screenshot, the normalized horizontal position (0=left, 1=right) of the spot. The app converts it back to a real world point under that pixel.' },
screenY: { type: 'number', minimum: 0, maximum: 1, description: 'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.' },
toScreenX: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).' },
toScreenY: { type: 'number', minimum: 0, maximum: 1, description: 'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).' },
}, },
required: ['type'], required: ['type'],
}, },
}, },
flyTo: { type: 'boolean', description: 'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.' }, flyTo: {
persist: { type: 'boolean', description: 'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).' }, type: 'boolean',
description:
'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.',
},
persist: {
type: 'boolean',
description:
'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).',
},
}, },
required: ['annotations'], required: ['annotations'],
}, },
@@ -526,7 +804,8 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'clear_annotations', name: 'clear_annotations',
description: 'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.', description:
'Erase ALL map annotations previously drawn with annotate_map. Call this ONLY when the user EXPLICITLY asks to clear or reset the map. Annotations accumulate and persist across navigation and topic changes by design — never clear on your own initiative.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -536,15 +815,29 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'move_camera', name: 'move_camera',
description: 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', description:
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
motion: { type: 'string', enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'] }, motion: {
direction: { type: 'string', enum: ['left', 'right', 'up', 'down'], description: 'Required except for orbit (defaults right/clockwise) and stop.' }, type: 'string',
enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'],
},
direction: {
type: 'string',
enum: ['left', 'right', 'up', 'down'],
description:
'Required except for orbit (defaults right/clockwise) and stop.',
},
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
mode: { type: 'string', enum: ['once', 'continuous'], description: 'once = bounded eased nudge (default); continuous = until stop/manual input.' }, mode: {
type: 'string',
enum: ['once', 'continuous'],
description:
'once = bounded eased nudge (default); continuous = until stop/manual input.',
},
}, },
required: ['motion'], required: ['motion'],
}, },
@@ -552,12 +845,16 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'fly_route', name: 'fly_route',
description: 'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.', description:
'Cinematic dolly along an EXISTING route annotation (drawn earlier with annotate_map type=route) — flies the street-following path from start to end. Omit label for the newest route. If no route is drawn, this fails with guidance: draw the route first.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
label: { type: 'string', description: 'Match an existing route mark by (partial) label.' }, label: {
type: 'string',
description: 'Match an existing route mark by (partial) label.',
},
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] }, speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
}, },
}, },
@@ -565,59 +862,112 @@ const GEV_REALTIME_TOOLS = [
{ {
type: 'function', type: 'function',
name: 'analyst_query', name: 'analyst_query',
description: 'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.', description:
'Answer questions ABOUT the data currently loaded on the map — counts, lists, superlatives, and attribute filters over live layers (flights, military, ships, fires, earthquakes). Examples: "how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet", "fastest thing in view". Queries ONLY client-side data from ENABLED layers — if the needed layer is off, say so and offer to enable it. For a follow-up about the previous answer\'s set ("which of those is closest?"), set followUp=true and send only the new filters/sort.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
layers: { layers: {
type: 'array', type: 'array',
items: { type: 'string', enum: ['flights', 'military', 'ais-live-vessels', 'local-firms', 'earthquakes'] }, items: {
description: 'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.', type: 'string',
enum: [
'flights',
'military',
'ais-live-vessels',
'local-firms',
'earthquakes',
],
},
description:
'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
}, },
scope: { scope: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
description: 'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.', description:
'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
properties: { properties: {
kind: { type: 'string', enum: ['view', 'region', 'radius', 'anywhere'] }, kind: {
name: { type: 'string', description: 'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").' }, type: 'string',
enum: ['view', 'region', 'radius', 'anywhere'],
},
name: {
type: 'string',
description:
'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").',
},
km: { type: 'number', description: 'For kind=radius.' }, km: { type: 'number', description: 'For kind=radius.' },
center: { type: 'object', additionalProperties: false, properties: { lat: { type: 'number' }, lon: { type: 'number' } } }, center: {
type: 'object',
additionalProperties: false,
properties: { lat: { type: 'number' }, lon: { type: 'number' } },
},
}, },
}, },
filters: { filters: {
type: 'array', type: 'array',
description: 'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).', description:
'Attribute predicates, ANDed. ALTITUDE IS METERS (40,000 ft = 12192). Fields: altitudeM, speedMps, military, onGround, aircraftClass, callsign, operator, routeOrigin, routeDestination, originCountry (flights); speedKts, shipType, destination (ships); frp, confidence (fires); magnitude, depthKm, place (earthquakes).',
items: { items: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
field: { type: 'string' }, field: { type: 'string' },
op: { type: 'string', enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'] }, op: {
type: 'string',
enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'],
},
value: {}, value: {},
}, },
required: ['field', 'op', 'value'], required: ['field', 'op', 'value'],
}, },
}, },
sortBy: { type: 'string', description: 'Field to rank by, or "distance" for nearest-first.' }, sortBy: {
type: 'string',
description: 'Field to rank by, or "distance" for nearest-first.',
},
sortDir: { type: 'string', enum: ['asc', 'desc'] }, sortDir: { type: 'string', enum: ['asc', 'desc'] },
limit: { type: 'number' }, limit: { type: 'number' },
followUp: { type: 'boolean', description: 'true = re-query the PREVIOUS result set instead of fresh data.' }, followUp: {
type: 'boolean',
description:
'true = re-query the PREVIOUS result set instead of fresh data.',
},
}, },
}, },
}, },
{ {
type: 'function', type: 'function',
name: 'next_iss_pass', name: 'next_iss_pass',
description: "When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.", description:
"When the user asks when the ISS / the space station will next fly over: returns the next visible ISS pass for the current camera location (or an explicit lat/lon) — rise time (ISO + minutes from now), rise compass direction, peak elevation, and duration. Requires the satellites layer to have loaded its catalog at least once this session; if it hasn't, tell the user to enable the satellites layer and try again.",
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
latitude: { type: 'number', minimum: -90, maximum: 90, description: 'Optional observer latitude. Omit to use the current camera position.' }, latitude: {
longitude: { type: 'number', minimum: -180, maximum: 180, description: 'Optional observer longitude. Omit to use the current camera position.' }, type: 'number',
minElevationDeg: { type: 'number', minimum: 5, maximum: 60, description: 'Minimum peak elevation (deg) to count as a pass. Default 10.' }, minimum: -90,
maximum: 90,
description:
'Optional observer latitude. Omit to use the current camera position.',
},
longitude: {
type: 'number',
minimum: -180,
maximum: 180,
description:
'Optional observer longitude. Omit to use the current camera position.',
},
minElevationDeg: {
type: 'number',
minimum: 5,
maximum: 60,
description:
'Minimum peak elevation (deg) to count as a pass. Default 10.',
},
}, },
}, },
}, },
+41 -12
View File
@@ -1,9 +1,23 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { readRequestBodyCapped } from './common/request.js'; import { readRequestBodyCapped } from './common/request.js';
import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT } from './overpass/constants.js'; import {
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
} from './overpass/constants.js';
import { sanitizeOverpassBody } from './overpass/query.js'; import { sanitizeOverpassBody } from './overpass/query.js';
import { resolveOverpassPreflight, _overpassCache, readOverpassDisk, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk } from './overpass/cache.js'; import {
import { overpassPayloadIsData, fetchOverpassPayload } from './overpass/transport.js'; resolveOverpassPreflight,
_overpassCache,
readOverpassDisk,
overpassDiskTtlMs,
readStaleOverpass,
trimOverpassCache,
writeOverpassDisk,
} from './overpass/cache.js';
import {
overpassPayloadIsData,
fetchOverpassPayload,
} from './overpass/transport.js';
import { installRouteMiddleware } from './places/routes.js'; import { installRouteMiddleware } from './places/routes.js';
/** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */ /** @type {Map<string,Promise>} In-flight Overpass requests keyed by normalized query body. */
@@ -11,7 +25,11 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0; let _overpassConcurrent = 0;
const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, globalMax: 300 }); const _overpassRateLimiter = makeRateLimiter({
windowMs: 60_000,
max: 90,
globalMax: 300,
});
/** /**
* Write a completed Overpass payload to the HTTP response. * Write a completed Overpass payload to the HTTP response.
@@ -57,7 +75,9 @@ function overpassProxy() {
// Collect POST body with a hard byte cap (Overpass QL queries are small) // Collect POST body with a hard byte cap (Overpass QL queries are small)
let body; let body;
try { try {
body = (await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)).toString(); body = (
await readRequestBodyCapped(req, OVERPASS_MAX_BODY_BYTES)
).toString();
} catch (err) { } catch (err) {
if (err?.code === 'BODY_TOO_LARGE') { if (err?.code === 'BODY_TOO_LARGE') {
res.writeHead(413, { 'Content-Type': 'application/json' }); res.writeHead(413, { 'Content-Type': 'application/json' });
@@ -90,11 +110,15 @@ function overpassProxy() {
inFlight: _overpassInFlight, inFlight: _overpassInFlight,
// Fresh-enough disk entries survive restarts and skip the public // Fresh-enough disk entries survive restarts and skip the public
// mirrors; boundary-class queries keep their month-long TTL. // mirrors; boundary-class queries keep their month-long TTL.
readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)), allowUpstream: () => _overpassRateLimiter(clientKey(req)),
}); });
if (preflight.source === 'RATE_LIMITED') { if (preflight.source === 'RATE_LIMITED') {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '5',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' })); res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return; return;
} }
@@ -119,8 +143,15 @@ function overpassProxy() {
// From here onward the request is genuinely upstream-bound and has // From here onward the request is genuinely upstream-bound and has
// consumed one local limiter slot. Cache and dedupe hits above do not. // consumed one local limiter slot. Cache and dedupe hits above do not.
if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) {
res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '2' }); res.writeHead(503, {
res.end(JSON.stringify({ error: 'Overpass proxy busy — try again shortly' })); 'Content-Type': 'application/json',
'Retry-After': '2',
});
res.end(
JSON.stringify({
error: 'Overpass proxy busy — try again shortly',
}),
);
return; return;
} }
_overpassConcurrent += 1; _overpassConcurrent += 1;
@@ -158,9 +189,7 @@ function overpassProxy() {
sendOverpassResponse(res, payload, 'MISS'); sendOverpassResponse(res, payload, 'MISS');
} catch (e) { } catch (e) {
// Every mirror threw (network-level). Same serve-stale rule. // Every mirror threw (network-level). Same serve-stale rule.
const stale = cacheKey const stale = cacheKey ? await readStaleOverpass(cacheKey) : null;
? await readStaleOverpass(cacheKey)
: null;
if (stale) { if (stale) {
sendOverpassResponse(res, stale, 'STALE'); sendOverpassResponse(res, stale, 'STALE');
return; return;
+45 -10
View File
@@ -1,5 +1,11 @@
import { isOverpassBoundaryQuery } from './query.js'; import { isOverpassBoundaryQuery } from './query.js';
import { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES } from './constants.js'; import {
OVERPASS_BOUNDARY_DISK_TTL_MS,
OVERPASS_DISK_TTL_MS,
OVERPASS_DISK_DIR,
OVERPASS_CACHE_MS,
OVERPASS_CACHE_MAX_ENTRIES,
} from './constants.js';
import path from 'node:path'; import path from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs'; import { promises as fsp } from 'node:fs';
@@ -10,12 +16,17 @@ const _overpassCache = new Map();
/** Disk TTL for a query: boundary geometry keeps for a month, the rest 7 days. */ /** Disk TTL for a query: boundary geometry keeps for a month, the rest 7 days. */
function overpassDiskTtlMs(cacheKey) { function overpassDiskTtlMs(cacheKey) {
return isOverpassBoundaryQuery(cacheKey) ? OVERPASS_BOUNDARY_DISK_TTL_MS : OVERPASS_DISK_TTL_MS; return isOverpassBoundaryQuery(cacheKey)
? OVERPASS_BOUNDARY_DISK_TTL_MS
: OVERPASS_DISK_TTL_MS;
} }
/** Normalized Overpass query -> stable disk-cache file path. */ /** Normalized Overpass query -> stable disk-cache file path. */
function overpassDiskPath(cacheKey) { function overpassDiskPath(cacheKey) {
return path.join(OVERPASS_DISK_DIR, `${createHash('sha1').update(cacheKey).digest('hex')}.json`); return path.join(
OVERPASS_DISK_DIR,
`${createHash('sha1').update(cacheKey).digest('hex')}.json`,
);
} }
/** /**
@@ -27,7 +38,12 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
try { try {
const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8'); const raw = await fsp.readFile(overpassDiskPath(cacheKey), 'utf8');
const payload = JSON.parse(raw); const payload = JSON.parse(raw);
if (!payload || typeof payload.body !== 'string' || !Number.isFinite(payload.cachedAt)) return null; if (
!payload ||
typeof payload.body !== 'string' ||
!Number.isFinite(payload.cachedAt)
)
return null;
// Older versions persisted 4xx refusals with normal data TTLs. Ignore // Older versions persisted 4xx refusals with normal data TTLs. Ignore
// them on both fresh and stale reads so an upgrade can recover immediately. // them on both fresh and stale reads so an upgrade can recover immediately.
if (!overpassPayloadIsData(payload)) return null; if (!overpassPayloadIsData(payload)) return null;
@@ -40,9 +56,17 @@ async function readOverpassDisk(cacheKey, maxAgeMs) {
/** Fire-and-forget disk write for a successful Overpass payload. */ /** Fire-and-forget disk write for a successful Overpass payload. */
function writeOverpassDisk(cacheKey, payload) { function writeOverpassDisk(cacheKey, payload) {
fsp.mkdir(OVERPASS_DISK_DIR, { recursive: true }) fsp
.then(() => fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload))) .mkdir(OVERPASS_DISK_DIR, { recursive: true })
.catch((err) => console.warn('[Overpass Proxy] disk cache write failed:', err?.message || err)); .then(() =>
fsp.writeFile(overpassDiskPath(cacheKey), JSON.stringify(payload)),
)
.catch((err) =>
console.warn(
'[Overpass Proxy] disk cache write failed:',
err?.message || err,
),
);
} }
/** /**
@@ -71,7 +95,8 @@ async function resolveOverpassPreflight({
cacheMs = OVERPASS_CACHE_MS, cacheMs = OVERPASS_CACHE_MS,
}) { }) {
const cached = memoryCache.get(cacheKey); const cached = memoryCache.get(cacheKey);
if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached }; if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs)
return { source: 'HIT', payload: cached };
const pending = inFlight.get(cacheKey); const pending = inFlight.get(cacheKey);
if (pending) return { source: 'INFLIGHT', payload: await pending }; if (pending) return { source: 'INFLIGHT', payload: await pending };
@@ -87,7 +112,9 @@ async function resolveOverpassPreflight({
/** Return only last-good Overpass data, regardless of its age. */ /** Return only last-good Overpass data, regardless of its age. */
async function readStaleOverpass(cacheKey) { async function readStaleOverpass(cacheKey) {
const cached = _overpassCache.get(cacheKey); const cached = _overpassCache.get(cacheKey);
return overpassPayloadIsData(cached) ? cached : readOverpassDisk(cacheKey, Infinity); return overpassPayloadIsData(cached)
? cached
: readOverpassDisk(cacheKey, Infinity);
} }
/** Evict oldest Overpass cache entries until size is within the cap. */ /** Evict oldest Overpass cache entries until size is within the cap. */
@@ -99,4 +126,12 @@ function trimOverpassCache() {
} }
} }
export { readOverpassDisk, resolveOverpassPreflight, _overpassCache, overpassDiskTtlMs, readStaleOverpass, trimOverpassCache, writeOverpassDisk }; export {
readOverpassDisk,
resolveOverpassPreflight,
_overpassCache,
overpassDiskTtlMs,
readStaleOverpass,
trimOverpassCache,
writeOverpassDisk,
};
+30 -4
View File
@@ -97,12 +97,38 @@ const OVERPASS_MAX_BBOX_DEG = 12;
const OVERPASS_ELEMENT_TYPES = 'node|way|relation|nwr|nw|nr|wr|rel'; const OVERPASS_ELEMENT_TYPES = 'node|way|relation|nwr|nw|nr|wr|rel';
/** Element-selector (incl. `area`) whose statements must be individually bounded. */ /** Element-selector (incl. `area`) whose statements must be individually bounded. */
const OVERPASS_SELECTOR_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`); const OVERPASS_SELECTOR_RE = new RegExp(
`\\b(?:${OVERPASS_ELEMENT_TYPES}|area)\\b`,
);
/** An element selector bounded BY an area — the country-scan abuse shape. */ /** An element selector bounded BY an area — the country-scan abuse shape. */
const OVERPASS_AREA_ELEMENT_RE = new RegExp(`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`, 'i'); const OVERPASS_AREA_ELEMENT_RE = new RegExp(
`\\b(?:${OVERPASS_ELEMENT_TYPES})\\s*\\(\\s*area\\b`,
'i',
);
/** A single bbox 4-tuple `(s,w,n,e)` (non-global so it does not advance lastIndex). */ /** A single bbox 4-tuple `(s,w,n,e)` (non-global so it does not advance lastIndex). */
const OVERPASS_BBOX_RE = /\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/; const OVERPASS_BBOX_RE =
/\(\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*\)/;
export { OVERPASS_BOUNDARY_DISK_TTL_MS, OVERPASS_DISK_TTL_MS, OVERPASS_DISK_DIR, OVERPASS_CACHE_MS, OVERPASS_CACHE_MAX_ENTRIES, OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT, OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG, OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS }; export {
OVERPASS_BOUNDARY_DISK_TTL_MS,
OVERPASS_DISK_TTL_MS,
OVERPASS_DISK_DIR,
OVERPASS_CACHE_MS,
OVERPASS_CACHE_MAX_ENTRIES,
OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT,
OVERPASS_MAX_AROUND_M,
OVERPASS_MAX_BBOX_DEG,
OVERPASS_AREA_ELEMENT_RE,
OVERPASS_SELECTOR_RE,
OVERPASS_BBOX_RE,
OVERPASS_MAX_QL_TIMEOUT,
OVERPASS_SIMPLIFY_MIN_BYTES,
OVERPASS_SIMPLIFY_MIN_POINTS,
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
OVERPASS_MAX_RESPONSE_BYTES,
OVERPASS_UPSTREAMS,
OVERPASS_TIMEOUT_MS,
};
+13 -4
View File
@@ -1,4 +1,8 @@
import { OVERPASS_SIMPLIFY_MIN_BYTES, OVERPASS_SIMPLIFY_MIN_POINTS, OVERPASS_SIMPLIFY_TOLERANCE_DEG } from './constants.js'; import {
OVERPASS_SIMPLIFY_MIN_BYTES,
OVERPASS_SIMPLIFY_MIN_POINTS,
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
} from './constants.js';
/** Iterative Douglas-Peucker on [{lat,lon},...] (planar-degree approx — fine at /** Iterative Douglas-Peucker on [{lat,lon},...] (planar-degree approx — fine at
* the ~44 m tolerance used here). Endpoints always kept. */ * the ~44 m tolerance used here). Endpoints always kept. */
@@ -51,7 +55,10 @@ function simplifyElementGeometry(el, minPoints, toleranceDeg) {
} }
if (Array.isArray(el?.members)) { if (Array.isArray(el?.members)) {
for (const member of el.members) { for (const member of el.members) {
if (Array.isArray(member?.geometry) && member.geometry.length >= minPoints) { if (
Array.isArray(member?.geometry) &&
member.geometry.length >= minPoints
) {
member.geometry = douglasPeucker(member.geometry, toleranceDeg); member.geometry = douglasPeucker(member.geometry, toleranceDeg);
} }
} }
@@ -73,7 +80,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
const minBytes = opts.minBytes ?? OVERPASS_SIMPLIFY_MIN_BYTES; const minBytes = opts.minBytes ?? OVERPASS_SIMPLIFY_MIN_BYTES;
const minPoints = opts.minPoints ?? OVERPASS_SIMPLIFY_MIN_POINTS; const minPoints = opts.minPoints ?? OVERPASS_SIMPLIFY_MIN_POINTS;
const toleranceDeg = opts.toleranceDeg ?? OVERPASS_SIMPLIFY_TOLERANCE_DEG; const toleranceDeg = opts.toleranceDeg ?? OVERPASS_SIMPLIFY_TOLERANCE_DEG;
if (typeof bodyText !== 'string' || bodyText.length < minBytes) return bodyText; if (typeof bodyText !== 'string' || bodyText.length < minBytes)
return bodyText;
let data; let data;
try { try {
data = JSON.parse(bodyText); data = JSON.parse(bodyText);
@@ -81,7 +89,8 @@ function simplifyOverpassPayloadBody(bodyText, opts = {}) {
return bodyText; return bodyText;
} }
if (!Array.isArray(data?.elements)) return bodyText; if (!Array.isArray(data?.elements)) return bodyText;
for (const el of data.elements) simplifyElementGeometry(el, minPoints, toleranceDeg); for (const el of data.elements)
simplifyElementGeometry(el, minPoints, toleranceDeg);
try { try {
return JSON.stringify(data); return JSON.stringify(data);
} catch { } catch {
+54 -17
View File
@@ -1,4 +1,11 @@
import { OVERPASS_MAX_AROUND_M, OVERPASS_MAX_BBOX_DEG, OVERPASS_AREA_ELEMENT_RE, OVERPASS_SELECTOR_RE, OVERPASS_BBOX_RE, OVERPASS_MAX_QL_TIMEOUT } from './constants.js'; import {
OVERPASS_MAX_AROUND_M,
OVERPASS_MAX_BBOX_DEG,
OVERPASS_AREA_ELEMENT_RE,
OVERPASS_SELECTOR_RE,
OVERPASS_BBOX_RE,
OVERPASS_MAX_QL_TIMEOUT,
} from './constants.js';
/** /**
* Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups * Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups
@@ -41,8 +48,14 @@ function stripOverpassNoise(src) {
const quote = c; const quote = c;
i += 1; i += 1;
while (i < n) { while (i < n) {
if (src[i] === '\\') { i += 2; continue; } // escaped char if (src[i] === '\\') {
if (src[i] === quote) { i += 1; break; } // closing quote i += 2;
continue;
} // escaped char
if (src[i] === quote) {
i += 1;
break;
} // closing quote
i += 1; i += 1;
} }
out += quote + quote; // collapse the literal to empty quotes out += quote + quote; // collapse the literal to empty quotes
@@ -69,11 +82,17 @@ function stripOverpassNoise(src) {
function sanitizeOverpassBody(rawBody) { function sanitizeOverpassBody(rawBody) {
let params; let params;
try { params = new URLSearchParams(rawBody); } catch { return { ok: false, error: 'Malformed query body' }; } try {
params = new URLSearchParams(rawBody);
} catch {
return { ok: false, error: 'Malformed query body' };
}
const all = params.getAll('data'); const all = params.getAll('data');
if (all.length !== 1) return { ok: false, error: 'Exactly one data query is required' }; if (all.length !== 1)
return { ok: false, error: 'Exactly one data query is required' };
const data = all[0]; const data = all[0];
if (!data || !data.trim()) return { ok: false, error: 'Missing Overpass data query' }; if (!data || !data.trim())
return { ok: false, error: 'Missing Overpass data query' };
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a // Blank quoted literals + strip comments in one lexer pass so a fake bound or a
// `//` inside a string can't hide an unbounded selector (or satisfy a bound). // `//` inside a string can't hide an unbounded selector (or satisfy a bound).
@@ -89,9 +108,17 @@ function sanitizeOverpassBody(rawBody) {
} }
} }
// Reject world-sized / oversized bboxes. // Reject world-sized / oversized bboxes.
for (const m of stripped.matchAll(/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g)) { for (const m of stripped.matchAll(
const s = Number(m[1]); const w = Number(m[2]); const n = Number(m[3]); const e = Number(m[4]); /\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g,
if (Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG || Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG) { )) {
const s = Number(m[1]);
const w = Number(m[2]);
const n = Number(m[3]);
const e = Number(m[4]);
if (
Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG ||
Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG
) {
return { ok: false, error: 'Overpass bbox too large' }; return { ok: false, error: 'Overpass bbox too large' };
} }
} }
@@ -122,7 +149,10 @@ function sanitizeOverpassBody(rawBody) {
// misread as a spatial bound. Bounds live in (...) / function calls / set // misread as a spatial bound. Bounds live in (...) / function calls / set
// refs, never inside [...], so the probe loses nothing real. // refs, never inside [...], so the probe loses nothing real.
const outSets = []; const outSets = [];
const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; }); const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name);
return ' ';
});
const probe = body.replace(/\[[^\]]*\]/g, ' '); const probe = body.replace(/\[[^\]]*\]/g, ' ');
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter // Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
@@ -131,15 +161,21 @@ function sanitizeOverpassBody(rawBody) {
// SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/ // SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/
// relation(area...). The probe collapses tags so `way (area.a)` is caught. // relation(area...). The probe collapses tags so `way (area.a)` is caught.
if (OVERPASS_AREA_ELEMENT_RE.test(probe)) { if (OVERPASS_AREA_ELEMENT_RE.test(probe)) {
return { ok: false, error: 'Overpass area-bounded element selector not allowed' }; return {
ok: false,
error: 'Overpass area-bounded element selector not allowed',
};
} }
const hasSelector = OVERPASS_SELECTOR_RE.test(probe); const hasSelector = OVERPASS_SELECTOR_RE.test(probe);
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map((m) => m[1]); const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map(
const directBound = /around:\s*\d/.test(probe) (m) => m[1],
|| OVERPASS_BBOX_RE.test(probe) );
|| /is_in\s*\(/.test(probe) // is_in(lat,lon) — the function form only const directBound =
|| /\barea\s*\(/.test(probe); // area(id) — bounded as a set definition /around:\s*\d/.test(probe) ||
OVERPASS_BBOX_RE.test(probe) ||
/is_in\s*\(/.test(probe) || // is_in(lat,lon) — the function form only
/\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
const setBound = inputSets.some((s) => boundedSets.has(s)); const setBound = inputSets.some((s) => boundedSets.has(s));
const bounded = directBound || setBound; const bounded = directBound || setBound;
@@ -153,7 +189,8 @@ function sanitizeOverpassBody(rawBody) {
const clamped = data.replace( const clamped = data.replace(
/\[timeout:\s*(\d+)\s*\]/gi, /\[timeout:\s*(\d+)\s*\]/gi,
(_, n) => `[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`, (_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
); );
return { ok: true, body: `data=${encodeURIComponent(clamped)}` }; return { ok: true, body: `data=${encodeURIComponent(clamped)}` };
} }
+36 -17
View File
@@ -1,4 +1,8 @@
import { OVERPASS_MAX_RESPONSE_BYTES, OVERPASS_UPSTREAMS, OVERPASS_TIMEOUT_MS } from './constants.js'; import {
OVERPASS_MAX_RESPONSE_BYTES,
OVERPASS_UPSTREAMS,
OVERPASS_TIMEOUT_MS,
} from './constants.js';
import { readResponseTextCapped } from '../common/http.js'; import { readResponseTextCapped } from '../common/http.js';
import { simplifyOverpassPayloadBody } from './geometry.js'; import { simplifyOverpassPayloadBody } from './geometry.js';
@@ -13,10 +17,12 @@ import { simplifyOverpassPayloadBody } from './geometry.js';
*/ */
function overpassLooksRateLimited(bodyText) { function overpassLooksRateLimited(bodyText) {
const text = String(bodyText || '').toLowerCase(); const text = String(bodyText || '').toLowerCase();
return text.includes('rate_limited') return (
|| text.includes('quota of your ip address') text.includes('rate_limited') ||
|| text.includes('dispatcher_client::request_read_and_idx::rate_limited') text.includes('quota of your ip address') ||
|| text.includes('too many requests'); text.includes('dispatcher_client::request_read_and_idx::rate_limited') ||
text.includes('too many requests')
);
} }
/** /**
@@ -26,9 +32,11 @@ function overpassLooksRateLimited(bodyText) {
*/ */
function overpassLooksRuntimeError(bodyText) { function overpassLooksRuntimeError(bodyText) {
const text = String(bodyText || '').toLowerCase(); const text = String(bodyText || '').toLowerCase();
return text.includes('runtime error') return (
|| text.includes('timed out') text.includes('runtime error') ||
|| text.includes('out of memory'); text.includes('timed out') ||
text.includes('out of memory')
);
} }
/** /**
@@ -42,10 +50,13 @@ function overpassLooksRuntimeError(bodyText) {
*/ */
function overpassPayloadIsData(payload) { function overpassPayloadIsData(payload) {
const status = Number(payload?.status); const status = Number(payload?.status);
return Number.isFinite(status) return (
&& status >= 200 && status < 300 Number.isFinite(status) &&
&& !payload.rateLimited status >= 200 &&
&& !payload.runtimeError; status < 300 &&
!payload.rateLimited &&
!payload.runtimeError
);
} }
/** /**
@@ -57,12 +68,16 @@ function overpassPayloadIsData(payload) {
* @param {object} [options] Server-only endpoint and I/O overrides for tests. * @param {object} [options] Server-only endpoint and I/O overrides for tests.
* @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>} * @returns {Promise<{status:number,body:string,contentType:string,endpoint:string,rateLimited:boolean}>}
*/ */
async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES, { async function fetchOverpassPayload(
body,
maxResponseBytes = OVERPASS_MAX_RESPONSE_BYTES,
{
endpoints = OVERPASS_UPSTREAMS, endpoints = OVERPASS_UPSTREAMS,
fetchImpl = fetch, fetchImpl = fetch,
readBody = readResponseTextCapped, readBody = readResponseTextCapped,
simplify = simplifyOverpassPayloadBody, simplify = simplifyOverpassPayloadBody,
} = {}) { } = {},
) {
let lastError = null; let lastError = null;
let lastRateLimitPayload = null; let lastRateLimitPayload = null;
let lastRefusalPayload = null; let lastRefusalPayload = null;
@@ -83,9 +98,11 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
}); });
const responseBody = await readBody(upstream, maxResponseBytes); const responseBody = await readBody(upstream, maxResponseBytes);
const contentType = upstream.headers.get('content-type') || 'application/json'; const contentType =
upstream.headers.get('content-type') || 'application/json';
const status = upstream.status; const status = upstream.status;
const rateLimited = status === 429 || overpassLooksRateLimited(responseBody); const rateLimited =
status === 429 || overpassLooksRateLimited(responseBody);
const runtimeError = overpassLooksRuntimeError(responseBody); const runtimeError = overpassLooksRuntimeError(responseBody);
const payload = { const payload = {
status, status,
@@ -116,7 +133,9 @@ async function fetchOverpassPayload(body, maxResponseBytes = OVERPASS_MAX_RESPON
// every mirror has had the chance to answer it. // every mirror has had the chance to answer it.
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {
if (!lastRefusalPayload) lastRefusalPayload = payload; if (!lastRefusalPayload) lastRefusalPayload = payload;
lastError = new Error(`Overpass upstream returned ${status} (${endpoint})`); lastError = new Error(
`Overpass upstream returned ${status} (${endpoint})`,
);
continue; continue;
} }
+4 -1
View File
@@ -1,3 +1,6 @@
export { regionalBriefProxy, regionalBriefHasAnySource } from './regional/briefing.js'; export {
regionalBriefProxy,
regionalBriefHasAnySource,
} from './regional/briefing.js';
export { weatherEffectsProxy } from './regional/weather-effects.js'; export { weatherEffectsProxy } from './regional/weather-effects.js';
export { validRegionalPoint } from './regional/query.js'; export { validRegionalPoint } from './regional/query.js';
+40 -10
View File
@@ -18,7 +18,11 @@ const _regionalBriefCache = new Map();
const _regionalBriefInFlight = new Map(); const _regionalBriefInFlight = new Map();
const _regionalBriefRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 90 }); const _regionalBriefRateLimiter = makeRateLimiter({
windowMs: 60_000,
max: 30,
globalMax: 90,
});
function trimRegionalBriefCache() { function trimRegionalBriefCache() {
while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) { while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) {
@@ -40,13 +44,15 @@ function regionalBriefProxy() {
fetchRegionalWeather(point), fetchRegionalWeather(point),
]); ]);
const place = placeResult.status === 'fulfilled' ? placeResult.value : null; const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
const weather = weatherResult.status === 'fulfilled' ? weatherResult.value : null; const weather =
weatherResult.status === 'fulfilled' ? weatherResult.value : null;
const news = await fetchRegionalNews(place); const news = await fetchRegionalNews(place);
if (!regionalBriefHasAnySource({ place, weather, news })) { if (!regionalBriefHasAnySource({ place, weather, news })) {
throw new Error('All regional briefing sources unavailable'); throw new Error('All regional briefing sources unavailable');
} }
const payload = { const payload = {
status: place && weather && news.status !== 'unavailable' ? 'ready' : 'partial', status:
place && weather && news.status !== 'unavailable' ? 'ready' : 'partial',
retrievedAt: new Date().toISOString(), retrievedAt: new Date().toISOString(),
coordinates: point, coordinates: point,
place, place,
@@ -71,7 +77,10 @@ function regionalBriefProxy() {
return; return;
} }
if (!_regionalBriefRateLimiter(clientKey(req))) { if (!_regionalBriefRateLimiter(clientKey(req))) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' }); res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' })); res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return; return;
} }
@@ -79,18 +88,28 @@ function regionalBriefProxy() {
const point = validRegionalPoint(url.searchParams); const point = validRegionalPoint(url.searchParams);
if (!point) { if (!point) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' })); res.end(
JSON.stringify({
error: 'Valid latitude and longitude are required',
}),
);
return; return;
} }
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`; const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
const now = Date.now(); const now = Date.now();
const cached = _regionalBriefCache.get(key); const cached = _regionalBriefCache.get(key);
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) { if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_CACHE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60', 'X-Regional-Brief': 'HIT' }); res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60',
'X-Regional-Brief': 'HIT',
});
res.end(JSON.stringify({ ...cached.payload, status: 'cached' })); res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
return; return;
} }
const request = coalesceProxyRequest(_regionalBriefInFlight, key, () => refresh(point, key)); const request = coalesceProxyRequest(_regionalBriefInFlight, key, () =>
refresh(point, key),
);
try { try {
const payload = await request.promise; const payload = await request.promise;
res.writeHead(200, { res.writeHead(200, {
@@ -101,12 +120,23 @@ function regionalBriefProxy() {
res.end(JSON.stringify(payload)); res.end(JSON.stringify(payload));
} catch { } catch {
if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) { if (cached && now - cached.cachedAt <= REGIONAL_BRIEF_STALE_MS) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Regional-Brief': 'STALE' }); res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'X-Regional-Brief': 'STALE',
});
res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
return; return;
} }
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(503, {
res.end(JSON.stringify({ error: 'Regional briefing is temporarily unavailable' })); 'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'Regional briefing is temporarily unavailable',
}),
);
} }
}); });
} }
+14 -5
View File
@@ -1,12 +1,18 @@
import { readResponseJsonCapped, readResponseTextCapped } from '../common/http.js'; import {
readResponseJsonCapped,
readResponseTextCapped,
} from '../common/http.js';
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
async function fetchRegionalJson(url, { async function fetchRegionalJson(
url,
{
headers = {}, headers = {},
timeoutMs = 9000, timeoutMs = 9000,
maxBytes = REGIONAL_MAX_RESPONSE_BYTES, maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
} = {}) { } = {},
) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
@@ -18,11 +24,14 @@ async function fetchRegionalJson(url, {
} }
} }
async function fetchRegionalText(url, { async function fetchRegionalText(
url,
{
headers = {}, headers = {},
timeoutMs = 9000, timeoutMs = 9000,
maxBytes = REGIONAL_MAX_RESPONSE_BYTES, maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
} = {}) { } = {},
) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
+47 -15
View File
@@ -4,24 +4,39 @@ import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js';
function decodeRssText(value) { function decodeRssText(value) {
return String(value || '') return String(value || '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'") .replace(/&amp;/g, '&')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>') .replace(/&quot;/g, '"')
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); .replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
} }
function rssTag(block, tag) { function rssTag(block, tag) {
return decodeRssText(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(block)?.[1] || ''); return decodeRssText(
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(
block,
)?.[1] || '',
);
} }
function normalizeRssArticles(xml, limit = 5) { function normalizeRssArticles(xml, limit = 5) {
const seen = new Set(); const seen = new Set();
const articles = []; const articles = [];
for (const match of String(xml || '').matchAll(/<item>([\s\S]*?)<\/item>/gi)) { for (const match of String(xml || '').matchAll(
/<item>([\s\S]*?)<\/item>/gi,
)) {
const item = match[1]; const item = match[1];
const title = rssTag(item, 'title').slice(0, 180); const title = rssTag(item, 'title').slice(0, 180);
const url = rssTag(item, 'link'); const url = rssTag(item, 'link');
let parsedUrl; let parsedUrl;
try { parsedUrl = new URL(url); } catch { continue; } try {
parsedUrl = new URL(url);
} catch {
continue;
}
if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue; if (!title || !['http:', 'https:'].includes(parsedUrl.protocol)) continue;
const source = rssTag(item, 'source'); const source = rssTag(item, 'source');
const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`; const signature = `${title.toLowerCase()}|${source.toLowerCase() || parsedUrl.hostname}`;
@@ -32,7 +47,9 @@ function normalizeRssArticles(xml, limit = 5) {
title, title,
url: parsedUrl.href, url: parsedUrl.href,
domain: source || parsedUrl.hostname.replace(/^www\./, ''), domain: source || parsedUrl.hostname.replace(/^www\./, ''),
publishedAt: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(), publishedAt: Number.isNaN(Date.parse(rawDate))
? null
: new Date(rawDate).toISOString(),
sourceCountry: null, sourceCountry: null,
}); });
if (articles.length >= limit) break; if (articles.length >= limit) break;
@@ -42,7 +59,8 @@ function normalizeRssArticles(xml, limit = 5) {
async function fetchRegionalNews(place) { async function fetchRegionalNews(place) {
const query = place?.locality || place?.region || place?.country; const query = place?.locality || place?.region || place?.country;
if (!query) return { status: 'unavailable', query: null, articles: [], source: null }; if (!query)
return { status: 'unavailable', query: null, articles: [], source: null };
const rssParams = new URLSearchParams({ const rssParams = new URLSearchParams({
q: String(query).replace(/["\\]/g, ' ').trim(), q: String(query).replace(/["\\]/g, ' ').trim(),
hl: 'en-US', hl: 'en-US',
@@ -50,13 +68,19 @@ async function fetchRegionalNews(place) {
ceid: 'US:en', ceid: 'US:en',
}); });
try { try {
const xml = await fetchRegionalText(`https://news.google.com/rss/search?${rssParams}`, { const xml = await fetchRegionalText(
`https://news.google.com/rss/search?${rssParams}`,
{
headers: { 'User-Agent': 'GodsEyeView/0.1' }, headers: { 'User-Agent': 'GodsEyeView/0.1' },
timeoutMs: 12_000, timeoutMs: 12_000,
}); },
);
const articles = normalizeRssArticles(xml, 5); const articles = normalizeRssArticles(xml, 5);
if (articles.length) return { status: 'ready', query, articles, source: 'Google News RSS' }; if (articles.length)
} catch { /* fall through to the existing free index */ } return { status: 'ready', query, articles, source: 'Google News RSS' };
} catch {
/* fall through to the existing free index */
}
const params = new URLSearchParams({ const params = new URLSearchParams({
query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`, query: `"${String(query).replace(/["\\]/g, ' ').trim()}"`,
mode: 'artlist', mode: 'artlist',
@@ -66,12 +90,20 @@ async function fetchRegionalNews(place) {
timespan: '48h', timespan: '48h',
}); });
try { try {
const payload = await fetchRegionalJson(`https://api.gdeltproject.org/api/v2/doc/doc?${params}`, { const payload = await fetchRegionalJson(
`https://api.gdeltproject.org/api/v2/doc/doc?${params}`,
{
headers: { 'User-Agent': 'GodsEyeView/0.1' }, headers: { 'User-Agent': 'GodsEyeView/0.1' },
timeoutMs: 12_000, timeoutMs: 12_000,
}); },
);
const articles = normalizeRegionalArticles(payload, 5); const articles = normalizeRegionalArticles(payload, 5);
return { status: articles.length ? 'ready' : 'empty', query, articles, source: 'GDELT fallback' }; return {
status: articles.length ? 'ready' : 'empty',
query,
articles,
source: 'GDELT fallback',
};
} catch { } catch {
return { status: 'unavailable', query, articles: [], source: null }; return { status: 'unavailable', query, articles: [], source: null };
} }
+7 -3
View File
@@ -18,12 +18,16 @@ function fetchRegionalPlace(point) {
addressdetails: '1', addressdetails: '1',
'accept-language': 'en', 'accept-language': 'en',
}); });
const payload = await fetchRegionalJson(`https://nominatim.openstreetmap.org/reverse?${params}`, { const payload = await fetchRegionalJson(
`https://nominatim.openstreetmap.org/reverse?${params}`,
{
headers: { headers: {
'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', 'User-Agent':
'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view', Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
}, },
}); },
);
return normalizeRegionalPlace(payload); return normalizeRegionalPlace(payload);
}); });
_nominatimQueue = task.catch(() => null); _nominatimQueue = task.catch(() => null);
+2 -1
View File
@@ -4,7 +4,8 @@ function validRegionalPoint(params) {
const latitude = requiredFiniteQueryNumber(params, 'latitude'); const latitude = requiredFiniteQueryNumber(params, 'latitude');
const longitude = requiredFiniteQueryNumber(params, 'longitude'); const longitude = requiredFiniteQueryNumber(params, 'longitude');
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null; if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180)
return null;
return { latitude, longitude }; return { latitude, longitude };
} }
+26 -6
View File
@@ -13,7 +13,11 @@ const _weatherEffectsCache = new Map();
const _weatherEffectsInFlight = new Map(); const _weatherEffectsInFlight = new Map();
const _weatherEffectsRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 45, globalMax: 120 }); const _weatherEffectsRateLimiter = makeRateLimiter({
windowMs: 60_000,
max: 45,
globalMax: 120,
});
function trimWeatherEffectsCache() { function trimWeatherEffectsCache() {
while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) { while (_weatherEffectsCache.size > WEATHER_EFFECTS_MAX_CACHE) {
@@ -46,7 +50,10 @@ function weatherEffectsProxy() {
return; return;
} }
if (!_weatherEffectsRateLimiter(clientKey(req))) { if (!_weatherEffectsRateLimiter(clientKey(req))) {
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '10' }); res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' })); res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return; return;
} }
@@ -54,7 +61,11 @@ function weatherEffectsProxy() {
const point = validRegionalPoint(url.searchParams); const point = validRegionalPoint(url.searchParams);
if (!point) { if (!point) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Valid latitude and longitude are required' })); res.end(
JSON.stringify({
error: 'Valid latitude and longitude are required',
}),
);
return; return;
} }
const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`; const key = `${(Math.round(point.latitude * 10) / 10).toFixed(1)},${(Math.round(point.longitude * 10) / 10).toFixed(1)}`;
@@ -69,7 +80,9 @@ function weatherEffectsProxy() {
res.end(JSON.stringify({ ...cached.payload, status: 'cached' })); res.end(JSON.stringify({ ...cached.payload, status: 'cached' }));
return; return;
} }
const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () => refresh(point, key)); const request = coalesceProxyRequest(_weatherEffectsInFlight, key, () =>
refresh(point, key),
);
try { try {
const payload = await request.promise; const payload = await request.promise;
res.writeHead(200, { res.writeHead(200, {
@@ -88,8 +101,15 @@ function weatherEffectsProxy() {
res.end(JSON.stringify({ ...cached.payload, status: 'stale' })); res.end(JSON.stringify({ ...cached.payload, status: 'stale' }));
return; return;
} }
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(503, {
res.end(JSON.stringify({ error: 'Weather effects are temporarily unavailable' })); 'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: 'Weather effects are temporarily unavailable',
}),
);
} }
}); });
} }
+7 -3
View File
@@ -7,13 +7,17 @@ async function fetchRegionalWeather(point) {
const params = new URLSearchParams({ const params = new URLSearchParams({
latitude: point.latitude.toFixed(5), latitude: point.latitude.toFixed(5),
longitude: point.longitude.toFixed(5), longitude: point.longitude.toFixed(5),
current: 'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility', current:
'temperature_2m,apparent_temperature,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,visibility',
timezone: 'UTC', timezone: 'UTC',
}); });
try { try {
const payload = await fetchRegionalJson(`https://api.open-meteo.com/v1/forecast?${params}`, { const payload = await fetchRegionalJson(
`https://api.open-meteo.com/v1/forecast?${params}`,
{
maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES, maxBytes: WEATHER_EFFECTS_MAX_RESPONSE_BYTES,
}); },
);
return normalizeRegionalWeather(payload); return normalizeRegionalWeather(payload);
} catch { } catch {
return null; return null;
+64 -21
View File
@@ -1,4 +1,11 @@
import { knownKeySetupEnvVars, admitKeySetupRequest, isKeySetupExternallyManaged, keySetupStatus, validateKeySetupUpdates, upsertDotenvValues } from '../../src/keySetupCore.mjs'; import {
knownKeySetupEnvVars,
admitKeySetupRequest,
isKeySetupExternallyManaged,
keySetupStatus,
validateKeySetupUpdates,
upsertDotenvValues,
} from '../../src/keySetupCore.mjs';
import { defaultSourceRoot } from '../providers/common/source-root.js'; import { defaultSourceRoot } from '../providers/common/source-root.js';
import path from 'node:path'; import path from 'node:path';
import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs'; import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scripts/pinokio-environment.mjs';
@@ -23,9 +30,15 @@ const LAUNCHER_AT_BOOT = process.env.GEV_LAUNCHER;
* Recomputing the snapshot there would classify the panel's own keys as * Recomputing the snapshot there would classify the panel's own keys as
* external (read-only) until the whole process is relaunched. * external (read-only) until the whole process is relaunched.
*/ */
const PROVIDER_ENV_AT_BOOT = globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??= Object.freeze(Object.fromEntries( const PROVIDER_ENV_AT_BOOT = (globalThis.__GEV_PROVIDER_ENV_AT_BOOT ??=
[...knownKeySetupEnvVars()].map((name) => [name, String(process.env[name] ?? '').trim()]), Object.freeze(
)); Object.fromEntries(
[...knownKeySetupEnvVars()].map((name) => [
name,
String(process.env[name] ?? '').trim(),
]),
),
));
/** /**
* `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so * `dev-fresh.sh` resolves dotenv and Keychain values before it starts Vite, so
@@ -79,8 +92,13 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a // `GEV_LAUNCHER=pinokio` line in someone's .env would silently redirect a
// plain `npm run dev` to write the Pinokio store it never loaded. // plain `npm run dev` to write the Pinokio store it never loaded.
const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio'; const pinokioManaged = () => LAUNCHER_AT_BOOT === 'pinokio';
const storeName = () => (pinokioManaged() ? 'pinokio-environment' : 'env-file'); const storeName = () =>
const storePath = () => path.join(sourceRoot, ...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env'])); pinokioManaged() ? 'pinokio-environment' : 'env-file';
const storePath = () =>
path.join(
sourceRoot,
...(pinokioManaged() ? ['pinokio', 'ENVIRONMENT'] : ['.env']),
);
// Read the store, distinguishing "no store yet" from "cannot read this // Read the store, distinguishing "no store yet" from "cannot read this
// store". Only ENOENT means empty. Every other failure — a permission error, // store". Only ENOENT means empty. Every other failure — a permission error,
// an I/O fault, an undecodable file — must ABORT the save: upserting into a // an I/O fault, an undecodable file — must ABORT the save: upserting into a
@@ -97,7 +115,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
return fs.readFileSync(storePath(), 'utf8'); return fs.readFileSync(storePath(), 'utf8');
} catch (error) { } catch (error) {
if (error?.code === 'ENOENT') return ''; // The first saved key births the file. if (error?.code === 'ENOENT') return ''; // The first saved key births the file.
const unreadable = new Error('the existing configuration could not be read, so nothing was changed'); const unreadable = new Error(
'the existing configuration could not be read, so nothing was changed',
);
unreadable.code = 'GEV_STORE_UNREADABLE'; unreadable.code = 'GEV_STORE_UNREADABLE';
throw unreadable; throw unreadable;
} }
@@ -114,7 +134,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
}; };
// The gate itself is pure and unit-tested (admitKeySetupRequest in // The gate itself is pure and unit-tested (admitKeySetupRequest in
// src/keySetupCore.mjs) — this just feeds it the request. // src/keySetupCore.mjs) — this just feeds it the request.
const admit = (req) => admitKeySetupRequest({ const admit = (req) =>
admitKeySetupRequest({
method: req.method, method: req.method,
remoteAddress: req.socket?.remoteAddress, remoteAddress: req.socket?.remoteAddress,
hostHeader: req.headers?.host, hostHeader: req.headers?.host,
@@ -147,7 +168,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// offered); 'external' = supplied by env/Keychain/another workflow // offered); 'external' = supplied by env/Keychain/another workflow
// (read-only — the panel must never rewrite or delete it). // (read-only — the panel must never rewrite or delete it).
key.managed = key.set key.managed = key.set
? (key.envVars.some((name) => isExternallyManaged(name, inStore)) ? 'external' : 'file') ? key.envVars.some((name) => isExternallyManaged(name, inStore))
? 'external'
: 'file'
: null; : null;
} }
return { ...status, store: storeName() }; return { ...status, store: storeName() };
@@ -161,7 +184,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// Never write THROUGH a symlink into a credential path. // Never write THROUGH a symlink into a credential path.
try { try {
if (fs.lstatSync(filepath).isSymbolicLink()) { if (fs.lstatSync(filepath).isSymbolicLink()) {
throw new Error('refusing to write a credential store that is a symlink'); throw new Error(
'refusing to write a credential store that is a symlink',
);
} }
} catch (error) { } catch (error) {
if (error.code !== 'ENOENT') throw error; // absent is fine — first save. if (error.code !== 'ENOENT') throw error; // absent is fine — first save.
@@ -183,7 +208,9 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// hardening failure aborts with the previous store fully intact and the // hardening failure aborts with the previous store fully intact and the
// secret never on disk unprotected — no rollback path to get wrong. // secret never on disk unprotected — no rollback path to get wrong.
if (!hardenCredentialFile(tmp)) { if (!hardenCredentialFile(tmp)) {
const error = new Error('could not restrict the credential file to your account; nothing was saved'); const error = new Error(
'could not restrict the credential file to your account; nothing was saved',
);
error.code = 'GEV_HARDEN_FAILED'; error.code = 'GEV_HARDEN_FAILED';
throw error; throw error;
} }
@@ -215,18 +242,23 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// only install via configureServer (never configurePreviewServer), so they // only install via configureServer (never configurePreviewServer), so they
// are absent from preview today — but pinning apply here makes that a // are absent from preview today — but pinning apply here makes that a
// guarantee rather than an accident of which hook a future edit uses. // guarantee rather than an accident of which hook a future edit uses.
apply: (_config, { command, isPreview }) => command === 'serve' && !isPreview, apply: (_config, { command, isPreview }) =>
command === 'serve' && !isPreview,
configureServer(server) { configureServer(server) {
server.middlewares.use('/api/setup/status', (req, res) => { server.middlewares.use('/api/setup/status', (req, res) => {
if (req.method !== 'GET') return respond(res, 405, { error: 'Method not allowed' }); if (req.method !== 'GET')
return respond(res, 405, { error: 'Method not allowed' });
const admission = admit(req); const admission = admit(req);
if (!admission.ok) return respond(res, admission.status, { error: admission.error }); if (!admission.ok)
return respond(res, admission.status, { error: admission.error });
respond(res, 200, providerStatus()); respond(res, 200, providerStatus());
}); });
server.middlewares.use('/api/setup/keys', (req, res) => { server.middlewares.use('/api/setup/keys', (req, res) => {
if (req.method !== 'POST') return respond(res, 405, { error: 'Method not allowed' }); if (req.method !== 'POST')
return respond(res, 405, { error: 'Method not allowed' });
const admission = admit(req); const admission = admit(req);
if (!admission.ok) return respond(res, admission.status, { error: admission.error }); if (!admission.ok)
return respond(res, admission.status, { error: admission.error });
let body = ''; let body = '';
let overflowed = false; let overflowed = false;
req.on('data', (chunk) => { req.on('data', (chunk) => {
@@ -237,7 +269,8 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
} }
}); });
req.on('end', () => { req.on('end', () => {
if (overflowed) return respond(res, 413, { error: 'Request too large' }); if (overflowed)
return respond(res, 413, { error: 'Request too large' });
let parsed; let parsed;
try { try {
parsed = JSON.parse(body || '{}'); parsed = JSON.parse(body || '{}');
@@ -266,10 +299,17 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// "saved world-readable" must never be reported as a generic write // "saved world-readable" must never be reported as a generic write
// error. Everything else returns a fixed message (a raw filesystem // error. Everything else returns a fixed message (a raw filesystem
// error can carry an absolute path; that stays in the server log). // error can carry an absolute path; that stays in the server log).
if (error?.code === 'GEV_HARDEN_FAILED' || error?.code === 'GEV_STORE_UNREADABLE') { if (
return respond(res, 500, { error: `The key was not saved: ${error.message}` }); error?.code === 'GEV_HARDEN_FAILED' ||
error?.code === 'GEV_STORE_UNREADABLE'
) {
return respond(res, 500, {
error: `The key was not saved: ${error.message}`,
});
} }
return respond(res, 500, { error: `Could not write the ${storeName()} store` }); return respond(res, 500, {
error: `Could not write the ${storeName()} store`,
});
} }
// Live for the server-side proxies immediately; the restart below is // Live for the server-side proxies immediately; the restart below is
// what re-injects the client-exposed defines (Google, Cesium ion). // what re-injects the client-exposed defines (Google, Cesium ion).
@@ -289,7 +329,10 @@ function keySetupEndpoint({ sourceRoot = defaultSourceRoot } = {}) {
// .env watcher may fire too; a second queued restart is harmless. // .env watcher may fire too; a second queued restart is harmless.
setTimeout(() => { setTimeout(() => {
server.restart().catch((error) => { server.restart().catch((error) => {
console.warn('[KeySetup] Dev-server restart failed:', error?.message || error); console.warn(
'[KeySetup] Dev-server restart failed:',
error?.message || error,
);
}); });
}, 250); }, 250);
}); });
+160 -33
View File
@@ -1,12 +1,21 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { mkdtempSync, readFileSync, existsSync, rmSync, statSync } from 'node:fs'; import {
mkdtempSync,
readFileSync,
existsSync,
rmSync,
statSync,
} from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { overpassProxy } from 'gods-eye-view/server/providers/overpass'; import { overpassProxy } from 'gods-eye-view/server/providers/overpass';
import { militaryInstallationsProxy } from 'gods-eye-view/server/providers/military-installations'; import { militaryInstallationsProxy } from 'gods-eye-view/server/providers/military-installations';
import { regionalBriefProxy, weatherEffectsProxy } from 'gods-eye-view/server/providers/regional'; import {
regionalBriefProxy,
weatherEffectsProxy,
} from 'gods-eye-view/server/providers/regional';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai'; import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup'; import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
@@ -20,24 +29,57 @@ function install(plugin, preview = false) {
}); });
return routes; return routes;
} }
function request(handler, { method = 'GET', url = '/', body = '', origin = 'http://localhost:4173' } = {}) { function request(
handler,
{
method = 'GET',
url = '/',
body = '',
origin = 'http://localhost:4173',
} = {},
) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = Readable.from(body ? [Buffer.from(body)] : []); const req = Readable.from(body ? [Buffer.from(body)] : []);
Object.assign(req, { method, url, headers: { host: 'localhost:4173', origin, 'content-type': 'application/json' }, socket: { remoteAddress: '127.0.0.1' } }); Object.assign(req, {
method,
url,
headers: {
host: 'localhost:4173',
origin,
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const headers = {}; const headers = {};
const res = { const res = {
statusCode: 200, statusCode: 200,
setHeader(name, value) { headers[name.toLowerCase()] = value; }, setHeader(name, value) {
writeHead(status, values) { this.statusCode = status; for (const [k, v] of Object.entries(values)) this.setHeader(k, v); }, headers[name.toLowerCase()] = value;
end(body = '') { resolve({ status: this.statusCode, headers, body: String(body), json: () => JSON.parse(String(body)) }); }, },
writeHead(status, values) {
this.statusCode = status;
for (const [k, v] of Object.entries(values)) this.setHeader(k, v);
},
end(body = '') {
resolve({
status: this.statusCode,
headers,
body: String(body),
json: () => JSON.parse(String(body)),
});
},
}; };
Promise.resolve(handler(req, res)).catch(reject); Promise.resolve(handler(req, res)).catch(reject);
}); });
} }
function env(t, name, value) { function env(t, name, value) {
const old = process.env[name]; const old = process.env[name];
if (value === undefined) delete process.env[name]; else process.env[name] = value; if (value === undefined) delete process.env[name];
t.after(() => { if (old === undefined) delete process.env[name]; else process.env[name] = old; }); else process.env[name] = value;
t.after(() => {
if (old === undefined) delete process.env[name];
else process.env[name] = old;
});
} }
function root(t) { function root(t) {
const dir = mkdtempSync(path.join(tmpdir(), 'gev-services-')); const dir = mkdtempSync(path.join(tmpdir(), 'gev-services-'));
@@ -46,12 +88,29 @@ function root(t) {
} }
test('standalone service guards run in development and preview without upstream acquisition', async (t) => { test('standalone service guards run in development and preview without upstream acquisition', async (t) => {
t.mock.method(globalThis, 'fetch', () => { throw Error('invalid requests must not fetch'); }); t.mock.method(globalThis, 'fetch', () => {
throw Error('invalid requests must not fetch');
});
for (const preview of [false, true]) { for (const preview of [false, true]) {
for (const [factory, route] of [[overpassProxy, '/api/overpass'], [militaryInstallationsProxy, '/api/military-installations'], [regionalBriefProxy, '/api/regional-brief'], [weatherEffectsProxy, '/api/weather-effects']]) { for (const [factory, route] of [
[overpassProxy, '/api/overpass'],
[militaryInstallationsProxy, '/api/military-installations'],
[regionalBriefProxy, '/api/regional-brief'],
[weatherEffectsProxy, '/api/weather-effects'],
]) {
const routes = install(factory(), preview); const routes = install(factory(), preview);
assert.equal((await request(routes.get(route), { method: 'DELETE' })).status, 405); assert.equal(
assert.equal((await request(routes.get(route), { method: route === '/api/overpass' ? 'POST' : 'GET' })).status, 400); (await request(routes.get(route), { method: 'DELETE' })).status,
405,
);
assert.equal(
(
await request(routes.get(route), {
method: route === '/api/overpass' ? 'POST' : 'GET',
})
).status,
400,
);
} }
} }
}); });
@@ -64,17 +123,36 @@ test('weather-only requests share upstream work and retain fresh and stale respo
calls++; calls++;
assert.equal(new URL(url).hostname, 'api.open-meteo.com'); assert.equal(new URL(url).hostname, 'api.open-meteo.com');
if (calls > 1) throw Error('offline'); if (calls > 1) throw Error('offline');
await new Promise(resolve => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
return Response.json({ current: { time: '2026-09-12T12:00', temperature_2m: 20, weather_code: 0, wind_speed_10m: 10 } }); return Response.json({
current: {
time: '2026-09-12T12:00',
temperature_2m: 20,
weather_code: 0,
wind_speed_10m: 10,
},
});
}); });
const handler = install(weatherEffectsProxy()).get('/api/weather-effects'); const handler = install(weatherEffectsProxy()).get('/api/weather-effects');
const query = { url: '/?latitude=34.61&longitude=-112.43' }; const query = { url: '/?latitude=34.61&longitude=-112.43' };
const pair = await Promise.all([request(handler, query), request(handler, query)]); const pair = await Promise.all([
assert.deepEqual(pair.map(r => r.headers['x-weather-effects']).sort(), ['INFLIGHT', 'MISS']); request(handler, query),
request(handler, query),
]);
assert.deepEqual(pair.map((r) => r.headers['x-weather-effects']).sort(), [
'INFLIGHT',
'MISS',
]);
assert.equal(calls, 1); assert.equal(calls, 1);
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'HIT'); assert.equal(
(await request(handler, query)).headers['x-weather-effects'],
'HIT',
);
now += 6 * 60_000; now += 6 * 60_000;
assert.equal((await request(handler, query)).headers['x-weather-effects'], 'STALE'); assert.equal(
(await request(handler, query)).headers['x-weather-effects'],
'STALE',
);
}); });
test('Realtime handler preserves tools and default instructions, isolates supplied annotation guidance, and keeps the upstream key server-side', async (t) => { test('Realtime handler preserves tools and default instructions, isolates supplied annotation guidance, and keeps the upstream key server-side', async (t) => {
@@ -83,17 +161,33 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
const sent = []; const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => { t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(url, 'https://api.openai.com/v1/realtime/client_secrets'); assert.equal(url, 'https://api.openai.com/v1/realtime/client_secrets');
assert.equal(options.headers.Authorization, 'Bearer fixture-upstream-secret'); assert.equal(
options.headers.Authorization,
'Bearer fixture-upstream-secret',
);
sent.push(JSON.parse(options.body)); sent.push(JSON.parse(options.body));
return Response.json({ value: 'fixture-ephemeral' }); return Response.json({ value: 'fixture-ephemeral' });
}); });
for (const [options, guidance] of [[{}, undefined], [{ annotationGuidance: 'Fixture annotation instruction.' }, 'Fixture annotation instruction.'], [{}, undefined]]) { for (const [options, guidance] of [
const response = await request(install(openAiRealtimeProxy(options)).get('/api/realtime/token'), { url: '/?tier=unknown' }); [{}, undefined],
[
{ annotationGuidance: 'Fixture annotation instruction.' },
'Fixture annotation instruction.',
],
[{}, undefined],
]) {
const response = await request(
install(openAiRealtimeProxy(options)).get('/api/realtime/token'),
{ url: '/?tier=unknown' },
);
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(response.headers['x-gev-voice-tier'], 'standard'); assert.equal(response.headers['x-gev-voice-tier'], 'standard');
assert.equal(response.headers['x-gev-voice-tier-fallback'], '1'); assert.equal(response.headers['x-gev-voice-tier-fallback'], '1');
assert.equal(response.body.includes('fixture-upstream-secret'), false); assert.equal(response.body.includes('fixture-upstream-secret'), false);
assert.equal(sent.at(-1).session.instructions, realtimeInstructions(guidance)); assert.equal(
sent.at(-1).session.instructions,
realtimeInstructions(guidance),
);
assert.deepEqual(sent.at(-1).session.tools, GEV_REALTIME_TOOLS); assert.deepEqual(sent.at(-1).session.tools, GEV_REALTIME_TOOLS);
} }
assert.notEqual(sent[0].session.instructions, sent[1].session.instructions); assert.notEqual(sent[0].session.instructions, sent[1].session.instructions);
@@ -101,30 +195,63 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
}); });
test('debug logging resolves each supplied application directory independently', async (t) => { test('debug logging resolves each supplied application directory independently', async (t) => {
const first = root(t), second = root(t); const first = root(t),
for (const [sourceRoot, marker] of [[first, 'first'], [second, 'second']]) { second = root(t);
const handler = install(openAiRealtimeProxy({ sourceRoot })).get('/api/realtime/debug-log'); for (const [sourceRoot, marker] of [
assert.equal((await request(handler, { method: 'POST', body: JSON.stringify({ marker }) })).status, 204); [first, 'first'],
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'); [second, 'second'],
]) {
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
'/api/realtime/debug-log',
);
assert.equal(
(
await request(handler, {
method: 'POST',
body: JSON.stringify({ marker }),
})
).status,
204,
);
const file = path.join(
sourceRoot,
'.gev-logs/realtime-conversations.jsonl',
);
assert.equal(JSON.parse(readFileSync(file, 'utf8')).marker, marker); assert.equal(JSON.parse(readFileSync(file, 'utf8')).marker, marker);
} }
}); });
test('key setup writes only the supplied application root, retains request guards and stays absent from preview', async (t) => { test('key setup writes only the supplied application root, retains request guards and stays absent from preview', async (t) => {
const first = root(t), untouched = root(t); const first = root(t),
untouched = root(t);
env(t, 'OPENAI_API_KEY', undefined); env(t, 'OPENAI_API_KEY', undefined);
const plugin = keySetupEndpoint({ sourceRoot: first }); const plugin = keySetupEndpoint({ sourceRoot: first });
assert.equal(plugin.apply({}, { command: 'serve', isPreview: true }), false); assert.equal(plugin.apply({}, { command: 'serve', isPreview: true }), false);
assert.equal(plugin.configurePreviewServer, undefined); assert.equal(plugin.configurePreviewServer, undefined);
const routes = install(plugin); const routes = install(plugin);
const handler = routes.get('/api/setup/keys'); const handler = routes.get('/api/setup/keys');
const body = JSON.stringify({ OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key' }); const body = JSON.stringify({
assert.equal((await request(handler, { method: 'POST', body, origin: 'https://example.com' })).status, 403); OPENAI_API_KEY: 'sk-fixture-only-not-a-real-key',
});
assert.equal(
(
await request(handler, {
method: 'POST',
body,
origin: 'https://example.com',
})
).status,
403,
);
assert.equal(existsSync(path.join(first, '.env')), false); assert.equal(existsSync(path.join(first, '.env')), false);
const saved = await request(handler, { method: 'POST', body }); const saved = await request(handler, { method: 'POST', body });
assert.equal(saved.status, 200); assert.equal(saved.status, 200);
assert.match(readFileSync(path.join(first, '.env'), 'utf8'), /OPENAI_API_KEY=sk-fixture-only-not-a-real-key/); assert.match(
if (process.platform !== 'win32') assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600); readFileSync(path.join(first, '.env'), 'utf8'),
/OPENAI_API_KEY=sk-fixture-only-not-a-real-key/,
);
if (process.platform !== 'win32')
assert.equal(statSync(path.join(first, '.env')).mode & 0o777, 0o600);
assert.equal(saved.body.includes('sk-fixture-only-not-a-real-key'), false); assert.equal(saved.body.includes('sk-fixture-only-not-a-real-key'), false);
assert.equal(existsSync(path.join(untouched, '.env')), false); assert.equal(existsSync(path.join(untouched, '.env')), false);
}); });