diff --git a/changelog.d/fixes/9776-radar-entitlement-refresh.md b/changelog.d/fixes/9776-radar-entitlement-refresh.md new file mode 100644 index 0000000000..1b4e775629 --- /dev/null +++ b/changelog.d/fixes/9776-radar-entitlement-refresh.md @@ -0,0 +1 @@ +- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 8992027c93..3a239157d8 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -1,13 +1,13 @@ --- title: "Radar Free-Model Catalog" version: 3.8.50 -lastUpdated: 2026-08-07 +lastUpdated: 2026-08-08 --- # Radar Free-Model Catalog > **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/` -> **Last updated:** 2026-08-07 — v3.8.50 +> **Last updated:** 2026-08-08 — v3.8.50 Radar is an **optional add-on** that overlays a signed, freshly-curated free-model catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in @@ -23,6 +23,23 @@ below. --- +## Delivery status in v3.8.50 + +The following status distinguishes what this OSS release implements from later Radar +workstreams. It is a code-level status, not a promise that a particular hosted deployment +or external integration is currently available. + +| Area | Status in this release | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. | +| Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. | +| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. | +| Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. | +| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. | +| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. | + +--- + ## Flag: `RADAR_ENABLED` (default off) Radar is gated end-to-end by the `RADAR_ENABLED` feature flag @@ -67,7 +84,8 @@ When both are on, the sync path is: plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider configuration, or model traffic to the feed service. 3. The response is verified, validated, and cached locally (see - [Security model](#security-model)). Nothing else touches the network for Radar. + [Security model](#security-model)). Radar has exactly two server-side network paths: + `syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed. The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) that lets the feed service decide which tier to serve (see @@ -77,6 +95,9 @@ that lets the feed service decide which tier to serve (see helpers (`src/lib/db/encryption.ts`) used for provider credentials. - Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and **never echoed back** — the response returns a masked form (`omr_****abcd`). +- Changing or clearing it atomically invalidates both the catalog and referrals caches. The + next sync/read resolves the new entitlement server-side; saving a key does not itself make + a network request or consume a single-use activation key. - Sent to the feed service as a Bearer token on the sync GET — nothing else about the key ever leaves the client. @@ -102,10 +123,10 @@ pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing `GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the client component never reads `process.env` itself. -| Var | Purpose | -| -------------------------------- | ---------------------------------------------------------------------------------------------- | -| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). | -| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). | +| Var | Purpose | +| ----------------------------- | ------------------------------------------------------------------------------------------- | +| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). | +| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). | Once a visitor has a key (`omr_` + 40 hex chars), the activation screen (`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary @@ -117,7 +138,7 @@ as a UX nicety; the server's Zod schema is the authoritative check either way. O key is set, the activation screen shows the masked form (`supporterKeyMasked` from `GET /api/radar/settings`) instead of an empty input, with a "change key" control to paste a new one — the raw key is never redisplayed. The two claim/plans buttons above -remain the way to *obtain* a key in the first place; this input is where an operator +remain the way to _obtain_ a key in the first place; this input is where an operator who already has one activates it. --- @@ -207,11 +228,11 @@ handle. ### The served tier comes from a response header, not the signed body The signed feed **body**'s `tier` field is always `"live"` — the feed service ships -**one signed artifact per version**, so the body cannot carry a per-request tier -without invalidating the Ed25519 signature (re-signing per request would defeat the -point of a pinned, cacheable, verifiable artifact). The tier actually served for a -given request is instead carried in the **`x-omniroute-feed-tier` response header**, -decided server-side from the request's `Authorization` key. +**two signed artifacts per version**: live includes current campaigns and community +omits them. Each artifact is signed over its own exact bytes. The body still does not +serve as the entitlement decision; the tier actually selected for a request is carried +in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's +`Authorization` key. `syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place that resolves the tier a client should trust: @@ -223,7 +244,7 @@ that resolves the tier a client should trust: 2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1 yields nothing. 3. The resolved tier is what gets cached and returned as `{ status: "updated", - version, tier }` — this is the value the dashboard shows, never the raw body +version, tier }` — this is the value the dashboard shows, never the raw body field. --- @@ -265,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge: Five local routes back the UI, all under `src/app/api/radar/`: -| Route | Method | Purpose | -| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | -| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | -| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | -| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | -| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| Route | Method | Purpose | +| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | +| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | +| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | +| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | +| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | **Hard rule: these routes never proxy the feed service.** The browser only ever talks -to the local OmniRoute server; `syncRadar()` is the single module in the whole client -that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs -server-side, never client-side. This keeps the feed URL and any supporter key +to the local OmniRoute server. The two modules that touch the Radar service are +`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both +always run server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. All five routes return `404` when `RADAR_ENABLED` is off (see @@ -348,11 +369,13 @@ touches the network for referrals, mirroring `syncRadar()`'s contract exactly: f Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against `RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table (migration `142_radar_referrals_cache.sql`) — a table entirely separate from the -catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an -incoming feed with a `generatedAt` no newer than the cached one is treated as `stale` -and never overwrites the cache — guards against a replay of an older signed artifact) -mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws — -always returns a status object; errors never carry a stack trace in `reason`. +catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an +incoming feed older than the cached one, guarding against replay of an older signed +artifact. An equal timestamp is accepted: the server intentionally gives the community +and live referral variants the same deterministic `generatedAt`, so the signed payload +and served tier can change after a supporter-key change without the underlying link set +changing. Never throws — always returns a status object; errors never carry a stack trace +in `reason`. Two triggers keep the referrals cache warm, both independent of the catalog's own 24h cadence: diff --git a/src/app/(dashboard)/dashboard/radar/setup/page.tsx b/src/app/(dashboard)/dashboard/radar/setup/page.tsx index 1bbbe6e65b..745120d253 100644 --- a/src/app/(dashboard)/dashboard/radar/setup/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/setup/page.tsx @@ -1,10 +1,16 @@ "use client"; -import { useState, useEffect, useCallback, useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useState, useEffect, useCallback } from "react"; +import { useLocale, useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; import { Card } from "@/shared/components"; +import { + firstProviderConnectionId, + providerConnectionsRequestUrl, + type RadarSetupConnection, +} from "@/lib/radar/setupConnections"; +import type { RadarLocalizedText } from "@/lib/radar/feedSchema"; // --------------------------------------------------------------------------- // Types @@ -14,17 +20,16 @@ import { Card } from "@/shared/components"; * Localized text: either a plain string or an {en, pt?} object. * The renderer resolves the best locale with EN fallback (D25 compat). */ -type LocalizedText = string | { en: string; pt?: string }; - interface SetupInfo { keyUrl: string | null; - steps: LocalizedText[]; + steps: RadarLocalizedText[]; } interface ProviderSetupData { provider: string; setup: SetupInfo | null; configured: boolean; + connectionId: string | null; } // --------------------------------------------------------------------------- @@ -32,9 +37,9 @@ interface ProviderSetupData { // --------------------------------------------------------------------------- /** Resolve a LocalizedText to a display string. */ -function resolveText(text: LocalizedText, locale: string): string { +function resolveText(text: RadarLocalizedText, locale: string): string { if (typeof text === "string") return text; - if (locale === "pt" && text.pt) return text.pt; + if (locale.toLowerCase().startsWith("pt") && text.pt) return text.pt; return text.en; } @@ -44,9 +49,9 @@ function resolveText(text: LocalizedText, locale: string): string { export default function RadarSetupPage() { const t = useTranslations("radarSetupPage"); + const locale = useLocale(); const searchParams = useSearchParams(); const provider = searchParams.get("provider"); - const locale = "en"; // Could be derived from next-intl locale later const [setupData, setSetupData] = useState(null); const [loading, setLoading] = useState(true); @@ -63,18 +68,25 @@ export default function RadarSetupPage() { async function load() { try { - const res = await fetch("/api/radar/catalog"); + const [res, connectionsRes] = await Promise.all([ + fetch("/api/radar/catalog"), + fetch(providerConnectionsRequestUrl(provider)), + ]); if (res.status === 404) { setError(t("flagDisabled")); setLoading(false); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!connectionsRes.ok) throw new Error(`HTTP ${connectionsRes.status}`); const data = await res.json(); + const connectionsData = (await connectionsRes.json()) as { + connections?: RadarSetupConnection[]; + }; // Find ALL entries for this provider and extract setup from the first one that has it const providerEntries = data.entries.filter( - (e: { provider: string }) => e.provider === provider, + (e: { provider: string }) => e.provider === provider ); if (providerEntries.length === 0) { @@ -85,17 +97,19 @@ export default function RadarSetupPage() { // Find setup info from feed entries (they carry the setup field) const entryWithSetup = providerEntries.find( - (e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl), + (e: { setup?: SetupInfo | null }) => + e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl) ); - // Check if provider is configured (has connections) - // We infer this from whether the provider exists in the catalog at all - // The actual connection check would need a separate API — for now we show - // the guide regardless + const connectionId = firstProviderConnectionId( + Array.isArray(connectionsData.connections) ? connectionsData.connections : [], + provider + ); setSetupData({ provider, setup: entryWithSetup?.setup ?? null, - configured: false, // Will be enriched when connection-status API is available + configured: connectionId !== null, + connectionId, }); } catch (err) { setError(err instanceof Error ? err.message : t("loadFailed")); @@ -109,15 +123,11 @@ export default function RadarSetupPage() { // Test connection — uses the EXISTING connection-test endpoint const handleTestConnection = useCallback(async () => { - if (!provider) return; + if (!setupData?.connectionId) return; setTesting(true); setTestResult(null); try { - // The existing test endpoint is POST /api/providers/[id]/test - // We need the connection ID — for now we use the provider ID as a proxy. - // In a full implementation, the setup page would list connections for - // this provider and test each one. Here we test the first connection. - const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, { + const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), @@ -136,7 +146,7 @@ export default function RadarSetupPage() { } finally { setTesting(false); } - }, [provider, t]); + }, [setupData?.connectionId, t]); if (!provider) { return ( @@ -165,9 +175,7 @@ export default function RadarSetupPage() {

