mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'upstream/main' into fix/363-geocode-proxy
# Conflicts: # SECURITY.md # server/providers/places/google.js
This commit is contained in:
commit
e2ce7fa38e
223 files changed
+20759
-1752
No files matched your search
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Serves the local MCP server at /mcp on the development and preview servers.
|
||||
*
|
||||
* This is local transport safety, not authentication: requests must come from
|
||||
* this machine, carry no proxy forwarding headers, arrive while sharing is
|
||||
* off, name a loopback host on the port they arrived at (blocking DNS
|
||||
* rebinding) and, when a browser sends an Origin, come from that same host.
|
||||
*/
|
||||
|
||||
import { isSharingEnabled } from '../../src/keySetupCore.mjs';
|
||||
import { hasProxySignals } from '../../src/localRequestGate.mjs';
|
||||
import { createMcpHttpHandler } from '../../src/tools/mcp/index.js';
|
||||
import { createLocalMcpServer } from './server.js';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
/** How long a client may take to send its request body. */
|
||||
const BODY_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
/**
|
||||
* Whether a request may reach the local MCP server. `localPort` is the port
|
||||
* the connection arrived at; `headers` are the request's, keyed by lower-case
|
||||
* name; `env` is checked for launcher sharing.
|
||||
*/
|
||||
export function isLocalMcpRequest({
|
||||
remoteAddress,
|
||||
localPort,
|
||||
host,
|
||||
origin,
|
||||
headers = {},
|
||||
env = {},
|
||||
}) {
|
||||
if (!LOOPBACK_ADDRESSES.has(remoteAddress)) return false;
|
||||
// A proxy or tunnel in front of the server can make a remote request look
|
||||
// local, and sharing exposes the server beyond this machine.
|
||||
if (hasProxySignals(headers) || isSharingEnabled(env)) return false;
|
||||
let hostUrl;
|
||||
try {
|
||||
hostUrl = new URL(`http://${host}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!LOOPBACK_HOSTS.has(hostUrl.hostname)) return false;
|
||||
// The Host names the port the request reached, so tools are never pointed
|
||||
// at another local service.
|
||||
if (localPort != null && (hostUrl.port || '80') !== String(localPort))
|
||||
return false;
|
||||
if (origin == null) return true;
|
||||
try {
|
||||
const originUrl = new URL(origin);
|
||||
return (
|
||||
['http:', 'https:'].includes(originUrl.protocol) &&
|
||||
originUrl.host === hostUrl.host
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Vite plugin that mounts the local MCP server at /mcp. */
|
||||
export function localMcpPlugin({
|
||||
createServer = createLocalMcpServer,
|
||||
bodyTimeoutMs = BODY_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
const handlers = new Map();
|
||||
const handlerFor = (apiBase) => {
|
||||
if (!handlers.has(apiBase))
|
||||
handlers.set(apiBase, createMcpHttpHandler(createServer({ apiBase })));
|
||||
return handlers.get(apiBase);
|
||||
};
|
||||
const install = (server) => {
|
||||
server.middlewares.use('/mcp', async (req, res) => {
|
||||
const host = req.headers.host || '';
|
||||
if (
|
||||
!isLocalMcpRequest({
|
||||
remoteAddress: req.socket?.remoteAddress,
|
||||
localPort: req.socket?.localPort,
|
||||
host,
|
||||
origin: req.headers.origin,
|
||||
headers: req.headers,
|
||||
env: process.env,
|
||||
})
|
||||
) {
|
||||
res.writeHead(403, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'The MCP server only accepts local requests',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A client that disconnects before its answer cancels the tool call.
|
||||
const disconnect = new AbortController();
|
||||
const onClose = () => {
|
||||
if (!res.writableFinished) disconnect.abort();
|
||||
};
|
||||
res.on('close', onClose);
|
||||
try {
|
||||
const body =
|
||||
req.method === 'POST'
|
||||
? await readBody(req, bodyTimeoutMs)
|
||||
: undefined;
|
||||
const request = new Request(`http://${host}/mcp`, {
|
||||
method: req.method,
|
||||
headers: Object.entries(req.headers).flatMap(([name, value]) =>
|
||||
value === undefined ? [] : [[name, String(value)]],
|
||||
),
|
||||
body,
|
||||
signal: disconnect.signal,
|
||||
});
|
||||
const response = await handlerFor(`http://${host}`)(request);
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers));
|
||||
res.end(Buffer.from(await response.arrayBuffer()));
|
||||
} catch (error) {
|
||||
if (disconnect.signal.aborted) return;
|
||||
if (res.headersSent) return res.destroy();
|
||||
res.writeHead([408, 413].includes(error?.status) ? error.status : 400, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
error?.status === 413
|
||||
? 'Request too large'
|
||||
: error?.status === 408
|
||||
? 'Request timed out'
|
||||
: 'Bad request',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
res.off('close', onClose);
|
||||
}
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'local-mcp',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(req, timeoutMs = BODY_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
// A client that stops sending must not hold the request open.
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(Object.assign(new Error('Request timed out'), { status: 408 })),
|
||||
timeoutMs,
|
||||
);
|
||||
const fail = (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
};
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
// Keep draining without storing, so the 413 response can still be sent.
|
||||
if (tooLarge || size <= MAX_BODY_BYTES) {
|
||||
if (!tooLarge) chunks.push(chunk);
|
||||
return;
|
||||
}
|
||||
tooLarge = true;
|
||||
chunks.length = 0;
|
||||
fail(Object.assign(new Error('Request too large'), { status: 413 }));
|
||||
});
|
||||
req.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on('error', fail);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** The local MCP server: Core's tools over services backed by a running app. */
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
composeCatalog,
|
||||
coreTools,
|
||||
catalogForSurface,
|
||||
} from '../../src/tools/index.js';
|
||||
import {
|
||||
createGlobePanelResource,
|
||||
panelRuntime,
|
||||
} from '../../src/tools/panel.js';
|
||||
import { createMcpServer } from '../../src/tools/mcp/index.js';
|
||||
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
|
||||
|
||||
const INSTRUCTIONS =
|
||||
"Tools answer questions from God's Eye View's live public data. Location " +
|
||||
'tools take an area: a place name, a bbox, or lat/lon with radius_km. ' +
|
||||
'Results are capped; check truncated and total before concluding there is nothing more. ' +
|
||||
"Answers that can be shown in God's Eye View include data.view; to show one, call " +
|
||||
'show_in_gods_eye_view with that view, adding layers, style, a camera or marks as ' +
|
||||
"needed. It shows live God's Eye View where the client displays apps and " +
|
||||
'returns a link everywhere.';
|
||||
|
||||
/** Construct the local MCP server for Core's tools. */
|
||||
export function createLocalMcpServer({
|
||||
apiBase = DEFAULT_API_BASE,
|
||||
fetchImpl,
|
||||
} = {}) {
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
// Each server makes a new key for its panel. The panel's page carries it
|
||||
// and panel_request requires it; any client may read the page, so it keeps
|
||||
// the tool from clients that only list it, and is not access control.
|
||||
const panelKey = randomBytes(32).toString('base64url');
|
||||
return createMcpServer({
|
||||
catalog: catalogForSurface(
|
||||
composeCatalog({
|
||||
tools: coreTools,
|
||||
services: createLocalToolServices({ apiBase, fetchImpl, panelKey }),
|
||||
}),
|
||||
'mcp',
|
||||
),
|
||||
name: 'gods-eye-view',
|
||||
version,
|
||||
instructions: INSTRUCTIONS,
|
||||
resources: [createGlobePanelResource({ runtime: panelRuntime, panelKey })],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Local composition of tool services: Core's default services, pointed at a
|
||||
* running God's Eye View server's `/api` routes.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createToolServices } from '../../src/tools/services.js';
|
||||
|
||||
// Bundled data some sources read by module-relative URL.
|
||||
const BUNDLED_DATA = new URL('../../src/data/local_data/', import.meta.url);
|
||||
|
||||
export const DEFAULT_API_BASE = 'http://localhost:4173';
|
||||
|
||||
/**
|
||||
* Resolve the sources' relative `/api/...` requests against `apiBase`, and
|
||||
* serve `file:` URLs inside the bundled data directory from disk, as a
|
||||
* browser would load those module-relative assets.
|
||||
*/
|
||||
export function createApiFetch({
|
||||
apiBase = DEFAULT_API_BASE,
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
const base = new URL(apiBase);
|
||||
if (!['http:', 'https:'].includes(base.protocol))
|
||||
throw new TypeError(`apiBase must be an http(s) URL: ${apiBase}`);
|
||||
return (input, init) => {
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
if (url.startsWith('file:')) return readBundled(url);
|
||||
return fetchImpl(
|
||||
typeof input === 'string' && input.startsWith('/')
|
||||
? new URL(input, base)
|
||||
: input,
|
||||
init,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function readBundled(url) {
|
||||
const href = new URL(url).href;
|
||||
if (!href.startsWith(BUNDLED_DATA.href) || href.includes('/../'))
|
||||
return new Response(null, { status: 404 });
|
||||
try {
|
||||
const body = await readFile(fileURLToPath(href));
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': href.endsWith('.json')
|
||||
? 'application/json'
|
||||
: 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct every service Core's tools read, backed by the local server. */
|
||||
export function createLocalToolServices(options = {}) {
|
||||
return createToolServices({
|
||||
fetchImpl: createApiFetch(options),
|
||||
appUrl: options.apiBase ?? DEFAULT_API_BASE,
|
||||
panelKey: options.panelKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Local MCP server over stdio for agents such as Claude Code and Codex.
|
||||
*
|
||||
* npm run mcp -- [--api-base http://localhost:4173]
|
||||
*
|
||||
* Reads newline-delimited JSON-RPC from stdin and writes responses to stdout.
|
||||
* Tools read data from a running God's Eye View server (`npm run dev` or
|
||||
* `npm run preview`) at the API base. Diagnostics go to stderr.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseErrorResponse } from '../../src/tools/mcp/index.js';
|
||||
import { createLocalMcpServer } from './server.js';
|
||||
import { DEFAULT_API_BASE } from './services.js';
|
||||
|
||||
/**
|
||||
* A one-line description of a request for the diagnostic log: its method,
|
||||
* and the tool or resource it names. Arguments and data are never logged.
|
||||
*/
|
||||
export function describeRequest(message) {
|
||||
const method = typeof message?.method === 'string' ? message.method : null;
|
||||
if (!method) return null;
|
||||
const subject =
|
||||
method === 'tools/call'
|
||||
? message.params?.name
|
||||
: method === 'resources/read'
|
||||
? message.params?.uri
|
||||
: null;
|
||||
return typeof subject === 'string' ? `${method} ${subject}` : method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve newline-delimited JSON-RPC until `input` ends. A client's
|
||||
* `notifications/cancelled` aborts the named request, which then gets no
|
||||
* response, as the protocol asks.
|
||||
*/
|
||||
export async function serveStdio(server, { input, output, log = () => {} }) {
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
const pending = new Set();
|
||||
const running = new Map();
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const work = respond(server, line, log, running).then((response) => {
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
});
|
||||
pending.add(work);
|
||||
work.finally(() => pending.delete(work));
|
||||
}
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async function respond(server, line, log, running) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return parseErrorResponse();
|
||||
}
|
||||
if (message?.method === 'notifications/cancelled') {
|
||||
running.get(message.params?.requestId)?.abort();
|
||||
return null;
|
||||
}
|
||||
const described = describeRequest(message);
|
||||
if (described) log(described);
|
||||
const id = message?.id;
|
||||
const cancellable = id !== undefined && id !== null && !running.has(id);
|
||||
const controller = new AbortController();
|
||||
if (cancellable) running.set(id, controller);
|
||||
let response;
|
||||
try {
|
||||
response = await server.handle(message, { signal: controller.signal });
|
||||
} finally {
|
||||
if (cancellable) running.delete(id);
|
||||
}
|
||||
if (controller.signal.aborted) return null;
|
||||
// A failed tool call answers the client, not the log; say why here too.
|
||||
if (response?.result?.isError)
|
||||
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Read `--api-base <url>` from command-line arguments. */
|
||||
export function parseArgs(argv) {
|
||||
const options = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--api-base' && argv[index + 1])
|
||||
options.apiBase = argv[(index += 1)];
|
||||
else throw new Error(`Unknown argument: ${argv[index]}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
console.error(
|
||||
`God's Eye View MCP server reading ${options.apiBase || DEFAULT_API_BASE}`,
|
||||
);
|
||||
await serveStdio(createLocalMcpServer(options), {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
log: (line) => console.error(`<- ${line}`),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||
*
|
||||
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
|
||||
* Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
|
||||
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
|
||||
* cached body as STALE instead of relaying the failure, and it backs off from
|
||||
* upstream for the Retry-After period (bounded) so a rate limit is never
|
||||
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-ADS-B-Cache': cacheStatus,
|
||||
'X-Feed-Source': 'adsb.lol',
|
||||
'X-Feed-Cache': cacheStatus,
|
||||
...(['HIT', 'STALE'].includes(cacheStatus)
|
||||
? {
|
||||
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
}
|
||||
: {}),
|
||||
...extra,
|
||||
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
|
||||
}
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||
server.middlewares.use('/api/military', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -25,6 +25,36 @@ let _openskyCacheMeta = null;
|
||||
let _openskyCacheSourceEpochMs = null;
|
||||
/** TTL for the OpenSky response cache (ms). */
|
||||
const OPENSKY_CACHE_MS = 9000;
|
||||
/** Per-attempt limit for the global snapshot; it normally arrives in ~1.5 s. */
|
||||
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
|
||||
const OPENSKY_STATES_URL =
|
||||
'https://opensky-network.org/api/states/all?extended=1';
|
||||
|
||||
/**
|
||||
* Fetch and read the global snapshot, giving each attempt
|
||||
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
|
||||
* the connection fails. A second failure throws, which the proxy answers
|
||||
* from its stale cache or the regional fallback.
|
||||
* @param {Record<string, string>} headers Request headers.
|
||||
* @returns {Promise<{upstream: Response, body: string}>}
|
||||
*/
|
||||
async function fetchOpenSkyStates(headers) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const upstream = await fetch(OPENSKY_STATES_URL, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
|
||||
});
|
||||
return { upstream, body: await upstream.text() };
|
||||
} catch (error) {
|
||||
if (attempt >= 2) throw error;
|
||||
console.warn(
|
||||
'[OpenSky] global snapshot attempt failed, retrying:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- OpenSky credit governor (field-test fix 2026-07-06) -------------------
|
||||
// The global /states/all this proxy fetches costs 4 CREDITS per call against
|
||||
// OpenSky's ~4000/day authenticated budget — a day with the app open burned
|
||||
@@ -107,6 +137,7 @@ export async function getOpenSkyToken() {
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
|
||||
body: `grant_type=client_credentials&client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}`,
|
||||
},
|
||||
);
|
||||
@@ -349,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
||||
*/
|
||||
export function openSkyProxy() {
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||
server.middlewares.use('/api/flights', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
);
|
||||
@@ -470,10 +505,7 @@ export function openSkyProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
let upstream = await fetch(
|
||||
'https://opensky-network.org/api/states/all?extended=1',
|
||||
{ headers },
|
||||
);
|
||||
let { upstream, body } = await fetchOpenSkyStates(headers);
|
||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||
if (
|
||||
(upstream.status === 401 || upstream.status === 403) &&
|
||||
@@ -485,15 +517,11 @@ export function openSkyProxy() {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`,
|
||||
};
|
||||
upstream = await fetch(
|
||||
'https://opensky-network.org/api/states/all?extended=1',
|
||||
{ headers: retryHeaders },
|
||||
);
|
||||
({ upstream, body } = await fetchOpenSkyStates(retryHeaders));
|
||||
usedMode = 'basic';
|
||||
reason = 'oauth_rejected_fallback_basic';
|
||||
}
|
||||
|
||||
let body = await upstream.text();
|
||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||
if (
|
||||
upstream.ok &&
|
||||
|
||||
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
|
||||
/**
|
||||
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
|
||||
*
|
||||
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* own credit bucket, 4 credits per call on the free tier). OAuth via the
|
||||
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
|
||||
* client can fall back to its accumulated trail silently.
|
||||
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
|
||||
* of real history per aircraft. Treat as best-effort; data is ODbL —
|
||||
* credit "adsb.lol (ODbL)" in the UI.
|
||||
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/opensky-track', async (req, res) => {
|
||||
middlewares.use('/api/flights/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const icao24 = String(incoming.searchParams.get('icao24') || '')
|
||||
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
});
|
||||
|
||||
middlewares.use('/api/adsblol/trace', async (req, res) => {
|
||||
middlewares.use('/api/military/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const hex = String(incoming.searchParams.get('hex') || '')
|
||||
|
||||
@@ -143,8 +143,10 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
||||
|
||||
if (url.pathname === '/sources') {
|
||||
const body = {
|
||||
trimmedPacks: getCctvSources.trimmedPacks?.() ?? [],
|
||||
sources: sources.map((source) => ({
|
||||
id: source.id,
|
||||
pack: source.pack,
|
||||
name: source.name,
|
||||
city: source.city,
|
||||
cityId: source.cityId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js';
|
||||
import { allocateSourceCap, resolveCatalogCap } from './cap.js';
|
||||
import { loadGroundHeights, joinGroundHeights } from './groundHeights.js';
|
||||
import { normalizeSourceItem } from './normalize.js';
|
||||
import { cameraRegion, normalizeSourceItem } from './normalize.js';
|
||||
import {
|
||||
loadAustinSourcesFromOpenData,
|
||||
loadCaltransSourcesFromOpenData,
|
||||
@@ -143,6 +143,9 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
/** @type {Promise<Array<object>>|null} In-flight refresh, shared by concurrent
|
||||
* callers so a post-TTL burst launches ONE refetch, not one per request. */
|
||||
let _cctvSourceInflight = null;
|
||||
/** @type {Array<{pack: string, available: number, served: number}>} Packs
|
||||
* that serve fewer cameras than they offer. */
|
||||
let _cctvTrimmedPacks = [];
|
||||
|
||||
/**
|
||||
* Assemble and cache the merged CCTV source list.
|
||||
@@ -209,8 +212,12 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
name,
|
||||
sources: items
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => normalizeSourceItem(item))
|
||||
.map((item) => ({ ...normalizeSourceItem(item), pack: name }))
|
||||
.filter((item) => item.id),
|
||||
available: Number.isFinite(items.available)
|
||||
? items.available
|
||||
: items.length,
|
||||
region: items.region ?? null,
|
||||
});
|
||||
const packs = [
|
||||
...LIVE_PACKS.map((pack, index) =>
|
||||
@@ -234,6 +241,16 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
loadGroundHeights(sourceRoot),
|
||||
);
|
||||
const trimmed = allocation.packs.filter((pack) => pack.kept < pack.offered);
|
||||
// Each pack's own nearest-first cap and the shared catalog cap both trim.
|
||||
const trimmedPacks = packs
|
||||
.map((pack, index) => ({
|
||||
pack: pack.name,
|
||||
available: Math.max(pack.available, allocation.packs[index].offered),
|
||||
served: allocation.packs[index].kept,
|
||||
// Where the pack's cameras are, including those not served.
|
||||
region: pack.region ?? cameraRegion(pack.sources),
|
||||
}))
|
||||
.filter((pack) => pack.served < pack.available);
|
||||
if (trimmed.length) {
|
||||
const detail = trimmed
|
||||
.map((pack) => `${pack.name} ${pack.kept}/${pack.offered}`)
|
||||
@@ -244,6 +261,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
}
|
||||
if (capped.length > 0 || _cctvSourceCache.length === 0) {
|
||||
_cctvSourceCache = capped;
|
||||
_cctvTrimmedPacks = trimmedPacks;
|
||||
} else {
|
||||
// Every source came back empty (all live packs timed out / upstream outage)
|
||||
// but a good catalog is already cached — serve it stale rather than blanking
|
||||
@@ -257,5 +275,7 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) {
|
||||
return _cctvSourceCache;
|
||||
}
|
||||
|
||||
/** Packs serving fewer cameras than they offer, as of the last refresh. */
|
||||
getCctvSources.trimmedPacks = () => _cctvTrimmedPacks;
|
||||
return getCctvSources;
|
||||
}
|
||||
@@ -470,7 +470,31 @@ export function prioritizeSources(cameras, maxCount, anchors) {
|
||||
return a.idx - b.idx;
|
||||
});
|
||||
|
||||
return scored.slice(0, cap).map((entry) => entry.camera);
|
||||
const kept = scored.slice(0, cap).map((entry) => entry.camera);
|
||||
// How many cameras the pack offered before trimming, and where, for
|
||||
// coverage reports.
|
||||
Object.defineProperty(kept, 'available', { value: list.length });
|
||||
Object.defineProperty(kept, 'region', { value: cameraRegion(list) });
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** The bounding box of cameras with coordinates, or null. */
|
||||
export function cameraRegion(cameras) {
|
||||
let region = null;
|
||||
for (const camera of cameras || []) {
|
||||
const lat = Number(camera?.lat);
|
||||
const lon = Number(camera?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
region = region
|
||||
? {
|
||||
west: Math.min(region.west, lon),
|
||||
south: Math.min(region.south, lat),
|
||||
east: Math.max(region.east, lon),
|
||||
north: Math.max(region.north, lat),
|
||||
}
|
||||
: { west: lon, south: lat, east: lon, north: lat };
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,24 +2,52 @@ 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).
|
||||
* DEFAULT IS UNLIMITED: when the env var is unset, `0`, or non-numeric, this
|
||||
* returns `null` and the caller skips the check entirely — a runtime no-op that
|
||||
* preserves the original behavior. Only a positive integer N enables a fixed
|
||||
* 60s window of N requests/IP (built lazily once, then reused so its per-IP
|
||||
* window state persists across requests). The global backstop is set to a
|
||||
* generous multiple of the per-IP cap so a single host can't starve the rest.
|
||||
* Resolve the per-IP, per-minute cap for a cost-bearing proxy from its env var.
|
||||
*
|
||||
* @param {string|undefined} envValue - Raw env value (requests/min/IP).
|
||||
* The default is ON. An unset or blank value takes `defaultPerMin`; a positive
|
||||
* integer overrides it; an explicit `0` disables the limiter for deployments
|
||||
* that genuinely want no throttle.
|
||||
*
|
||||
* A value that cannot be read as a number — a typo, a stray unit, a negative —
|
||||
* also takes the default rather than disabling the guard. That asymmetry is the
|
||||
* point: `GEV_RATELIMIT_OPENAI_PER_MIN=3O` should not quietly hand an exposed
|
||||
* server's API key to whoever asks. Removing the throttle stays possible, but
|
||||
* only by saying `0`, which cannot be typed by accident.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {number} Requests/min/IP; `0` means unlimited.
|
||||
*/
|
||||
export function resolvePerMinuteCap(envValue, defaultPerMin) {
|
||||
const raw = typeof envValue === 'string' ? envValue.trim() : envValue;
|
||||
if (raw === undefined || raw === null || raw === '') return defaultPerMin;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return defaultPerMin;
|
||||
// Only an explicit 0 disables the guard: a positive fraction such as 0.5
|
||||
// would floor to 0, so it takes the strictest real cap instead.
|
||||
if (parsed > 0 && parsed < 1) return 1;
|
||||
return Math.floor(parsed); // includes the explicit 0 = unlimited opt-out
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for the cost-bearing API proxies (OpenAI / Google).
|
||||
* Returns `null` only when the cap resolves to `0` (the explicit opt-out), in
|
||||
* which case the caller skips the check entirely. Otherwise a fixed 60s window
|
||||
* of N requests/IP, built lazily once and reused so its per-IP window state
|
||||
* persists across requests. The global backstop is a generous multiple of the
|
||||
* per-IP cap so a single host can't starve the rest.
|
||||
*
|
||||
* @param {string|number|undefined|null} envValue - Raw env value (requests/min/IP).
|
||||
* @param {number} defaultPerMin - Cap to apply when the value is absent or unreadable.
|
||||
* @returns {((key:string)=>boolean)|null} An `allow(key)` fn, or null when unlimited.
|
||||
*/
|
||||
export function makeOptInRateLimiter(envValue) {
|
||||
const max = Number(envValue);
|
||||
if (!Number.isFinite(max) || max <= 0) return null; // unset/0/garbage -> unlimited
|
||||
export function makeCostRateLimiter(envValue, defaultPerMin) {
|
||||
const max = resolvePerMinuteCap(envValue, defaultPerMin);
|
||||
if (max <= 0) return null; // explicit opt-out
|
||||
return makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: Math.floor(max),
|
||||
globalMax: Math.floor(max) * 20,
|
||||
max,
|
||||
globalMax: max * 20,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
|
||||
|
||||
/**
|
||||
* Cross-site request gate for cost-bearing and log endpoints. Feeds the
|
||||
* request to the pure, unit-tested `admitSameSiteRequest` and answers 403
|
||||
* itself when the request is refused. Returns true when it has already
|
||||
* responded (the caller returns); false when the handler should continue.
|
||||
* Refuses cross-site browser requests (foreign or opaque Origin, a
|
||||
* Sec-Fetch-Site other than same-origin/none, or reverse-proxy headers) while
|
||||
* keeping loopback non-browser tools and the HOST=0.0.0.0 LAN opt-in working.
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @returns {boolean} True when a 403 was sent.
|
||||
*/
|
||||
export function admitSameSite(req, res) {
|
||||
const verdict = admitSameSiteRequest({
|
||||
hostHeader: req.headers?.host,
|
||||
protocol: req.socket?.encrypted ? 'https:' : 'http:',
|
||||
origin: req.headers?.origin,
|
||||
secFetchSite: req.headers?.['sec-fetch-site'],
|
||||
proxyHeaders: req.headers || {},
|
||||
});
|
||||
if (verdict.ok) return false;
|
||||
res.statusCode = verdict.status;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify({ error: verdict.error }));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a middleware handler so cross-site requests are refused with 403
|
||||
* before the handler runs. Loopback tools and same-origin browser requests
|
||||
* pass straight through.
|
||||
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown} handler
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown}
|
||||
*/
|
||||
export function sameSiteGated(handler) {
|
||||
return (req, res, next) => {
|
||||
if (admitSameSite(req, res)) return undefined;
|
||||
return handler(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -26,8 +26,11 @@ import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
/**
|
||||
* Construct the local provider plugins in their established order.
|
||||
* `realtime` configures the voice session token endpoint.
|
||||
*/
|
||||
function localProviderPlugins({ realtime } = {}) {
|
||||
return [
|
||||
openSkyProxy(),
|
||||
celestrakProxy(),
|
||||
@@ -49,7 +52,7 @@ function localProviderPlugins() {
|
||||
adsbLolProxy(),
|
||||
aisLiveProxy(),
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
openAiRealtimeProxy({ realtime }),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
weatherProxy(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defaultSourceRoot } from './common/source-root.js';
|
||||
import { handleHudSummary } from './openai/hud-summary.js';
|
||||
import { createDebugLogHandler } from './openai/debug-log.js';
|
||||
import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
import { sameSiteGated } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||
@@ -15,16 +16,20 @@ function openAiRealtimeProxy({
|
||||
realtime = {},
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
// Cost-bearing and log endpoints refuse cross-site browser requests
|
||||
// (see server/providers/common/same-site.js and SECURITY.md).
|
||||
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/debug-log',
|
||||
createDebugLogHandler({ sourceRoot }),
|
||||
sameSiteGated(createDebugLogHandler({ sourceRoot })),
|
||||
);
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
sameSiteGated(
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,11 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
|
||||
try {
|
||||
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
|
||||
const record = JSON.parse(body || '{}');
|
||||
// The server's own timestamp comes last, so a record cannot supply one.
|
||||
await append(
|
||||
`${JSON.stringify({
|
||||
loggedAt: new Date().toISOString(),
|
||||
...record,
|
||||
loggedAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
);
|
||||
res.statusCode = 204;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
HUD_SUMMARY_INSTRUCTIONS,
|
||||
keylessHudSummaryResponse,
|
||||
} from '../../../src/hudSummaryResponse.js';
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
|
||||
|
||||
@@ -46,10 +46,10 @@ async function handleHudSummary(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
|
||||
// fallback has no provider cost and resolves above without consuming a
|
||||
// paid-endpoint quota slot.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
// Keyless HUD fallback has no provider cost and resolves above without
|
||||
// consuming a paid-endpoint quota slot.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
try {
|
||||
const body = await readRequestBody(req, 64 * 1024);
|
||||
|
||||
@@ -19,6 +19,7 @@ function realtimeInstructions(
|
||||
'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.',
|
||||
'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data: report the returned feed source briefly, never call it an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.',
|
||||
'For ANALYTICAL questions about layer data — how many / which / fastest / highest / biggest / nearest flights, ships, fires, earthquakes, satellites, datacenters, or dams ("how many flights over Texas", "biggest fire near LA", "which ships are headed to Oakland", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Narrate the count plus two or three notable examples by name, and reflect the result\'s coverage note honestly: the answer covers data loaded by enabled layers, not the whole world. If the needed layer is disabled, say so and offer to enable it. For follow-ups about the same set ("which of THOSE is closest?"), call analyst_query with followUp=true and only the new filter/sort.',
|
||||
'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.',
|
||||
'FEED STATE CONTRACT — analyst_query and get_current_view_state carry feedProvenance from the same layerFeedState the Data Layers chips use (nominal/loading/degraded/stale/fallback/unavailable, or off). NEVER narrate a count as live or current when feedProvenance.overall is not nominal. Say the feedState in the same breath as the count ("12 flights in view, STALE"). If unavailable, do not invent a count; say the feed is unavailable. Fallback is valid data — name the fallback source, never call it an enable failure. Confirm only what the tool result returned.',
|
||||
'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, "near / nearby / how many aircraft" means the Contacts window: answer from contactsWindow in the tool result — those are the exact numbers on the user\'s panel. set_context_mode, analyst_query, and get_current_view_state carry it after Contacts settles. For "Open Contacts and tell me how many aircraft are within 250 km", call set_context_mode{mode:"contacts"} first and answer from contactsWindow.aircraft; do not answer from a pre-Contacts analyst query. analyst_query\'s own count measures currently-loaded records and is usually lower; never give it as the window count. CENTER PRECEDENCE for a nearby/how-many ask, in order: an explicit place in the question ("over Texas", "near Austin") always wins and ignores Contacts state; else the CONTACTS SUBJECT when Contacts is active and has one — a selected datacenter, dam, fire, or cable does NOT silently become the center; else an entity the user explicitly names ("around this datacenter"); else the current view, said aloud ("nothing is selected, so this is the current view"). With Contacts active but NO subject yet, use the view and say so; never read an empty panel. (2) With Contacts OFF, "nearby" means in view; "near <place>" means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two different numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge ("a few", "less than a dozen"): if a tool returns 46, say 46. (5) When it matters, add once: counts cover loaded data, and the flights layer loads where you look.',
|
||||
'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.',
|
||||
@@ -48,6 +49,7 @@ function realtimeInstructions(
|
||||
// string is the whole rollback.
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'SHIPS vs STYLES: "vessels" is easily heard as "visuals". "Show me vessels", "turn on vessels", "the vessels", "visuals" or "vestibules" said WITHOUT a style name means the ships layer: set_layer_visibility{layerId:"ais-live-vessels"}, exactly as "ships" or "boats" does. Call set_visual_style only when the user names a style (retro/CRT, surveillance/night vision, thermal, anime, noir, snow, normal) or says filter, effect or look. Never turn on a different data layer (such as fires) for a ships request.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_OPENAI_PER_MIN is unset. Both
|
||||
* places that already name a value agree on it: `.env.example` recommends 30
|
||||
* and the Pinokio build ships 30 (pinokio/_ENVIRONMENT), so the packaged app
|
||||
* is unaffected and only an unconfigured server changes. The HUD asks for a
|
||||
* summary every 15s (HUD_SUMMARY_INTERVAL_MS), so an open tab spends 4/min and
|
||||
* a voice session adds one token mint; the cap clears ordinary use several
|
||||
* times over and bites only a caller working the endpoint far harder than the
|
||||
* app does.
|
||||
*/
|
||||
const OPENAI_DEFAULT_PER_MIN = 30;
|
||||
|
||||
// Built LAZILY on first request, NOT at module load: `.env` values are applied to process.env later
|
||||
// (the plugin config hook calls loadEnv → process.env, AFTER this module is imported), so reading
|
||||
// process.env here at import time would always see them unset and silently stay unlimited even when
|
||||
// process.env here at import time would always see them unset and fall back to the default even when
|
||||
// configured via .env. Building on first request (like the OPENAI_API_KEY reads) sees the loaded env;
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = unlimited (default).
|
||||
// the result is cached so the limiter's per-IP window state persists. `null` = the explicit 0 opt-out.
|
||||
let _openAiRateLimiter;
|
||||
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null = unlimited (default). */
|
||||
/** OpenAI cost endpoints (realtime/token + hud-summary). Null only when set to 0. */
|
||||
function openAiRateLimiter() {
|
||||
if (_openAiRateLimiter === undefined)
|
||||
_openAiRateLimiter = makeOptInRateLimiter(
|
||||
_openAiRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
|
||||
OPENAI_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _openAiRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an opt-in limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null (unlimited, the default) this is a no-op returning
|
||||
* `true`, so the handler proceeds exactly as before.
|
||||
* Apply the limiter to a request, writing a 429 when over the cap.
|
||||
* When `limiter` is null — the explicit `0` opt-out — this is a no-op
|
||||
* returning `true`, so the handler proceeds unthrottled.
|
||||
*
|
||||
* @param {((key:string)=>boolean)|null} limiter
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @returns {boolean} True if the request may proceed; false if a 429 was sent.
|
||||
*/
|
||||
function enforceOptInRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // unlimited (default) — no behavior change
|
||||
function enforceRateLimit(limiter, req, res) {
|
||||
if (!limiter) return true; // explicit 0 opt-out — unthrottled
|
||||
if (limiter(clientKey(req))) return true;
|
||||
res.statusCode = 429;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -36,4 +49,4 @@ function enforceOptInRateLimit(limiter, req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export { enforceOptInRateLimit, openAiRateLimiter };
|
||||
export { enforceRateLimit, openAiRateLimiter, OPENAI_DEFAULT_PER_MIN };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import { enforceRateLimit, openAiRateLimiter } from './rate-limit.js';
|
||||
import {
|
||||
resolveVoiceModel,
|
||||
isKnownVoiceTier,
|
||||
@@ -20,6 +20,7 @@ function createRealtimeTokenHandler({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
||||
models = {},
|
||||
tools = GEV_REALTIME_TOOLS,
|
||||
} = {}) {
|
||||
return async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -30,8 +31,8 @@ function createRealtimeTokenHandler({
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
// Per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). On by default; 0 disables.
|
||||
if (!enforceRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
@@ -113,7 +114,7 @@ function createRealtimeTokenHandler({
|
||||
output: { voice },
|
||||
},
|
||||
instructions: realtimeInstructions(annotationGuidance),
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
tools,
|
||||
tool_choice: 'auto',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,3 +2,15 @@ import { createActionTools } from '../../../src/voice/actionSchemas.js';
|
||||
import { ACTION_DESCRIPTIONS } from './toolDescriptions.js';
|
||||
|
||||
export const GEV_REALTIME_TOOLS = createActionTools(ACTION_DESCRIPTIONS);
|
||||
|
||||
/**
|
||||
* The session's tools: the app actions, then additional function tools
|
||||
* whose names no action already uses.
|
||||
*/
|
||||
export function realtimeSessionTools(additional = []) {
|
||||
const names = new Set(GEV_REALTIME_TOOLS.map((tool) => tool.name));
|
||||
return [
|
||||
...GEV_REALTIME_TOOLS,
|
||||
...additional.filter((tool) => !names.has(tool.name)),
|
||||
];
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export {
|
||||
export { installRouteMiddleware } from './places/routes.js';
|
||||
export {
|
||||
makeRateLimiter,
|
||||
makeOptInRateLimiter,
|
||||
makeCostRateLimiter,
|
||||
clientKey,
|
||||
} from './common/rate-limit.js';
|
||||
export { haversineKm } from './common/geo.js';
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
keylessGooglePlacesResponse,
|
||||
keylessGoogleGeocodeResponse,
|
||||
} from './google-key.js';
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { makeCostRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import { readResponseJsonCapped } from '../common/http.js';
|
||||
import {
|
||||
projectNearbyPlaces,
|
||||
@@ -11,14 +12,27 @@ import {
|
||||
} from '../../../src/data/placeProviderPayloads.js';
|
||||
|
||||
// Construct lazily after the standalone environment has loaded.
|
||||
// undefined = not built yet; null = unlimited; fn = active limiter
|
||||
// undefined = not built yet; null = the explicit 0 opt-out; fn = active limiter
|
||||
let _googleRateLimiter;
|
||||
|
||||
/** Google cost endpoint (nearby-places). Null = unlimited (default). */
|
||||
/**
|
||||
* Requests/min/IP applied when GEV_RATELIMIT_GOOGLE_PER_MIN is unset. This is
|
||||
* the value the Pinokio build already ships (pinokio/_ENVIRONMENT), so the
|
||||
* packaged app keeps behaving exactly as it does today and only an
|
||||
* unconfigured server changes — from unlimited to what the product already
|
||||
* runs with. Places calls are user-driven (a search, a nearby lookup, an
|
||||
* installation probe) rather than polled, so this sits far above what the app
|
||||
* generates and bites only a caller enumerating the endpoint. `.env.example`
|
||||
* suggests a tighter 60 when the host is not localhost; that still applies.
|
||||
*/
|
||||
export const GOOGLE_DEFAULT_PER_MIN = 120;
|
||||
|
||||
/** Google cost endpoints (nearby-places, text-search, geocoding). Null only when set to 0. */
|
||||
function googleRateLimiter() {
|
||||
if (_googleRateLimiter === undefined)
|
||||
_googleRateLimiter = makeOptInRateLimiter(
|
||||
_googleRateLimiter = makeCostRateLimiter(
|
||||
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
|
||||
GOOGLE_DEFAULT_PER_MIN,
|
||||
);
|
||||
return _googleRateLimiter;
|
||||
}
|
||||
@@ -97,6 +111,9 @@ export function googlePlacesContextProxy({
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/google/nearby-places', async (req, res) => {
|
||||
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
|
||||
// about this endpoint's method surface.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -127,7 +144,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (not the shared helper) so the 429 body keeps this endpoint's
|
||||
// `places: []` contract that the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
@@ -208,6 +225,8 @@ export function googlePlacesContextProxy({
|
||||
// a view-biased Text Search lands on the actual feature. Same key, field
|
||||
// mask, throttle, and `places: []` error contract as nearby-places above.
|
||||
middlewares.use('/api/google/text-search', async (req, res) => {
|
||||
// Paid like nearby-places, so gated the same way, first.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -247,7 +266,7 @@ export function googlePlacesContextProxy({
|
||||
}
|
||||
const { latitude, longitude } = coordinates;
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). On by default; 0 disables.
|
||||
// Inlined (like nearby-places) so the 429 body keeps the `places: []`
|
||||
// contract the client expects on every error response.
|
||||
const _grl = googleRateLimiter();
|
||||
@@ -344,6 +363,9 @@ export function googlePlacesContextProxy({
|
||||
);
|
||||
};
|
||||
|
||||
// Gate first, like the Places routes above: a cross-site caller learns
|
||||
// nothing about this endpoint's method surface.
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (req.method !== 'GET') {
|
||||
refuse(405, 'Method not allowed');
|
||||
return;
|
||||
@@ -369,8 +391,8 @@ export function googlePlacesContextProxy({
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with
|
||||
// the Places routes because both spend the same Google budget.
|
||||
// Per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with the
|
||||
// Places routes because both spend the same Google budget.
|
||||
const _grl = googleRateLimiter();
|
||||
if (_grl && !_grl(clientKey(req))) {
|
||||
res.statusCode = 429;
|
||||
|
||||
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
|
||||
*/
|
||||
export function aisLiveProxy() {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/ais-live', async (req, res) => {
|
||||
middlewares.use('/api/vessels', async (req, res) => {
|
||||
try {
|
||||
res.setHeader('X-Feed-Source', 'AISStream');
|
||||
ensureAisStreamConnection();
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
|
||||
// Track sub-route MUST be handled before the rows snapshot — this
|
||||
// mount prefix-matches every subpath, so without this branch
|
||||
// /api/ais-live/track would be silently answered with vessel rows.
|
||||
// /api/vessels/track would be silently answered with vessel rows.
|
||||
if (
|
||||
incoming.pathname === '/track' ||
|
||||
incoming.pathname.startsWith('/track/')
|
||||
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the watchdog once. Called on every /api/ais-live request and on the
|
||||
* Drive the watchdog once. Called on every /api/vessels request and on the
|
||||
* background interval, so recovery does not depend on browser traffic.
|
||||
*/
|
||||
function ensureAisStreamConnection() {
|
||||
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
|
||||
keyFingerprint: aisKeyFingerprint(),
|
||||
});
|
||||
}
|
||||
/** Status metadata for /api/ais-live, safe to call before the first connect. */
|
||||
/** Status metadata for /api/vessels, safe to call before the first connect. */
|
||||
function aisStreamStatusSnapshot() {
|
||||
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
|
||||
if (snapshot) return snapshot;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { resolveAllowedHosts } from '../../build/allowedHosts.js';
|
||||
import { createBrowserViteConfig } from '../../build/vite.js';
|
||||
import { localProviderPlugins } from '../providers/local.js';
|
||||
import { localMcpPlugin } from '../mcp/plugin.js';
|
||||
import { apiNotFoundPlugin } from './api-not-found.js';
|
||||
import { standaloneVoiceTools } from './voiceTools.js';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
|
||||
@@ -13,11 +16,16 @@ export default defineConfig(({ command, mode }) => {
|
||||
if (process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
return createBrowserViteConfig({
|
||||
plugins: [...localProviderPlugins(), apiNotFoundPlugin()],
|
||||
plugins: [
|
||||
...localProviderPlugins({ realtime: { tools: standaloneVoiceTools() } }),
|
||||
localMcpPlugin(),
|
||||
apiNotFoundPlugin(),
|
||||
],
|
||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||
host: process.env.HOST,
|
||||
port: process.env.PORT,
|
||||
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
|
||||
command,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* The voice session's tools for the standalone server: the app actions plus
|
||||
* the catalog tools voice offers (see src/tools/surfaces.js), which the
|
||||
* browser runs through the same catalog.
|
||||
*/
|
||||
|
||||
import {
|
||||
coreTools,
|
||||
toFunctionTools,
|
||||
toolsForSurface,
|
||||
} from '../../src/tools/index.js';
|
||||
import { realtimeSessionTools } from '../providers/openai/tools.js';
|
||||
|
||||
export function standaloneVoiceTools() {
|
||||
return realtimeSessionTools(
|
||||
toFunctionTools(toolsForSurface(coreTools, 'voice')),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user