From d468ff4153bd79f45fd550c0dab4da465fd220f2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:06:41 -0300 Subject: [PATCH] feat(radar): sync signed Intel insights --- src/app/api/radar/intel/route.ts | 42 ++++ src/app/api/radar/intel/sync/route.ts | 56 +++++ src/app/api/radar/status/route.ts | 70 ++++++ src/app/api/radar/sync-all/route.ts | 47 ++++ .../db/migrations/145_radar_intel_cache.sql | 11 + src/lib/db/radar.ts | 61 +++++ src/lib/localDb.ts | 3 + src/lib/radar/index.ts | 45 ++++ src/lib/radar/intelFeedSchema.ts | 73 ++++++ src/lib/radar/intelSync.ts | 173 +++++++++++++++ src/lib/radar/scheduler.ts | 37 ++- tests/fixtures/radar-intel-canonical.json | 42 ++++ tests/unit/radar-intel-db.test.ts | 74 ++++++ tests/unit/radar-intel-routes.test.ts | 107 +++++++++ tests/unit/radar-intel-sync.test.ts | 210 ++++++++++++++++++ tests/unit/radar-scheduler.test.ts | 188 ++++++++++------ 16 files changed, 1174 insertions(+), 65 deletions(-) create mode 100644 src/app/api/radar/intel/route.ts create mode 100644 src/app/api/radar/intel/sync/route.ts create mode 100644 src/app/api/radar/status/route.ts create mode 100644 src/app/api/radar/sync-all/route.ts create mode 100644 src/lib/db/migrations/145_radar_intel_cache.sql create mode 100644 src/lib/radar/intelFeedSchema.ts create mode 100644 src/lib/radar/intelSync.ts create mode 100644 tests/fixtures/radar-intel-canonical.json create mode 100644 tests/unit/radar-intel-db.test.ts create mode 100644 tests/unit/radar-intel-routes.test.ts create mode 100644 tests/unit/radar-intel-sync.test.ts diff --git a/src/app/api/radar/intel/route.ts b/src/app/api/radar/intel/route.ts new file mode 100644 index 0000000000..5afcd0cd23 --- /dev/null +++ b/src/app/api/radar/intel/route.ts @@ -0,0 +1,42 @@ +/** GET the verified local Radar Intel cache. Never proxies the private service. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { getRadarIntel } from "@/lib/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + return NextResponse.json(getRadarIntel(), { + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar Intel"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/intel/sync/route.ts b/src/app/api/radar/intel/sync/route.ts new file mode 100644 index 0000000000..ca09377a0a --- /dev/null +++ b/src/app/api/radar/intel/sync/route.ts @@ -0,0 +1,56 @@ +/** POST a server-side Radar Intel sync. The browser never receives the supporter key. */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { syncRadarIntel } from "@/lib/radar/intelSync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const SyncBodySchema = z.object({}).strict().optional(); + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + if (!SyncBodySchema.safeParse(body).success) { + return NextResponse.json(buildErrorBody(400, "Invalid request body"), { + status: 400, + headers: CORS_HEADERS, + }); + } + try { + return NextResponse.json(await syncRadarIntel(), { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar Intel sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/status/route.ts b/src/app/api/radar/status/route.ts new file mode 100644 index 0000000000..b4d3a8caec --- /dev/null +++ b/src/app/api/radar/status/route.ts @@ -0,0 +1,70 @@ +/** Read-only aggregate status of local Radar state. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { + getRadarCache, + getRadarIntelCache, + getRadarOffersCache, + getRadarReferralsCache, + getRadarSettings, +} from "@/lib/db/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +function cacheStatus( + cache: { version?: string; generatedAt?: string; tier: string; fetchedAt: string } | null +) { + if (!cache) return { available: false }; + return { + available: true, + version: cache.version ?? cache.generatedAt, + tier: cache.tier, + fetchedAt: cache.fetchedAt, + }; +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + const settings = getRadarSettings(); + return NextResponse.json( + { + settings: { optIn: settings.optIn, hasSupporterKey: settings.supporterKey !== null }, + feeds: { + catalog: cacheStatus(getRadarCache()), + referrals: cacheStatus(getRadarReferralsCache()), + offers: cacheStatus(getRadarOffersCache()), + intel: cacheStatus(getRadarIntelCache()), + }, + }, + { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } } + ); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar status"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/sync-all/route.ts b/src/app/api/radar/sync-all/route.ts new file mode 100644 index 0000000000..807dabb5d7 --- /dev/null +++ b/src/app/api/radar/sync-all/route.ts @@ -0,0 +1,47 @@ +/** Aggregate local trigger for every Radar feed sync. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { syncRadarIntel } from "@/lib/radar/intelSync"; +import { syncRadarOffers } from "@/lib/radar/offersSync"; +import { syncRadarReferrals } from "@/lib/radar/referralsSync"; +import { syncRadar } from "@/lib/radar/sync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + const catalog = await syncRadar(); + const referrals = await syncRadarReferrals(); + const offers = await syncRadarOffers(); + const intel = await syncRadarIntel(); + return NextResponse.json({ catalog, referrals, offers, intel }, { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar aggregate sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/lib/db/migrations/145_radar_intel_cache.sql b/src/lib/db/migrations/145_radar_intel_cache.sql new file mode 100644 index 0000000000..63b4201a8c --- /dev/null +++ b/src/lib/db/migrations/145_radar_intel_cache.sql @@ -0,0 +1,11 @@ +-- Signed, live-only Radar Intel feed cache. The supporter identity is a +-- one-way SHA-256 marker (`radar:<64 hex>`) and never contains the raw key. +CREATE TABLE IF NOT EXISTS radar_intel_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + tier TEXT NOT NULL CHECK (tier = 'live'), + payload TEXT NOT NULL, + signature TEXT NOT NULL, + supporter_identity TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index fda649fb00..a9aee3b504 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -20,6 +20,10 @@ * Tables (migration 144): * - radar_offers_cache: single-row signed live offers feed cache. * + * Tables (migration 145): + * - radar_intel_cache: single-row signed live Intel feed cache plus a + * one-way supporter identity used for local recognition. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -62,6 +66,15 @@ export interface RadarOffersCache { fetchedAt: string; } +export interface RadarIntelCache { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt: string; +} + export interface RadarLocalModelState { provider: string; modelId: string; @@ -180,6 +193,7 @@ export function setRadarKey(key: string | null): void { const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1"); + const clearIntelCache = db.prepare("DELETE FROM radar_intel_cache WHERE id = 1"); db.transaction(() => { updateKey.run(encrypted); @@ -189,6 +203,7 @@ export function setRadarKey(key: string | null): void { clearCatalogCache.run(); clearReferralsCache.run(); clearOffersCache.run(); + clearIntelCache.run(); })(); } @@ -276,6 +291,52 @@ export function setRadarOffersCache(entry: { .run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); } +// --------------------------------------------------------------------------- +// radar_intel_cache +// --------------------------------------------------------------------------- + +export function getRadarIntelCache(): RadarIntelCache | null { + const row = getDbInstance() + .prepare( + "SELECT version, tier, payload, signature, supporter_identity AS supporterIdentity, " + + "fetched_at AS fetchedAt FROM radar_intel_cache WHERE id = 1" + ) + .get() as RadarIntelCache | undefined; + return row ?? null; +} + +export function setRadarIntelCache(entry: { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +}): void { + const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); + getDbInstance() + .prepare( + `INSERT INTO radar_intel_cache + (id, version, tier, payload, signature, supporter_identity, fetched_at) + VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + supporter_identity = excluded.supporter_identity, + fetched_at = excluded.fetched_at` + ) + .run( + entry.version, + entry.tier, + entry.payload, + entry.signature, + entry.supporterIdentity, + fetchedAt + ); +} + // --------------------------------------------------------------------------- // radar_local_model_state // --------------------------------------------------------------------------- diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index d621129aa0..2c2f672c9c 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -822,6 +822,8 @@ export { setRadarReferralsCache, getRadarOffersCache, setRadarOffersCache, + getRadarIntelCache, + setRadarIntelCache, listRadarLocalModelState, setRadarLocalModelOverride, clearRadarLocalModelOverride, @@ -833,6 +835,7 @@ export type { RadarSettings, RadarReferralsCache, RadarOffersCache, + RadarIntelCache, RadarLocalModelState, RadarLocalModelOverridePatch, RadarLocalMergeState, diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index ec06fa1340..f6b05c5fd7 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -17,6 +17,7 @@ import { RadarOffersFeedSchema, type RadarOffer, } from "./offersFeedSchema"; +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; @@ -24,6 +25,7 @@ import { getRadarCache, getRadarLocalMergeState, getRadarOffersCache, + getRadarIntelCache, getRadarReferralsCache, type RadarLocalMergeState, } from "@/lib/db/radar"; @@ -261,8 +263,51 @@ export function getRadarOffers(deps: GetRadarOffersDeps = {}): RadarOffersResult } } +export interface RadarIntelResult { + intel: RadarIntelFeed | null; + meta: { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; + } | null; +} + +export interface GetRadarIntelDeps { + getFlag?: (key: string) => boolean; + getCache?: typeof getRadarIntelCache; +} + +const EMPTY_INTEL: RadarIntelResult = { intel: null, meta: null }; + +/** Return only a defensively revalidated live Intel cache. */ +export function getRadarIntel(deps: GetRadarIntelDeps = {}): RadarIntelResult { + const { getFlag = isFeatureFlagEnabled, getCache: getCacheFn = getRadarIntelCache } = deps; + if (!getFlag("RADAR_ENABLED")) return EMPTY_INTEL; + const cache = getCacheFn(); + if (!cache || cache.tier !== "live" || !/^radar:[a-f0-9]{64}$/.test(cache.supporterIdentity)) { + return EMPTY_INTEL; + } + try { + const feed = RadarIntelFeedSchema.parse(JSON.parse(cache.payload)); + if (feed.version !== cache.version || feed.tier !== "live") return EMPTY_INTEL; + return { + intel: feed, + meta: { + version: cache.version, + tier: "live", + fetchedAt: cache.fetchedAt, + supporterVerified: true, + }, + }; + } catch { + return EMPTY_INTEL; + } +} + // Re-export merge types for convenience export { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; export { findDefaultReferral } from "./referrals"; export type { RadarReferral } from "./feedSchema"; export type { RadarOffer, RadarOfferBenefit, RadarOfferLocalizedText } from "./offersFeedSchema"; +export type { RadarIntelFeed, RadarIntelRanking, RadarIntelCatalog } from "./intelFeedSchema"; diff --git a/src/lib/radar/intelFeedSchema.ts b/src/lib/radar/intelFeedSchema.ts new file mode 100644 index 0000000000..c189aacc76 --- /dev/null +++ b/src/lib/radar/intelFeedSchema.ts @@ -0,0 +1,73 @@ +/** Closed client mirror of the private Radar Intel feed contract. */ + +import { z } from "zod"; + +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; +const CATEGORY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,239}$/; + +export const RadarIntelRankingSchema = z + .object({ + rank: z.number().int().positive(), + provider: z.string().regex(ID_PATTERN), + modelId: z.string().regex(MODEL_ID_PATTERN), + category: z.string().regex(CATEGORY_PATTERN), + rating: z.number().int(), + matches: z.number().int().nonnegative(), + wins: z.number().int().nonnegative(), + losses: z.number().int().nonnegative(), + draws: z.number().int().nonnegative(), + }) + .strict() + .superRefine((ranking, ctx) => { + if (ranking.matches !== ranking.wins + ranking.losses + ranking.draws) { + ctx.addIssue({ code: "custom", path: ["matches"], message: "match counters disagree" }); + } + }); + +const CatalogDeltaSchema = z + .object({ + current: z.number().int().nonnegative(), + added: z.number().int().nonnegative(), + removed: z.number().int().nonnegative(), + }) + .strict(); + +export const RadarIntelCatalogSchema = z + .object({ + currentVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + previousVersion: z + .string() + .regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/) + .nullable(), + currentGeneratedAt: z.string().datetime(), + ageDays: z.number().int().nonnegative(), + freshness: z.enum(["fresh", "aging", "stale"]), + providers: CatalogDeltaSchema, + models: CatalogDeltaSchema, + trend: z.enum(["growing", "stable", "shrinking"]), + }) + .strict(); + +export const RadarIntelFeedSchema = z + .object({ + feed: z.literal("omniroute-radar-intel"), + schemaVersion: z.literal(1), + version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + generatedAt: z.string().datetime(), + tier: z.literal("live"), + methodology: z + .object({ + kind: z.literal("elo"), + initialRating: z.literal(1000), + kFactor: z.literal(32), + }) + .strict(), + rankings: z.array(RadarIntelRankingSchema), + catalog: RadarIntelCatalogSchema, + }) + .strict(); + +export type RadarIntelFeed = z.infer; +export type RadarIntelRanking = z.infer; +export type RadarIntelCatalog = z.infer; diff --git a/src/lib/radar/intelSync.ts b/src/lib/radar/intelSync.ts new file mode 100644 index 0000000000..b039e772af --- /dev/null +++ b/src/lib/radar/intelSync.ts @@ -0,0 +1,173 @@ +/** Server-side sync for the signed, supporter-only Radar Intel feed. */ + +import crypto from "node:crypto"; + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; +import { compareVersions, type RadarSettingsSnapshot } from "./sync"; +import { verifyFeedBytes } from "./verify"; + +const DEFAULT_FEED_BASE_URL = "https://radar.omniroute.online"; +const SYNC_TIMEOUT_MS = 30_000; +const MAX_FEED_BYTES = 10 * 1024 * 1024; + +export type IntelSyncStatus = + | { status: "disabled" } + | { status: "opt_out" } + | { status: "no_key" } + | { status: "invalid_signature" } + | { status: "invalid_schema" } + | { status: "wrong_tier" } + | { status: "stale" } + | { status: "too_large" } + | { status: "updated"; version: string } + | { status: "error"; reason: string }; + +export interface RadarIntelCacheEntry { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +} + +export interface IntelSyncDeps { + fetch?: typeof globalThis.fetch; + now?: () => Date; + getFlag?: (key: string) => boolean; + getSettings?: () => RadarSettingsSnapshot; + getCache?: () => RadarIntelCacheEntry | null; + setCache?: (entry: RadarIntelCacheEntry) => void; + recognizeSupporter?: (identity: string) => Promise; +} + +async function readBoundedBytes(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const declared = Number(contentLength); + if (Number.isFinite(declared) && declared > MAX_FEED_BYTES) return null; + } + + const body = response.body as ReadableStream | null | undefined; + if (!body || typeof body.getReader !== "function") { + const buffered = Buffer.from(await response.arrayBuffer()); + return buffered.byteLength > MAX_FEED_BYTES ? null : buffered; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_FEED_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + +function supporterIdentity(key: string): string { + return `radar:${crypto.createHash("sha256").update(key, "utf8").digest("hex")}`; +} + +async function recognizeVerifiedSupporter(identity: string): Promise { + const { emitGamificationEvent } = await import("@/lib/gamification/events"); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); +} + +export async function syncRadarIntel(deps: IntelSyncDeps = {}): Promise { + const { + fetch: fetchFn = globalThis.fetch, + now = () => new Date(), + getFlag = isFeatureFlagEnabled, + getSettings: getSettingsFn, + getCache: getCacheFn, + setCache: setCacheFn, + recognizeSupporter = recognizeVerifiedSupporter, + } = deps; + + try { + if (!getFlag("RADAR_ENABLED")) return { status: "disabled" }; + const settings = getSettingsFn + ? getSettingsFn() + : (await import("@/lib/db/radar")).getRadarSettings(); + if (!settings.optIn) return { status: "opt_out" }; + if (!settings.supporterKey) return { status: "no_key" }; + + const baseUrl = (process.env.RADAR_FEED_URL || DEFAULT_FEED_BASE_URL).replace(/\/+$/, ""); + const response = await fetchFn(`${baseUrl}/v1/intel/latest`, { + method: "GET", + headers: { Authorization: `Bearer ${settings.supporterKey}` }, + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!response.ok) { + return { + status: "error", + reason: `Intel feed request failed with status ${response.status}`, + }; + } + + const rawBytes = await readBoundedBytes(response); + if (!rawBytes) return { status: "too_large" }; + + const signature = response.headers.get("x-omniroute-feed-signature") ?? ""; + if (!verifyFeedBytes(rawBytes, signature)) return { status: "invalid_signature" }; + + let feed: RadarIntelFeed; + try { + feed = RadarIntelFeedSchema.parse(JSON.parse(rawBytes.toString("utf8"))); + } catch { + return { status: "invalid_schema" }; + } + if (response.headers.get("x-omniroute-feed-tier") !== "live" || feed.tier !== "live") { + return { status: "wrong_tier" }; + } + + const existing = getCacheFn + ? getCacheFn() + : (await import("@/lib/db/radar")).getRadarIntelCache(); + if (existing && compareVersions(feed.version, existing.version) <= 0) { + return { status: "stale" }; + } + + const identity = supporterIdentity(settings.supporterKey); + const cacheEntry: RadarIntelCacheEntry = { + version: feed.version, + tier: "live", + payload: rawBytes.toString("utf8"), + signature, + supporterIdentity: identity, + fetchedAt: now().toISOString(), + }; + if (setCacheFn) setCacheFn(cacheEntry); + else (await import("@/lib/db/radar")).setRadarIntelCache(cacheEntry); + + // Recognition is local and best-effort. It runs only after the signed live + // bytes have been accepted and persisted, and never changes sync success. + await recognizeSupporter(identity).catch(() => undefined); + return { status: "updated", version: feed.version }; + } catch (error: unknown) { + const reason = (sanitizeErrorMessage(error) || "Radar Intel sync failed").replace( + /omr_[a-f0-9]{40}/gi, + "[REDACTED]" + ); + return { status: "error", reason }; + } +} + +export const RADAR_INTEL_STALE_MS = 24 * 60 * 60 * 1000; + +export function shouldSyncRadarIntel(fetchedAt: string | null, nowMs = Date.now()): boolean { + if (!fetchedAt) return true; + const fetchedMs = Date.parse(fetchedAt); + return !Number.isFinite(fetchedMs) || nowMs - fetchedMs >= RADAR_INTEL_STALE_MS; +} diff --git a/src/lib/radar/scheduler.ts b/src/lib/radar/scheduler.ts index 33de6cf52e..15e33cf782 100644 --- a/src/lib/radar/scheduler.ts +++ b/src/lib/radar/scheduler.ts @@ -24,7 +24,15 @@ */ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { getRadarCache, getRadarSettings, getRadarReferralsCache } from "@/lib/db/radar"; +import { + getRadarCache, + getRadarIntelCache, + getRadarOffersCache, + getRadarSettings, + getRadarReferralsCache, +} from "@/lib/db/radar"; +import { shouldSyncRadarIntel, syncRadarIntel, type IntelSyncStatus } from "./intelSync"; +import { syncRadarOffers, type OffersSyncStatus } from "./offersSync"; import { nextSyncTime, syncRadar, type SyncStatus } from "./sync"; import { syncRadarReferrals, @@ -49,6 +57,10 @@ export interface RadarSchedulerDeps { getReferralsCache?: () => { fetchedAt: string } | null; /** Referrals sync — separate from `sync` (the catalog sync). */ syncReferrals?: () => Promise; + getOffersCache?: () => { fetchedAt: string } | null; + syncOffers?: () => Promise; + getIntelCache?: () => { fetchedAt: string } | null; + syncIntel?: () => Promise; now?: () => number; setIntervalFn?: typeof setInterval; clearIntervalFn?: typeof clearInterval; @@ -73,6 +85,28 @@ async function maybeSyncReferrals(deps: RadarSchedulerDeps, nowMs: number): Prom } } +async function maybeSyncSupporterFeeds(deps: RadarSchedulerDeps, nowMs: number): Promise { + try { + const offersCache = (deps.getOffersCache ?? getRadarOffersCache)(); + if (nowMs >= nextSyncTime(offersCache?.fetchedAt ?? null).getTime()) { + await (deps.syncOffers ?? syncRadarOffers)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Offers side-sync failed (non-fatal):", msg); + } + + try { + const intelCache = (deps.getIntelCache ?? getRadarIntelCache)(); + if (shouldSyncRadarIntel(intelCache?.fetchedAt ?? null, nowMs)) { + await (deps.syncIntel ?? syncRadarIntel)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Intel side-sync failed (non-fatal):", msg); + } +} + /** * One scheduler evaluation. Exported for tests and for the immediate * post-start tick. @@ -92,6 +126,7 @@ export async function radarSchedulerTick(deps: RadarSchedulerDeps = {}): Promise // Referrals sync on their own staleness window — independent of the // catalog's due-ness below, same tick. await maybeSyncReferrals(deps, nowMs); + await maybeSyncSupporterFeeds(deps, nowMs); const cache = (deps.getCache ?? getRadarCache)(); if (nowMs < nextSyncTime(cache?.fetchedAt ?? null).getTime()) { diff --git a/tests/fixtures/radar-intel-canonical.json b/tests/fixtures/radar-intel-canonical.json new file mode 100644 index 0000000000..0112d568cb --- /dev/null +++ b/tests/fixtures/radar-intel-canonical.json @@ -0,0 +1,42 @@ +{ + "feed": "omniroute-radar-intel", + "schemaVersion": 1, + "version": "2026.08.09.1", + "generatedAt": "2026-08-09T12:00:00.000Z", + "tier": "live", + "methodology": { "kind": "elo", "initialRating": 1000, "kFactor": 32 }, + "rankings": [ + { + "rank": 1, + "provider": "example-a", + "modelId": "example-model-a", + "category": "general", + "rating": 1016, + "matches": 1, + "wins": 1, + "losses": 0, + "draws": 0 + }, + { + "rank": 2, + "provider": "example-b", + "modelId": "example-model-b", + "category": "general", + "rating": 984, + "matches": 1, + "wins": 0, + "losses": 1, + "draws": 0 + } + ], + "catalog": { + "currentVersion": "2026.08.09.1", + "previousVersion": "2026.08.08.1", + "currentGeneratedAt": "2026-08-09T11:00:00.000Z", + "ageDays": 0, + "freshness": "fresh", + "providers": { "current": 2, "added": 1, "removed": 0 }, + "models": { "current": 2, "added": 1, "removed": 0 }, + "trend": "growing" + } +} diff --git a/tests/unit/radar-intel-db.test.ts b/tests/unit/radar-intel-db.test.ts new file mode 100644 index 0000000000..d5f80496af --- /dev/null +++ b/tests/unit/radar-intel-db.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-intel-db-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-db-32b!"; + +const core = await import("../../src/lib/db/core.ts"); +const radar = await import("../../src/lib/db/radar.ts"); + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Intel migration provides a byte-preserving single-row cache", () => { + assert.equal(radar.getRadarIntelCache(), null); + radar.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + assert.deepEqual(radar.getRadarIntelCache(), { + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); +}); + +test("changing supporter key invalidates catalog, referrals, offers, and Intel atomically", () => { + radar.setRadarCache({ version: "2026.08.09.1", tier: "live", payload: "{}", signature: "a" }); + radar.setRadarReferralsCache({ + generatedAt: "2026-08-09T12:00:00.000Z", + tier: "live", + payload: "{}", + signature: "b", + }); + radar.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "c", + }); + radar.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "d", + supporterIdentity: `radar:${"a".repeat(64)}`, + }); + + radar.setRadarKey(`omr_${"b".repeat(40)}`); + + assert.equal(radar.getRadarCache(), null); + assert.equal(radar.getRadarReferralsCache(), null); + assert.equal(radar.getRadarOffersCache(), null); + assert.equal(radar.getRadarIntelCache(), null); +}); diff --git a/tests/unit/radar-intel-routes.test.ts b/tests/unit/radar-intel-routes.test.ts new file mode 100644 index 0000000000..9de90bac74 --- /dev/null +++ b/tests/unit/radar-intel-routes.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-intel-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-routes-32b!"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-intel-routes"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-intel-routes"; + +const core = await import("../../src/lib/db/core.ts"); +const radarDb = await import("../../src/lib/db/radar.ts"); + +async function authHeaders(): Promise> { + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + return { Cookie: `auth_token=${token}` }; +} + +function request(pathname: string, method: "GET" | "POST", headers: Record = {}) { + return new Request(`http://localhost:20128${pathname}`, { method, headers }); +} + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.RADAR_ENABLED; +}); + +test("Intel, status, and aggregate sync routes are 404 before auth when flag is off", async () => { + resetStorage(); + delete process.env.RADAR_ENABLED; + const intel = await import("../../src/app/api/radar/intel/route.ts"); + const intelSync = await import("../../src/app/api/radar/intel/sync/route.ts"); + const status = await import("../../src/app/api/radar/status/route.ts"); + const syncAll = await import("../../src/app/api/radar/sync-all/route.ts"); + + assert.equal((await intel.GET(request("/api/radar/intel", "GET"))).status, 404); + assert.equal((await intelSync.POST(request("/api/radar/intel/sync", "POST"))).status, 404); + assert.equal((await status.GET(request("/api/radar/status", "GET"))).status, 404); + assert.equal((await syncAll.POST(request("/api/radar/sync-all", "POST"))).status, 404); +}); + +test("verified local Intel is returned without supporter identity or key material", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const payload = fs.readFileSync( + path.resolve(process.cwd(), "tests/fixtures/radar-intel-canonical.json"), + "utf8" + ); + radarDb.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload, + signature: "fixture-signature", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + + const { GET } = await import("../../src/app/api/radar/intel/route.ts"); + const response = await GET(request("/api/radar/intel", "GET", await authHeaders())); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.intel.rankings.length, 2); + assert.equal(body.meta.supporterVerified, true); + assert.ok(!JSON.stringify(body).includes("radar:")); + assert.ok(!JSON.stringify(body).includes("omr_")); +}); + +test("Radar status is read-only and aggregate sync reports each feed separately", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const statusRoute = await import("../../src/app/api/radar/status/route.ts"); + const status = await statusRoute.GET(request("/api/radar/status", "GET", headers)); + const statusBody = await status.json(); + assert.deepEqual(statusBody.settings, { optIn: false, hasSupporterKey: false }); + assert.deepEqual(Object.keys(statusBody.feeds).sort(), [ + "catalog", + "intel", + "offers", + "referrals", + ]); + + const syncAllRoute = await import("../../src/app/api/radar/sync-all/route.ts"); + const synced = await syncAllRoute.POST(request("/api/radar/sync-all", "POST", headers)); + const syncBody = await synced.json(); + assert.deepEqual(syncBody, { + catalog: { status: "opt_out" }, + referrals: { status: "opt_out" }, + offers: { status: "opt_out" }, + intel: { status: "opt_out" }, + }); +}); diff --git a/tests/unit/radar-intel-sync.test.ts b/tests/unit/radar-intel-sync.test.ts new file mode 100644 index 0000000000..f9fa31e6ff --- /dev/null +++ b/tests/unit/radar-intel-sync.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +process.env.RADAR_FEED_PUBKEY = publicKey + .export({ type: "spki", format: "der" }) + .toString("base64"); + +const intelSync = await import("../../src/lib/radar/intelSync.ts"); +const { RadarIntelFeedSchema } = await import("../../src/lib/radar/intelFeedSchema.ts"); + +async function fixtureBytes(): Promise { + return readFile(new URL("../fixtures/radar-intel-canonical.json", import.meta.url)); +} + +function sign(bytes: Buffer): string { + return crypto.sign(null, bytes, privateKey).toString("base64"); +} + +function response(body: Buffer, headers: Record = {}, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + } as Response; +} + +const supporterKey = `omr_${"a".repeat(40)}`; +const liveSettings = { optIn: true, supporterKey }; + +test("canonical Intel fixture is byte-identical to the private contract", async () => { + const bytes = await fixtureBytes(); + assert.equal(bytes.byteLength, 1024); + assert.equal( + crypto.createHash("sha256").update(bytes).digest("hex"), + "c36aaa6ad53942afa0325d6b0fad0aa048ef66f24c805b743b9815446b0e6176" + ); + assert.equal(RadarIntelFeedSchema.parse(JSON.parse(bytes.toString("utf8"))).tier, "live"); +}); + +test("Intel schema rejects telemetry and inconsistent ranking counters", async () => { + const feed = JSON.parse((await fixtureBytes()).toString("utf8")); + assert.equal(RadarIntelFeedSchema.safeParse({ ...feed, uptime: 99.9 }).success, false); + feed.rankings[0].matches = 2; + assert.equal(RadarIntelFeedSchema.safeParse(feed).success, false); +}); + +test("Intel sync gates before fetch and only accepts exact signed live bytes", async () => { + for (const expected of ["disabled", "opt_out", "no_key"] as const) { + let fetched = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => expected !== "disabled", + getSettings: () => + expected === "opt_out" + ? { optIn: false, supporterKey: null } + : { optIn: true, supporterKey: null }, + fetch: (async () => { + fetched = true; + return response(Buffer.from("{}")); + }) as typeof fetch, + }); + assert.equal(result.status, expected); + assert.equal(fetched, false); + } + + const bytes = await fixtureBytes(); + const writes: intelSync.RadarIntelCacheEntry[] = []; + const supporterIdentities: string[] = []; + let authorization = ""; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: (entry) => writes.push(entry), + recognizeSupporter: async (identity) => supporterIdentities.push(identity), + fetch: (async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return response(bytes, { + "x-omniroute-feed-signature": sign(bytes), + "x-omniroute-feed-tier": "live", + }); + }) as typeof fetch, + now: () => new Date("2026-08-09T12:05:00.000Z"), + }); + + assert.deepEqual(result, { status: "updated", version: "2026.08.09.1" }); + assert.equal(authorization, `Bearer ${supporterKey}`); + assert.equal(writes[0]?.payload, bytes.toString("utf8")); + assert.equal(writes[0]?.tier, "live"); + assert.match(writes[0]?.supporterIdentity ?? "", /^radar:[a-f0-9]{64}$/); + assert.deepEqual(supporterIdentities, [writes[0]?.supporterIdentity]); + assert.ok(!writes[0]?.supporterIdentity.includes(supporterKey)); +}); + +test("Intel sync preserves the good cache on signature, tier, schema, replay, and size failures", async () => { + const bytes = await fixtureBytes(); + const validSignature = sign(bytes); + const cases = [ + { expected: "invalid_signature", body: bytes, signature: "bad", tier: "live" }, + { expected: "wrong_tier", body: bytes, signature: validSignature, tier: "community" }, + { + expected: "invalid_schema", + body: Buffer.from('{"feed":"wrong"}'), + signature: "", + tier: "live", + }, + ]; + + for (const item of cases) { + const signature = item.expected === "invalid_schema" ? sign(item.body) : item.signature; + let written = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.08.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(item.body, { + "x-omniroute-feed-signature": signature, + "x-omniroute-feed-tier": item.tier, + })) as typeof fetch, + }); + assert.equal(result.status, item.expected); + assert.equal(written, false); + } + + let written = false; + const stale = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(bytes, { + "x-omniroute-feed-signature": validSignature, + "x-omniroute-feed-tier": "live", + })) as typeof fetch, + }); + assert.equal(stale.status, "stale"); + + const oversized = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + response(Buffer.from("ignored"), { + "content-length": String(10 * 1024 * 1024 + 1), + })) as typeof fetch, + }); + assert.equal(oversized.status, "too_large"); + assert.equal(written, false); +}); + +test("Intel sync enforces the byte cap while reading streamed chunks", async () => { + let cancelled = false; + let written = false; + const firstChunk = new Uint8Array(6 * 1024 * 1024); + const secondChunk = new Uint8Array(5 * 1024 * 1024); + const chunks = [firstChunk, secondChunk]; + let chunkIndex = 0; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[chunkIndex]); + chunkIndex += 1; + }, + cancel() { + cancelled = true; + }, + }); + + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + new Response(body, { + status: 200, + headers: { "x-omniroute-feed-tier": "live" }, + })) as typeof fetch, + }); + + assert.equal(result.status, "too_large"); + assert.equal(cancelled, true); + assert.equal(written, false); +}); diff --git a/tests/unit/radar-scheduler.test.ts b/tests/unit/radar-scheduler.test.ts index 5f7275ab5c..e51e09784f 100644 --- a/tests/unit/radar-scheduler.test.ts +++ b/tests/unit/radar-scheduler.test.ts @@ -46,10 +46,14 @@ function fakeTimers() { function deps(overrides: Record = {}) { const syncCalls: number[] = []; const referralsSyncCalls: number[] = []; + const offersSyncCalls: number[] = []; + const intelSyncCalls: number[] = []; const timers = fakeTimers(); return { syncCalls, referralsSyncCalls, + offersSyncCalls, + intelSyncCalls, timers, d: { getFlag: () => true, @@ -66,7 +70,21 @@ function deps(overrides: Record = {}) { getReferralsCache: () => ({ fetchedAt: REFERRALS_FRESH }), syncReferrals: async () => { referralsSyncCalls.push(1); - return { status: "updated", generatedAt: "2026-08-06T12:00:00.000Z", tier: "live" } as const; + return { + status: "updated", + generatedAt: "2026-08-06T12:00:00.000Z", + tier: "live", + } as const; + }, + getOffersCache: () => ({ fetchedAt: FRESH }), + syncOffers: async () => { + offersSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; + }, + getIntelCache: () => ({ fetchedAt: FRESH }), + syncIntel: async () => { + intelSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; }, now: () => NOW, setIntervalFn: timers.setIntervalFn, @@ -126,18 +144,21 @@ test("radar sync scheduler", async (t) => { assert.equal(syncCalls.length, 1); }); - await t.test("ensure: registers one hourly timer, fires an immediate tick, idempotent", async () => { - const { d, timers, syncCalls } = deps(); - assert.equal(ensureRadarSyncScheduler(d), true); - assert.equal(timers.registered.length, 1); - assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); - // The immediate tick is fire-and-forget; give the microtask queue a turn. - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); - // Second ensure is a no-op — no second timer. - assert.equal(ensureRadarSyncScheduler(d), false); - assert.equal(timers.registered.length, 1); - }); + await t.test( + "ensure: registers one hourly timer, fires an immediate tick, idempotent", + async () => { + const { d, timers, syncCalls } = deps(); + assert.equal(ensureRadarSyncScheduler(d), true); + assert.equal(timers.registered.length, 1); + assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); + // The immediate tick is fire-and-forget; give the microtask queue a turn. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); + // Second ensure is a no-op — no second timer. + assert.equal(ensureRadarSyncScheduler(d), false); + assert.equal(timers.registered.length, 1); + } + ); await t.test("init: flag off => never arms (flag-off boot stays timer-free)", () => { const { d, timers } = deps({ getFlag: () => false }); @@ -174,61 +195,100 @@ test("radar sync scheduler", async (t) => { // only) so the catalog-sync result shape/assertions above stay unchanged. // ------------------------------------------------------------------------- - await t.test("tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", async () => { - const { d, syncCalls, referralsSyncCalls } = deps(); - const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "catalog was due and must still sync as before"); - assert.equal(syncCalls.length, 1); - assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); - }); + await t.test( + "tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps(); + const result = await radarSchedulerTick(d); + assert.equal(result.action, "synced", "catalog was due and must still sync as before"); + assert.equal(syncCalls.length, 1); + assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); + } + ); - await t.test("tick: referrals cache stale => referrals sync called, independent of catalog due-ness", async () => { - const { d, syncCalls, referralsSyncCalls } = deps({ - getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due - }); - const result = await radarSchedulerTick(d); - assert.deepEqual(result, { action: "skipped", reason: "not_due" }, "catalog result shape must stay unchanged"); - assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); - assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); - }); + await t.test( + "tick: referrals cache stale => referrals sync called, independent of catalog due-ness", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due + }); + const result = await radarSchedulerTick(d); + assert.deepEqual( + result, + { action: "skipped", reason: "not_due" }, + "catalog result shape must stay unchanged" + ); + assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); + assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); + } + ); - await t.test("tick: referrals cache missing => referrals sync called (missing counts as stale)", async () => { - const { d, referralsSyncCalls } = deps({ + await t.test( + "tick: referrals cache missing => referrals sync called (missing counts as stale)", + async () => { + const { d, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), + getReferralsCache: () => null, + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 1); + } + ); + + await t.test( + "tick: flag off => referrals sync NOT called (stopped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getFlag: () => false, + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: opt-in off => referrals sync NOT called (skipped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getSettings: () => ({ optIn: false }), + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: referrals sync throwing => swallowed, catalog tick still completes normally", + async () => { + const { d, syncCalls } = deps({ + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), + syncReferrals: async () => { + throw new Error("referrals upstream exploded"); + }, + }); + const result = await radarSchedulerTick(d); + assert.equal( + result.action, + "synced", + "a throwing referrals sync must never break the catalog tick" + ); + assert.equal(syncCalls.length, 1); + } + ); + + await t.test("tick: offers and Intel use independent staleness gates", async () => { + const { d, syncCalls, offersSyncCalls, intelSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), - getReferralsCache: () => null, - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 1); - }); - - await t.test("tick: flag off => referrals sync NOT called (stopped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getFlag: () => false, - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: opt-in off => referrals sync NOT called (skipped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getSettings: () => ({ optIn: false }), - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: referrals sync throwing => swallowed, catalog tick still completes normally", async () => { - const { d, syncCalls } = deps({ - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), - syncReferrals: async () => { - throw new Error("referrals upstream exploded"); - }, + getOffersCache: () => ({ fetchedAt: STALE }), + getIntelCache: () => null, }); const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "a throwing referrals sync must never break the catalog tick"); - assert.equal(syncCalls.length, 1); + assert.deepEqual(result, { action: "skipped", reason: "not_due" }); + assert.equal(syncCalls.length, 0); + assert.equal(offersSyncCalls.length, 1); + assert.equal(intelSyncCalls.length, 1); }); });