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.
- **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
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
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
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
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
@@ -31,6 +31,8 @@ export const KEY_REJECTED_MESSAGE =
export const NEAREST_TIMEOUT_MS = 10_000;
/** A panorama that has not shown by then has failed to open. */
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. */
export const NO_ANSWER_MESSAGE =
@@ -1,4 +1,5 @@
import {
DETAILS_RETRY_COOLDOWN_MS,
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
@@ -50,6 +51,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const described = new Map();
/** Panorama ids whose lookup is in flight. */
const lookingUp = new Set();
/** Panorama id → earliest retry time after a failed details lookup. */
const retryAfter = new Map();
const listeners = new Set();
function requestRender() {
@@ -81,11 +84,12 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
/**
* 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
* the next event.
* already out is sent again with them. After a failure, view events can
* retry once the cooldown expires.
*/
async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return;
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
lookingUp.add(panoId);
try {
service ||= getService
@@ -93,7 +97,9 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
: new (await ensureLibrary()).StreetViewService();
const { data } = await service.getPanorama({ pano: panoId });
described.set(panoId, metaFrom(data));
retryAfter.delete(panoId);
} catch {
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
return;
} finally {
lookingUp.delete(panoId);
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleViewer } from './viewer.js';
import { DETAILS_RETRY_COOLDOWN_MS } from './policy.js';
import {
fakeHost,
fakeMapsLoader,
@@ -265,19 +266,66 @@ test('a panorama that never shows fails after the timeout and leaves no listener
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();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 1;
let failures = 2;
let calls = 0;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
calls++;
if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
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 });
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();
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',
);
});