fix(street-level): pause Street View details retries after a failure

Maintainer follow-up to #966 review:

- A failed panorama-details lookup now waits DETAILS_RETRY_COOLDOWN_MS
  (5 s) per panorama before a view event can retry it, so dragging the
  view after a failure no longer issues a lookup on every pov change. A
  success clears the pause; one panorama's failure does not delay another.
- SECURITY.md notes that allowing maps.googleapis.com in script-src also
  trusts its JSONP endpoints, an accepted limitation alongside the
  existing 'unsafe-eval' allowance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-10-08 10:38:01 -07:00
1 parent ef396655fc
commit 84f634efb0
5 files changed
+65 -6

No files matched your search

+2
View File
@@ -75,6 +75,8 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **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 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. - **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.
## Network exposure — the operator threat model ## Network exposure — the operator threat model
The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults: The dev server is a **key broker**: every server-side key above is spendable by anyone who can send HTTP requests to it. That shapes the defaults:
+2 -1
View File
@@ -3108,7 +3108,8 @@ disabled until the camera is at street zoom. The viewer is Google's `StreetViewP
element in the viewer host, without Google's fullscreen, close and address element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one again when the panorama's month, photographer and address arrive (one
lookup per panorama per session, seeded by the nearest lookup). A key successful lookup per panorama per session, seeded by the nearest lookup;
failed details lookups wait five seconds before a view event can retry). A key
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
not allow, billing off) reads KEY REJECTED and fails a pending open at not allow, billing off) reads KEY REJECTED and fails a pending open at
once; a lookup or open Google never answers fails after 10 or 20 s. In CI once; a lookup or open Google never answers fails after 10 or 20 s. In CI
@@ -31,6 +31,8 @@ export const KEY_REJECTED_MESSAGE =
export const NEAREST_TIMEOUT_MS = 10_000; export const NEAREST_TIMEOUT_MS = 10_000;
/** A panorama that has not shown by then has failed to open. */ /** A panorama that has not shown by then has failed to open. */
export const OPEN_TIMEOUT_MS = 20_000; export const OPEN_TIMEOUT_MS = 20_000;
/** Minimum pause after a failed panorama-details lookup before a view can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
/** Google answered nothing: the network, or a key Google ignores. */ /** Google answered nothing: the network, or a key Google ignores. */
export const NO_ANSWER_MESSAGE = export const NO_ANSWER_MESSAGE =
@@ -1,4 +1,5 @@
import { import {
DETAILS_RETRY_COOLDOWN_MS,
GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE, KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE, NO_ANSWER_MESSAGE,
@@ -50,6 +51,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const described = new Map(); const described = new Map();
/** Panorama ids whose lookup is in flight. */ /** Panorama ids whose lookup is in flight. */
const lookingUp = new Set(); const lookingUp = new Set();
/** Panorama id → earliest retry time after a failed details lookup. */
const retryAfter = new Map();
const listeners = new Set(); const listeners = new Set();
function requestRender() { function requestRender() {
@@ -81,11 +84,12 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
/** /**
* Look up a panorama's date, photographer and address once; the pose * Look up a panorama's date, photographer and address once; the pose
* already out is sent again with them. A failed lookup is asked again on * already out is sent again with them. After a failure, view events can
* the next event. * retry once the cooldown expires.
*/ */
async function describe(panoId) { async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return; if (described.has(panoId) || lookingUp.has(panoId)) return;
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
lookingUp.add(panoId); lookingUp.add(panoId);
try { try {
service ||= getService service ||= getService
@@ -93,7 +97,9 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
: new (await ensureLibrary()).StreetViewService(); : new (await ensureLibrary()).StreetViewService();
const { data } = await service.getPanorama({ pano: panoId }); const { data } = await service.getPanorama({ pano: panoId });
described.set(panoId, metaFrom(data)); described.set(panoId, metaFrom(data));
retryAfter.delete(panoId);
} catch { } catch {
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
return; return;
} finally { } finally {
lookingUp.delete(panoId); lookingUp.delete(panoId);
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createGoogleViewer } from './viewer.js'; import { createGoogleViewer } from './viewer.js';
import { DETAILS_RETRY_COOLDOWN_MS } from './policy.js';
import { import {
fakeHost, fakeHost,
fakeMapsLoader, fakeMapsLoader,
@@ -265,19 +266,66 @@ test('a panorama that never shows fails after the timeout and leaves no listener
assert.equal(loader.listenerCount(), 0); assert.equal(loader.listenerCount(), 0);
}); });
test('a date lookup that failed is asked again on the next view change', async () => { test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const { fake, viewer, poses, host } = setup(); const { fake, viewer, poses, host } = setup();
await viewer.mount(host); await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama; const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 1; let failures = 2;
let calls = 0;
fake.library.StreetViewService.prototype.getPanorama = function (request) { fake.library.StreetViewService.prototype.getPanorama = function (request) {
calls++;
if (failures-- > 0) return Promise.reject(new Error('offline')); if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request); return getPanorama.call(this, request);
}; };
await viewer.open('pano-a'); await viewer.open('pano-a');
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed'); assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
assert.equal(calls, 1);
for (let attempt = 0; attempt < 2; attempt++) {
for (let heading = 0; heading < 20; heading++) {
fake.built[0].setPov({ heading, pitch: 0 });
await settle();
}
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
now += DETAILS_RETRY_COOLDOWN_MS - 1;
fake.built[0].setPov({ heading: 20, pitch: 0 });
await settle();
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
now++;
fake.built[0].setPov({ heading: 21, pitch: 0 });
await settle();
assert.equal(calls, attempt + 2, 'one retry at the deadline');
}
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
now += DETAILS_RETRY_COOLDOWN_MS;
fake.built[0].setPov({ heading: 10, pitch: 0 }); fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle(); await settle();
assert.equal(calls, 3, 'successful details stay cached');
});
test('a failed panorama does not delay details for another panorama', async (t) => {
t.mock.method(Date, 'now', () => 1_000);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
fake.built[0].setPano('pano-b');
await settle(); await settle();
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1)); assert.deepEqual(requests, ['pano-a', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
fake.built[0].setPano('pano-a');
await settle();
assert.deepEqual(
requests,
['pano-a', 'pano-b'],
'returning respects the pause',
);
}); });