From 9d3d5164fe42ec3ee25afb34c6817b0cfe4bd0db Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:28:00 +0100 Subject: [PATCH 1/3] fix(space): respect provider cooldowns and bound TLE groups --- docs/CURRENT-STATE.md | 7 ++ scripts/package-boundaries.json | 3 + server/providers/space/celestrak.js | 56 ++++++++++++++-- server/providers/space/cooldown.js | 12 ++++ server/providers/space/launch-library.js | 36 ++++++++++- src/proxyErrorResponses.test.mjs | 16 +++-- src/tooling/spaceProviders.test.mjs | 82 +++++++++++++++++++++++- 7 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 server/providers/space/cooldown.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..106e66ad9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +CelesTrak accepts only the seven satellite groups used by the app and gates +requests before provider work. CelesTrak and Launch Library failures start a +provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds +through 30 minutes, with a 60-second fallback. During cooldown dated last-good +data is served with stale provenance, otherwise the proxy returns a bounded +error without another upstream attempt. Error bodies are cancelled before reading. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..d7d9ba727 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -96,6 +96,9 @@ "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs", + "server/providers/space/cooldown.js", "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 8baf59ba7..315e9b3f0 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,8 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { providerCooldownMs } from './cooldown.js'; +import { admitSameSite } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -24,6 +26,16 @@ import { readResponseTextCapped } from '../common/http.js'; export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const groups = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + ]); + let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } const inflight = new Map(); // group -> Promise<{at, body}|null> @@ -64,7 +76,11 @@ export function celestrakProxy() { 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + cooldownUntil = Date.now() + providerCooldownMs(res); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } const body = await readResponseTextCapped( res, CELESTRAK_MAX_RESPONSE_BYTES, @@ -80,12 +96,18 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { + if (admitSameSite(req, res)) return; + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + if (!groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; } - const send = (status, body, cacheStatus) => { + const send = (status, body, cacheStatus, entry) => { // Guard against a double-send (e.g. a throw AFTER a response already // went out routing into the catch's send): writeHead after headersSent // throws "Cannot set headers after they are sent". @@ -93,6 +115,17 @@ export function celestrakProxy() { res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus, + 'Cache-Control': 'no-store', + ...(entry + ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + : {}), + ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), }); res.end(body); }; @@ -104,7 +137,16 @@ export function celestrakProxy() { if (entry) mem.set(group, entry); } if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); + send(200, entry.body, 'HIT', entry); + return; + } + if (Date.now() < cooldownUntil) { + send( + entry ? 200 : 502, + entry?.body || 'celestrak upstream cooling down', + entry ? 'STALE-ERROR' : 'NONE', + entry, + ); return; } // Stale or missing → refresh, single-flight per group. @@ -118,6 +160,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { + if (Date.now() >= cooldownUntil) + cooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -128,9 +172,9 @@ export function celestrakProxy() { } const fresh = await inflight.get(group); if (fresh) { - send(200, fresh.body, 'MISS'); + send(200, fresh.body, 'MISS', fresh); } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty } else { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } diff --git a/server/providers/space/cooldown.js b/server/providers/space/cooldown.js new file mode 100644 index 000000000..acb850124 --- /dev/null +++ b/server/providers/space/cooldown.js @@ -0,0 +1,12 @@ +/** Bound an upstream Retry-After delay (seconds or HTTP date). */ +export function providerCooldownMs(response, now = Date.now()) { + const raw = response?.headers?.get('retry-after')?.trim(); + const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN; + const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN; + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Number.isFinite(deadline) + ? deadline - now + : 60000; + return Math.min(30 * 60000, Math.max(5000, delay)); +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 7b56b6220..4e8ac629d 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { providerCooldownMs } from './cooldown.js'; import { promises as fsp } from 'node:fs'; import { readResponseTextCapped, @@ -30,6 +31,8 @@ export function rocketLaunchesProxy() { 'launch-library-2-v2.3.json', ); let cache = null; + let cooldownUntil = 0; + let cooldownStatus = 502; let diskLoaded = false; const inFlight = new Map(); @@ -62,7 +65,18 @@ export function rocketLaunchesProxy() { function send(res, status, body, cacheState) { res.writeHead(status, { 'Content-Type': 'application/json', - 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'Cache-Control': + status === 200 && cacheState !== 'STALE-ERROR' + ? 'public, max-age=900' + : 'no-store', + ...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}), + ...(Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), 'X-GEV-Cache': cacheState, }); res.end(body); @@ -75,17 +89,21 @@ export function rocketLaunchesProxy() { signal: AbortSignal.timeout(20000), headers: launchLibraryRequestHeaders(), }); - const body = await readResponseTextCapped(upstream, maxResponseBytes); if (!upstream.ok) { + cooldownUntil = Date.now() + providerCooldownMs(upstream); + cooldownStatus = upstream.status; + void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; throw error; } + const body = await readResponseTextCapped(upstream, maxResponseBytes); const parsed = JSON.parse(body); if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; + cooldownUntil = 0; void saveDiskCache(fresh); return fresh; } @@ -103,6 +121,16 @@ export function rocketLaunchesProxy() { return; } const stale = cache; + if (now < cooldownUntil) { + send( + res, + stale ? 200 : cooldownStatus, + stale?.body || + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + stale ? 'STALE-ERROR' : 'NONE', + ); + return; + } const request = coalesceProxyRequest( inFlight, 'recent-launches', @@ -112,6 +140,10 @@ export function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + if (Date.now() >= cooldownUntil) { + cooldownUntil = Date.now() + 60000; + cooldownStatus = 502; + } // Log only a bounded status, never upstream bodies, URLs, or credentials. const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 78f3f59f0..d215e78a3 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { providerCooldownMs } from '../server/providers/space/cooldown.js'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; @@ -23,7 +24,8 @@ function extract(name) { function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { - readResponseTextCapped, coalesceProxyRequest, + readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, + admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, @@ -117,7 +119,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met test('CelesTrak unexpected failures hide details', async () => { const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 500); assert.equal(res.body, 'celestrak proxy error'); assert.equal(res.headers['x-tle-cache'], 'ERROR'); @@ -126,7 +128,7 @@ test('CelesTrak unexpected failures hide details', async () => { test('CelesTrak retains invalid-group and unavailable responses', async () => { const app = fixture('celestrakProxy'); assert.equal((await app.request('/../')).status, 400); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 502); assert.equal(res.headers['x-tle-cache'], 'NONE'); }); @@ -135,13 +137,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => { let now = Date.now(); let calls = 0; const app = fixture('celestrakProxy', { - Date: { now: () => now }, + Date: class extends Date { static now() { return now; } }, fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, }); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT'); now += 7 * 3600_000; - const stale = await app.request('/active'); + const stale = await app.request('/stations'); assert.equal(stale.status, 200); assert.equal(stale.body, '1 valid-fixture-TLE'); assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index a5b2cecba..2abc9d5fc 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -161,7 +161,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap'); assert.equal(fsp.writeFile.mock.callCount(), 0); assert.equal((await request('/api/celestrak', '/stations')).status, 502); - assert.equal(bodies.length, 2, 'nothing was cached in memory'); + assert.equal( + bodies.length, + 1, + 'failed bodies are not cached but a cooldown bounds retry work', + ); }); for (const preview of [false, true]) @@ -190,3 +194,79 @@ for (const preview of [false, true]) assert.equal(hit.body, first.body); assert.equal(calls, 1); }); + +test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['tle', 'launches']) { + for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) { + let calls = 0; + let fail = false; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + status: kind === 'tle' ? 403 : 429, + headers: { 'Retry-After': retryAfter }, + }, + ); + return new Response( + kind === 'tle' + ? 'ISS\n1 25544U fixture\n2 25544 fixture\n' + : '{"results":[]}', + ); + }); + const request = install( + kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(), + ); + const route = kind === 'tle' ? '/api/celestrak' : '/api/launches'; + const url = kind === 'tle' ? '/stations' : '/'; + if (kind === 'tle') { + assert.equal( + (await request(route, '/not-a-supported-group')).status, + 400, + ); + assert.equal(calls, 0); + } + const first = await request(route, url); + now += 7 * 3600000; + // Recompute the HTTP-date relative to the clock at the failed refresh. + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, 'fetch', async (...args) => { + const response = await originalFetch(...args); + if (fail && !/^\d+$/.test(retryAfter)) + response.headers.set( + 'Retry-After', + new Date(now + 90000).toUTCString(), + ); + return response; + }); + fail = true; + const stale = await request(route, url); + assert.equal(stale.status, 200); + assert.equal(stale.body, first.body); + assert.equal(stale.headers['Retry-After'], '90'); + assert.ok( + stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'], + ); + assert.equal(cancelled, true); + await request(route, url); + if (kind === 'tle') + assert.equal((await request(route, '/visual')).status, 502); + assert.equal(calls, 2, 'cooldown applies across TLE groups'); + now += 91000; + fail = false; + assert.equal((await request(route, url)).status, 200); + assert.equal(calls, 3); + } + } +}); From 8602999056ca09fdd25e26f4ac8c6b24f592a496 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:29:24 +0100 Subject: [PATCH 2/3] docs: record space fix --- changelog.d/1270-space-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1270-space-issue-fix.md diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md new file mode 100644 index 000000000..b483c4c01 --- /dev/null +++ b/changelog.d/1270-space-issue-fix.md @@ -0,0 +1 @@ +- Respect provider cooldowns and bound tle groups (asturksever, #1270). From 0b5b897c114e5b1ae9bbd1678f28fba6b7c6ebb2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:26:41 +0100 Subject: [PATCH 3/3] Allow active TLE group, admit proxied same-origin requests, keep longest cooldown - CelesTrak allowlist includes `active`, which Space Missions' getActiveTle() requests; unknown groups still answer 400. - The CelesTrak gate uses admitSameSiteKeyless so the documented reverse proxy keeps working while foreign/opaque Origins and cross-site Sec-Fetch-Site are still refused with 403. - A provider failure never shortens an existing cooldown (CelesTrak and Launch Library keep the later deadline). - Tests: real getActiveTle() through the middleware, proxied app request vs unrelated Origin, concurrent failures finishing in both orders. - docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 15 +-- server/providers/space/celestrak.js | 15 ++- server/providers/space/launch-library.js | 8 +- src/proxyErrorResponses.test.mjs | 1 - src/tooling/spaceProviders.test.mjs | 126 ++++++++++++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 870eb51aa..82fc9201b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,12 +9,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -CelesTrak accepts only the seven satellite groups used by the app and gates -requests before provider work. CelesTrak and Launch Library failures start a -provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds -through 30 minutes, with a 60-second fallback. During cooldown dated last-good -data is served with stale provenance, otherwise the proxy returns a bounded -error without another upstream attempt. Error bodies are cancelled before reading. +CelesTrak accepts only the eight satellite groups used by the app (the seven +satellite-layer groups plus `active` for Space Missions) and gates cross-site +requests before provider work with the keyless same-site policy, so the +documented reverse proxy still works. CelesTrak and Launch Library failures +start a provider-wide cooldown that a later failure never shortens: Retry-After +seconds or HTTP dates are bounded to 5 seconds through 30 minutes, with a +60-second fallback. During cooldown dated last-good data is served with stale +provenance, otherwise the proxy returns a bounded error without another upstream +attempt. Error bodies are cancelled before reading. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 315e9b3f0..013fce1b1 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; import { providerCooldownMs } from './cooldown.js'; -import { admitSameSite } from '../common/same-site.js'; +import { admitSameSiteKeyless } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -34,6 +34,8 @@ export function celestrakProxy() { 'galileo', 'geo', 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', ]); let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } @@ -77,7 +79,12 @@ export function celestrakProxy() { }, }); if (!res.ok) { - cooldownUntil = Date.now() + providerCooldownMs(res); + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + cooldownUntil = Math.max( + cooldownUntil, + Date.now() + providerCooldownMs(res), + ); void res.body?.cancel().catch(() => {}); throw new Error(`HTTP ${res.status}`); } @@ -96,7 +103,9 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (admitSameSite(req, res)) return; + // Keyless and needed to render the satellites layer: refuse cross-site + // browsers but keep the documented reverse-proxy deployment working. + if (admitSameSiteKeyless(req, res)) return; if (req.method !== 'GET') { res.writeHead(405); res.end(); diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 4e8ac629d..f95be2056 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -90,8 +90,12 @@ export function rocketLaunchesProxy() { headers: launchLibraryRequestHeaders(), }); if (!upstream.ok) { - cooldownUntil = Date.now() + providerCooldownMs(upstream); - cooldownStatus = upstream.status; + const deadline = Date.now() + providerCooldownMs(upstream); + // Never shorten a longer cooldown that is already in force. + if (deadline >= cooldownUntil) { + cooldownUntil = deadline; + cooldownStatus = upstream.status; + } void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index d215e78a3..4f9ef7602 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -25,7 +25,6 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, - admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 2abc9d5fc..7aef775b1 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -12,6 +12,7 @@ import { launchLibraryRecentUrl, } from 'gods-eye-view/sources/space'; import * as compatibility from '../../server/providers/local.js'; +import { createLaunchSource } from 'gods-eye-view/layers/launches/source'; function install(plugin, preview = false) { const routes = new Map(); @@ -22,17 +23,23 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, + // The same-site gates answer 403 through statusCode + setHeader. + setHeader(name, value) { + this.headers[name] = value; + }, end(body) { this.body = body; + this.status ??= this.statusCode; }, }; - await routes.get(route)({ url, method }, res); + await routes.get(route)({ url, method, headers, socket: {} }, res); return res; }; } @@ -270,3 +277,118 @@ test('space providers honor Retry-After, gate groups, preserve stale dates and r } } }); + +test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => { + isolateDisk(t); + const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n'; + const fetched = []; + t.mock.method(globalThis, 'fetch', async (url) => { + fetched.push(new URL(url).searchParams.get('GROUP')); + return new Response(tle); + }); + const request = install(celestrakProxy()); + // Route the browser source's real request into the middleware. + const source = createLaunchSource({ + fetchImpl: async (url) => { + const prefix = '/api/celestrak'; + assert.ok(url.startsWith(`${prefix}/`)); + const res = await request(prefix, url.slice(prefix.length)); + return new Response(res.body, { status: res.status }); + }, + }); + assert.equal(await source.getActiveTle(), tle); + assert.deepEqual(fetched, ['active']); + for (const group of ['/not-a-group', '/active-extra', '/../active']) + assert.equal((await request('/api/celestrak', group)).status, 400); + assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream'); +}); + +test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => { + isolateDisk(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'); + }); + const request = install(celestrakProxy()); + const proxied = { + host: 'gev.example.test', + 'x-forwarded-for': '203.0.113.7', + 'x-forwarded-host': 'gev.example.test', + 'x-forwarded-proto': 'https', + via: '1.1 proxy', + }; + const app = await request('/api/celestrak', '/stations', 'GET', { + ...proxied, + origin: 'https://gev.example.test', + 'sec-fetch-site': 'same-origin', + }); + assert.equal(app.status, 200); + assert.equal(app.headers['x-tle-cache'], 'MISS'); + assert.equal(calls, 1); + for (const headers of [ + { ...proxied, origin: 'https://unrelated.example' }, + { ...proxied, origin: 'null' }, + { ...proxied, 'sec-fetch-site': 'cross-site' }, + ]) { + const refused = await request('/api/celestrak', '/visual', 'GET', headers); + assert.equal(refused.status, 403); + } + assert.equal(calls, 1, 'refused requests do no provider or cache work'); +}); + +for (const longFirst of [true, false]) + test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + const pending = new Map(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + calls++; + const group = new URL(url).searchParams.get('GROUP'); + await new Promise((resolve) => pending.set(group, resolve)); + return group === 'stations' + ? new Response('slow down', { + status: 429, + headers: { 'Retry-After': '1800' }, + }) + : new Response('unavailable', { status: 503 }); + }); + const request = install(celestrakProxy()); + const long = request('/api/celestrak', '/stations'); + const short = request('/api/celestrak', '/visual'); + while (pending.size < 2) await new Promise(setImmediate); + const order = longFirst + ? [ + ['stations', long], + ['visual', short], + ] + : [ + ['visual', short], + ['stations', long], + ]; + for (const [group, response] of order) { + pending.get(group)(); + assert.equal((await response).status, 502); + } + assert.equal(calls, 2); + const cooling = await request('/api/celestrak', '/geo'); + assert.equal(cooling.status, 502); + assert.equal(cooling.headers['Retry-After'], '1800'); + now += 61_000; + assert.equal((await request('/api/celestrak', '/geo')).status, 502); + assert.equal( + calls, + 2, + 'the 60 s fallback did not replace the 30 min deadline', + ); + now += 1800_000; + t.mock.method( + globalThis, + 'fetch', + async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'), + ); + assert.equal((await request('/api/celestrak', '/geo')).status, 200); + });