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

+124 -2
View File
@@ -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);
});