mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'origin/main' into fix/sanitize-openai-proxy-errors
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
92350b8412
332 files changed
+56273
-6359
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTransitHistory,
|
||||
TRANSIT_HISTORY_LIMITS,
|
||||
validTransitIdentifier,
|
||||
} from '../../src/sources/transitHistoryStore.js';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user