From a310cbc2f06b94a8d4fea3b74a40dbaaf6854718 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 17 Oct 2026 13:02:38 -0400 Subject: [PATCH] fix(space): align Launch Library requests with provider guidance --- .env.example | 4 +- README.md | 6 +- docs/CURRENT-STATE.md | 8 + scripts/format-scope.json | 1 + server/providers/space/launch-library.js | 2 + src/data/regionalProxy.test.mjs | 8 +- src/data/spaceProviderRequests.js | 6 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/ru.json | 3 +- src/keySetup.js | 6 +- src/keySetupCore.mjs | 11 +- src/tooling/ll2Integration.test.mjs | 232 +++++++++++++++++++++++ src/tooling/spaceProviders.test.mjs | 4 +- 13 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 src/tooling/ll2Integration.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..5ddf78383 100644 --- a/.env.example +++ b/.env.example @@ -118,8 +118,8 @@ OPENSKY_AUTH_MODE=oauth OPENSKY_CLIENT_ID= OPENSKY_CLIENT_SECRET= -# Optional: higher Launch Library 2 request allowance. Public access works -# without a token. +# Launch Library 2: leave blank for standard public access (no key needed). +# Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= # Optional: OpenSky credentials JSON file path (alternative to above) diff --git a/README.md b/README.md index c49c53ece..c3c3d7ef3 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | @@ -527,9 +527,11 @@ _What the TomTom key buys you: rush-hour density painted on the city — then di | | Key | Why | Get it | | --- | -------------------- | ------------------------------------------------------------- | -------------------------------------------------- | | 🟡 | **OpenSky** | ✈️ More flight-polling credits (🟢 anonymous works without) | [opensky-network.org](https://opensky-network.org) | -| 🟡 | **Launch Library 2** | 🚀 Higher space-missions request allowance (🟢 works without) | [thespacedevs.com](https://thespacedevs.com) | +| 🔴 | **Launch Library 2** | 🚀 Optional paid token for higher limits (🟢 no key needed for public access) | [thespacedevs.com/supportus](https://thespacedevs.com/supportus) | Add these if you need higher polling allowances. +Leave `LL2_API_TOKEN` blank for standard public access. LL2 tokens are optional +paid Patreon upgrades for higher limits; there is no free API-key signup. `npm run doctor` reports Node/npm readiness, the primary provider routes, and where each configured provider was found without printing credential values. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..a092350c7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1478,6 +1478,14 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories; `sources/space` supplies fixed upstream URL builders with no I/O or environment access. Callers retain validation, transport and response policy. +LL2 recent-launch requests use UTC minute-aligned bounds over the same 30-day +window, so installations refreshing in the same minute share an upstream cache +key. The upper bound trails the current time by less than one minute. Requests +identify the app and repository through User-Agent; the existing 15-minute local +cache, stale fallback and optional server-only Authorization remain unchanged. +Provider Settings identifies LL2 tokens as optional paid higher-rate upgrades: +standard public access needs no key, and its field can remain blank. + The shared TLE text parser skips incomplete named sets and resumes at the next complete set. A missing orbital line does not discard the later satellites in the catalog. A set whose two lines carry different catalog numbers, or a diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ec1adb0c..5ff9abe8c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -96,6 +96,7 @@ "server/providers/space/launch-library.js", "src/data/spaceProviderRequests.js", "src/tooling/spaceProviders.test.mjs", + "src/tooling/ll2Integration.test.mjs", "server/providers/terrain.js", "server/providers/traffic.js", "server/providers/firms.js", diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 50825df03..7b56b6220 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -13,6 +13,8 @@ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) { const normalized = String(token || '').trim(); return { Accept: 'application/json', + 'User-Agent': + 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', ...(normalized ? { Authorization: `Token ${normalized}` } : {}), }; } diff --git a/src/data/regionalProxy.test.mjs b/src/data/regionalProxy.test.mjs index acc1b1a22..97891bef8 100644 --- a/src/data/regionalProxy.test.mjs +++ b/src/data/regionalProxy.test.mjs @@ -59,9 +59,13 @@ test('new data proxies install the same routes in dev and preview servers', () = test('Launch Library uses a 15-minute cache and optional server-side token header', () => { assert.equal(LL2_CACHE_TTL_MS, 15 * 60_000); - assert.deepEqual(launchLibraryRequestHeaders(''), { Accept: 'application/json' }); - assert.deepEqual(launchLibraryRequestHeaders(' secret '), { + const headers = { Accept: 'application/json', + 'User-Agent': 'gods-eye-view-launch-library-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', + }; + assert.deepEqual(launchLibraryRequestHeaders(''), headers); + assert.deepEqual(launchLibraryRequestHeaders(' secret '), { + ...headers, Authorization: 'Token secret', }); }); diff --git a/src/data/spaceProviderRequests.js b/src/data/spaceProviderRequests.js index 8e3030f97..e0f1707f4 100644 --- a/src/data/spaceProviderRequests.js +++ b/src/data/spaceProviderRequests.js @@ -7,10 +7,12 @@ export function celestrakTleUrl(group) { } export function launchLibraryRecentUrl(end) { - const start = new Date(end.getTime() - 30 * 86400000); + // Stable minute boundaries let different installations share LL2's cache. + const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000); + const start = new Date(roundedEnd.getTime() - 30 * 86400000); const url = new URL('https://ll.thespacedevs.com/2.3.0/launches/'); url.searchParams.set('net__gte', start.toISOString()); - url.searchParams.set('net__lte', end.toISOString()); + url.searchParams.set('net__lte', roundedEnd.toISOString()); url.searchParams.set('limit', '100'); url.searchParams.set('mode', 'detailed'); return url; diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b5b0a3abb..f93f42070 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -842,7 +842,8 @@ "Real live traffic (keyless runs a simulation)": "Tráfico real en vivo (sin clave se usa una simulación)", "Bing imagery map stacks + world terrain": "Imágenes de Bing + terreno mundial", "More flight-polling credits (anonymous works without)": "Más créditos de consulta de vuelos (sin clave funciona el modo anónimo)", - "Higher space-missions request allowance": "Mayor límite de peticiones para misiones espaciales", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "El acceso público a misiones espaciales no necesita clave. Déjalo en blanco para el uso estándar; los tokens de pago solo aumentan los límites de solicitudes.", + "Optional paid token — public access works without one": "Token opcional de pago — el acceso público funciona sin él", "Scene action failed": "La acción de escena falló", "Project exported": "Proyecto exportado", "Next session: {pendingId} — this session stays on {modelId}": "Sesión siguiente: {pendingId} — esta sesión sigue en {modelId}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 019415690..6a58ab901 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -74,7 +74,8 @@ "Removed from {store}. Restarting — this page reloads itself.": "Удалено: {store}. Перезапуск — страница перезагрузится сама.", "More flight-polling credits (anonymous works without)": "Повышенный лимит запросов рейсов (анонимно работает и без ключа)", "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token).", - "Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Общедоступные данные космических миссий не требуют ключа. Для обычного использования оставьте поле пустым; платные токены только повышают лимиты запросов.", + "Optional paid token — public access works without one": "Необязательный платный токен — публичный доступ работает без него", "Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию", "Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth", "Live ships, worldwide": "Суда в реальном времени по всему миру", diff --git a/src/keySetup.js b/src/keySetup.js index aab3d62b0..b67c9df66 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -215,7 +215,7 @@ export function stripKeylessBasemapFromHash(hash) { } } -const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' }); +const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' }); function updateCloudVoiceAuthControl(auth, busy = false) { const mode = readStoredCloudVoiceAuthMode(); @@ -259,7 +259,9 @@ function buildRow(documentRef, key) { tier.title = key.tier === 'metered' ? t('Metered — a billing-enabled account') - : t('Free key — register, paste, done'); + : key.tier === 'paid' + ? t('Optional paid token — public access works without one') + : t('Free key — register, paste, done'); head.append(led, title, tier); if (key.clientExposed) { const exposed = documentRef.createElement('span'); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 30c695370..10bbe32a5 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -29,7 +29,8 @@ export const KEY_SETUP_APPEND_HEADER = /** * Provider credentials, in display order — most magic per * minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a - * billing-enabled account, 'free' (🟡) is a register-and-paste key. + * billing-enabled account, 'free' (🟡) is a register-and-paste key, and + * 'paid' (🔴) is an optional paid higher-rate token. * `clientExposed` marks the keys that are injected into the browser * bundle by design (restrict them at the provider, per SECURITY.md). * `hidden` keeps advanced configuration out of the panel and missing-key count. @@ -119,10 +120,12 @@ export const KEY_SETUP_KEYS = Object.freeze([ Object.freeze({ id: 'launch-library', title: 'LAUNCH LIBRARY', // i18n-ignore: provider product name - unlocks: msg('Higher space-missions request allowance'), - getUrl: 'https://thespacedevs.com', + unlocks: msg( + 'Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.', + ), + getUrl: 'https://thespacedevs.com/supportus', envVars: Object.freeze(['LL2_API_TOKEN']), - tier: 'free', + tier: 'paid', }), ]); diff --git a/src/tooling/ll2Integration.test.mjs b/src/tooling/ll2Integration.test.mjs new file mode 100644 index 000000000..30325eaab --- /dev/null +++ b/src/tooling/ll2Integration.test.mjs @@ -0,0 +1,232 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { promises as fsp } from 'node:fs'; +import http from 'node:http'; +import { launchLibraryRecentUrl } from '../data/spaceProviderRequests.js'; +import { + launchLibraryRequestHeaders, + rocketLaunchesProxy, +} from '../../server/providers/space/launch-library.js'; +import { keySetupStatus } from '../keySetupCore.mjs'; +import { initKeySetup } from '../keySetup.js'; + +test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => { + const first = new Date('2026-10-17T16:20:00.001Z'); + const last = new Date('2026-10-17T16:20:59.999Z'); + assert.equal( + launchLibraryRecentUrl(first).href, + launchLibraryRecentUrl(last).href, + ); + assert.equal(first.toISOString(), '2026-10-17T16:20:00.001Z'); + assert.equal(last.toISOString(), '2026-10-17T16:20:59.999Z'); +}); + +for (const [date, end] of [ + ['2026-10-17T16:20:00.001Z', '2026-10-17T16:20:00.000Z'], + ['2026-10-17T16:20:59.999Z', '2026-10-17T16:20:00.000Z'], + ['2026-10-17T16:21:00.000Z', '2026-10-17T16:21:00.000Z'], + ['2026-01-01T00:00:00.001Z', '2026-01-01T00:00:00.000Z'], +]) { + test(`LL2 bounds retain the 30-day UTC window at ${date}`, () => { + const url = launchLibraryRecentUrl(new Date(date)); + assert.equal(url.origin, 'https://ll.thespacedevs.com'); + assert.equal(url.pathname, '/2.3.0/launches/'); + assert.equal(url.searchParams.get('net__lte'), end); + assert.equal( + Date.parse(end) - Date.parse(url.searchParams.get('net__gte')), + 30 * 86400000, + ); + assert.equal(url.searchParams.get('limit'), '100'); + assert.equal(url.searchParams.get('mode'), 'detailed'); + }); +} + +test('LL2 headers identify the app and preserve optional server-only authorization', () => { + const publicHeaders = launchLibraryRequestHeaders(''); + assert.match(publicHeaders['User-Agent'], /^gods-eye-view.*\/1\.0 /); + assert.ok( + publicHeaders['User-Agent'].includes( + 'https://github.com/bilawalsidhu/gods-eye-view', + ), + ); + assert.equal(publicHeaders.Accept, 'application/json'); + assert.equal(publicHeaders.Authorization, undefined); + const paidHeaders = launchLibraryRequestHeaders(' owned-fixture-token '); + assert.equal(paidHeaders.Authorization, 'Token owned-fixture-token'); + assert.equal(paidHeaders['User-Agent'], publicHeaders['User-Agent']); +}); + +test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => { + const created = []; + const node = () => { + const value = { + dataset: {}, + children: [], + append(...children) { + this.children.push(...children); + }, + querySelector() { + return null; + }, + querySelectorAll() { + return []; + }, + addEventListener() {}, + removeEventListener() {}, + setAttribute() {}, + remove() {}, + }; + created.push(value); + return value; + }; + const rows = node(); + const root = node(); + root.querySelector = (selector) => + selector === '[data-key-setup-rows]' ? rows : null; + const chip = node(); + const status = keySetupStatus({}); + status.keys = status.keys.filter((key) => + ['launch-library', 'mapillary'].includes(key.id), + ); + const setup = await initKeySetup({ + documentRef: { + getElementById: (id) => (id === 'key-setup-chip' ? chip : root), + createElement: node, + addEventListener() {}, + removeEventListener() {}, + }, + fetchImpl: async () => ({ ok: true, json: async () => status }), + }); + assert.ok(setup); + try { + const ll2 = rows.children.find( + (row) => row.dataset.keyId === 'launch-library', + ); + const free = rows.children.find((row) => row.dataset.keyId === 'mapillary'); + const tier = (row) => + row.children[0].children.find( + (child) => child.className === 'key-setup-tier', + ); + assert.match(tier(ll2).title, /optional paid token/i); + assert.match(ll2.children[1].textContent, /leave blank/i); + assert.match(ll2.children[1].textContent, /no key/i); + assert.equal(tier(free).title, 'Free key — register, paste, done'); + const serialized = JSON.stringify(status); + assert.ok(!serialized.includes('owned-fixture-token')); + } finally { + setup.destroy(); + } +}); + +test('real LL2 proxy sends cacheable headers and bounds, retaining its memory cache', async (t) => { + for (const method of ['readFile', 'stat']) + t.mock.method(fsp, method, async () => { + throw new Error('owned fixture has no disk cache'); + }); + t.mock.method(fsp, 'mkdir', async () => {}); + t.mock.method(fsp, 'writeFile', async () => {}); + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url: new URL(url), options }); + return new Response( + JSON.stringify({ results: [{ id: 'owned-launch-fixture' }] }), + ); + }); + let handler; + rocketLaunchesProxy().configureServer({ + middlewares: { + use(route, value) { + assert.equal(route, '/api/launches'); + handler = value; + }, + }, + }); + async function request() { + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = body; + }, + }; + await handler({ method: 'GET' }, res); + return res; + } + const first = await request(); + const second = await request(); + assert.equal(first.status, 200); + assert.equal(second.body, first.body); + assert.equal(second.headers['X-GEV-Cache'], 'HIT'); + assert.equal(calls.length, 1); + assert.match(calls[0].options.headers['User-Agent'], /gods-eye-view/); + assert.match(calls[0].url.searchParams.get('net__lte'), /:00\.000Z$/); + assert.match(calls[0].url.searchParams.get('net__gte'), /:00\.000Z$/); +}); + +for (const preview of [false, true]) { + test(`LL2 proxy sends its identifying headers over real owned HTTP in ${preview ? 'preview' : 'development'}`, async (t) => { + for (const method of ['readFile', 'stat']) + t.mock.method(fsp, method, async () => { + throw new Error('no owned cache'); + }); + t.mock.method(fsp, 'mkdir', async () => {}); + t.mock.method(fsp, 'writeFile', async () => {}); + const received = []; + const upstream = http.createServer((req, res) => { + received.push({ + url: new URL(req.url, 'http://fixture.invalid'), + headers: req.headers, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ results: [{ id: 'owned-wire-fixture' }] })); + }); + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, 'fetch', (url, options) => { + const parsed = new URL(url); + assert.equal(parsed.origin, 'https://ll.thespacedevs.com'); + return originalFetch( + `http://127.0.0.1:${upstream.address().port}${parsed.pathname}${parsed.search}`, + options, + ); + }); + let handler; + const plugin = rocketLaunchesProxy(); + plugin[preview ? 'configurePreviewServer' : 'configureServer']({ + middlewares: { + use(_route, value) { + handler = value; + }, + }, + }); + const proxy = http.createServer((req, res) => { + void handler(req, res); + }); + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)); + try { + const result = await originalFetch( + `http://127.0.0.1:${proxy.address().port}/api/launches`, + ); + assert.equal(result.status, 200); + assert.deepEqual(await result.json(), { + results: [{ id: 'owned-wire-fixture' }], + }); + assert.equal(received.length, 1); + assert.match( + received[0].headers['user-agent'], + /^gods-eye-view-launch-library-proxy\/1\.0 /, + ); + assert.equal(received[0].headers.authorization, undefined); + assert.match(received[0].url.searchParams.get('net__lte'), /:00\.000Z$/); + } finally { + proxy.closeAllConnections(); + upstream.closeAllConnections(); + await Promise.all([ + new Promise((resolve) => proxy.close(resolve)), + new Promise((resolve) => upstream.close(resolve)), + ]); + } + }); +} diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 443ca5b96..a5b2cecba 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -81,8 +81,8 @@ test('portable requests keep fixed origins, encode group data and preserve the 3 assert.equal(url.origin, 'https://ll.thespacedevs.com'); assert.equal(url.pathname, '/2.3.0/launches/'); assert.deepEqual(Object.fromEntries(url.searchParams), { - net__gte: '2026-01-30T12:34:56.000Z', - net__lte: '2026-03-01T12:34:56.000Z', + net__gte: '2026-01-30T12:34:00.000Z', + net__lte: '2026-03-01T12:34:00.000Z', limit: '100', mode: 'detailed', });