Merge remote-tracking branch 'origin/main' into fix/sanitize-openai-proxy-errors

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Pedro Lobato committed 2026-09-15 11:58:25 -05:00
commit 92350b8412
332 files changed
+56273 -6359

No files matched your search

+5 -29
View File
@@ -1,3 +1,8 @@
import {
normalizeFeedType,
isVideoFeedType,
} from '../../../src/sources/cctvTypes.js';
export { normalizeFeedType, isVideoFeedType };
import { directionToHeading } from '../../../src/data/directionText.js';
import { haversineKm } from '../common/geo.js';
/**
@@ -31,35 +36,6 @@ export function escapeXml(text) {
.replace(/'/g, ''');
}
/**
* Canonicalize a CCTV feed type string to one of:
* 'image', 'mjpeg', 'mp4', 'webm', 'hls', or pass-through.
*
* @param {string} value - Raw feed type (e.g. 'jpeg', 'mjpg', 'video', 'stream').
* @returns {string} Normalized feed type.
*/
export function normalizeFeedType(value) {
const raw = String(value || '')
.trim()
.toLowerCase();
if (!raw) return 'image';
if (raw === 'jpeg' || raw === 'jpg' || raw === 'png') return 'image';
if (raw === 'mjpg') return 'mjpeg';
if (raw === 'video') return 'mp4';
if (raw === 'stream') return 'hls';
return raw;
}
/**
* Check whether a normalized feed type represents streaming video.
*
* @param {string} feedType
* @returns {boolean}
*/
export function isVideoFeedType(feedType) {
return feedType === 'mp4' || feedType === 'webm' || feedType === 'hls';
}
/**
* Coerce a value to a finite number, returning fallback if NaN/Infinity.
*
+6 -71
View File
@@ -1,74 +1,9 @@
/**
* Read a fetch() Response body as text with a hard byte cap. Rejects early on an
* oversized Content-Length, then streams with a running cap so a chunked or
* length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }.
*/
export async function readResponseTextCapped(response, maxBytes, signal) {
const tooLarge = () =>
Object.assign(new Error('Upstream response too large'), {
code: 'RESPONSE_TOO_LARGE',
});
const declared = Number(response.headers.get('content-length'));
if (Number.isFinite(declared) && declared > maxBytes) {
void response.body?.cancel().catch(() => {});
throw tooLarge();
}
signal?.throwIfAborted();
const reader = response.body?.getReader?.();
if (!reader) {
const text = await response.text();
signal?.throwIfAborted();
if (Buffer.byteLength(text) > maxBytes) throw tooLarge();
return text;
}
const cancel = () => {
void reader.cancel().catch(() => {});
};
signal?.addEventListener('abort', cancel, { once: true });
const decoder = new TextDecoder();
let out = '';
let total = 0;
try {
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw tooLarge();
out += decoder.decode(value, { stream: true });
}
return out + decoder.decode();
} catch (error) {
cancel();
throw error;
} finally {
signal?.removeEventListener('abort', cancel);
reader.releaseLock();
}
}
/** Parse a fetch() JSON response only after enforcing a hard byte cap. */
export async function readResponseJsonCapped(response, maxBytes) {
return JSON.parse(await readResponseTextCapped(response, maxBytes));
}
/**
* Return the existing promise for a cache key, or create one and remove it
* only when that exact promise settles.
*/
export function coalesceProxyRequest(inFlight, key, create) {
const existing = inFlight.get(key);
if (existing) return { promise: existing, shared: true };
let promise;
promise = Promise.resolve()
.then(create)
.finally(() => {
if (inFlight.get(key) === promise) inFlight.delete(key);
});
inFlight.set(key, promise);
return { promise, shared: false };
}
export {
readResponseTextCapped,
readResponseJsonCapped,
readResponseBytesCapped,
coalesceProxyRequest,
} from '../../../src/sources/httpBody.js';
/**
* Read a fetch Response body as text while enforcing a hard byte cap during
+2 -35
View File
@@ -1,38 +1,5 @@
/**
* Minimal fixed-window per-key rate limiter for the dev proxies. Not a hard
* security boundary (dev-only), just a backstop so a runaway client can't hammer
* the public Overpass / OSRM mirrors or exhaust this process.
*/
const RATE_LIMITER_MAX_KEYS = 2000;
export function makeRateLimiter({ windowMs, max, globalMax }) {
const hits = new Map(); // key -> number[] (timestamps within window)
let globalTimes = []; // all hits in window, for the global backstop
return function allow(key) {
const now = Date.now();
globalTimes = globalTimes.filter((t) => now - t < windowMs);
if (globalMax && globalTimes.length >= globalMax) return false; // global backstop
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs);
if (recent.length >= max) {
hits.set(key, recent);
return false;
}
recent.push(now);
hits.set(key, recent);
globalTimes.push(now);
// Hard key cap so a key-rotating caller can't grow the map without bound.
if (hits.size > RATE_LIMITER_MAX_KEYS) {
const oldest = hits.keys().next().value;
if (oldest !== undefined) hits.delete(oldest);
}
if (hits.size > 256) {
for (const [k, v] of hits) {
if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k);
}
}
return true;
};
}
import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
/**
* Opt-in per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
+4
View File
@@ -7,11 +7,13 @@ import { adsbdbProxy } from './aircraft/enrichment.js';
import { overpassProxy } from './overpass.js';
import { militaryInstallationsProxy } from './military-installations.js';
import { regionalBriefProxy } from './regional/briefing.js';
import { geocodeProxy } from './regional/place.js';
import { weatherEffectsProxy } from './regional/weather-effects.js';
import { cctvProxy } from './cctv.js';
import { defaultSourceRoot } from './common/source-root.js';
import { radioBrowserProxy } from './radio.js';
import { gbfsProxy } from './gbfs.js';
import { transitProxy } from './transit.js';
import { adsbLolProxy } from './aircraft/adsb-lol.js';
import { aisLiveProxy } from './vessels/ais-live.js';
import { trackBackfillProxies } from './aircraft/tracks.js';
@@ -32,10 +34,12 @@ function localProviderPlugins() {
overpassProxy(),
militaryInstallationsProxy(),
regionalBriefProxy(),
geocodeProxy(),
weatherEffectsProxy(),
cctvProxy({ sourceRoot: defaultSourceRoot }),
radioBrowserProxy(),
gbfsProxy(),
transitProxy(),
adsbLolProxy(),
aisLiveProxy(),
trackBackfillProxies(),
+574
View File
@@ -0,0 +1,574 @@
export const ACTION_DESCRIPTIONS = {
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.",
$position: 1,
parameters: {
properties: {
locationId: {
description:
'Known city preset ID. Use when the requested place matches one of these cities.',
$position: 2,
},
query: {
description:
'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
$position: 1,
},
viewMode: {
description:
'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
$position: 2,
},
rangeM: {
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.',
$position: 3,
},
waitForArrival: {
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.',
$position: 1,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
layerId: {
description:
'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
$position: 2,
},
locationId: {
description:
'Known city preset ID when the place matches one of these cities.',
$position: 2,
},
locationQuery: {
description: 'Free-form destination when no locationId matches.',
$position: 2,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
amount: {
description:
'Use little for phrases like "a bit" or "a little", medium for ordinary zoom requests, and lot for "way out/in".',
$position: 2,
},
},
},
},
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.',
$position: 1,
},
set_layer_visibility: {
description: "Enable or disable one registered God's Eye View data layer.",
$position: 1,
parameters: {
properties: {
layerId: {
description:
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
$position: 1,
},
},
},
},
show_data_layers_menu: {
description:
'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
$position: 1,
parameters: {
properties: {
layerId: {
description: 'Optional layer row to scroll into view and highlight.',
$position: 2,
},
},
},
},
set_panel_open: {
description: 'Open or close a GEV UI panel/dropdown.',
$position: 1,
},
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.',
$position: 1,
parameters: {
properties: {
mode: {
description: 'Use off to exit context mode.',
$position: 2,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
action: {
description:
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
$position: 2,
},
targetLayer: {
description:
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
$position: 2,
},
aircraftClass: {
description:
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
$position: 1,
},
},
},
},
set_visual_style: {
description: "Set the active God's Eye View visual filter/style.",
$position: 1,
},
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.',
$position: 1,
parameters: {
properties: {
scope: {
description:
'Use auto by default. selected returns the clicked/selected entity; in_view returns visible entities near the screen center.',
$position: 2,
},
layerId: {
description: 'Optional layer filter for visible entity context.',
$position: 2,
},
},
},
},
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.',
$position: 1,
},
set_hud: {
description:
'Control the intelligence HUD overlay: visibility and/or layout variant.',
$position: 1,
parameters: {
properties: {
visible: {
description: 'auto restores style-driven show/hide.',
$position: 2,
},
},
},
},
set_detection: {
description:
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
$position: 1,
parameters: {
properties: {
enabled: {
description:
'false turns detection OFF; true restores the current density-derived profile.',
$position: 1,
},
densityPct: {
description:
'Density snaps to 0, 25, 50, 75, or 100 and derives the active profile.',
$position: 1,
},
allocationStrategy: {
description:
'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.',
$position: 2,
},
},
},
},
set_map_stack: {
description:
'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
$position: 1,
parameters: {
properties: {
stack: {
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.',
$position: 2,
},
},
},
},
set_post_processing: {
description:
'Control bloom and sharpen post-processing toggles and intensities.',
$position: 1,
parameters: {
properties: {
bloom: {
properties: {
intensityPct: {
description: '0-200 (UI percent).',
$position: 1,
},
},
},
sharpen: {
properties: {
intensityPct: {
description: '0-100 (UI percent).',
$position: 1,
},
},
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
sceneId: {
description: 'Scene id or (partial) title for play.',
$position: 1,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
cameraQuery: {
description: 'Camera name or id for select.',
$position: 1,
},
enabled: {
description:
'Explicit on/off for coverage/viewshed/adjust/projection/autohop; omit to toggle.',
$position: 1,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
action: {
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.',
$position: 2,
},
volumePct: {
description:
'Required for volume; sets the persistent Radio playback volume.',
$position: 3,
},
category: {
description:
'Station category for select/next/previous. When the user requests playback with a category, action must be select, not play.',
$position: 2,
},
locationId: {
description: 'Known nearby-city anchor for select.',
$position: 2,
},
locationQuery: {
description:
'Place to search near, such as "Austin, Texas" or "Seattle". Selection does not fly the camera.',
$position: 2,
},
country: {
description:
'Country code or name filter, for example US or United States.',
$position: 2,
},
stationQuery: {
description: 'Optional station name/tag substring.',
$position: 2,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
query: {
description:
'Callsign, ship name, satellite name, ICAO hex, MMSI, or NORAD id.',
$position: 1,
},
layerId: {
description:
'Optional layer hint: flights | military | ais-live-vessels | satellites.',
$position: 1,
},
},
},
},
stop_tracking: {
description:
'Stop following the tracked aircraft/satellite and clear any selected vessel.',
$position: 1,
},
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.',
$position: 1,
parameters: {
properties: {
radiusKm: {
description:
'Search radius around the view target. Defaults: 150 aircraft, 120 ships, 3000 satellites.',
$position: 1,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
annotations: {
description:
'One or more things to mark. Mark multiple related places together when describing them as a group.',
$position: 1,
items: {
properties: {
type: {
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.',
$position: 2,
},
target: {
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.',
$position: 2,
},
points: {
description:
'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
$position: 1,
items: {
properties: {
target: {
description: 'Waypoint place name.',
$position: 2,
},
},
},
},
mode: {
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.',
$position: 2,
},
latitude: {
description:
'Explicit latitude (use only if no good place name exists).',
$position: 3,
},
toTarget: {
description: 'For type=arrow: the destination place name.',
$position: 2,
},
label: {
description:
'Short caption shown on the map (a few words). Optional.',
$position: 2,
},
color: {
description:
'Accent color. primary = neutral, amber = point of interest, cyan = infrastructure, green = confirmed/safe, red = alert.',
$position: 2,
},
footprint: {
description:
'For type=area/highlight: trace the real building or campus outline from map data. Defaults true for area.',
$position: 1,
},
intent: {
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.',
$position: 2,
},
entityKind: {
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).',
$position: 2,
},
screenX: {
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.',
$position: 3,
},
screenY: {
description:
'Fallback only: normalized vertical position (0=top, 1=bottom) of the spot in the latest viewport screenshot.',
$position: 3,
},
toScreenX: {
description:
'For type=arrow: normalized x of the arrow destination from the screenshot (pixel fallback).',
$position: 3,
},
toScreenY: {
description:
'For type=arrow: normalized y of the arrow destination from the screenshot (pixel fallback).',
$position: 3,
},
},
},
},
flyTo: {
description:
'Also move the camera to frame the first annotation. Default false — leave false if the user is already looking at the spot.',
$position: 1,
},
persist: {
description:
'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).',
$position: 1,
},
},
},
},
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.',
$position: 1,
},
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").',
$position: 1,
parameters: {
properties: {
direction: {
description:
'Required except for orbit (defaults right/clockwise) and stop.',
$position: 2,
},
mode: {
description:
'once = bounded eased nudge (default); continuous = until stop/manual input.',
$position: 2,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
label: {
description: 'Match an existing route mark by (partial) label.',
$position: 1,
},
},
},
},
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.',
$position: 1,
parameters: {
properties: {
layers: {
description:
'Layers to query. fires/wildfires → local-firms; ships/vessels → ais-live-vessels.',
$position: 2,
},
scope: {
description:
'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
$position: 2,
properties: {
name: {
description:
'For kind=region: a state/country ("Texas", "France") or a named natural region ("the Alps", "Gulf of Mexico").',
$position: 1,
},
km: {
description: 'For kind=radius.',
$position: 1,
},
},
},
filters: {
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).',
$position: 1,
},
sortBy: {
description: 'Field to rank by, or "distance" for nearest-first.',
$position: 1,
},
followUp: {
description:
'true = re-query the PREVIOUS result set instead of fresh data.',
$position: 1,
},
},
},
},
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.",
$position: 1,
parameters: {
properties: {
latitude: {
description:
'Optional observer latitude. Omit to use the current camera position.',
$position: 3,
},
longitude: {
description:
'Optional observer longitude. Omit to use the current camera position.',
$position: 3,
},
minElevationDeg: {
description:
'Minimum peak elevation (deg) to count as a pass. Default 10.',
$position: 3,
},
},
},
},
};
+3 -977
View File
@@ -1,978 +1,4 @@
const GEV_REALTIME_TOOLS = [
{
type: 'function',
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.",
parameters: {
type: 'object',
additionalProperties: false,
properties: {
locationId: {
type: 'string',
enum: [
'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
description:
'Known city preset ID. Use when the requested place matches one of these cities.',
},
query: {
type: 'string',
description:
'Plain place search query, e.g. "London", "Eiffel Tower", or "Dubai Marina".',
},
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
viewMode: {
type: 'string',
enum: ['close', 'overview'],
description:
'Optional framing intent. Usually omit this; GEV infers whole-place framing for countries/cities and close framing for landmarks.',
},
rangeM: {
type: 'number',
minimum: 100,
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.',
},
waitForArrival: {
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.',
},
},
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
layerId: {
type: 'string',
enum: ['flights', 'military'],
description:
'Aircraft layer to enable and search. Use flights unless the user explicitly asks for military aircraft.',
},
locationId: {
type: 'string',
enum: [
'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
description:
'Known city preset ID when the place matches one of these cities.',
},
locationQuery: {
type: 'string',
maxLength: 160,
description: 'Free-form destination when no locationId matches.',
},
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
},
required: ['layerId'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
direction: {
type: 'string',
enum: ['in', 'out'],
},
amount: {
type: 'string',
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".',
},
},
required: ['direction', 'amount'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
{
type: 'function',
name: 'set_layer_visibility',
description: "Enable or disable one registered God's Eye View data layer.",
parameters: {
type: 'object',
additionalProperties: false,
properties: {
layerId: {
type: 'string',
description:
'Common-name mapping for the non-obvious ids: space mission(s) → rocket-launches; fires/wildfires/active fires → local-firms (NASA FIRMS); ships/vessels/boats → ais-live-vessels; undersea/submarine cables → telegeography-submarine-cables; datacenters → local-datacenters; dams → local-dams; bikes/bike share → bikeshare; street traffic/congestion → traffic; traffic cameras → cctv; internet radio/stations → radio; ALPR/license plate readers/Flock cameras → alpr-cameras.',
enum: [
'flights',
'military',
'earthquakes',
'satellites',
'rocket-launches',
'traffic',
'cctv',
'radio',
'bikeshare',
'ais-live-vessels',
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'alpr-cameras',
],
},
enabled: { type: 'boolean' },
},
required: ['layerId', 'enabled'],
},
},
{
type: 'function',
name: 'show_data_layers_menu',
description:
'Open the data layers dropdown/menu and optionally scroll to a specific layer row without toggling it.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
layerId: {
type: 'string',
enum: [
'flights',
'military',
'earthquakes',
'satellites',
'traffic',
'cctv',
'radio',
'bikeshare',
'ais-live-vessels',
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'alpr-cameras',
],
description: 'Optional layer row to scroll into view and highlight.',
},
},
},
},
{
type: 'function',
name: 'set_panel_open',
description: 'Open or close a GEV UI panel/dropdown.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
panelId: {
type: 'string',
enum: [
'data-panel',
'location-bar',
'control-panel',
'cctv-panel',
'radio-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
],
},
open: { type: 'boolean' },
},
required: ['panelId', 'open'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
mode: {
type: 'string',
enum: ['off', 'contacts', 'flights', 'space-missions', 'missions'],
description: 'Use off to exit context mode.',
},
},
required: ['mode'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
action: {
type: 'string',
enum: ['enter', 'exit', 'previous', 'next', 'prev', 'status'],
description:
'previous/next (or prev) navigates through nearby contacts in Cockpit context.',
},
targetLayer: {
type: 'string',
enum: [
'flights',
'military',
'ais-live-vessels',
'military-installations',
],
description:
'Optional contact layer filter for next/previous (for example military for a military-only cycle).',
},
aircraftClass: {
type: 'string',
description:
'Optional aircraft class filter (for example helicopter) when using next/previous navigation.',
},
},
required: ['action'],
},
},
{
type: 'function',
name: 'set_visual_style',
description: "Set the active God's Eye View visual filter/style.",
parameters: {
type: 'object',
additionalProperties: false,
properties: {
style: {
type: 'string',
enum: [
'normal',
'retro',
'surveillance',
'thermal',
'anime',
'noir',
'snow',
],
},
},
required: ['style'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
scope: {
type: 'string',
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.',
},
layerId: {
type: 'string',
enum: [
'local-datacenters',
'local-dams',
'telegeography-submarine-cables',
'local-firms',
],
description: 'Optional layer filter for visible entity context.',
},
limit: {
type: 'number',
minimum: 1,
maximum: 12,
},
},
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
{
type: 'function',
name: 'set_hud',
description:
'Control the intelligence HUD overlay: visibility and/or layout variant.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
visible: {
type: 'string',
enum: ['on', 'off', 'auto'],
description: 'auto restores style-driven show/hide.',
},
layout: { type: 'string', enum: ['tactical', 'operator', 'minimal'] },
},
},
},
{
type: 'function',
name: 'set_detection',
description:
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
enabled: {
type: 'boolean',
description:
'false turns detection OFF; true restores the current density-derived profile.',
},
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.',
},
allocationStrategy: {
type: 'string',
enum: ['elastic', 'weighted'],
description:
'Elastic splits evenly then lends unused slots; Weighted follows demand and semantic weight.',
},
},
},
},
{
type: 'function',
name: 'set_map_stack',
description:
'Switch the basemap/imagery stack (NOT the satellites data layer and NOT a visual style filter).',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
stack: {
type: 'string',
enum: [
'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'],
},
},
{
type: 'function',
name: 'set_post_processing',
description:
'Control bloom and sharpen post-processing toggles and intensities.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
bloom: {
type: 'object',
additionalProperties: false,
properties: {
enabled: { type: 'boolean' },
intensityPct: {
type: 'number',
description: '0-200 (UI percent).',
},
},
},
sharpen: {
type: 'object',
additionalProperties: false,
properties: {
enabled: { type: 'boolean' },
intensityPct: {
type: 'number',
description: '0-100 (UI percent).',
},
},
},
},
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
action: {
type: 'string',
enum: ['list', 'play', 'stop', 'next', 'status'],
},
sceneId: {
type: 'string',
description: 'Scene id or (partial) title for play.',
},
},
required: ['action'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
action: {
type: 'string',
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'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
action: {
type: 'string',
enum: [
'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.',
},
category: {
type: 'string',
enum: [
'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: {
type: 'string',
enum: [
'austin',
'sf',
'nyc',
'tokyo',
'london',
'paris',
'dubai',
'dc',
],
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.',
},
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
country: {
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'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: {
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'],
},
},
{
type: 'function',
name: 'stop_tracking',
description:
'Stop following the tracked aircraft/satellite and clear any selected vessel.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
target: {
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'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
annotations: {
type: 'array',
description:
'One or more things to mark. Mark multiple related places together when describing them as a group.',
minItems: 1,
maxItems: 24,
items: {
type: 'object',
additionalProperties: false,
properties: {
type: {
type: 'string',
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.',
},
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: {
type: 'array',
description:
'For type=route: 2+ ordered waypoints the path passes through, each a place name (or coordinates / screen point).',
minItems: 2,
maxItems: 12,
items: {
type: 'object',
additionalProperties: false,
properties: {
target: {
type: 'string',
maxLength: 200,
description: 'Waypoint place name.',
},
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
screenX: { type: 'number', minimum: 0, maximum: 1 },
screenY: { type: 'number', minimum: 0, maximum: 1 },
},
},
},
mode: {
type: 'string',
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.',
},
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 },
toTarget: {
type: 'string',
maxLength: 200,
description: 'For type=arrow: the destination place name.',
},
toLatitude: { type: 'number', minimum: -90, maximum: 90 },
toLongitude: { type: 'number', minimum: -180, maximum: 180 },
label: {
type: 'string',
maxLength: 120,
description:
'Short caption shown on the map (a few words). Optional.',
},
color: {
type: 'string',
enum: ['primary', 'amber', 'cyan', 'green', 'red'],
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).',
},
},
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.',
},
persist: {
type: 'boolean',
description:
'Keep annotations until cleared (true, default) or let them auto-fade after ~20s (false).',
},
},
required: ['annotations'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
},
},
{
type: 'function',
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").',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
motion: {
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'] },
mode: {
type: 'string',
enum: ['once', 'continuous'],
description:
'once = bounded eased nudge (default); continuous = until stop/manual input.',
},
},
required: ['motion'],
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
label: {
type: 'string',
description: 'Match an existing route mark by (partial) label.',
},
speed: { type: 'string', enum: ['slow', 'normal', 'fast'] },
},
},
},
{
type: 'function',
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.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
layers: {
type: 'array',
items: {
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: {
type: 'object',
additionalProperties: false,
description:
'Spatial scope. Default: view (near the camera). Use kind=region for "over Texas"-style asks; kind=anywhere for global questions.',
properties: {
kind: {
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.' },
center: {
type: 'object',
additionalProperties: false,
properties: { lat: { type: 'number' }, lon: { type: 'number' } },
},
},
},
filters: {
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).',
items: {
type: 'object',
additionalProperties: false,
properties: {
field: { type: 'string' },
op: {
type: 'string',
enum: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'contains'],
},
value: {},
},
required: ['field', 'op', 'value'],
},
},
sortBy: {
type: 'string',
description: 'Field to rank by, or "distance" for nearest-first.',
},
sortDir: { type: 'string', enum: ['asc', 'desc'] },
limit: { type: 'number' },
followUp: {
type: 'boolean',
description:
'true = re-query the PREVIOUS result set instead of fresh data.',
},
},
},
},
{
type: 'function',
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.",
parameters: {
type: 'object',
additionalProperties: false,
properties: {
latitude: {
type: 'number',
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.',
},
},
},
},
];
import { createActionTools } from '../../../src/voice/actionSchemas.js';
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
export { GEV_REALTIME_TOOLS };
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
+14
View File
@@ -3,6 +3,19 @@ import path from 'node:path';
// ---------------------------------------------------------------------------
// Overpass API proxy constants and cache state
// ---------------------------------------------------------------------------
/**
* User-Agent sent to every Overpass mirror.
*
* The OSM API usage policy asks for a "Valid User-Agent identifying application
* and version"; a generic proxy label is not one. A mirror is free to refuse a
* client it cannot identify, and `src/overpassProxy.test.mjs` pins what that
* costs: a refusal is never data, so the query falls through to whatever
* mirrors are left. Keep this honest and stable — if it is ever refused, the
* answer is less query volume, not a new name.
*/
const OVERPASS_USER_AGENT =
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)';
/** Ordered list of Overpass API mirrors; tried sequentially on failure/rate-limit. */
const OVERPASS_UPSTREAMS = [
'https://overpass-api.de/api/interpreter',
@@ -130,5 +143,6 @@ export {
OVERPASS_SIMPLIFY_TOLERANCE_DEG,
OVERPASS_MAX_RESPONSE_BYTES,
OVERPASS_UPSTREAMS,
OVERPASS_USER_AGENT,
OVERPASS_TIMEOUT_MS,
};
+1 -48
View File
@@ -1,3 +1,4 @@
import { stripOverpassNoise } from '../../../src/sources/overpass.js';
import {
OVERPASS_MAX_AROUND_M,
OVERPASS_MAX_BBOX_DEG,
@@ -31,54 +32,6 @@ function isOverpassBoundaryQuery(cacheKey) {
*
* @returns {{ok:true, body:string} | {ok:false, error:string}}
*/
/**
* Single-pass lexer: blank out quoted literals (→ empty quotes) and strip line
* and block comments — recognizing each in one walk so a comment marker INSIDE a
* quoted string is treated as string content, not a comment (and vice versa).
* Chained regex replaces get the ordering wrong (a quoted slash-slash would hide
* the rest of the line), which is exactly the bypass this avoids.
*/
function stripOverpassNoise(src) {
let out = '';
let i = 0;
const n = src.length;
while (i < n) {
const c = src[i];
if (c === '"' || c === "'") {
const quote = c;
i += 1;
while (i < n) {
if (src[i] === '\\') {
i += 2;
continue;
} // escaped char
if (src[i] === quote) {
i += 1;
break;
} // closing quote
i += 1;
}
out += quote + quote; // collapse the literal to empty quotes
continue;
}
if (c === '/' && src[i + 1] === '*') {
i += 2;
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1;
i += 2;
out += ' ';
continue;
}
if (c === '/' && src[i + 1] === '/') {
i += 2;
while (i < n && src[i] !== '\n') i += 1;
out += ' ';
continue;
}
out += c;
i += 1;
}
return out;
}
function sanitizeOverpassBody(rawBody) {
let params;
+7 -7
View File
@@ -1,6 +1,7 @@
import {
OVERPASS_MAX_RESPONSE_BYTES,
OVERPASS_UPSTREAMS,
OVERPASS_USER_AGENT,
OVERPASS_TIMEOUT_MS,
} from './constants.js';
import { readResponseTextCapped } from '../common/http.js';
@@ -91,7 +92,7 @@ async function fetchOverpassPayload(
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'gods-eye-view-overpass-proxy/1.0',
'User-Agent': OVERPASS_USER_AGENT,
},
body,
signal: controller.signal,
@@ -125,12 +126,11 @@ async function fetchOverpassPayload(
}
// Anything but 2xx is this mirror declining, not an answer. Only 5xx used
// to rotate, so a 4xx ended the fan-out and was returned — and cached —
// as data: overpass-api.de and its lz4 alias answer 406 to this proxy's
// User-Agent while kumi.systems and private.coffee answer 200 to the very
// same request, so every Overpass-backed layer failed on an Apache error
// page with two healthy mirrors untried. The first refusal is kept so a
// genuinely bad query still reports what upstream said, but only after
// every mirror has had the chance to answer it.
// as data: a mirror refusing this client answers 406 while the others
// answer 200 to the very same request, so every Overpass-backed layer
// failed on an error page with healthy mirrors untried. The first
// refusal is kept so a genuinely bad query still reports what upstream
// said, but only after every mirror has had the chance to answer it.
if (status < 200 || status >= 300) {
if (!lastRefusalPayload) lastRefusalPayload = payload;
lastError = new Error(
+1 -2
View File
@@ -10,8 +10,7 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil(
);
export const RADIO_USER_AGENT =
'GodsEyeView/1.0 (Radio Browser directory client)';
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js';
export const RADIO_FALLBACK_MIRRORS = Object.freeze([
'https://de1.api.radio-browser.info',
'https://de2.api.radio-browser.info',
+7 -155
View File
@@ -1,155 +1,7 @@
import { RADIO_UUID_RE } from './constants.js';
import { normalizeRadioCountryInput } from '../../../src/data/radioCountry.js';
export function cleanRadioText(value, maxLength) {
return String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength)
.trim();
}
export function isNonGlobalIpv4(hostname) {
const pieces = hostname.split('.');
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
return false;
const values = pieces.map(Number);
if (values.some((value) => value > 255)) return true;
const [a, b, c] = values;
return (
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 0) ||
(a === 192 && b === 88 && c === 99) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 198 && b === 51 && c === 100) ||
(a === 203 && b === 0 && c === 113)
);
}
/** Return a normalized public HTTPS URL, or null for local/private targets. */
export function publicRadioHttpsUrl(value) {
try {
const url = new URL(String(value ?? ''));
const hostname = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (url.protocol !== 'https:' || url.username || url.password || !hostname)
return null;
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
isNonGlobalIpv4(hostname) ||
hostname.includes(':')
)
return null;
url.hash = '';
return url.href;
} catch {
return null;
}
}
/** Normalize one Radio Browser station and omit favicons and unsafe streams. */
export function normalizeRadioBrowserStation(raw) {
const id = cleanRadioText(raw?.stationuuid, 40).toLowerCase();
const lat =
raw?.geo_lat === null || raw?.geo_lat === '' ? null : Number(raw?.geo_lat);
const lon =
raw?.geo_long === null || raw?.geo_long === ''
? null
: Number(raw?.geo_long);
const codec = cleanRadioText(raw?.codec, 16).toUpperCase();
const streamUrl = publicRadioHttpsUrl(raw?.url_resolved || raw?.url);
if (
!RADIO_UUID_RE.test(id) ||
Number(raw?.lastcheckok) !== 1 ||
Number(raw?.hls) === 1 ||
!Number.isFinite(lat) ||
lat < -90 ||
lat > 90 ||
!Number.isFinite(lon) ||
lon < -180 ||
lon > 180 ||
!/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(codec) ||
!streamUrl
)
return null;
const name = cleanRadioText(raw?.name, 140);
if (!name) return null;
const tags = String(raw?.tags ?? '')
.split(',')
.map((tag) =>
cleanRadioText(tag, 80)
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim(),
)
.filter(Boolean)
.filter((tag, index, all) => all.indexOf(tag) === index)
.slice(0, 24);
const languages = String(raw?.language ?? '')
.split(',')
.map((language) => cleanRadioText(language, 40))
.filter(Boolean)
.slice(0, 8);
const rawCountryCode = cleanRadioText(raw?.countrycode, 2).toUpperCase();
const normalizedCode = normalizeRadioCountryInput(rawCountryCode);
const normalizedCountry =
normalizedCode.valid && !normalizedCode.empty
? normalizedCode
: normalizeRadioCountryInput(cleanRadioText(raw?.country, 80));
const bitrate = Number(raw?.bitrate);
return {
id,
name,
lat,
lon,
streamUrl,
homepage: publicRadioHttpsUrl(raw?.homepage),
tags,
languages,
state: cleanRadioText(raw?.state, 80),
country:
normalizedCountry.valid && !normalizedCountry.empty
? normalizedCountry.name
: cleanRadioText(raw?.country, 80),
countryCode: normalizedCountry.valid ? normalizedCountry.code : '',
metadataTrust: 'untrusted-community',
codec,
bitrate:
Number.isInteger(bitrate) && bitrate >= 8 && bitrate <= 1024
? bitrate
: null,
clickCount: Math.max(0, Math.min(10_000_000, Number(raw?.clickcount) || 0)),
};
}
export function publicRadioStation(station) {
return {
id: station.id,
name: station.name,
lat: station.lat,
lon: station.lon,
streamUrl: station.streamUrl,
homepage: station.homepage,
tags: station.tags,
languages: station.languages,
state: station.state,
country: station.country,
countryCode: station.countryCode,
metadataTrust: station.metadataTrust,
codec: station.codec,
bitrate: station.bitrate,
};
}
export {
cleanRadioText,
isNonGlobalIpv4,
publicRadioHttpsUrl,
normalizeRadioBrowserStation,
publicRadioStation,
} from '../../../src/sources/radioBrowser.js';
+5 -1
View File
@@ -4,4 +4,8 @@ export {
} from './regional/briefing.js';
export { weatherEffectsProxy } from './regional/weather-effects.js';
export { validRegionalPoint } from './regional/query.js';
export { createRegionalPlaceProvider } from './regional/place.js';
export {
createRegionalPlaceProvider,
createNominatimSearchProvider,
geocodeProxy,
} from './regional/place.js';
+34 -24
View File
@@ -5,42 +5,52 @@ import {
const REGIONAL_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
async function fetchRegionalJson(
url,
{
/**
* One bounded upstream read for the regional providers.
*
* The deadline covers the BODY, not just the headers. `return`ing the reader's
* promise from inside a try/finally leaves the try block the moment the promise
* exists, so the abort timer used to be cleared before a single byte of body had
* arrived — an upstream that answered its headers and then stalled had no
* deadline at all. Awaiting inside the try keeps the timer alive until the body
* is in hand, and the same signal is handed to the reader so an abort stops the
* read rather than just the connect.
*
* `redirect` is stated rather than inherited: feeds that legitimately redirect
* (news RSS) follow, and a fixed API endpoint can be given 'error' so a
* redirect cannot steer the proxy at a destination this code never named.
*
* @param {string} url
* @param {{headers?: object, timeoutMs?: number, maxBytes?: number, redirect?: 'follow'|'error'|'manual'}} [options]
*/
async function fetchRegional(url, read, options = {}) {
const {
headers = {},
timeoutMs = 9000,
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
} = {},
) {
redirect = 'follow',
} = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal, headers });
const response = await fetch(url, {
signal: controller.signal,
headers,
redirect,
});
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
return readResponseJsonCapped(response, maxBytes);
return await read(response, maxBytes, controller.signal);
} finally {
clearTimeout(timeout);
}
}
async function fetchRegionalText(
url,
{
headers = {},
timeoutMs = 9000,
maxBytes = REGIONAL_MAX_RESPONSE_BYTES,
} = {},
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal, headers });
if (!response.ok) throw new Error(`Upstream returned ${response.status}`);
return readResponseTextCapped(response, maxBytes);
} finally {
clearTimeout(timeout);
}
function fetchRegionalJson(url, options = {}) {
return fetchRegional(url, readResponseJsonCapped, options);
}
function fetchRegionalText(url, options = {}) {
return fetchRegional(url, readResponseTextCapped, options);
}
export { fetchRegionalJson, fetchRegionalText };
+262 -16
View File
@@ -1,20 +1,123 @@
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { coalesceProxyRequest } from '../common/http.js';
import { fetchRegionalJson } from './http.js';
import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js';
import {
nominatimToGeocodeResult,
nominatimViewboxFromBounds,
} from '../../../src/nominatimGeocode.js';
/** Construct the serialized Nominatim adapter with a trusted endpoint. */
/**
* The usage policy for the public Nominatim instance asks for a User-Agent or
* Referer that identifies the application, and states that stock library
* agents will not do. Both are sent.
*/
const NOMINATIM_HEADERS = Object.freeze({
'User-Agent':
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
});
/**
* Minimum spacing between upstream calls. The policy states an absolute
* maximum of one request per second; 1.1 s keeps clock jitter from crossing it.
*/
const NOMINATIM_MIN_SPACING_MS = 1100;
/**
* How many searches may be waiting for their turn.
*
* One request per second and an unbounded queue are incompatible: a burst of
* searches would keep the upstream busy long after everyone who asked has given
* up, which is exactly the load the policy asks callers not to create. Past
* this depth a search is refused at once instead of being promised a slot
* minutes away.
*/
const NOMINATIM_MAX_PENDING = 4;
/**
* How long a queued search may wait before it is not worth sending. The
* browser gives up well before this, so anything reaching the front later than
* this is answering nobody.
*/
const NOMINATIM_MAX_WAIT_MS = 10_000;
const NOMINATIM_SEARCH_CACHE_MS = 5 * 60_000;
const NOMINATIM_SEARCH_MAX_CACHE = 80;
const NOMINATIM_SEARCH_MAX_QUERY = 200;
// The pacer is deliberately module state while everything else here is
// per-instance. One request per second is a budget for the whole application,
// not for each provider object: two instances each pacing themselves would
// send two requests a second between them. Reverse lookups and searches
// therefore queue together.
let _nominatimQueue = Promise.resolve();
let _nominatimLastRequestAt = 0;
let _nominatimPending = 0;
/** Raised when the queue is already as deep as it is allowed to get. */
function queueFullError() {
return Object.assign(new Error('Place search queue is full'), {
code: 'NOMINATIM_QUEUE_FULL',
});
}
/** Raised when a queued search waited so long that nobody is left to answer. */
function abandonedError() {
return Object.assign(new Error('Place search was abandoned'), {
code: 'NOMINATIM_ABANDONED',
});
}
/**
* Run one piece of upstream work, never closer than the policy spacing to the
* last one.
*
* `bounded` applies the queue-depth and staleness limits. The cockpit's reverse
* lookups are a slow background trickle and stay unbounded; the search route,
* which a person can fire as fast as they can type, does not.
*
* @param {() => Promise<unknown>} work
* @param {{bounded?: boolean, signal?: AbortSignal}} [options]
*/
function enqueueNominatim(work, { bounded = false, signal } = {}) {
if (bounded && _nominatimPending >= NOMINATIM_MAX_PENDING)
return Promise.reject(queueFullError());
if (bounded) _nominatimPending += 1;
const queuedAt = Date.now();
const task = _nominatimQueue.then(async () => {
try {
const waitMs = Math.max(
0,
NOMINATIM_MIN_SPACING_MS - (Date.now() - _nominatimLastRequestAt),
);
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
// Nobody is waiting for this any more: sending it would spend the one
// request per second the policy allows on an answer with no reader.
if (bounded && Date.now() - queuedAt > NOMINATIM_MAX_WAIT_MS)
throw abandonedError();
if (signal?.aborted) throw abandonedError();
_nominatimLastRequestAt = Date.now();
return await work();
} finally {
if (bounded) _nominatimPending -= 1;
}
});
_nominatimQueue = task.catch(() => null);
return task;
}
/** Construct the serialized Nominatim reverse adapter with a trusted endpoint. */
export function createRegionalPlaceProvider({
endpoint = 'https://nominatim.openstreetmap.org/reverse',
requestJson = fetchRegionalJson,
} = {}) {
let _nominatimQueue = Promise.resolve();
let _nominatimLastRequestAt = 0;
function fetchRegionalPlace(point) {
const task = _nominatimQueue.then(async () => {
const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt));
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
_nominatimLastRequestAt = Date.now();
return enqueueNominatim(async () => {
const params = new URLSearchParams({
format: 'jsonv2',
lat: point.latitude.toFixed(5),
@@ -24,19 +127,162 @@ export function createRegionalPlaceProvider({
'accept-language': 'en',
});
const payload = await requestJson(`${endpoint}?${params}`, {
headers: {
'User-Agent':
'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
},
headers: NOMINATIM_HEADERS,
redirect: 'error',
});
return normalizeRegionalPlace(payload);
});
_nominatimQueue = task.catch(() => null);
return task;
}
return fetchRegionalPlace;
}
export const fetchRegionalPlace = createRegionalPlaceProvider();
/**
* Construct the forward search adapter with a trusted endpoint.
*
* The policy asks that results be cached, and warns that a client repeating the
* same query may be treated as faulty, so an answer is remembered and identical
* searches already in flight share one upstream call rather than queueing
* behind each other.
*/
export function createNominatimSearchProvider({
endpoint = 'https://nominatim.openstreetmap.org/search',
requestJson = fetchRegionalJson,
} = {}) {
const cache = new Map();
const inFlight = new Map();
const trimCache = () => {
while (cache.size > NOMINATIM_SEARCH_MAX_CACHE) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
};
return async function fetchNominatimSearch(query, bounds, { signal } = {}) {
const cacheKey = `${query.toLowerCase()}|${bounds || ''}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.cachedAt <= NOMINATIM_SEARCH_CACHE_MS) {
return { ...cached.payload, cached: true };
}
const { promise } = coalesceProxyRequest(inFlight, cacheKey, async () => {
const params = new URLSearchParams({
format: 'jsonv2',
q: query,
addressdetails: '1',
limit: '1',
'accept-language': 'en',
});
const viewbox = nominatimViewboxFromBounds(bounds);
if (viewbox) params.set('viewbox', viewbox);
const rows = await enqueueNominatim(
() =>
requestJson(`${endpoint}?${params}`, {
headers: NOMINATIM_HEADERS,
redirect: 'error',
}),
{ bounded: true, signal },
);
const result = nominatimToGeocodeResult(
Array.isArray(rows) ? rows[0] : null,
);
const payload = result
? { status: 'OK', results: [result] }
: { status: 'ZERO_RESULTS', results: [] };
cache.set(cacheKey, { payload, cachedAt: Date.now() });
trimCache();
return payload;
});
return await promise;
};
}
export const fetchNominatimSearch = createNominatimSearchProvider();
/** Vite plugin: last-resort place search over the public Nominatim instance. */
export function geocodeProxy({ search = fetchNominatimSearch } = {}) {
const limiter = makeRateLimiter({
windowMs: 60_000,
max: 30,
globalMax: 90,
});
function install(middlewares) {
middlewares.use('/api/geocode', async (req, res) => {
if (req.method !== 'GET') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return;
}
if (!limiter(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
const url = new URL(req.url || '', 'http://localhost');
const query = String(url.searchParams.get('q') || '').trim();
if (!query || query.length > NOMINATIM_SEARCH_MAX_QUERY) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error: 'A place query of 1-200 characters is required',
}),
);
return;
}
// A browser that gave up is no longer waiting; the queue reads this
// before spending its slot.
const abandoned = new AbortController();
req.on?.('aborted', () => abandoned.abort());
res.on?.('close', () => abandoned.abort());
try {
const payload = await search(query, url.searchParams.get('bounds'), {
signal: abandoned.signal,
});
if (res.writableEnded) return;
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': payload.cached ? 'public, max-age=60' : 'no-store',
});
res.end(
JSON.stringify({ status: payload.status, results: payload.results }),
);
} catch (error) {
if (res.writableEnded) return;
const busy =
error?.code === 'NOMINATIM_QUEUE_FULL' ||
error?.code === 'NOMINATIM_ABANDONED';
res.writeHead(busy ? 429 : 503, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(busy ? { 'Retry-After': '5' } : {}),
});
res.end(
JSON.stringify({
error: busy
? 'Place search is busy'
: 'Place search is temporarily unavailable',
}),
);
}
});
}
return {
name: 'geocode-proxy',
configureServer(server) {
install(server.middlewares);
},
configurePreviewServer(server) {
install(server.middlewares);
},
};
}
export { NOMINATIM_MAX_PENDING };
+24
View File
@@ -0,0 +1,24 @@
import { createTransitService } from '../../src/sources/transitService.js';
export { fetchTransitFeed } from '../../src/sources/transitService.js';
/** Connect the reusable transit request service to development and preview. */
export function transitProxy(options = {}) {
const service = createTransitService(options);
function install(server) {
server.middlewares.use('/api/transit', async (req, res) => {
const response = await service.handle({
url: `http://localhost/api/transit${req.url || '/'}`,
method: req.method,
});
res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(await response.text());
});
server.httpServer?.once('close', service.close);
}
return {
name: 'transit-proxy',
closeBundle: service.close,
configureServer: install,
configurePreviewServer: install,
};
}
+5
View File
@@ -0,0 +1,5 @@
export {
createTransitHistory,
TRANSIT_HISTORY_LIMITS,
validTransitIdentifier,
} from '../../src/sources/transitHistoryStore.js';
+179
View File
@@ -0,0 +1,179 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createTransitHistory,
validTransitIdentifier,
} from './transitHistory.js';
const feed = { id: 'mbta', historyRetention: true, defaultMode: 'bus' };
const row = (t, id = 'bus', extra = {}) => ({
id,
timestamp: t / 1000,
lat: 42,
lon: -71,
tripId: 'trip',
routeId: '1',
timestampSource: 'vehicle',
...extra,
});
const storage = () => ({ bytes: 0, keys: 0, stores: new Set() });
function history(t, options = {}) {
const h = createTransitHistory({
sweep: false,
storage: storage(),
...options,
});
t.after(() => h.clear());
return h;
}
test('TTL ages reports and reads do not extend it; duplicate/conflicting times never append', (t) => {
const h = history(t);
h.ingest(feed, [row(100000)], 100000);
h.ingest(feed, [row(100000), row(100000, 'bus', { lat: 43 })], 500000);
assert.equal(h.get('mbta', 'bus', 500000).fixes.length, 1);
assert.equal(h.get('mbta', 'bus', 999999).fixes.length, 1);
assert.equal(h.get('mbta', 'bus', 1000000).fixes.length, 0);
assert.equal(h.diagnostics().allocatedBytes, 0);
});
test('128-fix ring and bounded epoch contexts preserve breaks, never scene heights', (t) => {
const h = history(t);
for (let i = 0; i < 200; i++)
h.ingest(
feed,
[
row(i * 1000, 'bus', {
tripId: i < 100 ? 'first' : 'second',
heightM: 999,
}),
],
i * 1000,
);
const result = h.get('mbta', 'bus', 199000);
assert.equal(result.fixes.length, 128);
assert.equal(result.truncated, true);
assert.equal(result.epochs.length, 2);
assert.ok(result.fixes.find((f) => f[0] === 100000)[3] & 16);
assert.ok(result.fixes.every((f) => f.length === 5));
assert.ok(Buffer.byteLength(JSON.stringify(result)) < 32768);
for (let i = 200; i < 220; i++)
h.ingest(feed, [row(i * 1000, 'bus', { tripId: `trip-${i}` })], i * 1000);
const churn = h.get('mbta', 'bus', 219000);
assert.ok(churn.epochs.length <= 8);
assert.ok(churn.fixes.every((f) => churn.epochs.some((e) => e.id === f[4])));
});
test('allocated key, metadata and chunk bytes obey feed/process ceilings across stores', (t) => {
const shared = storage(),
limits = { feedBytes: 16000, processBytes: 24000, keys: 4 };
const a = history(t, { storage: shared, limits }),
b = history(t, { storage: shared, limits });
for (let i = 0; i < 60; i++) {
a.ingest(feed, [row(i * 1000, `a${i}`)], i * 1000);
b.ingest(
{ ...feed, id: i % 2 ? 'mbta' : 'other' },
[row(i * 1000, `b${i}`)],
i * 1000,
);
assert.ok(shared.bytes <= limits.processBytes);
assert.ok(shared.keys <= limits.keys);
assert.ok(
[...shared.feedBytes.values()].every(
(bytes) => bytes <= limits.feedBytes,
),
);
assert.equal(
a.diagnostics().allocatedBytes + b.diagnostics().allocatedBytes,
shared.bytes,
);
}
a.clear();
b.clear();
assert.equal(shared.bytes, 0);
assert.equal(shared.keys, 0);
});
test('accelerated two-hour ingestion plateaus and expires idle keys', (t) => {
const h = history(t, { limits: { feedBytes: 128000, processBytes: 128000 } });
const samples = [];
for (let now = 0; now <= 7200000; now += 15000) {
h.ingest(
feed,
Array.from({ length: 12 }, (_, i) => row(now, `v${i}`)),
now,
);
if (now >= 900000) samples.push(h.diagnostics().allocatedBytes);
}
assert.ok(Math.max(...samples) <= 128000);
assert.equal(Math.max(...samples), Math.min(...samples));
h.prune(8100001);
assert.equal(h.diagnostics().keys, 0);
});
test('retention is opt-in and entire decoded identifiers are bounded', (t) => {
const h = history(t);
h.ingest({ ...feed, historyRetention: false }, [row(1000)], 1000);
assert.equal(h.diagnostics().keys, 0);
for (const id of [
'',
'.',
'..',
'abc/def',
'abc\\def',
'abc\0def',
'é'.repeat(129),
'\ud800',
]) {
assert.equal(validTransitIdentifier(id), false);
h.ingest(feed, [row(1000, id)], 1000);
}
assert.equal(h.diagnostics().keys, 0);
assert.equal(validTransitIdentifier('🚋 vehicle 1'), true);
});
test('unref sweep is owned and clear closes the cache', (t) => {
const h = history(t, { sweep: true });
assert.equal(h.diagnostics().sweepActive, true);
h.clear();
h.ingest(feed, [row(1000)], 1000);
assert.equal(h.diagnostics().sweepActive, false);
assert.equal(h.diagnostics().keys, 0);
});
test('three identical-order 15000-vehicle snapshots retain paths within a bounded ingestion time', (t) => {
const h = history(t);
const times = [];
for (let poll = 0; poll < 3; poll++) {
const now = 100000 + poll * 15000;
const rows = Array.from({ length: 15000 }, (_, i) =>
row(now, `vehicle-${i}`),
);
const start = performance.now();
h.ingest(feed, rows, now);
times.push(performance.now() - start);
}
assert.equal(h.diagnostics().keys, 15000);
for (let i = 0; i < 15000; i++) {
const result = h.get('mbta', `vehicle-${i}`, 130000);
assert.equal(result.fixes.length, 3);
assert.equal(result.truncated, false);
}
assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024);
t.diagnostic(
`15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
);
// A ceiling that says "not quadratic", not a benchmark: 15,000 rows in
// well under two seconds on a loaded laptop or a slow CI runner. The
// per-run figure above is the diagnostic to read.
assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`);
});
test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => {
const h = history(t, { limits: { keys: 2 } });
h.ingest(feed, [row(1000, 'a'), row(1000, 'b')], 1000);
// Newcomers precede the retained members in every snapshot.
for (const now of [2000, 3000])
h.ingest(feed, [row(now, 'c'), row(now, 'a'), row(now, 'b')], now);
assert.equal(h.get('mbta', 'a', 3000).fixes.length, 3);
assert.equal(h.get('mbta', 'b', 3000).fixes.length, 3);
assert.equal(h.get('mbta', 'c', 3000).truncated, true);
h.ingest(feed, [row(4000, 'c')], 4000);
h.ingest(feed, [row(5000, 'a')], 5000);
const recreated = h.get('mbta', 'a', 5000);
assert.equal(recreated.fixes.length, 1);
assert.equal(recreated.truncated, true);
});
+218
View File
@@ -0,0 +1,218 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import {
commandCompletedSuccessfully,
parseWindowsUserSid,
} from '../../src/keySetupCore.mjs';
/** PowerShell verification for the exact owner-only Windows credential DACL. */
const WINDOWS_ACL_VERIFY_SCRIPT = [
"$ErrorActionPreference = 'Stop'",
// Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the
// interpreter that is actually running. $PSHOME is that interpreter's own
// physical directory, so this is correct even when the executable was named
// through the Sysnative bridge, and it cannot be steered by anything the
// parent environment set.
"$env:PSModulePath = Join-Path $PSHOME 'Modules'",
'$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE',
'if (-not $acl.AreAccessRulesProtected) { exit 2 }',
"$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')",
'$seen = @{}',
'$rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
'if ($rules.Count -ne 3) { exit 7 }',
'foreach ($rule in $rules) {',
' $ruleSid = $rule.IdentityReference.Value',
' if ($rule.IsInherited) { exit 3 }',
' if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { exit 4 }',
' if ($allowed -notcontains $ruleSid) { exit 5 }',
' if ($seen.ContainsKey($ruleSid)) { exit 8 }',
' $full = [System.Security.AccessControl.FileSystemRights]::FullControl',
' if ($rule.FileSystemRights -ne $full) { exit 6 }',
' $seen[$ruleSid] = $true',
'}',
'if ($seen.Count -ne 3) { exit 9 }',
].join('; ');
/**
* Resolve the native Windows ACL tools without consulting PATH.
*
* Provider Settings supports the standard Windows installation layout only:
* a local drive root named `Windows` (for example C:\\Windows or D:\\Windows).
* Requiring consistent aliases, canonical paths, and regular files prevents an
* inherited environment override, UNC share, device path, junction, or PATH
* shim from being treated as an operating-system security tool.
*/
function resolveWindowsNativeTools(environment, fileSystem, architecture) {
const aliases = ['SystemRoot', 'SYSTEMROOT', 'WINDIR', 'windir'];
const configured = aliases
.map((name) => environment[name])
.filter((value) => typeof value === 'string' && value.length > 0);
if (configured.length === 0) return null;
const roots = configured.map((value) => {
if (value !== value.trim() || !/^[A-Za-z]:\\Windows\\?$/i.test(value))
return null;
return value.endsWith('\\') ? value.slice(0, -1) : value;
});
if (roots.some((root) => !root)) return null;
if (roots.some((root) => root.toLowerCase() !== roots[0].toLowerCase()))
return null;
const systemRoot = roots[0];
const systemDirectory = architecture === 'ia32' ? 'Sysnative' : 'System32';
const expected = {
whoami: path.win32.join(systemRoot, systemDirectory, 'whoami.exe'),
icacls: path.win32.join(systemRoot, systemDirectory, 'icacls.exe'),
powershell: path.win32.join(
systemRoot,
systemDirectory,
'WindowsPowerShell',
'v1.0',
'powershell.exe',
),
};
try {
const realpath = fileSystem.realpathSync.native || fileSystem.realpathSync;
const rootEntry = fileSystem.lstatSync(systemRoot);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) return null;
const canonicalRoot = realpath.call(fileSystem.realpathSync, systemRoot);
if (canonicalRoot.toLowerCase() !== systemRoot.toLowerCase()) return null;
for (const executable of Object.values(expected)) {
const entry = fileSystem.lstatSync(executable);
if (!entry.isFile() || entry.isSymbolicLink()) return null;
const canonicalExecutable = realpath.call(
fileSystem.realpathSync,
executable,
);
const canonicalCandidates = [executable];
if (architecture === 'ia32') {
canonicalCandidates.push(
executable.replace('\\Sysnative\\', '\\System32\\'),
);
}
if (
!canonicalCandidates.some(
(candidate) =>
candidate.toLowerCase() === canonicalExecutable.toLowerCase(),
)
)
return null;
}
} catch {
return null;
}
return expected;
}
/**
* Restrict a credential file before any secret is written to it.
* Dependencies are injectable so every fail-closed branch is unit-testable.
*/
export function hardenCredentialFile(
filepath,
{
platform = process.platform,
architecture = process.arch,
spawn = spawnSync,
fileSystem = fs,
environment = process.env,
} = {},
) {
if (platform !== 'win32') {
try {
if (platform === 'darwin') {
const aclRemoval = spawn('chmod', ['-N', filepath], {
stdio: 'ignore',
});
if (!commandCompletedSuccessfully(aclRemoval)) return false;
}
fileSystem.chmodSync(filepath, 0o600);
return (fileSystem.statSync(filepath).mode & 0o777) === 0o600;
} catch {
return false;
}
}
const tools = resolveWindowsNativeTools(
environment,
fileSystem,
architecture,
);
if (!tools) return false;
try {
// Grant by the CURRENT PROCESS TOKEN'S SID, never a bare username. Parsing
// the second CSV field structurally prevents an SID-looking account name or
// a broad group SID from becoming the credential owner.
const whoami = spawn(tools.whoami, ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf8',
windowsHide: true,
});
const sid = commandCompletedSuccessfully(whoami)
? parseWindowsUserSid(whoami.stdout)
: null;
if (!sid) return false;
const applied = spawn(
tools.icacls,
[
filepath,
'/inheritance:r',
'/grant:r',
`*${sid}:F`,
'*S-1-5-18:F',
'*S-1-5-32-544:F',
],
{ stdio: 'ignore', windowsHide: true },
);
if (!commandCompletedSuccessfully(applied)) return false;
// Command success is not proof of the resulting DACL. Query it back and
// accept only three explicit FullControl allow principals, with inheritance
// disabled. Any unexpected rule, right, command error, or missing principal
// fails closed before the secret reaches disk.
//
// The verify process must load Microsoft.PowerShell.Security (Get-Acl)
// from the Windows PowerShell system module tree ONLY. A side-by-side
// PowerShell 7 install prepends its own module trees to PSModulePath at
// startup; inherited into a 5.1 process, the incompatible 7.x manifest
// cannot be autoloaded and the verify step fails. The script itself sets
// the path from $PSHOME; this is the same value computed ahead of time, so
// nothing inherited is in force even for the moment before it runs. The
// Sysnative spelling is a 32-bit caller's bridge and not a directory the
// launched native process can read, so the physical name is used.
const powershellModuleDirectory = path.win32.join(
path.win32
.dirname(tools.powershell)
.replace(/\\Sysnative\\/i, '\\System32\\'),
'Modules',
);
// Windows environment names are case-insensitive, and a child can end up
// carrying a differently cased alias alongside the value set here. Drop
// every spelling before setting the trusted one.
const verifyEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([name]) => name.toLowerCase() !== 'psmodulepath',
),
);
const verified = spawn(
tools.powershell,
['-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_VERIFY_SCRIPT],
{
env: {
...verifyEnvironment,
GEV_ACL_FILE: filepath,
GEV_ACL_USER_SID: sid,
PSModulePath: powershellModuleDirectory,
},
stdio: 'ignore',
windowsHide: true,
},
);
return commandCompletedSuccessfully(verified);
} catch {
return false;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { readEnvironmentSource as readPinokioEnvironmentSource } from '../../scr
import fs from 'node:fs';
import { parseEnv as parseDotenvText } from 'node:util';
import { randomUUID } from 'node:crypto';
import { hardenCredentialFile } from '../../src/keySetupHardening.mjs';
import { hardenCredentialFile } from './key-setup-hardening.mjs';
/**
* Which launcher started this process, captured at MODULE LOAD — before the