{t("setupSubtitle")}

- {error && ( -
{error}
- )} + {error &&
{error}
} {loading ? (
@@ -248,15 +256,13 @@ export default function RadarSetupPage() {
{testResult && ( - + {testResult.message} )} diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index 0677df361e..6b76630362 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -103,9 +103,7 @@ export function setRadarCache(entry: { export function getRadarSettings(): RadarSettings { const db = getDbInstance(); const row = db - .prepare( - "SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1" - ) + .prepare("SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1") .get() as { opt_in: number; supporter_key_encrypted: string | null; updated_at: string }; return { @@ -120,9 +118,9 @@ export function getRadarSettings(): RadarSettings { */ export function setRadarOptIn(optIn: boolean): void { const db = getDbInstance(); - db.prepare( - "UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1" - ).run(optIn ? 1 : 0); + db.prepare("UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1").run( + optIn ? 1 : 0 + ); } /** @@ -133,9 +131,20 @@ export function setRadarOptIn(optIn: boolean): void { export function setRadarKey(key: string | null): void { const db = getDbInstance(); const encrypted = key !== null ? encrypt(key) : null; - db.prepare( + const updateKey = db.prepare( "UPDATE radar_settings SET supporter_key_encrypted = ?, updated_at = datetime('now') WHERE id = 1" - ).run(encrypted); + ); + const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); + const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); + + db.transaction(() => { + updateKey.run(encrypted); + // Both signed feeds are entitlement-sensitive. Clearing their cached + // variants forces the next sync/read to resolve the new key server-side + // instead of serving data fetched under the previous entitlement. + clearCatalogCache.run(); + clearReferralsCache.run(); + })(); } // --------------------------------------------------------------------------- diff --git a/src/lib/radar/applyFeed.ts b/src/lib/radar/applyFeed.ts index 4974a201fc..55ba532099 100644 --- a/src/lib/radar/applyFeed.ts +++ b/src/lib/radar/applyFeed.ts @@ -5,12 +5,15 @@ * The baseline (`FREE_MODEL_BUDGETS`) is NEVER mutated. * * Merge rules: - * 1. Feed never overwrites a local override. + * 1. Feed never overwrites a local override, except the safety-critical + * `enabled:false` signal for an upstream model confirmed unavailable. * 2. `enabled:false` disables the entry with `disabledBy: "radar"` provenance. * 3. User-added entry NOT in the feed survives untouched. * 4. User deletion tombstone prevents feed from resurrecting the entry. */ +import type { RadarLocalizedText } from "./feedSchema"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -73,7 +76,7 @@ export interface MergedEntry { /** Setup guide (key URL + steps) reported by the feed. Undefined for baseline-only entries. */ setup?: { keyUrl: string | null; - steps: string[]; + steps: RadarLocalizedText[]; } | null; } @@ -107,7 +110,7 @@ export interface FeedModel { tosRisk: MergedEntry["tos"]; setup: { keyUrl: string | null; - steps: string[]; + steps: RadarLocalizedText[]; } | null; enabled: boolean; } @@ -127,9 +130,7 @@ function entryKey(provider: string, modelId: string): string { * Convert a FeedModel budget into a `monthlyTokens` number compatible * with the baseline catalog shape. */ -function feedBudgetToMonthlyTokens( - budget: FeedModel["budget"], -): number { +function feedBudgetToMonthlyTokens(budget: FeedModel["budget"]): number { if (budget.kind === "per_model") return budget.tokensPerMonth; if (budget.kind === "shared_pool") return budget.tokensPerMonth; return 0; // rate_only @@ -138,9 +139,7 @@ function feedBudgetToMonthlyTokens( /** * Convert a FeedModel budget into a `poolKey` compatible with the baseline. */ -function feedBudgetToPoolKey( - budget: FeedModel["budget"], -): string | null { +function feedBudgetToPoolKey(budget: FeedModel["budget"]): string | null { if (budget.kind === "shared_pool") return budget.poolId; return null; } @@ -242,7 +241,7 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] { function mergeOne( base: MergedEntry, feed: FeedModel, - overrides: Partial | undefined, + overrides: Partial | undefined ): MergedEntry { // Start from baseline const result: MergedEntry = { ...base }; @@ -307,6 +306,13 @@ function mergeOne( if (overrides.setup !== undefined) result.setup = overrides.setup; } + // Safety exception to rule 1: a model confirmed unavailable upstream is + // never resurrected by a stale local enabled:true override. + if (!feed.enabled) { + result.enabled = false; + result.disabledBy = "radar"; + } + // Origin: "local" if user has overrides, else "radar" (feed updated it) result.origin = overriddenKeys.size > 0 ? "local" : "radar"; @@ -318,7 +324,7 @@ function mergeOne( */ function feedModelToMerged( feed: FeedModel, - overrides: Partial | undefined, + overrides: Partial | undefined ): MergedEntry { const entry: MergedEntry = { provider: feed.provider, @@ -329,8 +335,8 @@ function feedModelToMerged( freeType: overrides?.freeType ?? feed.freeType, poolKey: overrides?.poolKey ?? feedBudgetToPoolKey(feed.budget), tos: overrides?.tos ?? feed.tosRisk, - trainsOnPrompts: overrides?.trainsOnPrompts ?? (feed.trainsOnPrompts ?? undefined), - enabled: overrides?.enabled ?? feed.enabled, + trainsOnPrompts: overrides?.trainsOnPrompts ?? feed.trainsOnPrompts ?? undefined, + enabled: feed.enabled ? (overrides?.enabled ?? true) : false, origin: overrides ? "local" : "radar", contextWindow: overrides?.contextWindow ?? feed.contextWindow, capabilities: overrides?.capabilities ?? feed.capabilities, @@ -338,10 +344,8 @@ function feedModelToMerged( setup: overrides?.setup ?? feed.setup, }; - // Rule 2 (feed disable) — but rule 1 (local override wins) takes precedence, - // matching mergeOne(): only force-disable when the user has NOT explicitly - // overridden `enabled` locally. - if (!feed.enabled && overrides?.enabled === undefined) { + // Rule 2 is the safety exception to local override precedence. + if (!feed.enabled) { entry.enabled = false; entry.disabledBy = "radar"; } diff --git a/src/lib/radar/feedSchema.ts b/src/lib/radar/feedSchema.ts index 15db476436..b1e97ea685 100644 --- a/src/lib/radar/feedSchema.ts +++ b/src/lib/radar/feedSchema.ts @@ -43,6 +43,16 @@ export type RadarTier = z.infer; const IntNullable = z.number().int().nullable(); +/** D25: English is canonical; Portuguese is an optional localized companion. */ +export const RadarLocalizedTextSchema = z.union([ + z.string(), // compatibility with schema-v1 feeds published before D25 + z.object({ + en: z.string().min(1), + pt: z.string().min(1).optional(), + }), +]); +export type RadarLocalizedText = z.infer; + /** * Budget is a discriminated union on `kind`: * - per_model: tokensPerMonth (positive int) @@ -80,7 +90,7 @@ const CapabilitiesSchema = z.object({ const SetupSchema = z .object({ keyUrl: z.string().url().nullable(), - steps: z.array(z.string()), + steps: z.array(RadarLocalizedTextSchema), }) .nullable(); @@ -167,8 +177,8 @@ const QuirkTargetSchema = z.object({ const QuirkSchema = z.object({ slug: z.string(), - title: z.string(), - body: z.string(), + title: RadarLocalizedTextSchema, + body: RadarLocalizedTextSchema, severity: SeverityEnum, targets: z.array(QuirkTargetSchema), }); @@ -182,10 +192,9 @@ export const RadarFeedSchema = z.object({ schemaVersion: z.literal(1), version: z.string(), generatedAt: z.string().datetime(), - // NOTE: this body field is ALWAYS "live", by design — the community tier - // is the exact same signed bytes served from an older snapshot, and there - // is only one signed artifact per version (rewriting this field - // server-side per request would break the exact-bytes Ed25519 signature). + // NOTE: this body field is not the entitlement decision. The server can + // publish separate exact-byte live/community artifacts for one version, + // while the selected request tier is still communicated by the header. // The tier ACTUALLY served is decided by the server per-request based on // the Authorization key, and is surfaced via the `x-omniroute-feed-tier` // response header instead. NEVER read this field for UI/display — use the diff --git a/src/lib/radar/referralsSync.ts b/src/lib/radar/referralsSync.ts index 6b84ab2a1e..c46eed1a0c 100644 --- a/src/lib/radar/referralsSync.ts +++ b/src/lib/radar/referralsSync.ts @@ -18,10 +18,7 @@ * Deps are injectable for testing. */ -import { - RadarReferralsFeedSchema, - type RadarReferralsFeed, -} from "./referralsFeedSchema"; +import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema"; import { RadarTierSchema, type RadarTier } from "./feedSchema"; import { verifyFeedBytes } from "./verify"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; @@ -144,9 +141,10 @@ export function shouldSyncReferralsOnRead( * 4. Verify Ed25519 signature over exact bytes (same pinned key as the * catalog feed — one key pins both artifacts). * 5. Parse+validate with RadarReferralsFeedSchema. - * 6. Replay guard: incoming `generatedAt` must be strictly newer than the - * cache (a same/older `generatedAt` is a no-op — nothing changed, or a - * stale replay — either way the cache is left untouched). + * 6. Replay guard: reject an incoming `generatedAt` older than the cache. + * Equal timestamps remain valid because the community and live referral + * variants deliberately share one deterministic `generatedAt`; the + * served tier can still change after the supporter key changes. * 7. Cache the result. * * @param deps - Injectable dependencies for testing. @@ -262,7 +260,8 @@ export async function syncRadarReferrals( return { status: "invalid_schema" }; } - // Step 7: generatedAt floor — replay/no-op guard. + // Step 7: generatedAt floor — reject older signed replays. Equal + // timestamps are accepted because entitlement variants share generatedAt. let existingCache: RadarReferralsCacheEntry | null = null; if (getCacheFn) { existingCache = getCacheFn(); @@ -274,7 +273,7 @@ export async function syncRadarReferrals( if (existingCache) { const existingMs = Date.parse(existingCache.generatedAt); const incomingMs = Date.parse(feed.generatedAt); - if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs <= existingMs) { + if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs < existingMs) { return { status: "stale" }; } } @@ -282,7 +281,8 @@ export async function syncRadarReferrals( // Step 8: Resolve served tier — the body carries no `tier` field at all // for this feed, so the header is the only source; absent/garbage header // degrades to the least-privileged "community" default. - const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community"; + const servedTier = + parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community"; // Step 9: Cache the result const cacheEntry: RadarReferralsCacheEntry = { diff --git a/src/lib/radar/setupConnections.ts b/src/lib/radar/setupConnections.ts new file mode 100644 index 0000000000..10c51f34ac --- /dev/null +++ b/src/lib/radar/setupConnections.ts @@ -0,0 +1,26 @@ +export interface RadarSetupConnection { + id: string; + provider: string; + isActive?: boolean; +} + +/** Reuse the authenticated provider-connections API with a bounded provider filter. */ +export function providerConnectionsRequestUrl(provider: string): string { + return `/api/providers?provider=${encodeURIComponent(provider)}`; +} + +/** + * Pick a concrete connection id for the setup test endpoint. Prefer an active + * connection, then fall back to the first valid connection for the provider. + */ +export function firstProviderConnectionId( + connections: readonly RadarSetupConnection[], + provider: string +): string | null { + const matching = connections.filter( + (connection) => connection.provider === provider && connection.id.length > 0 + ); + return ( + matching.find((connection) => connection.isActive !== false)?.id ?? matching[0]?.id ?? null + ); +} diff --git a/src/lib/radar/sync.ts b/src/lib/radar/sync.ts index 863f624ac3..fbd172bde1 100644 --- a/src/lib/radar/sync.ts +++ b/src/lib/radar/sync.ts @@ -266,7 +266,14 @@ export async function syncRadar(deps: SyncDeps = {}): Promise { return { status: "invalid_schema" }; } - // Step 7: Version floor + // Step 7: Resolve the served tier before the version floor. A single-use + // supporter key deliberately transitions from live to community after its + // first catalog pull, and the community snapshot can be older. + const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier; + + // Step 8: Version floor. Same/older versions are rejected within a tier, + // but a verified live -> community transition must replace the privileged + // cache even when the community snapshot is older. let existingCache: RadarCacheEntry | null = null; if (getCacheFn) { existingCache = getCacheFn(); @@ -275,17 +282,15 @@ export async function syncRadar(deps: SyncDeps = {}): Promise { existingCache = mod.getRadarCache(); } - if (existingCache && compareVersions(feed.version, existingCache.version) <= 0) { + const isEntitlementDowngrade = existingCache?.tier === "live" && servedTier === "community"; + if ( + existingCache && + !isEntitlementDowngrade && + compareVersions(feed.version, existingCache.version) <= 0 + ) { return { status: "stale" }; } - // Step 8: Resolve the served tier. - // The `x-omniroute-feed-tier` header reflects the tier ACTUALLY served - // (see `parseServedTierHeader`); fall back to the signed body's `tier` - // field only when the header is absent or unrecognized — never trust an - // arbitrary header value into the cache/UI. - const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier; - // Step 9: Cache the result const cacheEntry: RadarCacheEntry = { version: feed.version, diff --git a/tests/fixtures/radar-feed-canonical.json b/tests/fixtures/radar-feed-canonical.json index 3823862cd7..c0189bd236 100644 --- a/tests/fixtures/radar-feed-canonical.json +++ b/tests/fixtures/radar-feed-canonical.json @@ -46,7 +46,10 @@ "setup": { "keyUrl": "https://console.groq.com/keys", "steps": [ - "Crie uma conta gratuita no console da Groq", + { + "en": "Create a free account in the Groq console", + "pt": "Crie uma conta gratuita no console da Groq" + }, "Gere uma API key na página de keys", "Adicione a key no OmniRoute com o provider groq" ] diff --git a/tests/unit/radar-apply-feed.test.ts b/tests/unit/radar-apply-feed.test.ts index 0ec89baa22..a5fd8b07d4 100644 --- a/tests/unit/radar-apply-feed.test.ts +++ b/tests/unit/radar-apply-feed.test.ts @@ -15,11 +15,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { - applyFeed, - type MergedEntry, - type FeedModel, -} from "../../src/lib/radar/applyFeed.ts"; +import { applyFeed, type MergedEntry, type FeedModel } from "../../src/lib/radar/applyFeed.ts"; import { getRadarCatalog, baselineToMergedEntries, @@ -70,7 +66,9 @@ function makeBaseline(): MergedEntry[] { ]; } -function makeFeedModel(overrides: Partial & { provider: string; modelId: string }): FeedModel { +function makeFeedModel( + overrides: Partial & { provider: string; modelId: string } +): FeedModel { return { displayName: overrides.displayName ?? overrides.modelId, familyId: null, @@ -116,7 +114,7 @@ test("rule 1: feed does NOT overwrite a local override field", () => { }); const groq = result.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" )!; // Local override fields must survive @@ -150,7 +148,7 @@ test("rule 2: feed enabled:false disables entry and carries disabledBy provenanc }); const groq = result.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" )!; assert.equal(groq.enabled, false); @@ -191,9 +189,7 @@ test("rule 3: user-added entry not in feed survives untouched", () => { tombstones: new Set(), }); - const custom = result.find( - (e) => e.provider === "custom" && e.modelId === "my-local-model", - )!; + const custom = result.find((e) => e.provider === "custom" && e.modelId === "my-local-model")!; assert.equal(custom.displayName, "My Local Model"); assert.equal(custom.monthlyTokens, 50_000); @@ -242,7 +238,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => { }); const groq = result.filter( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" ); // Should be deduplicated to ONE entry @@ -262,7 +258,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => { test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () => { // Baseline has an entry for gemini, but user deleted it const baseline = makeBaseline().filter( - (e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash"), + (e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash") ); const feed: FeedModel[] = [ @@ -283,9 +279,7 @@ test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () => tombstones, }); - const gemini = result.find( - (e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash", - ); + const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash"); // Must NOT be resurrected assert.equal(gemini, undefined); @@ -353,9 +347,7 @@ test("applyFeed: feed-only entry is added with origin 'radar'", () => { tombstones: new Set(), }); - const added = result.find( - (e) => e.provider === "new-provider" && e.modelId === "new-model", - ); + const added = result.find((e) => e.provider === "new-provider" && e.modelId === "new-model"); assert.ok(added, "feed-only entry should be present"); assert.equal(added.displayName, "Brand New Model"); @@ -388,7 +380,7 @@ test("applyFeed: feed fields merge over baseline where no local override", () => }); const groq = result.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" )!; // Feed values win when no local override @@ -440,7 +432,7 @@ test("applyFeed: duplicate key (baseline + feed) produces single merged entry", }); const groqEntries = result.filter( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" ); assert.equal(groqEntries.length, 1, "should be deduplicated to one entry"); @@ -465,9 +457,7 @@ test("rule 4b: tombstoned entry removed even when baseline has it", () => { tombstones, }); - const groq = result.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", - ); + const groq = result.find((e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"); assert.equal(groq, undefined, "tombstoned entry should be excluded"); }); @@ -494,9 +484,7 @@ test("origin switches to 'radar' when feed updates a baseline entry", () => { tombstones: new Set(), }); - const gemini = result.find( - (e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash", - )!; + const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash")!; assert.equal(gemini.origin, "radar"); assert.equal(gemini.displayName, "Updated Gemini"); @@ -612,7 +600,7 @@ test("getRadarCatalog: valid cache returns merged entries with meta", () => { // The feed has groq:llama-3.3-70b-versatile, which merges over baseline const groq = result.entries.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" )!; assert.equal(groq.displayName, "Feed Groq Name"); @@ -714,7 +702,7 @@ test("FIX2 mergeOne path: contextWindow/capabilities/limits/setup survive merge }); const groq = result.find( - (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile", + (e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile" )!; assert.equal(groq.contextWindow, 131072); @@ -758,13 +746,11 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi }); // =========================================================================== -// FIX 4 — feedModelToMerged() must honor an `enabled` local override instead -// of unconditionally forcing `enabled:false` when the feed disables the model. -// mergeOne() already gets this right (overrides applied AFTER rule 2); this -// pins the same semantics on the feed-only path. +// Feed `enabled:false` is the safety exception to local override precedence: +// a model confirmed dead upstream must not be resurrected locally. // =========================================================================== -test("FIX4: feed-only entry with local override enabled:true wins over feed enabled:false", () => { +test("rule 2: feed-only entry stays disabled even with local enabled:true", () => { const baseline = makeBaseline(); const feed: FeedModel[] = [ makeFeedModel({ @@ -786,11 +772,11 @@ test("FIX4: feed-only entry with local override enabled:true wins over feed enab }); const entry = result.find( - (e) => e.provider === "new-provider" && e.modelId === "disabled-model", + (e) => e.provider === "new-provider" && e.modelId === "disabled-model" )!; - assert.equal(entry.enabled, true, "local override must win over feed disable"); - assert.equal(entry.disabledBy, undefined, "must not carry radar disabledBy when overridden on"); + assert.equal(entry.enabled, false, "a local override must not resurrect a dead upstream model"); + assert.equal(entry.disabledBy, "radar"); }); test("FIX4: feed-only entry with NO override still gets disabled with disabledBy provenance", () => { @@ -811,7 +797,7 @@ test("FIX4: feed-only entry with NO override still gets disabled with disabledBy }); const entry = result.find( - (e) => e.provider === "new-provider" && e.modelId === "disabled-model-2", + (e) => e.provider === "new-provider" && e.modelId === "disabled-model-2" )!; assert.equal(entry.enabled, false); diff --git a/tests/unit/radar-db.test.ts b/tests/unit/radar-db.test.ts index e2b0ba73c1..1aaaaa6455 100644 --- a/tests/unit/radar-db.test.ts +++ b/tests/unit/radar-db.test.ts @@ -165,7 +165,9 @@ test("setRadarKey encrypts at rest and getRadarSettings decrypts", () => { assert.equal(settings.supporterKey, clearKey, "getRadarSettings must return the clear key"); // Direct DB query to prove encryption at rest - interface SettingsRow { supporter_key_encrypted: string | null } + interface SettingsRow { + supporter_key_encrypted: string | null; + } const row = db .prepare("SELECT supporter_key_encrypted FROM radar_settings WHERE id = 1") .get() as SettingsRow; @@ -197,6 +199,36 @@ test("setRadarKey(null) clears the key", () => { assert.equal(cleared.supporter_key_encrypted, null, "DB value must be null"); }); +test("changing the supporter key invalidates the entitlement-sensitive referrals cache", () => { + radar.setRadarCache({ + version: "2026.08.07.1", + tier: "live", + payload: '{"models":[]}', + signature: "catalog-live-signature", + }); + radar.setRadarReferralsCache({ + generatedAt: "2026-08-07T12:00:00.000Z", + tier: "live", + payload: '{"referrals":{"fixed":[],"campaigns":[{"provider":"groq"}]}}', + signature: "live-signature", + }); + assert.ok(radar.getRadarReferralsCache(), "precondition: live referrals cache exists"); + assert.ok(radar.getRadarCache(), "precondition: live catalog cache exists"); + + radar.setRadarKey("omr_" + "d".repeat(40)); + + assert.equal( + radar.getRadarReferralsCache(), + null, + "a new key must force the next referrals read to resolve entitlement server-side" + ); + assert.equal( + radar.getRadarCache(), + null, + "a new key must force the next catalog sync to resolve entitlement server-side" + ); +}); + test("setRadarKey uses existing AES-256-GCM encryption from encryption.ts", () => { const db = core.getDbInstance(); const clearKey = "omr_" + "c".repeat(40); @@ -271,7 +303,9 @@ test("second setRadarReferralsCache REPLACES the row (still single row)", () => assert.equal(result.tier, "live", "must have the second tier"); assert.equal(result.payload, '{"new":true}', "must have the second payload"); - const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as { c: number }; + const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as { + c: number; + }; assert.equal(count.c, 1, "must have exactly one row"); }); diff --git a/tests/unit/radar-localized-feed.test.ts b/tests/unit/radar-localized-feed.test.ts new file mode 100644 index 0000000000..4b1b8404e3 --- /dev/null +++ b/tests/unit/radar-localized-feed.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { RadarFeedSchema } from "../../src/lib/radar/feedSchema.ts"; + +const fixture = JSON.parse( + readFileSync(new URL("../fixtures/radar-feed-canonical.json", import.meta.url), "utf8") +) as Record; + +test("canonical fixture carries D25 localized setup and accepts localized quirks", () => { + const localized = structuredClone(fixture) as { + models: Array<{ setup: { steps: unknown[] } | null }>; + quirks: Array<{ title: unknown; body: unknown }>; + }; + localized.quirks = [ + { + slug: "shared-pool", + title: { en: "Shared quota", pt: "Cota compartilhada" }, + body: { en: "Models share one pool." }, + severity: "info", + targets: [{ provider: "groq", modelGlob: null }], + }, + ]; + const parsed = RadarFeedSchema.parse(localized); + assert.deepEqual(parsed.models[0]!.setup!.steps[0], { + en: "Create a free account in the Groq console", + pt: "Crie uma conta gratuita no console da Groq", + }); +}); + +test("RadarFeedSchema preserves schema-v1 legacy setup and quirk strings", () => { + const legacy = structuredClone(fixture) as { + models: Array<{ setup: { steps: unknown[] } | null }>; + quirks: Array<{ title: unknown; body: unknown }>; + }; + legacy.models[0]!.setup!.steps[0] = "Create an account"; + legacy.quirks[0]!.title = "Shared quota"; + legacy.quirks[0]!.body = "Models share one pool."; + + const parsed = RadarFeedSchema.parse(legacy); + assert.equal(parsed.models[0]!.setup!.steps[0], "Create an account"); + assert.equal(parsed.quirks[0]!.title, "Shared quota"); + assert.equal(parsed.quirks[0]!.body, "Models share one pool."); +}); diff --git a/tests/unit/radar-referrals-sync.test.ts b/tests/unit/radar-referrals-sync.test.ts index 885a78def6..07c6cc9eec 100644 --- a/tests/unit/radar-referrals-sync.test.ts +++ b/tests/unit/radar-referrals-sync.test.ts @@ -118,7 +118,11 @@ test("RadarReferralsFeedSchema: rejects missing referrals section", () => { const feed = baseReferralsFeed(); delete (feed as Record).referrals; const result = referralsFeedSchema.RadarReferralsFeedSchema.safeParse(feed); - assert.equal(result.success, false, "referrals section is required (no old-feed compat needed here)"); + assert.equal( + result.success, + false, + "referrals section is required (no old-feed compat needed here)" + ); }); test("RadarReferralsFeedSchema: rejects a non-https referral url", () => { @@ -215,7 +219,10 @@ test("syncRadarReferrals: valid signature => cache updated, payload byte-identic }, fetch: (() => Promise.resolve( - mockResponse(bytes, { "x-omniroute-feed-signature": sig, "x-omniroute-feed-tier": "community" }) + mockResponse(bytes, { + "x-omniroute-feed-signature": sig, + "x-omniroute-feed-tier": "community", + }) )) as unknown as typeof globalThis.fetch, now: () => new Date("2026-08-07T12:05:00.000Z"), }); @@ -296,32 +303,47 @@ test("syncRadarReferrals: valid sig over garbage JSON => invalid_schema, cache u // syncRadarReferrals — generatedAt floor (replay/no-op guard) // =========================================================================== -test("syncRadarReferrals: same generatedAt as cache => stale, cache untouched", async () => { +test("syncRadarReferrals: same generatedAt with a new served tier replaces the cache", async () => { const feed = baseReferralsFeed("2026-08-07T12:00:00.000Z"); + (feed.referrals as { campaigns: Array> }).campaigns = [ + { + provider: "groq", + url: "https://groq.com/?campaign=live", + kind: "campanha", + validUntil: null, + requiredAction: null, + isDefault: false, + }, + ]; const bytes = feedBytes(feed); const sig = signBytes(bytes); - let cacheWritten = false; + const cacheStore: referralsSync.RadarReferralsCacheEntry[] = []; const result = await referralsSync.syncRadarReferrals({ getFlag: () => true, - getSettings: () => ({ optIn: true, supporterKey: null }), + getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }), getCache: () => ({ generatedAt: "2026-08-07T12:00:00.000Z", tier: "community", payload: "{}", signature: "old-sig", }), - setCache: () => { - cacheWritten = true; + setCache: (entry) => { + cacheStore.push(entry); }, fetch: (() => Promise.resolve( - mockResponse(bytes, { "x-omniroute-feed-signature": sig }) + mockResponse(bytes, { + "x-omniroute-feed-signature": sig, + "x-omniroute-feed-tier": "live", + }) )) as unknown as typeof globalThis.fetch, }); - assert.equal(result.status, "stale"); - assert.equal(cacheWritten, false); + assert.equal(result.status, "updated"); + assert.equal(cacheStore.length, 1); + assert.equal(cacheStore[0]!.tier, "live"); + assert.equal(cacheStore[0]!.payload, bytes.toString("utf-8")); }); test("syncRadarReferrals: older generatedAt than cache => stale (replay rejected)", async () => { @@ -487,7 +509,9 @@ test("syncRadarReferrals: header absent => falls back to 'community' (no body ti cacheStore.push(entry); }, fetch: (() => - Promise.resolve(mockResponse(bytes, { "x-omniroute-feed-signature": sig }))) as unknown as typeof globalThis.fetch, + Promise.resolve( + mockResponse(bytes, { "x-omniroute-feed-signature": sig }) + )) as unknown as typeof globalThis.fetch, }); assert.equal(result.status, "updated"); @@ -596,7 +620,9 @@ test("syncRadarReferrals: HTTP non-200 => error mentioning the status code", asy getFlag: () => true, getSettings: () => ({ optIn: true, supporterKey: null }), fetch: (() => - Promise.resolve(mockResponse(Buffer.from("Internal Server Error"), {}, 500))) as unknown as typeof globalThis.fetch, + Promise.resolve( + mockResponse(Buffer.from("Internal Server Error"), {}, 500) + )) as unknown as typeof globalThis.fetch, }); assert.equal(result.status, "error"); @@ -646,7 +672,8 @@ test("FIX: oversized body without a trustworthy Content-Length header => too_lar setCache: () => { setCacheCalled = true; }, - fetch: (() => Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch, + fetch: (() => + Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch, }); assert.deepEqual(result, { status: "too_large" }); diff --git a/tests/unit/radar-setup-connections.test.ts b/tests/unit/radar-setup-connections.test.ts new file mode 100644 index 0000000000..71771e1f98 --- /dev/null +++ b/tests/unit/radar-setup-connections.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + firstProviderConnectionId, + providerConnectionsRequestUrl, +} from "../../src/lib/radar/setupConnections.ts"; + +test("providerConnectionsRequestUrl filters the existing providers API", () => { + assert.equal( + providerConnectionsRequestUrl("openrouter/custom"), + "/api/providers?provider=openrouter%2Fcustom" + ); +}); + +test("firstProviderConnectionId selects a real connection id, never the provider slug", () => { + assert.equal( + firstProviderConnectionId( + [ + { id: "connection-disabled", provider: "groq", isActive: false }, + { id: "connection-active", provider: "groq", isActive: true }, + ], + "groq" + ), + "connection-active" + ); + assert.equal(firstProviderConnectionId([], "groq"), null); +}); diff --git a/tests/unit/radar-sync.test.ts b/tests/unit/radar-sync.test.ts index 0ebf3687b4..dd7b1ff1e5 100644 --- a/tests/unit/radar-sync.test.ts +++ b/tests/unit/radar-sync.test.ts @@ -82,7 +82,7 @@ test("contract: fixture sha256 matches the server's canonical hash", () => { const hash = crypto.createHash("sha256").update(FIXTURE_BYTES).digest("hex"); assert.equal( hash, - "13992d27702071bcb240306b052d0fdfacb2a2c688056c5e5118e92957e28a3d", + "80194e15a8add2a3be57eaef63b26ab75976c83e20b5589172aefa806eae72d3", "Fixture sha256 must match the server's canonical fixture. " + "If this fails, the fixture was modified or re-downloaded with different formatting." ); @@ -409,6 +409,35 @@ test("syncRadar: version floor — incoming older => stale", async () => { assert.equal(cacheWritten, false, "cache must NOT be overwritten with older version"); }); +test("syncRadar: an entitlement downgrade replaces a newer live cache with community", async () => { + const sig = signBytes(FIXTURE_BYTES); + const cacheStore: syncMod.RadarCacheEntry[] = []; + + const result = await syncMod.syncRadar({ + getFlag: () => true, + getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }), + getCache: () => ({ + version: "2026.08.02.1", + tier: "live", + payload: "{}", + signature: "old-live-sig", + }), + setCache: (entry) => cacheStore.push(entry), + fetch: (() => + Promise.resolve( + mockResponse(FIXTURE_BYTES, { + "x-omniroute-feed-signature": sig, + "x-omniroute-feed-tier": "community", + }) + )) as unknown as typeof globalThis.fetch, + }); + + assert.equal(result.status, "updated"); + assert.equal(cacheStore.length, 1); + assert.equal(cacheStore[0]!.tier, "community"); + assert.equal(cacheStore[0]!.version, "2026.08.01.1"); +}); + test("syncRadar: version floor — incoming newer => updated", async () => { // Modify fixture to have a newer version const fixtureObj = JSON.parse(FIXTURE_STRING);