Merge remote-tracking branch 'upstream/main' into feature/catalonia-cctv-cameras

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
ulihhhh committed 2026-09-21 19:00:01 +02:00
commit 49a322519a
29 files changed
+1025 -64

No files matched your search

+11 -1
View File
@@ -308,11 +308,21 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
}
const timedOut =
error?.name === 'AbortError' || error?.name === 'TimeoutError';
// GET /api/cctv/health serializes `message`, and the CCTV panel
// renders it as a status label, so the raw error would leave the
// server by a different door than the sanitized body below and
// land on screen.
//
// The log line drops the text too, unlike the catch at the bottom
// of this file: a media-fetch failure names the camera's upstream
// host, and these lines get pasted into issues. The status codes
// below carry the diagnosis — 504 for a timeout, 502 otherwise.
console.warn('[CCTV Proxy] media fetch failed');
setHealth(cameraId, {
status: 'degraded',
sourceKind: 'upstream',
label: source?.provider || 'Configured source',
message: error?.message || 'Media fetch failed',
message: 'Media fetch failed',
});
res.writeHead(timedOut ? 504 : 502, {
'Content-Type': 'application/json',
+15 -3
View File
@@ -17,17 +17,29 @@ async function readRequestBodyCapped(req, maxBytes) {
function readRequestBody(req, maxBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
let total = 0;
let bodyTooLarge = false;
const chunks = [];
req.on('data', (chunk) => {
// Keep draining after the cap without retaining more bytes. Destroying
// the request also destroys its socket, so the handler cannot deliver
// the fixed error response it constructs for BODY_TOO_LARGE.
if (bodyTooLarge) return;
total += chunk.length;
if (total > maxBytes) {
reject(new Error(`Request body exceeds ${maxBytes} bytes`));
req.destroy();
// Same marker readRequestBodyCapped sets, so a caller can tell an
// oversized body from a genuine failure without matching on text.
bodyTooLarge = true;
chunks.length = 0;
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
error.code = 'BODY_TOO_LARGE';
reject(error);
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('end', () => {
if (!bodyTooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', reject);
});
}
+74 -15
View File
@@ -1,16 +1,65 @@
import { defaultSourceRoot } from '../common/source-root.js';
import path from 'node:path';
import { readRequestBody } from '../common/request.js';
import fs from 'node:fs';
import { promises as fsp } from 'node:fs';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
/** Cap on one request body. */
const REALTIME_DEBUG_LOG_MAX_BYTES = 8 * 1024 * 1024;
/**
* Ceiling for the live log before it rotates. One generation is kept
* (`…jsonl.1`), so the sink is bounded at twice this however long a session
* runs. REALTIME_DEBUG_LOG_MAX_BYTES caps a single request; it never bounded
* the file those requests accumulate into.
*/
const REALTIME_DEBUG_LOG_MAX_FILE_BYTES = 32 * 1024 * 1024;
/**
* Per-IP write ceiling. Always on, unlike the opt-in limiter the cost-bearing
* OpenAI routes share: throttling those by default would change what a user
* spends, while this sink spends disk and event-loop time. A voice session
* writes far below this, so the cap needs no configuration to stay invisible.
*/
const REALTIME_DEBUG_LOG_MAX_PER_MIN = 120;
function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
const REALTIME_DEBUG_LOG_DIR = path.join(sourceRoot, '.gev-logs');
const REALTIME_DEBUG_LOG_FILE = path.join(
REALTIME_DEBUG_LOG_DIR,
'realtime-conversations.jsonl',
);
const logDir = path.join(sourceRoot, '.gev-logs');
const logFile = path.join(logDir, 'realtime-conversations.jsonl');
const allow = makeRateLimiter({
windowMs: 60_000,
max: REALTIME_DEBUG_LOG_MAX_PER_MIN,
globalMax: 400,
});
// Appends are chained rather than fired concurrently because the rotation
// check is a read-then-write on file size: run in parallel, every caller
// stats a file still under the ceiling, none of them rotates, and the log
// grows past it. The records themselves are O_APPEND and never interleave.
let queue = Promise.resolve();
function append(line) {
const done = queue.then(async () => {
await fsp.mkdir(logDir, { recursive: true });
const size = await fsp.stat(logFile).then(
(stat) => stat.size,
() => 0,
);
if (size + Buffer.byteLength(line) > REALTIME_DEBUG_LOG_MAX_FILE_BYTES) {
// Node does not replace an existing destination on Windows. Remove the
// retained generation explicitly so every rotation behaves the same on
// supported platforms rather than failing after the first 64 MB.
await fsp.rm(`${logFile}.1`, { force: true });
await fsp.rename(logFile, `${logFile}.1`);
}
await fsp.appendFile(logFile, line);
});
// The caller still sees its own rejection, but one failed append must not
// poison the chain for every request queued behind it.
queue = done.catch(() => {});
return done;
}
return async (req, res) => {
if (req.method !== 'POST') {
res.statusCode = 405;
@@ -19,12 +68,19 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
return;
}
if (!allow(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '60',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
try {
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
const record = JSON.parse(body || '{}');
fs.mkdirSync(REALTIME_DEBUG_LOG_DIR, { recursive: true });
fs.appendFileSync(
REALTIME_DEBUG_LOG_FILE,
await append(
`${JSON.stringify({
loggedAt: new Date().toISOString(),
...record,
@@ -33,13 +89,16 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) {
res.statusCode = 204;
res.end();
} catch (error) {
res.statusCode = 400;
// A malformed record or an oversized body is the caller's fault; a failed
// write is ours, and only the second is worth a log line. Neither answer
// carries the error text, which for an fs failure is an errno and an
// absolute path.
const callerFault =
error instanceof SyntaxError || error?.code === 'BODY_TOO_LARGE';
if (!callerFault) console.warn('[realtime-debug-log] append failed');
res.statusCode = callerFault ? 400 : 500;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error?.message || 'Failed to write Realtime debug log',
}),
);
res.end(JSON.stringify({ error: 'Failed to write Realtime debug log' }));
}
};
}
+8 -5
View File
@@ -78,20 +78,23 @@ async function handleHudSummary(req, res) {
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (!response.ok)
console.warn(`[hud-summary] upstream HTTP ${response.status}`);
res.end(
JSON.stringify({
summary: summary || null,
error: response.ok
? null
: data.error?.message || 'OpenAI HUD summary request failed',
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
}),
);
} catch (error) {
} catch {
console.warn('[hud-summary] request failed');
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error?.message || 'OpenAI HUD summary request failed',
error: 'OpenAI HUD summary request failed',
}),
);
}
+18 -9
View File
@@ -132,26 +132,35 @@ function createRealtimeTokenHandler({
});
const body = await response.text();
res.statusCode = response.status;
res.setHeader(
'Content-Type',
response.headers.get('content-type') || 'application/json',
);
// Which tier/model this secret was actually minted for. The upstream
// body is passed through untouched (the client parses it verbatim), so
// these headers are the authoritative echo — including the case where a
// bogus ?tier= was silently downgraded to standard.
// success body is passed through untouched (the client parses it
// verbatim), so these headers are the authoritative echo — including the
// case where a bogus ?tier= was silently downgraded to standard.
res.setHeader('X-GEV-Voice-Tier', tier);
res.setHeader('X-GEV-Voice-Model', model);
if (requestedTier && !isKnownVoiceTier(requestedTier)) {
res.setHeader('X-GEV-Voice-Tier-Fallback', '1');
}
if (!response.ok) {
console.warn(`[realtime-token] upstream HTTP ${response.status}`);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: 'Failed to create Realtime token' }));
return;
}
res.setHeader(
'Content-Type',
response.headers.get('content-type') || 'application/json',
);
res.end(body);
} catch (error) {
} catch {
// For a network fault this was a resolver message naming the upstream
// host; the client only needs to know the mint failed.
console.warn('[realtime-token] mint failed');
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: error?.message || 'Failed to create Realtime token',
error: 'Failed to create Realtime token',
}),
);
}
+12 -4
View File
@@ -23,9 +23,16 @@ import {
* Budget governor (mirrors the OpenSky credit-governor philosophy — last-good
* data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json,
* keyed by UTC date, reset on day change) counts upstream fetch attempts
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET, default 40,000 of the free
* tier's ~50k/day). Over the cap the proxy serves stale tiles when available,
* else 429 {error:'budget'}.
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy
* serves stale tiles when available, else 429 {error:'budget'}.
*
* The default is derived from TomTom's published free allowance, which is
* granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests
* per month (https://docs.tomtom.com/pricing/). The previous 40,000/day
* default — and the "~50k/day" figure this comment used to cite — exhausted
* a month's entire allowance in five days, leaving the traffic layer dead
* for the rest of the billing period. 6,000/day keeps a full 31-day month
* inside the allowance (186,000) with headroom to spare.
*
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
@@ -37,7 +44,8 @@ export function tomtomProxy() {
const TILE_TTL_MS = 120_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom');
const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json');
const DEFAULT_DAILY_BUDGET = 40000;
// 200,000/month free tier / 31 days = 6,451. Rounded down for headroom.
const DEFAULT_DAILY_BUDGET = 6000;
const MEM_MAX_ENTRIES = 256;
const UPSTREAM_TIMEOUT_MS = 15000;
+24 -2
View File
@@ -72,8 +72,8 @@ export function ingestAisStreamEnvelope(envelope) {
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
type: vesselTypeFromAis(message, staticData),
destination: stringValue(message.Destination ?? staticData.destination),
speed: numberValue(message.Sog ?? message.SOG),
course: numberValue(message.Cog ?? message.COG),
speed: normalizedSpeedOverGround(message.Sog ?? message.SOG),
course: normalizedCourseOverGround(message.Cog ?? message.COG),
heading: normalizedHeading(message.TrueHeading ?? message.Heading),
last_position_UTC: normalizeAisTimestamp(
metadata.time_utc ?? metadata.TimeUtc,
@@ -277,6 +277,28 @@ function normalizedHeading(value) {
return heading !== null && heading >= 0 && heading <= 360 ? heading : null;
}
/**
* Speed over ground in knots, or null when the report carries AIS's "not
* available" code. ITU-R M.1371 encodes SOG in 0.1-knot units where 1022 means
* "102.2 knots or higher" and 1023 means unavailable, so 102.3 is a sentinel
* and not a reading. It is finite, so every consumer would otherwise treat it
* as a genuine speed.
*/
function normalizedSpeedOverGround(value) {
const speed = numberValue(value);
return speed !== null && speed >= 0 && speed < 102.3 ? speed : null;
}
/**
* Course over ground in degrees, or null when the report carries AIS's "not
* available" code (3600 in 0.1-degree units, i.e. 360). Valid courses stop at
* 359.9; heading keeps its own separate sentinel of 511.
*/
function normalizedCourseOverGround(value) {
const course = numberValue(value);
return course !== null && course >= 0 && course < 360 ? course : null;
}
function normalizeAisTimestamp(value) {
const text = stringValue(value);
if (!text) return new Date().toISOString();