Compare commits

..
12 changed files with 99 additions and 84 deletions

No files matched your search

@@ -1,4 +0,0 @@
- A layer that is only waiting for a key now ends the load chip on an amber
`KEY REQUIRED` instead of a red `LOAD FAILED`, so a keyless Environmental
first run no longer reports a failure while earthquakes load. A real error
still wins over a missing key (asturksever, #1600).
@@ -0,0 +1,5 @@
- The CCTV Street View fallback serves only catalog cameras, at their catalog
position, heading, field of view and pitch, and ignores coordinates a
request supplies. It spends from the per-client Google budget
(`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120), so the Google key can no
longer be used as an unmetered Street View service (asturksever, #1602).
+4 -1
View File
@@ -1276,7 +1276,10 @@ and follows redirects only on the camera's origin. Catalog image URLs, and
redirects on those hosts, are pinned by parsed URL to the agency's image redirects on those hosts, are pinned by parsed URL to the agency's image
directory. Every `/api/cctv` route refuses cross-site browser requests but directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder. request skips the key-bearing Street View fallback and gets the placeholder. The
Street View fallback serves only catalog cameras, at the catalog position,
heading, field of view and pitch, never coordinates a request supplies, and it
shares the per-client Google budget (`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120).
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
keeps one camera per ground cell sized to the view, preferring a healthy feed, keeps one camera per ground cell sized to the view, preferring a healthy feed,
+3 -1
View File
@@ -377,7 +377,9 @@
"src/data/cctvFootprint.js", "src/data/cctvFootprint.js",
"src/sources/cctvTypes.js", "src/sources/cctvTypes.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js" "src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
], ],
"external": [] "external": []
}, },
+32 -16
View File
@@ -19,7 +19,22 @@ import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs'; import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js'; import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js'; import { googleServerApiKey } from './places/google-key.js';
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream }; export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
let _streetViewRateLimiter;
/**
* Per-IP budget for the Street View fallback, on the same setting as the
* other Google routes (GEV_RATELIMIT_GOOGLE_PER_MIN; 0 disables it).
*/
function streetViewRateLimiter() {
if (_streetViewRateLimiter === undefined)
_streetViewRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
120,
);
return _streetViewRateLimiter;
}
/** /**
* Vite plugin: CCTV camera proxy with source registry, frame/media serving, * Vite plugin: CCTV camera proxy with source registry, frame/media serving,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking. * fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
@@ -509,13 +524,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const source = sourceById.get(cameraId); const source = sourceById.get(cameraId);
const label = url.searchParams.get('label') || source?.name || cameraId; const label = url.searchParams.get('label') || source?.name || cameraId;
const city = url.searchParams.get('city') || source?.city || ''; const city = url.searchParams.get('city') || source?.city || '';
const lat = Number(url.searchParams.get('lat') || source?.lat);
const lon = Number(url.searchParams.get('lon') || source?.lon);
const heading = Number(
url.searchParams.get('heading') || source?.headingDeg,
);
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
// Only use server-registered upstream URLs — never accept client-supplied URLs // Only use server-registered upstream URLs — never accept client-supplied URLs
// (prevents SSRF via ?upstream= query parameter) // (prevents SSRF via ?upstream= query parameter)
@@ -549,18 +557,26 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
// request (whose client this server cannot see) gets the placeholder, // request (whose client this server cannot see) gets the placeholder,
// as if no key were configured. Any forwarding header counts, even an // as if no key were configured. Any forwarding header counts, even an
// empty one. // empty one.
// It is also only for catalog cameras, framed from the catalog (never
// a spot the query names), within the per-IP Google budget.
const forwarded = PROXY_SIGNALS.some( const forwarded = PROXY_SIGNALS.some(
(name) => req.headers?.[name] !== undefined, (name) => req.headers?.[name] !== undefined,
); );
const sv = forwarded const googleBudget = googleServerApiKey()
? null ? streetViewRateLimiter()
: await streetViewFallback({ : null;
lat, const sv =
lon, forwarded ||
heading, !source ||
fov, (googleBudget && !googleBudget(clientKey(req)))
pitch, ? null
}); : await streetViewFallback({
lat: Number(source.lat),
lon: Number(source.lon),
heading: Number(source.headingDeg),
fov: Number(source.fovDeg),
pitch: Number(source.pitchDeg ?? -10),
});
if (sv?.ok) { if (sv?.ok) {
setHealth(cameraId, { setHealth(cameraId, {
status: 'degraded', status: 'degraded',
+27
View File
@@ -528,3 +528,30 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree
assert.equal(direct.body, 'street-view'); assert.equal(direct.body, 'street-view');
assert.equal(streetViewCalls(), 1); assert.equal(streetViewCalls(), 1);
}); });
test('the Street View fallback frames catalog cameras only, from the catalog position', async (t) => {
const app = mount(t, ({ url }) =>
url.startsWith('https://maps.googleapis.com/')
? trackedUpstream({
headers: { 'content-type': 'image/jpeg' },
body: 'street-view',
}).response
: upstreamResponse({ status: 503 }),
);
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
const streetView = () =>
app.requests.filter(({ url }) =>
url.startsWith('https://maps.googleapis.com/'),
);
const unknown = await app.call('/frame/not-a-camera?lat=48.85&lon=2.29');
assert.equal(unknown.headers['X-CCTV-Source'], 'synthetic');
assert.deepEqual(streetView(), [], 'no Google call for an unknown id');
const steered = await app.call(
`/frame/${STILL.id}?lat=48.85&lon=2.29&heading=90`,
);
assert.equal(steered.headers['X-CCTV-Source'], 'streetview');
const location = new URL(streetView()[0].url).searchParams.get('location');
assert.equal(location, `${STILL.lat},${STILL.lon}`);
});
+9 -3
View File
@@ -98,9 +98,15 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile // for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile
// promises both, so it turns on both, and the subcopy in index.html says so. // promises both, so it turns on both, and the subcopy in index.html says so.
// //
// Keyless, FIRMS's own row reads "KEY REQUIRED", and the quakes half of // Keyless, FIRMS is honest where it counts — its own layer row reads
// the tile still delivers in full; the global status chip ends on KEY // "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of
// REQUIRED too (src/loadingFeedback.js) rather than LOAD FAILED. // the tile still delivers in full. What is NOT honest is the GLOBAL status
// chip, which has no key-required terminal state and folds that row into
// "LOAD FAILED". That aggregation is the defect, not this preset: fixing it
// means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state
// machine shared by every layer and not a thing to refactor the night
// before a launch. LEDGERED post-launch. Until it lands, keyless visitors
// are judged on the layer row, which tells them the truth.
layerIds: Object.freeze(['earthquakes', 'local-firms']), layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyKey: msg('Scanning active events…'), busyKey: msg('Scanning active events…'),
}), }),
+6 -5
View File
@@ -606,10 +606,11 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
const environmental = FIRST_RUN_MISSIONS.environmental; const environmental = FIRST_RUN_MISSIONS.environmental;
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']); assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
// Keyless, the LAYER ROW reads "KEY REQUIRED", and the global chip now ends // Keyless, the honest surface is the LAYER ROW ("KEY REQUIRED"), which the
// on KEY REQUIRED as well instead of LOAD FAILED (src/loadingFeedback.js, // FIRMS layer already reports. The misleading part is the GLOBAL chip folding
// covered in loadingFeedback.test.mjs). The note says so where the next // that row into LOAD FAILED — a defect in the shared state machine, ledgered
// editor will read it. // post-launch, and the note must stay where the next editor will read it
// rather than being re-discovered as a launcher bug.
const module = fs.readFileSync( const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url), new URL('./firstRunExperience.js', import.meta.url),
'utf8', 'utf8',
@@ -620,7 +621,7 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
); );
assert.match(table, /KEY REQUIRED/); assert.match(table, /KEY REQUIRED/);
assert.match(table, /src\/loadingFeedback\.js/); assert.match(table, /src\/loadingFeedback\.js/);
assert.match(table, /rather than LOAD FAILED/); assert.match(table, /LEDGERED post-launch/);
}); });
test('every visitor gets the same tile — there is no degraded keyless variant', async () => { test('every visitor gets the same tile — there is no degraded keyless variant', async () => {
+7 -13
View File
@@ -85,18 +85,13 @@ export function normalizeLayerLoading(layer = {}) {
function terminalFromParticipantStats(summary, participantIds) { function terminalFromParticipantStats(summary, participantIds) {
if (!participantIds?.length) return null; if (!participantIds?.length) return null;
const participants = new Set(participantIds); const participants = new Set(participantIds);
const records = summary.records.filter((record) => return summary.records.some(
participants.has(record.id), (record) =>
); participants.has(record.id) &&
// A layer waiting for a key did not fail: its row names the key. Only an (record.error || record.unavailable || record.keyRequired),
// actual error makes the whole batch read LOAD FAILED.
if (
records.some(
(record) => !record.keyRequired && (record.error || record.unavailable),
)
) )
return 'error'; ? 'error'
return records.some((record) => record.keyRequired) ? 'key' : null; : null;
} }
/** Aggregate all manager layers without changing their lifecycle authority. */ /** Aggregate all manager layers without changing their lifecycle authority. */
@@ -287,7 +282,7 @@ function terminalFromEvent(event) {
} }
function mergeTerminalOutcome(current, next) { function mergeTerminalOutcome(current, next) {
const severity = { complete: 1, key: 2, cancelled: 3, error: 4 }; const severity = { complete: 1, cancelled: 2, error: 3 };
if (!next) return current || null; if (!next) return current || null;
if (!current || severity[next] > severity[current]) return next; if (!current || severity[next] > severity[current]) return next;
return current; return current;
@@ -429,7 +424,6 @@ export function presentLoadingFeedback(state, summary, nowMs) {
const labels = { const labels = {
complete: 'LOAD COMPLETE', complete: 'LOAD COMPLETE',
cancelled: 'LOAD CANCELLED', cancelled: 'LOAD CANCELLED',
key: 'KEY REQUIRED',
error: 'LOAD FAILED', error: 'LOAD FAILED',
}; };
const label = const label =
+3 -33
View File
@@ -247,11 +247,11 @@ test('terminal loading feedback centers its label without an empty detail slot',
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match( assert.match(
css, css,
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/,
); );
assert.match( assert.match(
css, css,
/#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/, /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/,
); );
}); });
@@ -346,7 +346,7 @@ test('participant stats failure outranks a simultaneous visibility completion',
name: 'AIS Vessels', name: 'AIS Vessels',
enabled: true, enabled: true,
lifecycleState: 'enabled', lifecycleState: 'enabled',
stats: { loading: false, error: 'Upstream closed' }, stats: { loading: false, keyRequired: true },
}]); }]);
state = reduceLoadingFeedback(state, missingKey, 250, { state = reduceLoadingFeedback(state, missingKey, 250, {
type: 'visibility', layerId: 'ais-live-vessels', enabled: true, type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
@@ -355,36 +355,6 @@ test('participant stats failure outranks a simultaneous visibility completion',
assert.equal(state.terminal, 'error'); assert.equal(state.terminal, 'error');
}); });
test('a layer waiting for a key ends the batch as KEY REQUIRED, not LOAD FAILED', () => {
const quakes = (stats) => ({ id: 'earthquakes', name: 'Earthquakes', ...stats });
const fires = (stats) => ({ id: 'local-firms', name: 'Active Fires', ...stats });
const enabling = aggregateLayerLoading([
quakes({ lifecycleState: 'enabling', stats: { loading: true } }),
fires({ lifecycleState: 'enabling', stats: { loading: true } }),
]);
let state = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
state = reduceLoadingFeedback(state, enabling, 200);
const settled = aggregateLayerLoading([
quakes({ enabled: true, stats: { count: 27, lastUpdate: 1 } }),
fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true, error: 'KEY REQUIRED' } }),
]);
state = reduceLoadingFeedback(state, settled, 250);
assert.equal(state.terminal, 'key');
assert.deepEqual(state.failedEventIds, []);
const view = presentLoadingFeedback(state, settled, 260);
assert.equal(view.state, 'key');
assert.equal(view.label, 'KEY REQUIRED');
// A real failure beside it still reads as one.
const broken = aggregateLayerLoading([
quakes({ enabled: true, stats: { error: 'USGS 503' } }),
fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true } }),
]);
let failing = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0);
failing = reduceLoadingFeedback(failing, enabling, 200);
assert.equal(reduceLoadingFeedback(failing, broken, 250).terminal, 'error');
});
test('retains the worst terminal outcome until every concurrent load drains', () => { test('retains the worst terminal outcome until every concurrent load drains', () => {
const both = aggregateLayerLoading([ const both = aggregateLayerLoading([
{ id: 'a', name: 'A', lifecycleState: 'enabling' }, { id: 'a', name: 'A', lifecycleState: 'enabling' },
-1
View File
@@ -23,7 +23,6 @@ const STATUS_LABEL_KEYS = Object.freeze({
'LOAD COMPLETE': msg('LOAD COMPLETE'), 'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'), 'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'), 'LOAD FAILED': msg('LOAD FAILED'),
'KEY REQUIRED': msg('KEY REQUIRED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'), 'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'), 'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'), 'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
+3 -7
View File
@@ -131,7 +131,6 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is( #global-loading-status:is(
[data-state='complete'], [data-state='complete'],
[data-state='cancelled'], [data-state='cancelled'],
[data-state='key'],
[data-state='error'] [data-state='error']
) { ) {
justify-content: center; justify-content: center;
@@ -140,7 +139,6 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is( #global-loading-status:is(
[data-state='complete'], [data-state='complete'],
[data-state='cancelled'], [data-state='cancelled'],
[data-state='key'],
[data-state='error'] [data-state='error']
) )
#global-loading-detail { #global-loading-detail {
@@ -157,13 +155,11 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status[data-state='cancelled'] { #global-loading-status[data-state='cancelled'] {
color: #ffd488; color: #ffd488;
} }
#global-loading-status:is([data-state='retry'], [data-state='key']) { #global-loading-status[data-state='retry'] {
border-color: rgba(255, 212, 136, 0.55); border-color: rgba(255, 212, 136, 0.55);
} }
#global-loading-status:is([data-state='retry'], [data-state='key']) #global-loading-status[data-state='retry'] #global-loading-label,
#global-loading-label, #global-loading-status[data-state='retry'] #global-loading-detail {
#global-loading-status:is([data-state='retry'], [data-state='key'])
#global-loading-detail {
color: #ffd488; color: #ffd488;
} }
#global-loading-status[data-state='cancelled'] #global-loading-label, #global-loading-status[data-state='cancelled'] #global-loading-label,