From a166752138b04f634156debff8e4aac562ef83ac Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:57:24 +0200 Subject: [PATCH] fix(radar): keep the feed's build date in the catalog cache (#11435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retargetado para release/v3.8.51 (release/v3.8.50 está congelada — freeze issue #11439). Validado em lote combinado (batch-0824h2) contra o tip de release/v3.8.51: typecheck:core limpo, gates estáticos + migration-numbering OK, 127/127 testes focados passando (8/8 do PR entre migration-163 e radar-feed-cache-generated-at). Migração limpa (ADD COLUMN nullable, sem backfill necessário), aditiva na API, mantém "unknown" honesto para linhas antigas. Obrigado pela contribuição! --- .../11435-radar-feed-cache-generated-at.md | 1 + docs/frameworks/RADAR.md | 26 +++ src/app/api/radar/status/route.ts | 8 +- .../163_radar_feed_cache_generated_at.sql | 12 + src/lib/db/radar.ts | 31 ++- src/lib/radar/index.ts | 15 +- src/lib/radar/sync.ts | 3 + tests/unit/db/migration-163.test.ts | 80 +++++++ .../radar-feed-cache-generated-at.test.ts | 216 ++++++++++++++++++ 9 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/11435-radar-feed-cache-generated-at.md create mode 100644 src/lib/db/migrations/163_radar_feed_cache_generated_at.sql create mode 100644 tests/unit/db/migration-163.test.ts create mode 100644 tests/unit/radar-feed-cache-generated-at.test.ts diff --git a/changelog.d/fixes/11435-radar-feed-cache-generated-at.md b/changelog.d/fixes/11435-radar-feed-cache-generated-at.md new file mode 100644 index 0000000000..c80fc5e5e9 --- /dev/null +++ b/changelog.d/fixes/11435-radar-feed-cache-generated-at.md @@ -0,0 +1 @@ +- **fix(radar):** the catalog feed cache now keeps `generatedAt`, the date the feed's data was built, next to `fetchedAt`, the date this install downloaded it (#11435). The feed schema requires that date and the sync path validates it, but the cache dropped it — so a feed fetched minutes ago and one carrying weeks-old figures looked identical to everything downstream, including the dashboard's "Last fetched" line. `getRadarCatalog().meta` and `GET /api/radar/status` now report both dates, the latter as its own field rather than folded into `version` — and omitted entirely for the offers and intel caches, which keep no build date, where a `null` would read as "unknown" rather than "never stored". The dashboard still shows only the fetch time; surfacing the build date there needs a new translated label and is left to a follow-up. Rows cached before migration 163 read back as `null`: unknown stays unknown instead of borrowing the fetch time. The referrals cache has persisted the same date since migration 142. diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 622a39e450..51753e5730 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -283,6 +283,32 @@ currently cached version (`compareVersions()`, dotted `YYYY.MM.DD.n` comparison) `{ status: "stale" }`. This prevents a compromised or misconfigured feed endpoint from rolling a client back to an older, differently-signed payload. +### Two dates, and why both are kept + +A cached feed carries two distinct dates, and confusing them is the whole point of +keeping both: + +| Field | Comes from | Answers | +| ------------- | -------------------- | ----------------------------------- | +| `generatedAt` | the signed feed body | how old the **data** is | +| `fetchedAt` | this install's clock | when this install **downloaded** it | + +A feed fetched minutes ago can carry weeks-old figures, so `fetchedAt` alone cannot +tell an operator whether the overlay is fresher than the baseline it sits on. Both are +persisted in `radar_feed_cache`, returned by `getRadarCatalog().meta`, and reported +separately by `GET /api/radar/status`. A row cached before the `generated_at` column +existed (migration 163) reads back as `null` — unknown stays unknown rather than +borrowing the fetch time. `radar_referrals_cache` has kept its own `generated_at` since +migration 142. + +The version floor above compares `version`, not either date. + +Two gaps remain, both deliberate: the dashboard still shows only `Last fetched`, so reading +the build date there needs a new label (and its 42 locale entries); and the offers and intel +caches keep no build date at all, even though their feed schemas carry one — `GET +/api/radar/status` therefore omits the field for those two rather than reporting a `null` +that would read as "unknown". + ### Schema validation The downloaded bytes are parsed and validated against `RadarFeedSchema` diff --git a/src/app/api/radar/status/route.ts b/src/app/api/radar/status/route.ts index b4d3a8caec..8c40e4a75c 100644 --- a/src/app/api/radar/status/route.ts +++ b/src/app/api/radar/status/route.ts @@ -23,12 +23,18 @@ export async function OPTIONS() { } function cacheStatus( - cache: { version?: string; generatedAt?: string; tier: string; fetchedAt: string } | null + cache: { version?: string; generatedAt?: string | null; tier: string; fetchedAt: string } | null ) { if (!cache) return { available: false }; return { available: true, version: cache.version ?? cache.generatedAt, + // Reported on its own where the cache carries it — folding the build date + // into `version` loses the distinction between when a feed was built and + // when this install downloaded it. Absent for the offers and intel caches, + // which store no build date: a null there would claim the date is unknown + // when in fact it was never kept. + ...("generatedAt" in cache ? { generatedAt: cache.generatedAt ?? null } : {}), tier: cache.tier, fetchedAt: cache.fetchedAt, }; diff --git a/src/lib/db/migrations/163_radar_feed_cache_generated_at.sql b/src/lib/db/migrations/163_radar_feed_cache_generated_at.sql new file mode 100644 index 0000000000..d3b7459206 --- /dev/null +++ b/src/lib/db/migrations/163_radar_feed_cache_generated_at.sql @@ -0,0 +1,12 @@ +-- 163_radar_feed_cache_generated_at.sql +-- +-- radar_feed_cache (migration 136) kept only fetched_at — when this install +-- downloaded the feed — while the feed itself carries generatedAt, the date +-- its data was built. Nothing downstream could tell a recent download from +-- recent data: a feed fetched minutes ago can carry weeks-old figures. +-- +-- radar_referrals_cache (migration 142) already persists that date; this +-- brings the catalog cache in line. NULL on rows cached before this column +-- existed — the date is unknown, and stays unknown rather than being stood in +-- for by fetched_at. +ALTER TABLE radar_feed_cache ADD COLUMN generated_at TEXT DEFAULT NULL; diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index a1e553819b..e654bdec17 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -38,6 +38,8 @@ import { encrypt, decrypt } from "./encryption"; export interface RadarCache { version: string; + /** Date the feed's data was built, from the feed itself. Null when unknown. */ + generatedAt: string | null; tier: string; payload: string; signature: string; @@ -114,8 +116,8 @@ export function getRadarCache(): RadarCache | null { const db = getDbInstance(); const row = db .prepare( - "SELECT version, tier, payload, signature, fetched_at AS fetchedAt " + - "FROM radar_feed_cache WHERE id = 1" + "SELECT version, generated_at AS generatedAt, tier, payload, signature, " + + "fetched_at AS fetchedAt FROM radar_feed_cache WHERE id = 1" ) .get() as RadarCache | undefined; @@ -128,6 +130,7 @@ export function getRadarCache(): RadarCache | null { */ export function setRadarCache(entry: { version: string; + generatedAt?: string | null; tier: string; payload: string; signature: string; @@ -137,15 +140,23 @@ export function setRadarCache(entry: { const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); db.prepare( - `INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at) - VALUES (1, ?, ?, ?, ?, ?) + `INSERT INTO radar_feed_cache (id, version, generated_at, tier, payload, signature, fetched_at) + VALUES (1, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - version = excluded.version, - tier = excluded.tier, - payload = excluded.payload, - signature = excluded.signature, - fetched_at = excluded.fetched_at` - ).run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); + version = excluded.version, + generated_at = excluded.generated_at, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + fetched_at = excluded.fetched_at` + ).run( + entry.version, + entry.generatedAt ?? null, + entry.tier, + entry.payload, + entry.signature, + fetchedAt + ); } // --------------------------------------------------------------------------- diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index f6b05c5fd7..34f1425bc3 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -40,6 +40,12 @@ export interface RadarCatalogResult { /** Feed metadata — null when falling back to baseline. */ meta: { version: string; + /** + * Date the feed's data was built. Null for a cache row written before the + * column existed — unknown, never substituted by `fetchedAt`, which only + * says when this install downloaded it. + */ + generatedAt: string | null; tier: string; fetchedAt: string; } | null; @@ -48,7 +54,13 @@ export interface RadarCatalogResult { /** Injectable deps for testing. */ export interface GetRadarCatalogDeps { getFlag?: (key: string) => boolean; - getCache?: () => { version: string; tier: string; payload: string; fetchedAt: string } | null; + getCache?: () => { + version: string; + generatedAt?: string | null; + tier: string; + payload: string; + fetchedAt: string; + } | null; baseline?: MergedEntry[]; localOverrides?: Map>; tombstones?: Set; @@ -142,6 +154,7 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes entries, meta: { version: cache.version, + generatedAt: cache.generatedAt ?? null, tier: cache.tier, fetchedAt: cache.fetchedAt, }, diff --git a/src/lib/radar/sync.ts b/src/lib/radar/sync.ts index 4d3865680f..41104023bc 100644 --- a/src/lib/radar/sync.ts +++ b/src/lib/radar/sync.ts @@ -55,6 +55,8 @@ export type SyncStatus = export interface RadarCacheEntry { version: string; + /** Date the feed's data was built (`generatedAt`), as validated by the schema. */ + generatedAt?: string | null; tier: string; payload: string; signature: string; @@ -313,6 +315,7 @@ export async function syncRadar(deps: SyncDeps = {}): Promise { // Step 9: Cache the result const cacheEntry: RadarCacheEntry = { version: feed.version, + generatedAt: feed.generatedAt, tier: servedTier, payload: rawBytes.toString("utf-8"), signature, diff --git a/tests/unit/db/migration-163.test.ts b/tests/unit/db/migration-163.test.ts new file mode 100644 index 0000000000..dcad8bd959 --- /dev/null +++ b/tests/unit/db/migration-163.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for migration 163 — radar_feed_cache.generated_at. + * + * Verifies: + * - the column exists once after the migration runs (fresh database) + * - a row written the way the previous schema wrote it — no build date at all — + * reads back as null rather than borrowing the fetch time + * - the rest of that row survives the upgrade untouched + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-163-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const radarDb = await import("../../../src/lib/db/radar.ts"); + +function resetDb() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => { + resetDb(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("migration 163 — radar_feed_cache carries generated_at exactly once", () => { + const db = core.getDbInstance(); + + const columns = ( + db.prepare("PRAGMA table_info(radar_feed_cache)").all() as Array<{ name: string }> + ).map((c) => c.name); + + assert.equal( + columns.filter((c) => c === "generated_at").length, + 1, + "generated_at must be added once, whatever the number of migration runs" + ); +}); + +test("migration 163 — a row from the previous schema keeps its data and reads no build date", () => { + const db = core.getDbInstance(); + + // Exactly the INSERT the previous schema could write: no generated_at column. + db.prepare( + `INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at) + VALUES (1, ?, ?, ?, ?, ?)` + ).run( + "2026.08.02.1", + "community", + '{"feed":"omniroute-radar"}', + "sig", + "2026-08-24T07:00:00.000Z" + ); + + const cache = radarDb.getRadarCache(); + + assert.ok(cache); + assert.equal( + cache.generatedAt, + null, + "an upgraded row has no build date, and must not invent one" + ); + assert.equal(cache.version, "2026.08.02.1", "the pre-migration data must survive untouched"); + assert.equal(cache.tier, "community"); + assert.equal(cache.fetchedAt, "2026-08-24T07:00:00.000Z"); +}); diff --git a/tests/unit/radar-feed-cache-generated-at.test.ts b/tests/unit/radar-feed-cache-generated-at.test.ts new file mode 100644 index 0000000000..d364c69cc5 --- /dev/null +++ b/tests/unit/radar-feed-cache-generated-at.test.ts @@ -0,0 +1,216 @@ +/** + * tests/unit/radar-feed-cache-generated-at.test.ts + * + * The catalog feed carries the date its data was built (`generatedAt`, required + * by the feed schema). Until now the cache kept only `fetched_at` — when this + * install downloaded it — so nothing downstream could tell a recent download + * from recent data. The referrals cache (migration 142) already persists it; + * this file is the guard that the catalog cache does too, all the way out to + * `getRadarCatalog()` and `GET /api/radar/status`. + * + * A cache row written before the migration has no data date. It must read back + * as null — never the fetch time standing in for it, which is the exact + * confusion this column exists to end. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { SignJWT } from "jose"; + +// Ephemeral signing key, injected before any Radar module loads so the sync +// path verifies against it (the fork override documented in RADAR.md). +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +process.env.RADAR_FEED_PUBKEY = publicKey + .export({ type: "spki", format: "der" }) + .toString("base64"); + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-generated-at-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-genat-tests-32b"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-genat-tests"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-genat-tests"; +process.env.RADAR_ENABLED = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const radarDb = await import("../../src/lib/db/radar.ts"); +const { getRadarCatalog } = await import("../../src/lib/radar/index.ts"); + +const FIXTURE = JSON.parse( + fs.readFileSync( + path.resolve(import.meta.dirname!, "../fixtures/radar-feed-canonical.json"), + "utf8" + ) +) as { generatedAt: string; version: string }; + +const FETCHED_AT = "2026-08-24T07:00:00.000Z"; + +async function authCookieHeader(): Promise { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(secret); + return `auth_token=${token}`; +} + +function seed(entry: Partial[0]> = {}): void { + radarDb.setRadarCache({ + version: FIXTURE.version, + generatedAt: FIXTURE.generatedAt, + tier: "community", + payload: JSON.stringify(FIXTURE), + signature: "test-signature-not-verified-on-read", + fetchedAt: FETCHED_AT, + ...entry, + }); +} + +test("the catalog cache persists the feed's own build date", () => { + seed(); + + const cache = radarDb.getRadarCache(); + + assert.ok(cache); + assert.equal(cache.generatedAt, FIXTURE.generatedAt); + assert.equal(cache.fetchedAt, FETCHED_AT); + assert.notEqual( + cache.generatedAt, + cache.fetchedAt, + "the data date and the download date are two different facts" + ); +}); + +test("a row cached before this column existed reads back as an unknown date", () => { + seed({ generatedAt: undefined }); + + const cache = radarDb.getRadarCache(); + + assert.ok(cache); + assert.equal(cache.generatedAt, null, "unknown must stay unknown, never the fetch time"); + assert.equal(cache.fetchedAt, FETCHED_AT); +}); + +test("syncRadar writes the build date it just validated", async () => { + const syncMod = await import("../../src/lib/radar/sync.ts"); + const bytes = Buffer.from(JSON.stringify(FIXTURE), "utf8"); + const signature = crypto.sign(null, bytes, privateKey).toString("base64"); + const written: Array<{ generatedAt?: string | null }> = []; + + const result = await syncMod.syncRadar({ + getFlag: () => true, + getSettings: () => ({ optIn: true, supporterKey: null }), + getCache: () => null, + setCache: (entry) => { + written.push(entry); + }, + fetch: (() => + Promise.resolve( + new Response(bytes, { + status: 200, + headers: { + "x-omniroute-feed-signature": signature, + "x-omniroute-feed-tier": "community", + }, + }) + )) as unknown as typeof globalThis.fetch, + }); + + assert.equal(result.status, "updated"); + assert.equal(written.length, 1, "a valid feed must be cached"); + assert.equal(written[0].generatedAt, FIXTURE.generatedAt); +}); + +test("getRadarCatalog reports the build date alongside the fetch date", () => { + seed(); + + const { meta } = getRadarCatalog(); + + assert.ok(meta, "an active feed must expose its metadata"); + assert.equal(meta.generatedAt, FIXTURE.generatedAt); + assert.equal(meta.fetchedAt, FETCHED_AT); +}); + +test("GET /api/radar/status reports the build date as its own field", async () => { + seed(); + const { GET } = await import("../../src/app/api/radar/status/route.ts"); + + const res = await GET( + new Request("http://localhost:20128/api/radar/status", { + headers: { cookie: await authCookieHeader() }, + }) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { + feeds: { catalog: { version?: string; generatedAt?: string | null; fetchedAt: string } }; + }; + + assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt); + assert.equal( + body.feeds.catalog.version, + FIXTURE.version, + "the build date must not be folded into the version field" + ); +}); + +test("status omits the build date for the caches that never store one", async () => { + seed(); + // Both must be present in the response, otherwise the assertion below would + // pass on an `{ available: false }` stub that carries no field either. + radarDb.setRadarOffersCache({ + version: "2026.08.24.1", + tier: "live", + payload: JSON.stringify({ offers: [] }), + signature: "test-signature", + fetchedAt: FETCHED_AT, + }); + radarDb.setRadarIntelCache({ + version: "2026.08.24.1", + tier: "live", + payload: JSON.stringify({ intel: {} }), + signature: "test-signature", + supporterIdentity: "test-identity", + fetchedAt: FETCHED_AT, + }); + const { GET } = await import("../../src/app/api/radar/status/route.ts"); + + const res = await GET( + new Request("http://localhost:20128/api/radar/status", { + headers: { cookie: await authCookieHeader() }, + }) + ); + const body = (await res.json()) as { + feeds: Record>; + }; + + // offers and intel are cached without a build date. Reporting null there + // would say "unknown", when the truth is that it was never kept. + for (const feed of ["offers", "intel"]) { + assert.equal( + body.feeds[feed].available, + true, + `${feed} must be cached for this to mean anything` + ); + assert.equal( + "generatedAt" in body.feeds[feed], + false, + `${feed} must not advertise a build date it never stores` + ); + } + assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt); +}); + +test.after(() => { + core.resetDbInstance(); + delete process.env.RADAR_ENABLED; + delete process.env.INITIAL_PASSWORD; + try { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } catch { + // ignore + } +});