Compare commits

..
12 changed files with 84 additions and 99 deletions

No files matched your search

@@ -0,0 +1,4 @@
- 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).
@@ -1,5 +0,0 @@
- 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).
+1 -4
View File
@@ -1276,10 +1276,7 @@ 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
directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
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).
request skips the key-bearing Street View fallback and gets the placeholder.
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,
+1 -3
View File
@@ -377,9 +377,7 @@
"src/data/cctvFootprint.js",
"src/sources/cctvTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
"src/i18n/marker.js"
],
"external": []
},
+16 -32
View File
@@ -19,22 +19,7 @@ import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
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,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
@@ -524,6 +509,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const source = sourceById.get(cameraId);
const label = url.searchParams.get('label') || source?.name || cameraId;
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
// (prevents SSRF via ?upstream= query parameter)
@@ -557,26 +549,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
// request (whose client this server cannot see) gets the placeholder,
// as if no key were configured. Any forwarding header counts, even an
// 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(
(name) => req.headers?.[name] !== undefined,
);
const googleBudget = googleServerApiKey()
? streetViewRateLimiter()
: null;
const sv =
forwarded ||
!source ||
(googleBudget && !googleBudget(clientKey(req)))
? null
: await streetViewFallback({
lat: Number(source.lat),
lon: Number(source.lon),
heading: Number(source.headingDeg),
fov: Number(source.fovDeg),
pitch: Number(source.pitchDeg ?? -10),
});
const sv = forwarded
? null
: await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
if (sv?.ok) {
setHealth(cameraId, {
status: 'degraded',
-27
View File
@@ -528,30 +528,3 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree
assert.equal(direct.body, 'street-view');
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}`);
});
+3 -9
View File
@@ -98,15 +98,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// 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.
//
// Keyless, FIRMS is honest where it counts — its own layer row reads
// "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of
// 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.
// Keyless, FIRMS's own row reads "KEY REQUIRED", and the quakes half of
// the tile still delivers in full; the global status chip ends on KEY
// REQUIRED too (src/loadingFeedback.js) rather than LOAD FAILED.
layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyKey: msg('Scanning active events…'),
}),
+5 -6
View File
@@ -606,11 +606,10 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
const environmental = FIRST_RUN_MISSIONS.environmental;
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
// Keyless, the honest surface is the LAYER ROW ("KEY REQUIRED"), which the
// FIRMS layer already reports. The misleading part is the GLOBAL chip folding
// that row into LOAD FAILED — a defect in the shared state machine, ledgered
// post-launch, and the note must stay where the next editor will read it
// rather than being re-discovered as a launcher bug.
// Keyless, the LAYER ROW reads "KEY REQUIRED", and the global chip now ends
// on KEY REQUIRED as well instead of LOAD FAILED (src/loadingFeedback.js,
// covered in loadingFeedback.test.mjs). The note says so where the next
// editor will read it.
const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url),
'utf8',
@@ -621,7 +620,7 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
);
assert.match(table, /KEY REQUIRED/);
assert.match(table, /src\/loadingFeedback\.js/);
assert.match(table, /LEDGERED post-launch/);
assert.match(table, /rather than LOAD FAILED/);
});
test('every visitor gets the same tile — there is no degraded keyless variant', async () => {
+13 -7
View File
@@ -85,13 +85,18 @@ export function normalizeLayerLoading(layer = {}) {
function terminalFromParticipantStats(summary, participantIds) {
if (!participantIds?.length) return null;
const participants = new Set(participantIds);
return summary.records.some(
(record) =>
participants.has(record.id) &&
(record.error || record.unavailable || record.keyRequired),
const records = summary.records.filter((record) =>
participants.has(record.id),
);
// A layer waiting for a key did not fail: its row names the key. Only an
// actual error makes the whole batch read LOAD FAILED.
if (
records.some(
(record) => !record.keyRequired && (record.error || record.unavailable),
)
)
? 'error'
: null;
return 'error';
return records.some((record) => record.keyRequired) ? 'key' : null;
}
/** Aggregate all manager layers without changing their lifecycle authority. */
@@ -282,7 +287,7 @@ function terminalFromEvent(event) {
}
function mergeTerminalOutcome(current, next) {
const severity = { complete: 1, cancelled: 2, error: 3 };
const severity = { complete: 1, key: 2, cancelled: 3, error: 4 };
if (!next) return current || null;
if (!current || severity[next] > severity[current]) return next;
return current;
@@ -424,6 +429,7 @@ export function presentLoadingFeedback(state, summary, nowMs) {
const labels = {
complete: 'LOAD COMPLETE',
cancelled: 'LOAD CANCELLED',
key: 'KEY REQUIRED',
error: 'LOAD FAILED',
};
const label =
+33 -3
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));
assert.match(
css,
/#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;/,
/#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;/,
);
assert.match(
css,
/#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;/,
/#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;/,
);
});
@@ -346,7 +346,7 @@ test('participant stats failure outranks a simultaneous visibility completion',
name: 'AIS Vessels',
enabled: true,
lifecycleState: 'enabled',
stats: { loading: false, keyRequired: true },
stats: { loading: false, error: 'Upstream closed' },
}]);
state = reduceLoadingFeedback(state, missingKey, 250, {
type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
@@ -355,6 +355,36 @@ test('participant stats failure outranks a simultaneous visibility completion',
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', () => {
const both = aggregateLayerLoading([
{ id: 'a', name: 'A', lifecycleState: 'enabling' },
+1
View File
@@ -23,6 +23,7 @@ const STATUS_LABEL_KEYS = Object.freeze({
'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'),
'KEY REQUIRED': msg('KEY REQUIRED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
+7 -3
View File
@@ -131,6 +131,7 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
[data-state='key'],
[data-state='error']
) {
justify-content: center;
@@ -139,6 +140,7 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
[data-state='key'],
[data-state='error']
)
#global-loading-detail {
@@ -155,11 +157,13 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status[data-state='cancelled'] {
color: #ffd488;
}
#global-loading-status[data-state='retry'] {
#global-loading-status:is([data-state='retry'], [data-state='key']) {
border-color: rgba(255, 212, 136, 0.55);
}
#global-loading-status[data-state='retry'] #global-loading-label,
#global-loading-status[data-state='retry'] #global-loading-detail {
#global-loading-status:is([data-state='retry'], [data-state='key'])
#global-loading-label,
#global-loading-status:is([data-state='retry'], [data-state='key'])
#global-loading-detail {
color: #ffd488;
}
#global-loading-status[data-state='cancelled'] #global-loading-label,