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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-23 12:26:41 +01:00
1 parent 0caffd8b70
commit 0b5b897c11
5 files changed
+151 -14

No files matched your search

+9 -6
View File
@@ -9,12 +9,15 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled
lookups preserve the previous analyst cohort and boundary source. Navigation lookups preserve the previous analyst cohort and boundary source. Navigation
search keeps a successful geocode, with no search or view-based region recovery. 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 CelesTrak accepts only the eight satellite groups used by the app (the seven
requests before provider work. CelesTrak and Launch Library failures start a satellite-layer groups plus `active` for Space Missions) and gates cross-site
provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds requests before provider work with the keyless same-site policy, so the
through 30 minutes, with a 60-second fallback. During cooldown dated last-good documented reverse proxy still works. CelesTrak and Launch Library failures
data is served with stale provenance, otherwise the proxy returns a bounded start a provider-wide cooldown that a later failure never shortens: Retry-After
error without another upstream attempt. Error bodies are cancelled before reading. 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 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 existing batch size and one bounded upstream chunk. Larger requests return HTTP
+12 -3
View File
@@ -2,7 +2,7 @@ import path from 'node:path';
import { promises as fsp } from 'node:fs'; import { promises as fsp } from 'node:fs';
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
import { providerCooldownMs } from './cooldown.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'; import { readResponseTextCapped } from '../common/http.js';
/** /**
@@ -34,6 +34,8 @@ export function celestrakProxy() {
'galileo', 'galileo',
'geo', 'geo',
'starlink', 'starlink',
// Space Missions (`getActiveTle`) reads the full active catalog.
'active',
]); ]);
let cooldownUntil = 0; let cooldownUntil = 0;
const mem = new Map(); // group -> { at: epochMs, body: string } const mem = new Map(); // group -> { at: epochMs, body: string }
@@ -77,7 +79,12 @@ export function celestrakProxy() {
}, },
}); });
if (!res.ok) { 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(() => {}); void res.body?.cancel().catch(() => {});
throw new Error(`HTTP ${res.status}`); throw new Error(`HTTP ${res.status}`);
} }
@@ -96,7 +103,9 @@ export function celestrakProxy() {
const group = String(req.url || '') const group = String(req.url || '')
.replace(/^\//, '') .replace(/^\//, '')
.split('?')[0]; .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') { if (req.method !== 'GET') {
res.writeHead(405); res.writeHead(405);
res.end(); res.end();
+6 -2
View File
@@ -90,8 +90,12 @@ export function rocketLaunchesProxy() {
headers: launchLibraryRequestHeaders(), headers: launchLibraryRequestHeaders(),
}); });
if (!upstream.ok) { if (!upstream.ok) {
cooldownUntil = Date.now() + providerCooldownMs(upstream); const deadline = Date.now() + providerCooldownMs(upstream);
cooldownStatus = upstream.status; // Never shorten a longer cooldown that is already in force.
if (deadline >= cooldownUntil) {
cooldownUntil = deadline;
cooldownStatus = upstream.status;
}
void upstream.body?.cancel().catch(() => {}); void upstream.body?.cancel().catch(() => {});
const error = new Error(`upstream HTTP ${upstream.status}`); const error = new Error(`upstream HTTP ${upstream.status}`);
error.upstreamStatus = upstream.status; error.upstreamStatus = upstream.status;
-1
View File
@@ -25,7 +25,6 @@ function fixture(name, overrides = {}, preview = false) {
const logs = []; const logs = [];
const deps = { const deps = {
readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, readResponseTextCapped, coalesceProxyRequest, providerCooldownMs,
admitSameSite: () => false,
// The rate-limited proxies reach for these; give them the real ones so a // The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be. // fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey, makeRateLimiter, clientKey,
+124 -2
View File
@@ -12,6 +12,7 @@ import {
launchLibraryRecentUrl, launchLibraryRecentUrl,
} from 'gods-eye-view/sources/space'; } from 'gods-eye-view/sources/space';
import * as compatibility from '../../server/providers/local.js'; import * as compatibility from '../../server/providers/local.js';
import { createLaunchSource } from 'gods-eye-view/layers/launches/source';
function install(plugin, preview = false) { function install(plugin, preview = false) {
const routes = new Map(); 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 = { const res = {
headersSent: false, headersSent: false,
headers: {},
writeHead(status, headers) { writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true }); 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) { end(body) {
this.body = 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; 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);
});