mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into feat/tools-area-breakdowns
This commit is contained in:
20 files changed
+608
-88
No files matched your search
+5
-21
@@ -1,26 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
|
||||
band edges, skips the starting station and rejects spectra from an earlier
|
||||
tune (Aditya, @Rohilalala, #1174).
|
||||
- Local RTL-SDR keeps USB reads queued in order and bounds pending decoder
|
||||
work. A stalled or overloaded decoder stops with an explicit error; stopping
|
||||
terminates its worker and rejects late audio. FM playback buffers short
|
||||
scheduling gaps, caps its audio queue at 256 ms, and reduces display updates
|
||||
and spectrum calculations while listening.
|
||||
|
||||
- Add Bogotá (Tembici, 250 stations) and Santiago de Chile (Bike Itaú, 200
|
||||
stations) to the GBFS bikeshare registry, the layer's first stations in
|
||||
Colombia and Chile. Both are PBSC feeds like the Brazilian Bike Itaú cities,
|
||||
so no proxy change is needed. Both feeds also list depot stations on other
|
||||
continents (São Paulo, Madrid) as in service; stations outside a city's load
|
||||
radius are now dropped instead of drawn as live docks.
|
||||
|
||||
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
|
||||
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
|
||||
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
|
||||
MobilityData's systems catalogue; AMBici's is CC0. `gbfs.nextbike.net` joins
|
||||
the proxy allowlist. These are the layer's first stations in Spain.
|
||||
- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the
|
||||
civil and military layers, so a consumer reading `getTrackedInfo()` no longer
|
||||
has to reach past it into the raw poll record for climb/descent. Reported,
|
||||
never derived: an aircraft that has not transmitted a rate reads as `null`
|
||||
rather than a level-flight `0`.
|
||||
|
||||
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
|
||||
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
- Label camera-tool Street View fallbacks as historical Google imagery rather
|
||||
than the current camera view; keep unknown frame provenance explicit
|
||||
(rudycelekli, #1118).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Scenes keep the "Scene not saved" status when a capture, update, append or
|
||||
import cannot be saved, and an unreadable saved project no longer overwrites
|
||||
the recovery checkpoint. `startScene()` resolves to its documented result,
|
||||
`afterShotId` works without an explicit scene, and `scenesRun` counts the
|
||||
distinct scenes queued (djabhi31, #1135).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Cross-site browser requests can no longer spend the TomTom traffic-tile
|
||||
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
|
||||
cache, and upstream work; same-origin app requests and non-browser local
|
||||
callers remain supported (Sunil56224972, #973).
|
||||
+19
-1
@@ -867,6 +867,13 @@ use the supplied asset resolver, including the preload path. Enrichment receives
|
||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||
|
||||
The tracked-aircraft descriptor both flight layers hand to the cockpit carries
|
||||
`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as
|
||||
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
||||
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
|
||||
rate is reported, never derived: a blank poll keeps the last transmitted value and an
|
||||
absent rate stays null rather than reading as level flight.
|
||||
|
||||
## Transponder emergency highlighting
|
||||
|
||||
`src/data/aircraftEmergency.js` derives one status per poll from the Mode A
|
||||
@@ -1369,6 +1376,14 @@ outside its promise, so an unusual mirror status cannot crash the server.
|
||||
Node-only package entries expose both provider factories. Routes, payloads,
|
||||
fallback behavior and existing dev/preview hook registration are preserved;
|
||||
browser rendering is unchanged.
|
||||
The portable frame source retains the proxy's known `X-CCTV-Source` kind. Camera
|
||||
snapshot tools label Street View fallback imagery as historical and credit Google,
|
||||
while upstream frames retain their current-view label and catalog attribution.
|
||||
Synthetic placeholders are unavailable as snapshots. Injected frame sources may
|
||||
supply `sourceKind`; missing or unknown provenance keeps `current_view` null and
|
||||
does not claim the image is current. The `current_view` field distinguishes camera-feed
|
||||
provenance from historical fallback imagery; it does not measure capture time or
|
||||
image freshness.
|
||||
|
||||
## CCTV missing numeric fields
|
||||
|
||||
@@ -1391,7 +1406,10 @@ plugin order, server-key selection, validation, disk caches, budgets, retries
|
||||
and stale/error responses remain unchanged. Each has a Node-only package entry
|
||||
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
|
||||
math and GBFS source rules are available under `gods-eye-view/sources/`.
|
||||
The browser layers and their rendering remain in their existing modules.
|
||||
The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
|
||||
status, cache or upstream work, refusing cross-site browser requests while
|
||||
preserving same-origin app requests and non-browser local callers. The browser
|
||||
layers and their rendering remain in their existing modules.
|
||||
GBFS null or empty availability counts retain an unknown value through station
|
||||
parsing and rendering; they use the neutral station color rather than reporting
|
||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
|
||||
| `get_cctv_snapshot` | `cctv` | An image from one camera, returned as image content; Street View fallbacks are labelled historical |
|
||||
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
|
||||
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
|
||||
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
|
||||
|
||||
@@ -131,7 +131,9 @@
|
||||
"server/providers/common/http.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
"src/data/tomtomTiles.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeBudget as normalizeTomTomBudget,
|
||||
isOverBudget as isTomTomOverBudget,
|
||||
} from '../../src/data/tomtomTiles.js';
|
||||
import { admitSameSite } from './common/same-site.js';
|
||||
|
||||
/**
|
||||
* TomTom traffic-flow vector-tile proxy with a daily budget governor.
|
||||
@@ -35,6 +36,13 @@ import {
|
||||
* 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.
|
||||
*
|
||||
* Cross-site gate: both routes refuse cross-site browser requests with 403
|
||||
* (the gate Mapillary, OpenAI and Places already use). Every uncached tile is
|
||||
* a billed upstream attempt counted against the persistent daily budget, so
|
||||
* without the gate any web page the user visits could fire `<img>` requests
|
||||
* at distinct tiles, spend the TomTom quota and leave the traffic layer on
|
||||
* 429 budget for the rest of the UTC day.
|
||||
*
|
||||
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
|
||||
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
|
||||
* without touching upstream — the traffic layer then stays in simulation mode.
|
||||
@@ -161,6 +169,8 @@ export function tomtomProxy() {
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/tomtom', async (req, res) => {
|
||||
// Gate first: a cross-site <img>/fetch must not reach the budget counter.
|
||||
if (admitSameSite(req, res)) return;
|
||||
// Sanitized responses only (proxy/security baseline): no upstream
|
||||
// error details, and never echo the key or the upstream URL.
|
||||
const sendJson = (status, obj, extraHeaders = {}) => {
|
||||
|
||||
@@ -726,7 +726,15 @@ const LABEL_ICAO = 'ae1fa4';
|
||||
const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
|
||||
/** Seed one civil contact through the production refresh seam. */
|
||||
function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
function seedLabelContact({
|
||||
callsign,
|
||||
registration,
|
||||
tracked = false,
|
||||
// Per-test metadata overrides, spread last so a test can set a field to
|
||||
// `undefined` to model "the feed never sent it" — a default parameter
|
||||
// could not express that, it would just re-supply the default.
|
||||
meta: metaOverrides = {},
|
||||
}) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
icao24: LABEL_ICAO,
|
||||
entity: null,
|
||||
@@ -752,6 +760,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) {
|
||||
turnRateDps: 0,
|
||||
rawLat: 30.21,
|
||||
rawLon: -97.71,
|
||||
...metaOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -825,6 +834,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration',
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.registration, null);
|
||||
});
|
||||
|
||||
test('the tracked descriptor publishes the reported vertical rate', () => {
|
||||
// `mapAnalystRecord` has always published `verticalRateMps`; the tracked
|
||||
// seam did not, so a consumer that needs climb/descent (a flight-phase
|
||||
// classifier, the cockpit) had to reach past getTrackedInfo() into the
|
||||
// raw record. Both seams now answer with the same field name and unit.
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: -6.4 },
|
||||
});
|
||||
assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4);
|
||||
assert.equal(
|
||||
mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps,
|
||||
-6.4,
|
||||
'the analyst seam and the tracked seam must agree',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unreported vertical rate stays null, never a level-flight zero', () => {
|
||||
// Absent is not level. `stickyNumber` seeds this field with null, so a
|
||||
// contact that has never transmitted a rate must not read as 0 m/s — a
|
||||
// phase classifier would call that cruise while the aircraft climbs.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedLabelContact({
|
||||
callsign: 'SWA696',
|
||||
registration: 'N123AB',
|
||||
tracked: true,
|
||||
meta: { verticalRate: missing },
|
||||
});
|
||||
assert.equal(
|
||||
flightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('civil label chain: identity stays icao24 while the label moves', () => {
|
||||
for (const fixture of [
|
||||
{ callsign: 'SWA696', registration: 'N123AB' },
|
||||
|
||||
@@ -834,3 +834,48 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
|
||||
test('the military tracked descriptor publishes the reported vertical rate', () => {
|
||||
// Shape parity with the civil layer's getTrackedInfo(). The two layers store
|
||||
// the rate under different record keys (`verticalRateMps` here,
|
||||
// `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one
|
||||
// shape if BOTH descriptors map it — a consumer that reads the field off the
|
||||
// civil seam and finds it missing on the military one would silently treat
|
||||
// every military contact as level flight.
|
||||
const seedRate = (verticalRateMps) =>
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae7f01',
|
||||
entity: { gevLabelModel: { title: 'TEST', details: [] } },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000),
|
||||
color: Cesium.Color.WHITE,
|
||||
show: true,
|
||||
},
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||
meta: {
|
||||
callsign: 'RCH512',
|
||||
rawLat: 31,
|
||||
rawLon: -97,
|
||||
altitudeFt: 26_000,
|
||||
speedMps: 220,
|
||||
track: 95,
|
||||
onGround: false,
|
||||
verticalRateMps,
|
||||
},
|
||||
});
|
||||
|
||||
seedRate(12.7);
|
||||
assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7);
|
||||
|
||||
// Absent is not level: a contact that has never transmitted a rate must read
|
||||
// as null, not as a 0 m/s a phase classifier would call cruise.
|
||||
for (const missing of [undefined, null, Number.NaN]) {
|
||||
seedRate(missing);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getTrackedInfo()?.verticalRateMps,
|
||||
null,
|
||||
`vertical rate ${String(missing)} must publish as null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
FRAME_ENDPOINT,
|
||||
MEDIA_ENDPOINT,
|
||||
} from './sourcePolicy.js';
|
||||
const FRAME_SOURCE_KINDS = new Set([
|
||||
'upstream-image',
|
||||
'hls-pull',
|
||||
'streetview',
|
||||
'synthetic',
|
||||
]);
|
||||
function safeNumber(value, fallback = NaN) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
@@ -51,7 +57,7 @@ export function createCctvSource({
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
/** Read one current frame through the registered frame endpoint. */
|
||||
/** Read a registered frame and its provenance, including fallback frames. */
|
||||
async getFrame(camera, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(frameUrlFor(camera), {
|
||||
@@ -62,7 +68,12 @@ export function createCctvSource({
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
const sourceKind = response.headers.get('x-cctv-source');
|
||||
return {
|
||||
contentType: type.split(';')[0].trim().toLowerCase(),
|
||||
bytes,
|
||||
sourceKind: FRAME_SOURCE_KINDS.has(sourceKind) ? sourceKind : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -173,3 +173,26 @@ test('frames are read through the registered frame endpoint', async () => {
|
||||
});
|
||||
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
|
||||
});
|
||||
|
||||
test('frame provenance admits the proxy kinds and leaves missing or unknown headers unknown', async () => {
|
||||
for (const kind of [
|
||||
'upstream-image',
|
||||
'hls-pull',
|
||||
'streetview',
|
||||
'synthetic',
|
||||
'other',
|
||||
null,
|
||||
]) {
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([1]), {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
...(kind ? { 'X-CCTV-Source': kind } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const frame = await source.getFrame(camera);
|
||||
assert.equal(frame.sourceKind, kind === 'other' ? null : kind);
|
||||
}
|
||||
});
|
||||
@@ -114,7 +114,7 @@ export function createQueries({
|
||||
* Build a public descriptor for one aircraft using its best current position
|
||||
* (dead-reckoned when history exists, billboard position otherwise).
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address.
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
||||
*/
|
||||
|
||||
@@ -164,6 +164,15 @@ export function createQueries({
|
||||
onGround: info?.onGround === true,
|
||||
velocityMps: displayed.speedMps,
|
||||
track: displayed.trackDeg,
|
||||
// Climb/descent rate, the one kinematic field `mapAnalystRecord` already
|
||||
// published (as `verticalRateMps`) but this descriptor did not, so a
|
||||
// consumer reading the tracked seam had to reach back into `_flightData`
|
||||
// for it. Reported, never derived: `stickyNumber` in records.js keeps the
|
||||
// last value across a blank poll, and an absent rate stays null rather
|
||||
// than becoming a level-flight 0 the aircraft never transmitted.
|
||||
verticalRateMps: Number.isFinite(info?.verticalRate)
|
||||
? info.verticalRate
|
||||
: null,
|
||||
stale: Boolean(
|
||||
flightState.records.missingPolls.get(icao24) ||
|
||||
flightState.feed._backoff,
|
||||
@@ -856,7 +865,7 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Describe the currently tracked aircraft at its dead-reckoned position.
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Tracked aircraft info, or null when nothing is tracked.
|
||||
*/
|
||||
getTrackedInfo() {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function createQueries({
|
||||
* Build a public descriptor for one aircraft using its best current position
|
||||
* (dead-reckoned when history exists, billboard position otherwise).
|
||||
* @param {string} icao24 - ICAO hex identifier of the aircraft.
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Descriptor with a cloned position, or null if the aircraft is unknown.
|
||||
*/
|
||||
|
||||
@@ -131,6 +131,13 @@ export function createQueries({
|
||||
onGround: info?.onGround === true,
|
||||
velocityMps: displayed.speedMps,
|
||||
track: displayed.trackDeg,
|
||||
// Same field, same name, same null-when-absent rule as the civil
|
||||
// descriptor — the two layers store the record under different keys
|
||||
// (`verticalRateMps` here, `verticalRate` there), so only the mapping
|
||||
// makes `resolveTrackedAircraftInfo` hand back one shape for both.
|
||||
verticalRateMps: Number.isFinite(info?.verticalRateMps)
|
||||
? info.verticalRateMps
|
||||
: null,
|
||||
stale: Boolean(
|
||||
flightState.records.missingPolls.get(icao24) ||
|
||||
flightState.feed._backoff,
|
||||
@@ -777,7 +784,7 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Describe the currently tracked aircraft at its dead-reckoned position.
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null}
|
||||
* @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null}
|
||||
* Tracked aircraft info, or null when nothing is tracked.
|
||||
*/
|
||||
getTrackedInfo() {
|
||||
|
||||
+50
-39
@@ -189,20 +189,22 @@ export class SceneDirector {
|
||||
...this._presentation,
|
||||
hasRun: !!this._lastRunJson,
|
||||
}));
|
||||
this._bootstrapLegacyShotPacks();
|
||||
// Upgrade only already-installed packs; unrelated Scenes are untouched.
|
||||
for (const scene of this._project.scenes) {
|
||||
for (const marker of [...(scene.appliedShotPacks || [])]) {
|
||||
const recipe = getSceneAppendRecipeById(marker.id);
|
||||
if (
|
||||
recipe?.expansionFromVersion &&
|
||||
marker.version > 0 &&
|
||||
marker.version <= recipe.expansionFromVersion
|
||||
) {
|
||||
this.appendShotPack(scene.id, marker.id, {
|
||||
render: false,
|
||||
announce: false,
|
||||
});
|
||||
if (!this._storageReadError) {
|
||||
this._bootstrapLegacyShotPacks();
|
||||
// Upgrade only already-installed packs; unrelated Scenes are untouched.
|
||||
for (const scene of this._project.scenes) {
|
||||
for (const marker of [...(scene.appliedShotPacks || [])]) {
|
||||
const recipe = getSceneAppendRecipeById(marker.id);
|
||||
if (
|
||||
recipe?.expansionFromVersion &&
|
||||
marker.version > 0 &&
|
||||
marker.version <= recipe.expansionFromVersion
|
||||
) {
|
||||
this.appendShotPack(scene.id, marker.id, {
|
||||
render: false,
|
||||
announce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,13 +326,14 @@ export class SceneDirector {
|
||||
'Scene not saved — existing saved project could not be read. Export edits or import a valid file.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
this._project.updatedAt = new Date().toISOString();
|
||||
try {
|
||||
const payload = JSON.stringify(this._project);
|
||||
parseSceneDocument(payload);
|
||||
localStorage.setItem(STORAGE_KEY, payload);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Private browsing / block-all-cookies / quota-exceeded throws here. The
|
||||
// in-memory project stays usable this session, but persistence failed —
|
||||
@@ -351,6 +354,7 @@ export class SceneDirector {
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,12 +927,12 @@ export class SceneDirector {
|
||||
}
|
||||
this._selectedSceneId = scene.id;
|
||||
this._selectedShotId = appendedShots[0]?.id || this._selectedShotId;
|
||||
this._saveProject();
|
||||
const saved = this._saveProject();
|
||||
if (render) {
|
||||
this._renderSceneSelect();
|
||||
this._renderShotList();
|
||||
}
|
||||
if (announce) {
|
||||
if (announce && saved) {
|
||||
this._updateStatus(
|
||||
marker
|
||||
? tn(
|
||||
@@ -984,14 +988,16 @@ export class SceneDirector {
|
||||
|
||||
scene.shots.push(shot);
|
||||
this._selectedShotId = shot.id;
|
||||
this._saveProject();
|
||||
const saved = this._saveProject();
|
||||
this._shotOutcome('shot-captured', scene, shot);
|
||||
this._updateStatus(
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
if (saved) {
|
||||
this._updateStatus(
|
||||
t('Captured: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1017,14 +1023,16 @@ export class SceneDirector {
|
||||
shot.visual = this.styleManager.getVisualState();
|
||||
shot.layers = this._captureLayerStates();
|
||||
|
||||
this._saveProject();
|
||||
const saved = this._saveProject();
|
||||
this._shotOutcome('shot-updated', scene, shot);
|
||||
this._updateStatus(
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
if (saved) {
|
||||
this._updateStatus(
|
||||
t('Updated: {scene} / {shot}', {
|
||||
scene: displaySceneTitle(scene),
|
||||
shot: displayShotTitle(shot.title, shot, scene),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1755,13 +1763,13 @@ export class SceneDirector {
|
||||
) {
|
||||
if (this._running) return { started: false, reason: 'already-running' };
|
||||
|
||||
let queue = this._buildPlaybackQueue(
|
||||
sceneId || this._selectedSceneId || this._project.scenes[0]?.id,
|
||||
{ single },
|
||||
);
|
||||
const startSceneId =
|
||||
sceneId || this._selectedSceneId || this._project.scenes[0]?.id;
|
||||
let queue = this._buildPlaybackQueue(startSceneId, { single });
|
||||
if (afterShotId !== null) {
|
||||
const index = queue.findIndex(
|
||||
({ scene, shot }) => scene.id === sceneId && shot.id === afterShotId,
|
||||
({ scene, shot }) =>
|
||||
scene.id === startSceneId && shot.id === afterShotId,
|
||||
);
|
||||
if (index < 0) return { started: false, reason: 'shot-not-found' };
|
||||
queue = queue.slice(index + 1);
|
||||
@@ -1834,7 +1842,7 @@ export class SceneDirector {
|
||||
title: 'Editable Scene Run',
|
||||
startedAt: new Date().toISOString(),
|
||||
estimatedDurationSec,
|
||||
scenesRun: queue.length,
|
||||
scenesRun: new Set(queue.map(({ scene }) => scene.id)).size,
|
||||
events: [],
|
||||
};
|
||||
|
||||
@@ -1842,6 +1850,7 @@ export class SceneDirector {
|
||||
this._logEvent('scene_run_start', { count: queue.length });
|
||||
this._setPlaybackKeyboardEnabled(true);
|
||||
|
||||
let runResult = { started: true, shots: queue.length };
|
||||
try {
|
||||
await playSceneQueue(queue, {
|
||||
token,
|
||||
@@ -1860,9 +1869,11 @@ export class SceneDirector {
|
||||
this._logEvent('scene_run_error', {
|
||||
message: error.message || 'unknown error',
|
||||
});
|
||||
runResult = { started: false, reason: 'run-failed' };
|
||||
} finally {
|
||||
this._finishRun();
|
||||
}
|
||||
return runResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2090,9 +2101,9 @@ export class SceneDirector {
|
||||
this._selectedShotId =
|
||||
selection?.shotId || project.scenes[0]?.shots[0]?.id || null;
|
||||
this._loadedSceneId = null;
|
||||
this._saveProject();
|
||||
const saved = this._saveProject();
|
||||
this._publish({ type: 'project-imported', project });
|
||||
this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||
if (saved) this._updateStatus(t('Imported {file}', { file: file.name }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -601,8 +601,12 @@ const PROJECT_FIXTURE = {
|
||||
}],
|
||||
};
|
||||
|
||||
/** Stub the browser globals the director touches, headlessly. */
|
||||
function installSceneRuntime(project = PROJECT_FIXTURE) {
|
||||
/**
|
||||
* Stub the browser globals the director touches, headlessly. `storage` replaces
|
||||
* the seeded entries (key -> raw string); setting `localStorage.full` makes
|
||||
* every write throw a quota error.
|
||||
*/
|
||||
function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalLocalStorage = globalThis.localStorage;
|
||||
const noopClassList = { add() {}, remove() {}, toggle() {}, contains: () => false };
|
||||
@@ -614,10 +618,17 @@ function installSceneRuntime(project = PROJECT_FIXTURE) {
|
||||
removeEventListener() {},
|
||||
body: { classList: noopClassList, appendChild() {} },
|
||||
};
|
||||
const stored = new Map([['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
||||
const stored = new Map(storage
|
||||
? Object.entries(storage)
|
||||
: [['godsEyeView.sceneProject.v2', JSON.stringify(project)]]);
|
||||
globalThis.localStorage = {
|
||||
stored,
|
||||
full: false,
|
||||
getItem: (key) => stored.get(key) ?? null,
|
||||
setItem: (key, value) => stored.set(key, value),
|
||||
setItem(key, value) {
|
||||
if (this.full) throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
stored.set(key, value);
|
||||
},
|
||||
removeItem: (key) => stored.delete(key),
|
||||
};
|
||||
|
||||
@@ -718,7 +729,8 @@ async function settle(turns = 8) {
|
||||
|
||||
/** Build a director over doubles, with the fixture project loaded. */
|
||||
function makeDirector(options = {}) {
|
||||
const restore = installSceneRuntime(options.project);
|
||||
const restore = installSceneRuntime(options.project, { storage: options.storage });
|
||||
const storage = globalThis.localStorage;
|
||||
const viewer = fakeViewer();
|
||||
const styleManager = fakeStyleManager(options.style);
|
||||
const dataManager = fakeDataManager(options.data);
|
||||
@@ -728,7 +740,7 @@ function makeDirector(options = {}) {
|
||||
// Telemetry is only accumulated during a run; observable-failure assertions
|
||||
// need the accumulator without driving a whole run.
|
||||
director._activeRun = { events: [] };
|
||||
return { director, viewer, styleManager, dataManager, restore };
|
||||
return { director, viewer, styleManager, dataManager, storage, restore };
|
||||
}
|
||||
|
||||
test('only Play Shot or a scene run grants transient media authority; LOAD, Stop and replacement revoke it', async () => {
|
||||
@@ -1584,3 +1596,65 @@ test('camera refusal starts no authored frame or playback clock', async () => {
|
||||
assert.deepEqual(viewer.flights, []);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('an unreadable saved project does not overwrite its recovery checkpoint (#971)', async () => {
|
||||
const unreadable = '{"version":99,"scenes":[]}';
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
const { director, storage, restore } = makeDirector({ storage: {
|
||||
'godsEyeView.sceneProject.v2': unreadable,
|
||||
'godsEyeView.sceneProject.checkpoint.v1': 'EARLIER-CHECKPOINT',
|
||||
} });
|
||||
try {
|
||||
assert.equal(storage.stored.get('godsEyeView.sceneProject.v2'), unreadable);
|
||||
assert.equal(storage.stored.get('godsEyeView.sceneProject.checkpoint.v1'), 'EARLIER-CHECKPOINT');
|
||||
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a capture, update or import that cannot be saved keeps the "Scene not saved" status (#971)', async () => {
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
const { director, storage, restore } = makeDirector();
|
||||
let status = '';
|
||||
director.subscribe(({ state }) => { status = state.status; });
|
||||
try {
|
||||
storage.full = true;
|
||||
director._selectedSceneId = 'scene-1';
|
||||
director._selectedShotId = 'shot-a';
|
||||
director.captureShot();
|
||||
assert.match(status, /^Scene not saved/, 'failed capture keeps the save failure');
|
||||
director.updateSelectedShot();
|
||||
assert.match(status, /^Scene not saved/, 'failed update keeps the save failure');
|
||||
assert.equal(await director.importProjectFile(
|
||||
{ name: 'fixture.json' },
|
||||
{ prepared: { project: structuredClone(PROJECT_FIXTURE), assets: [] } },
|
||||
), true);
|
||||
assert.match(status, /^Scene not saved/, 'failed import keeps the save failure');
|
||||
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('startScene resolves its result, resumes after a shot without a scene id, and counts distinct scenes (#971)', async () => {
|
||||
const { director, restore } = makeDirector();
|
||||
try {
|
||||
const options = { single: true, preview: false };
|
||||
assert.deepEqual(await director.startScene('scene-1', options), { started: true, shots: 2 });
|
||||
assert.equal(director._lastRun.scenesRun, 1, 'one scene with two shots is one scene run');
|
||||
assert.deepEqual(
|
||||
await director.startScene(undefined, { ...options, afterShotId: 'shot-a' }),
|
||||
{ started: true, shots: 1 },
|
||||
);
|
||||
} finally { await director.destroy(); restore(); }
|
||||
});
|
||||
|
||||
test('a caught run failure resolves as run-failed, not success (#971)', async () => {
|
||||
const { director, styleManager, restore } = makeDirector();
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
styleManager.applyVisualState = async () => { throw new Error('visual failed'); };
|
||||
try {
|
||||
assert.deepEqual(
|
||||
await director.startScene('scene-1', { single: true, preview: false }),
|
||||
{ started: false, reason: 'run-failed' },
|
||||
);
|
||||
} finally { console.warn = warn; await director.destroy(); restore(); }
|
||||
});
|
||||
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
|
||||
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
|
||||
function install(plugin) {
|
||||
function install(plugin, hook = 'configureServer') {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
@@ -17,9 +18,16 @@ function install(plugin) {
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
return async (url = '/', method = 'GET') => {
|
||||
return async (url = '/', method = 'GET', headers = {}) => {
|
||||
const res = {
|
||||
headersSent: false,
|
||||
headers: {},
|
||||
set statusCode(status) {
|
||||
this.status = status;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers, headersSent: true });
|
||||
},
|
||||
@@ -27,7 +35,7 @@ function install(plugin) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await [...routes.values()][0]({ url, method }, res);
|
||||
await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('traffic refuses cross-site requests in dev and preview before spending the TomTom budget', async (t) => {
|
||||
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls++;
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
const host = 'localhost:5173';
|
||||
const refused = [
|
||||
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
|
||||
{ host, 'sec-fetch-site': 'same-site' },
|
||||
{ host, origin: 'https://evil.example' }, // fetch from another page
|
||||
{ host, origin: 'null' }, // sandboxed frame
|
||||
{ host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy
|
||||
];
|
||||
for (const [mode, hook] of [
|
||||
['dev', 'configureServer'],
|
||||
['preview', 'configurePreviewServer'],
|
||||
]) {
|
||||
const request = install(tomtomProxy(), hook);
|
||||
// Each refused shape asks for distinct uncached tiles: without the gate
|
||||
// every one would be a billed attempt counted against the daily budget.
|
||||
let x = 0;
|
||||
for (const headers of refused)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers);
|
||||
assert.equal(res.status, 403, `${mode}: ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.equal(
|
||||
(await request('/status', 'GET', refused[0])).status,
|
||||
403,
|
||||
`${mode}: status is gated too`,
|
||||
);
|
||||
assert.equal(
|
||||
calls,
|
||||
mode === 'dev' ? 0 : 2,
|
||||
`${mode}: no refused upstream work`,
|
||||
);
|
||||
const app = { host, 'sec-fetch-site': 'same-origin' };
|
||||
assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0);
|
||||
// The app itself and loopback tools (no browser headers) still get tiles.
|
||||
const tile = await request('/flow/8/1/1.pbf', 'GET', app);
|
||||
assert.equal(tile.status, 200);
|
||||
assert.equal(tile.headers['x-tomtom-cache'], 'MISS');
|
||||
assert.equal((await request('/flow/8/2/1.pbf')).status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/flow/8/1/1.pbf', 'GET', {
|
||||
host,
|
||||
origin: `http://${host}`,
|
||||
})
|
||||
).headers['x-tomtom-cache'],
|
||||
'HIT',
|
||||
);
|
||||
assert.equal(calls, mode === 'dev' ? 2 : 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
|
||||
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
|
||||
const bodies = [];
|
||||
|
||||
@@ -118,9 +118,11 @@ export const findCctvCameras = defineTool({
|
||||
|
||||
export const getCctvSnapshot = defineTool({
|
||||
name: 'get_cctv_snapshot',
|
||||
title: 'Current camera view',
|
||||
title: 'Camera snapshot',
|
||||
description:
|
||||
'The current image from one public camera, by the id from find_cctv_cameras.',
|
||||
'An image from one public camera, by the id from find_cctv_cameras. ' +
|
||||
'Street View fallback imagery is historical, not the current camera view; ' +
|
||||
'unknown frame provenance is explicit.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
|
||||
@@ -138,7 +140,7 @@ export const getCctvSnapshot = defineTool({
|
||||
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
|
||||
);
|
||||
const frame = await services.cctv.getFrame(camera, { signal });
|
||||
if (!FRAME_TYPES.has(frame.contentType))
|
||||
if (frame.sourceKind === 'synthetic' || !FRAME_TYPES.has(frame.contentType))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`Camera ${camera.name || camera.id} has no still image right now`,
|
||||
@@ -149,8 +151,22 @@ export const getCctvSnapshot = defineTool({
|
||||
'The camera image is too large to return',
|
||||
);
|
||||
const name = text(camera.name) || camera.id;
|
||||
const fallback = frame.sourceKind === 'streetview';
|
||||
const current = ['upstream-image', 'hls-pull'].includes(frame.sourceKind);
|
||||
const credit = fallback ? 'Google Street View' : text(camera.credit);
|
||||
const label = fallback
|
||||
? 'Street View fallback for'
|
||||
: current
|
||||
? 'Current view from'
|
||||
: 'Image from';
|
||||
return {
|
||||
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
|
||||
summary:
|
||||
`${label} ${name}${camera.city ? ` in ${camera.city}` : ''}${credit ? `, courtesy of ${credit}` : ''}.` +
|
||||
(fallback
|
||||
? ' Historical imagery; the current camera frame is unavailable.'
|
||||
: current
|
||||
? ''
|
||||
: ' Frame provenance is unknown.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
point: { lat: camera.lat, lon: camera.lon },
|
||||
@@ -160,8 +176,10 @@ export const getCctvSnapshot = defineTool({
|
||||
id: camera.id,
|
||||
name,
|
||||
city: text(camera.city),
|
||||
provider: text(camera.provider),
|
||||
credit: text(camera.credit),
|
||||
provider: fallback ? 'Google Street View' : text(camera.provider),
|
||||
credit,
|
||||
source_kind: frame.sourceKind ?? null,
|
||||
current_view: current ? true : fallback ? false : null,
|
||||
mime_type: frame.contentType,
|
||||
bytes: frame.bytes.byteLength,
|
||||
},
|
||||
|
||||
@@ -30,7 +30,13 @@ const cameras = [
|
||||
{ id: 'no-position', name: 'Unplaced' },
|
||||
];
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
|
||||
const cctv = (
|
||||
frame = {
|
||||
contentType: 'image/png',
|
||||
bytes: PNG,
|
||||
sourceKind: 'upstream-image',
|
||||
},
|
||||
) => ({
|
||||
frames: [],
|
||||
async getCatalog() {
|
||||
return { sources: cameras };
|
||||
@@ -104,6 +110,183 @@ test('a snapshot returns the frame as an image with its credit', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('mounted camera fallback provenance survives the source and MCP image result', async (t) => {
|
||||
const { cctvProxy } = await import('../../../server/providers/cctv.js');
|
||||
const { createCctvSource } = await import('../../layers/cctv/source.js');
|
||||
const camera = {
|
||||
id: 'fixture-camera',
|
||||
name: 'Fixture crossing',
|
||||
city: 'Fixture City',
|
||||
provider: 'Fixture transport agency',
|
||||
credit: 'Fixture transport agency',
|
||||
lat: 30.2685,
|
||||
lon: -97.7425,
|
||||
headingDeg: 180,
|
||||
fovDeg: 60,
|
||||
url: 'https://camera.example/current.png',
|
||||
feedType: 'image',
|
||||
};
|
||||
const environment = {
|
||||
CCTV_SOURCES_JSON: JSON.stringify([camera]),
|
||||
CCTV_PREFER_AUSTIN: '0',
|
||||
CCTV_FORCE_AUSTIN: '0',
|
||||
CCTV_SOURCES_FILE: 'missing-cctv-provenance-fixture.json',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-key',
|
||||
GOOGLE_MAPS_API_KEY: '',
|
||||
};
|
||||
for (const [name, value] of Object.entries(environment)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
process.env[name] = value;
|
||||
}
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let upstreamAvailable = false;
|
||||
const calls = [];
|
||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||
const url = new URL(raw);
|
||||
calls.push(url.origin);
|
||||
if (url.origin === 'https://camera.example')
|
||||
return upstreamAvailable
|
||||
? new Response(PNG, { headers: { 'Content-Type': 'image/png' } })
|
||||
: new Response(null, { status: 503 });
|
||||
assert.equal(
|
||||
url.origin,
|
||||
'https://maps.googleapis.com',
|
||||
'no actual upstream is contacted',
|
||||
);
|
||||
assert.equal(url.searchParams.get('key'), 'fixture-key');
|
||||
return new Response(PNG, { headers: { 'Content-Type': 'image/png' } });
|
||||
});
|
||||
let middleware;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
assert.equal(route, '/api/cctv');
|
||||
middleware = handler;
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async (path) => {
|
||||
assert.ok(path.startsWith('/api/cctv/'));
|
||||
const response = {
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await middleware(
|
||||
{ url: path.slice('/api/cctv'.length), method: 'GET', headers: {} },
|
||||
response,
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: source },
|
||||
});
|
||||
const fallback = await catalog.call('get_cctv_snapshot', {
|
||||
camera_id: camera.id,
|
||||
});
|
||||
assert.equal(fallback.data.source_kind, 'streetview');
|
||||
assert.equal(fallback.data.current_view, false);
|
||||
assert.equal(fallback.data.credit, 'Google Street View');
|
||||
assert.match(fallback.summary, /Street View fallback/);
|
||||
assert.match(fallback.summary, /Historical imagery/);
|
||||
assert.doesNotMatch(
|
||||
fallback.summary,
|
||||
/Current view|courtesy of Fixture transport agency/,
|
||||
);
|
||||
assert.equal(
|
||||
fallback.images.length,
|
||||
1,
|
||||
'the historical image stays available with its caveat',
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'https://camera.example',
|
||||
'https://maps.googleapis.com',
|
||||
]);
|
||||
upstreamAvailable = true;
|
||||
const current = await catalog.call('get_cctv_snapshot', {
|
||||
camera_id: camera.id,
|
||||
});
|
||||
assert.equal(current.data.source_kind, 'upstream-image');
|
||||
assert.equal(current.data.current_view, true);
|
||||
assert.equal(current.data.credit, camera.credit);
|
||||
assert.match(current.summary, /^Current view from/);
|
||||
const server = createMcpServer({ catalog, name: 'test', version: '1' });
|
||||
upstreamAvailable = false;
|
||||
const result = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
|
||||
});
|
||||
assert.ok(
|
||||
result.result.content.some(
|
||||
(entry) => entry.type === 'text' && /Historical imagery/.test(entry.text),
|
||||
),
|
||||
);
|
||||
assert.ok(result.result.content.some((entry) => entry.type === 'image'));
|
||||
process.env.GOOGLE_MAPS_SERVER_API_KEY = '';
|
||||
const placeholder = await source.getFrame(camera);
|
||||
assert.equal(placeholder.sourceKind, 'synthetic');
|
||||
assert.equal(placeholder.contentType, 'image/svg+xml');
|
||||
await assert.rejects(
|
||||
catalog.call('get_cctv_snapshot', { camera_id: camera.id }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
const unavailable = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_cctv_snapshot', arguments: { camera_id: camera.id } },
|
||||
});
|
||||
assert.equal(unavailable.result.isError, true);
|
||||
assert.ok(
|
||||
!unavailable.result.content.some((entry) => entry.type === 'image'),
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot placeholders are refused and unknown frame provenance is explicit', async () => {
|
||||
const synthetic = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
cctv: cctv({
|
||||
contentType: 'image/png',
|
||||
bytes: PNG,
|
||||
sourceKind: 'synthetic',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
synthetic.call('get_cctv_snapshot', { camera_id: 'austin-1' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
const unknown = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: cctv({ contentType: 'image/png', bytes: PNG }) },
|
||||
}).call('get_cctv_snapshot', { camera_id: 'austin-1' });
|
||||
assert.equal(unknown.data.current_view, null);
|
||||
assert.equal(
|
||||
unknown.data.credit,
|
||||
cameras[0].credit,
|
||||
'catalog attribution is preserved',
|
||||
);
|
||||
assert.match(unknown.summary, /provenance is unknown/);
|
||||
assert.doesNotMatch(unknown.summary, /Current view/);
|
||||
});
|
||||
|
||||
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
|
||||
const catalog = (frame) =>
|
||||
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
|
||||
|
||||
Reference in new issue
Block a user