From 6dbe899840e4e81b322ed3a5e31f6eeef6ccae02 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 10 Aug 2026 20:11:24 -0300 Subject: [PATCH] feat(radar): add signed supporter offers (#9912) * feat(radar): sync signed supporter offers * feat(radar): add supporter offers dashboard * docs(radar): format offers route table * docs(changelog): record Radar supporter offers --------- Co-authored-by: diegosouzapw --- .../features/9912-radar-supporter-offers.md | 1 + docs/frameworks/RADAR.md | 69 ++++- docs/reference/ENVIRONMENT.md | 2 +- .../dashboard/radar/offers/page.tsx | 273 ++++++++++++++++++ src/app/(dashboard)/dashboard/radar/page.tsx | 8 + src/app/api/radar/offers/route.ts | 41 +++ src/app/api/radar/offers/sync/route.ts | 55 ++++ src/i18n/messages/ar.json | 24 +- src/i18n/messages/az.json | 24 +- src/i18n/messages/bg.json | 24 +- src/i18n/messages/bn.json | 24 +- src/i18n/messages/cs.json | 24 +- src/i18n/messages/da.json | 24 +- src/i18n/messages/de.json | 24 +- src/i18n/messages/en.json | 24 +- src/i18n/messages/es.json | 24 +- src/i18n/messages/fa.json | 24 +- src/i18n/messages/fi.json | 24 +- src/i18n/messages/fr.json | 24 +- src/i18n/messages/gu.json | 24 +- src/i18n/messages/he.json | 24 +- src/i18n/messages/hi.json | 24 +- src/i18n/messages/hu.json | 24 +- src/i18n/messages/id.json | 24 +- src/i18n/messages/in.json | 24 +- src/i18n/messages/it.json | 24 +- src/i18n/messages/ja.json | 24 +- src/i18n/messages/ko.json | 24 +- src/i18n/messages/mr.json | 24 +- src/i18n/messages/ms.json | 24 +- src/i18n/messages/nl.json | 24 +- src/i18n/messages/no.json | 24 +- src/i18n/messages/phi.json | 24 +- src/i18n/messages/pl.json | 24 +- src/i18n/messages/pt-BR.json | 24 +- src/i18n/messages/pt.json | 24 +- src/i18n/messages/ro.json | 24 +- src/i18n/messages/ru.json | 24 +- src/i18n/messages/sk.json | 24 +- src/i18n/messages/sv.json | 24 +- src/i18n/messages/sw.json | 24 +- src/i18n/messages/ta.json | 24 +- src/i18n/messages/te.json | 24 +- src/i18n/messages/th.json | 24 +- src/i18n/messages/tr.json | 24 +- src/i18n/messages/uk-UA.json | 24 +- src/i18n/messages/ur.json | 24 +- src/i18n/messages/vi.json | 24 +- src/i18n/messages/zh-CN.json | 24 +- src/i18n/messages/zh-TW.json | 24 +- src/lib/db/AGENTS.md | 6 +- .../db/migrations/144_radar_offers_cache.sql | 11 + src/lib/db/radar.ts | 52 +++- src/lib/localDb.ts | 3 + src/lib/radar/index.ts | 53 ++++ src/lib/radar/offersFeedSchema.ts | 147 ++++++++++ src/lib/radar/offersSync.ts | 152 ++++++++++ tests/fixtures/radar-offers-canonical.json | 63 ++++ tests/unit/radar-offers-accessor.test.ts | 60 ++++ tests/unit/radar-offers-contract.test.ts | 71 +++++ tests/unit/radar-offers-db.test.ts | 85 ++++++ tests/unit/radar-offers-page.test.ts | 72 +++++ tests/unit/radar-offers-routes.test.ts | 120 ++++++++ tests/unit/radar-offers-sync.test.ts | 185 ++++++++++++ 64 files changed, 2499 insertions(+), 62 deletions(-) create mode 100644 changelog.d/features/9912-radar-supporter-offers.md create mode 100644 src/app/(dashboard)/dashboard/radar/offers/page.tsx create mode 100644 src/app/api/radar/offers/route.ts create mode 100644 src/app/api/radar/offers/sync/route.ts create mode 100644 src/lib/db/migrations/144_radar_offers_cache.sql create mode 100644 src/lib/radar/offersFeedSchema.ts create mode 100644 src/lib/radar/offersSync.ts create mode 100644 tests/fixtures/radar-offers-canonical.json create mode 100644 tests/unit/radar-offers-accessor.test.ts create mode 100644 tests/unit/radar-offers-contract.test.ts create mode 100644 tests/unit/radar-offers-db.test.ts create mode 100644 tests/unit/radar-offers-page.test.ts create mode 100644 tests/unit/radar-offers-routes.test.ts create mode 100644 tests/unit/radar-offers-sync.test.ts diff --git a/changelog.d/features/9912-radar-supporter-offers.md b/changelog.d/features/9912-radar-supporter-offers.md new file mode 100644 index 0000000000..a394c737e9 --- /dev/null +++ b/changelog.d/features/9912-radar-supporter-offers.md @@ -0,0 +1 @@ +- **feat(radar):** add a signed live offers feed and supporter offers dashboard ([#9912](https://github.com/diegosouzapw/OmniRoute/pull/9912)) diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 1e36a4e228..b090f49357 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-08 +lastUpdated: 2026-08-09 --- # Radar Free-Model Catalog > **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/` -> **Last updated:** 2026-08-08 — v3.8.50 +> **Last updated:** 2026-08-09 — 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 @@ -34,8 +34,9 @@ or external integration is currently available. | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, persistent display/enabled overrides, reversible tombstones, 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. | +| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by server-side sync. Changing or clearing the key invalidates all three 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. | +| Supporter offers | Implemented as a separate signed, live-only feed and dashboard page. The client revalidates the closed benefit schema, preserves the last good cache, filters expired entries, and labels partner offers explicitly. | | 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. | @@ -51,13 +52,14 @@ Radar is gated end-to-end by the `RADAR_ENABLED` feature flag - All `/api/radar/*` endpoints, including local model-state reads and writes, return `404` before touching any Radar module. -- The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`) render +- The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`, + `/dashboard/radar/combos`, `/dashboard/radar/offers`) render `notFound()`. - `getRadarCatalog()` (`src/lib/radar/index.ts`) returns the untouched baseline — same entry count, same values, every entry tagged `origin: "baseline"` — and never reads the feed cache. -- No network call is ever made; `syncRadar()` (`src/lib/radar/sync.ts`) returns - `{ status: "disabled" }` at step 1 without touching `fetch`. +- No Radar network call is ever made; each sync module returns `{ status: "disabled" }` + before touching `fetch`. This is a strict superset gate: flipping the flag on unlocks the _screens_, nothing more. It does not upload data, does not start a background sync, and does not change @@ -85,8 +87,9 @@ 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)). Radar has exactly two server-side network paths: - `syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed. + [Security model](#security-model)). Radar has exactly three server-side network paths: + `syncRadar()` for the catalog, `syncRadarReferrals()` for referrals, and + `syncRadarOffers()` for supporter-only offers. The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) that lets the feed service decide which tier to serve (see @@ -96,7 +99,7 @@ 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 +- Changing or clearing it atomically invalidates the catalog, referrals, and offers 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 @@ -332,16 +335,18 @@ The local Radar route families below back the UI under `src/app/api/radar/`: | `/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. | +| `/api/radar/offers` | GET | Returns active offers from the verified local live cache; never returns the supporter key. | +| `/api/radar/offers/sync` | POST | Triggers the server-side, live-key-only `syncRadarOffers()` pipeline. | | `/api/radar/local-model-state` | GET | Lists persisted overrides and tombstones for edit/restore controls. | | `/api/radar/local-model-state` | PATCH | Sets or clears the validated `displayName`/`enabled` override fields. | | `/api/radar/local-model-state` | PUT | Creates or removes a tombstone with `{ provider, modelId, tombstoned }`. | | `/api/radar/local-model-state` | DELETE | Clears editable override fields while preserving any tombstone. | **Hard rule: these routes never proxy the feed service.** The browser only ever talks -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. +to the local OmniRoute server. The three modules that touch the Radar service are +`src/lib/radar/sync.ts` (catalog), `src/lib/radar/referralsSync.ts` (referrals), and +`src/lib/radar/offersSync.ts` (offers); all run server-side, never client-side. This keeps +the feed URL and any supporter key out of client-facing network traffic entirely. All Radar endpoints return `404` when `RADAR_ENABLED` is off (see [Flag](#flag-radar_enabled-default-off) above), and route error responses through @@ -361,6 +366,35 @@ auth state — only the masked form and a `hasSupporterKey` boolean. --- +## Supporter offers + +Offers use their own signed artifact, `GET /v1/offers/latest`, and never share the catalog or +referrals cache. The server endpoint requires a valid live supporter Bearer key; there is no +community fallback. `syncRadarOffers()` therefore stops before the network when the feature flag is +off, the operator has not opted in, or no supporter key is configured. + +After a successful GET, the client verifies the Ed25519 signature over the exact response bytes, +validates `RadarOffersFeedSchema`, requires both the signed body and +`x-omniroute-feed-tier` header to say `live`, enforces a strictly newer dotted version, and only then +atomically replaces `radar_offers_cache` (migration `144_radar_offers_cache.sql`). The same 10 MB +header-plus-stream cap used by the other feeds applies. Signature, schema, tier, replay, size, HTTP, +and network failures all preserve the last verified cache. + +The closed offer shape supports three comparable benefit types: percentage in basis points, credit +in minor currency units, or trial days. A partner offer must include a same-kind public baseline and +its benefit must be strictly greater; official offers have no partner baseline. URLs must be +credential-free HTTPS. `getRadarOffers()` defensively revalidates the cached payload and filters +expired entries on every local read; `/dashboard/radar/offers` filters expiry again before rendering, +uses Portuguese text when available with English fallback, and labels partner offers explicitly. + +The browser calls only local routes: it reads the masked settings snapshot, asks +`POST /api/radar/offers/sync` to refresh server-side, then reads `GET /api/radar/offers`. Without a +key it shows the existing contributor/support links instead of attempting a feed request. External +offer links open in a new tab with `noopener noreferrer`. No `radar_offers` MCP tool is exposed in +this release. + +--- + ## Referral links (free credits) Referral links are served from a **standalone, always-current** feed — @@ -541,11 +575,18 @@ instead of failing the rest of the page. To also offer referral links, serve (`src/lib/radar/referralsFeedSchema.ts`) and sign it with the same Ed25519 key pair as the catalog feed. +Supporter offers are another optional artifact. To serve them, implement +`GET /v1/offers/latest` with the closed `RadarOffersFeedSchema` +(`src/lib/radar/offersFeedSchema.ts`), require live entitlement, return +`x-omniroute-feed-tier: live`, and sign the exact bytes with the same key. A fork that omits this +endpoint keeps the catalog/referrals behavior unchanged; offer refresh fails non-destructively and +the last verified local offer cache remains available. + --- ## Related docs - [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the - error-response pattern the five `/api/radar/*` routes follow. + error-response pattern the `/api/radar/*` routes follow. - [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md#27-radar-feed-self-hosting) — `RADAR_FEED_URL` / `RADAR_FEED_PUBKEY` reference. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 161b9f1252..d8b49d3f58 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1294,7 +1294,7 @@ module doc. | Variable | Default | Source File | Description | | -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. | +| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync}.ts` | Base URL shared by the separately signed catalog, referrals, and supporter-offers feeds. Override to point at a self-hosted or forked service. | | `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. | | `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). | | `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). | diff --git a/src/app/(dashboard)/dashboard/radar/offers/page.tsx b/src/app/(dashboard)/dashboard/radar/offers/page.tsx new file mode 100644 index 0000000000..6c6a608a6c --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/offers/page.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; + +import { + filterActiveRadarOffers, + localizeRadarOfferText, + type RadarOffer, + type RadarOfferBenefit, +} from "@/lib/radar/offersFeedSchema"; +import { Card } from "@/shared/components"; + +interface OffersMeta { + version: string; + tier: "live"; + fetchedAt: string; +} + +interface SettingsPayload { + hasSupporterKey?: boolean; + contributorClaimUrl?: string; + supporterPlansUrl?: string; +} + +export default function RadarOffersPage() { + const t = useTranslations("radarOffersPage"); + const locale = useLocale(); + const [offers, setOffers] = useState([]); + const [meta, setMeta] = useState(null); + const [hasSupporterKey, setHasSupporterKey] = useState(false); + const [contributorClaimUrl, setContributorClaimUrl] = useState(null); + const [supporterPlansUrl, setSupporterPlansUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [error, setError] = useState(""); + + const loadOffers = useCallback(async () => { + const response = await fetch("/api/radar/offers"); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("offers_load_failed"); + const body = (await response.json()) as { offers?: RadarOffer[]; meta?: OffersMeta | null }; + setOffers(Array.isArray(body.offers) ? body.offers : []); + setMeta(body.meta ?? null); + }, []); + + const syncAndLoad = useCallback(async () => { + setRefreshing(true); + setError(""); + try { + const response = await fetch("/api/radar/offers/sync", { method: "POST" }); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("offers_sync_failed"); + const status = (await response.json()) as { status?: string; reason?: string }; + if (status.status === "no_key") { + setHasSupporterKey(false); + return; + } + if ( + status.status === "error" || + status.status === "invalid_signature" || + status.status === "invalid_schema" || + status.status === "wrong_tier" || + status.status === "too_large" + ) { + setError(t("loadFailed")); + } + // Preserve availability: even when refresh fails, render the last + // verified local cache rather than clearing it. + await loadOffers(); + } catch { + setError(t("loadFailed")); + try { + await loadOffers(); + } catch { + // The primary error already explains the failed local read. + } + } finally { + setRefreshing(false); + } + }, [loadOffers, t]); + + useEffect(() => { + async function load(): Promise { + try { + const response = await fetch("/api/radar/settings"); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("settings_load_failed"); + const settings = (await response.json()) as SettingsPayload; + const hasKey = settings.hasSupporterKey === true; + setHasSupporterKey(hasKey); + setContributorClaimUrl( + typeof settings.contributorClaimUrl === "string" ? settings.contributorClaimUrl : null + ); + setSupporterPlansUrl( + typeof settings.supporterPlansUrl === "string" ? settings.supporterPlansUrl : null + ); + if (hasKey) await syncAndLoad(); + } catch { + setError(t("loadFailed")); + } finally { + setLoading(false); + } + } + void load(); + }, [syncAndLoad, t]); + + const activeOffers = useMemo(() => filterActiveRadarOffers(offers, new Date()), [offers]); + + const formatBenefit = useCallback( + (benefit: RadarOfferBenefit): string => { + if (benefit.kind === "percent_off") { + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format( + benefit.basisPoints / 100 + )}%`; + } + if (benefit.kind === "credit") { + return new Intl.NumberFormat(locale, { + style: "currency", + currency: benefit.currency, + }).format(benefit.amountMinor / 100); + } + return t("trialDays", { days: benefit.days }); + }, + [locale, t] + ); + + if (flagOff) notFound(); + + return ( +
+
+ + ← {t("backToRadar")} + +
+
+

{t("title")}

+

{t("subtitle")}

+
+ {hasSupporterKey && ( + + )} +
+
+ + {error &&
{error}
} + + {loading ? ( +
+ {t("loading")} +
+ ) : !hasSupporterKey ? ( + +
+ redeem +

{t("keyRequiredTitle")}

+

{t("keyRequiredDescription")}

+
+ {contributorClaimUrl && ( + + {t("contributorButton")} + + )} + {supporterPlansUrl && ( + + {t("supporterButton")} + + )} +
+
+
+ ) : activeOffers.length === 0 ? ( + +

{t("empty")}

+
+ ) : ( +
+ {activeOffers.map((offer) => ( + +
+
+
+

+ {offer.provider} +

+

+ {localizeRadarOfferText(offer.title, locale)} +

+
+ + {offer.partner ? t("partnerBadge") : t("officialBadge")} + +
+ +

{formatBenefit(offer.benefit)}

+

+ {localizeRadarOfferText(offer.description, locale)} +

+
+ {t("conditionsLabel")}{" "} + + {localizeRadarOfferText(offer.conditions, locale)} + +
+

+ {offer.validUntil + ? t("validUntil", { + date: new Date(offer.validUntil).toLocaleDateString(locale), + }) + : t("noExpiry")} +

+ + {t("openOffer")} + open_in_new + +
+
+ ))} +
+ )} + + {meta && ( +

+ {meta.version} · {new Date(meta.fetchedAt).toLocaleString(locale)} +

+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 75803dc17a..7f209202a8 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -317,6 +317,14 @@ export default function RadarPage() {

{t("subtitle")}

+ {(pageState === "empty" || pageState === "populated") && ( + + {t("offers")} + + )} {(pageState === "empty" || pageState === "populated") && ( { updateKey.run(encrypted); - // Both signed feeds are entitlement-sensitive. Clearing their cached + // All 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(); + clearOffersCache.run(); })(); } @@ -226,6 +239,43 @@ export function setRadarReferralsCache(entry: { ).run(entry.generatedAt, entry.tier, entry.payload, entry.signature, fetchedAt); } +// --------------------------------------------------------------------------- +// radar_offers_cache +// --------------------------------------------------------------------------- + +export function getRadarOffersCache(): RadarOffersCache | null { + const row = getDbInstance() + .prepare( + "SELECT version, tier, payload, signature, fetched_at AS fetchedAt " + + "FROM radar_offers_cache WHERE id = 1" + ) + .get() as RadarOffersCache | undefined; + + return row ?? null; +} + +export function setRadarOffersCache(entry: { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt?: string; +}): void { + const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); + getDbInstance() + .prepare( + `INSERT INTO radar_offers_cache (id, version, tier, payload, signature, fetched_at) + VALUES (1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + fetched_at = excluded.fetched_at` + ) + .run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); +} + // --------------------------------------------------------------------------- // radar_local_model_state // --------------------------------------------------------------------------- diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 6ee7a0939d..d621129aa0 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -820,6 +820,8 @@ export { setRadarKey, getRadarReferralsCache, setRadarReferralsCache, + getRadarOffersCache, + setRadarOffersCache, listRadarLocalModelState, setRadarLocalModelOverride, clearRadarLocalModelOverride, @@ -830,6 +832,7 @@ export type { RadarCache, RadarSettings, RadarReferralsCache, + RadarOffersCache, RadarLocalModelState, RadarLocalModelOverridePatch, RadarLocalMergeState, diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index 1ae06486f0..ec06fa1340 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -12,12 +12,18 @@ import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog"; import { RadarFeedSchema, type RadarFeed, type RadarReferral } from "./feedSchema"; import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema"; +import { + filterActiveRadarOffers, + RadarOffersFeedSchema, + type RadarOffer, +} from "./offersFeedSchema"; import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { getRadarCache, getRadarLocalMergeState, + getRadarOffersCache, getRadarReferralsCache, type RadarLocalMergeState, } from "@/lib/db/radar"; @@ -209,7 +215,54 @@ export function getDefaultReferralFor( return findDefaultReferral(fixed, provider); } +// --------------------------------------------------------------------------- +// getRadarOffers +// --------------------------------------------------------------------------- + +export interface RadarOffersResult { + offers: RadarOffer[]; + meta: { version: string; tier: "live"; fetchedAt: string } | null; +} + +export interface GetRadarOffersDeps { + getFlag?: (key: string) => boolean; + getCache?: () => { + version: string; + tier: string; + payload: string; + fetchedAt: string; + } | null; + now?: () => Date; +} + +const EMPTY_OFFERS: RadarOffersResult = { offers: [], meta: null }; + +/** Return only revalidated, unexpired offers from the local live cache. */ +export function getRadarOffers(deps: GetRadarOffersDeps = {}): RadarOffersResult { + const { + getFlag = isFeatureFlagEnabled, + getCache: getCacheFn = getRadarOffersCache, + now = () => new Date(), + } = deps; + if (!getFlag("RADAR_ENABLED")) return EMPTY_OFFERS; + + const cache = getCacheFn(); + if (!cache || cache.tier !== "live") return EMPTY_OFFERS; + + try { + const feed = RadarOffersFeedSchema.parse(JSON.parse(cache.payload)); + if (feed.version !== cache.version || feed.tier !== "live") return EMPTY_OFFERS; + return { + offers: filterActiveRadarOffers(feed.offers, now()), + meta: { version: cache.version, tier: "live", fetchedAt: cache.fetchedAt }, + }; + } catch { + return EMPTY_OFFERS; + } +} + // 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"; diff --git a/src/lib/radar/offersFeedSchema.ts b/src/lib/radar/offersFeedSchema.ts new file mode 100644 index 0000000000..a1e6579a63 --- /dev/null +++ b/src/lib/radar/offersFeedSchema.ts @@ -0,0 +1,147 @@ +/** + * Closed client mirror of the private Radar offers feed contract. + * + * Keep this shape byte-compatible with `src/offers/schema.ts` in the private + * server. The canonical fixture in `tests/fixtures/` pins that cross-repo + * contract without embedding any real offer or partner data. + */ + +import { z } from "zod"; + +const OFFER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; + +export const RadarOfferLocalizedTextSchema = z + .object({ + en: z.string().min(1), + pt: z.string().min(1).optional(), + }) + .strict(); + +export type RadarOfferLocalizedText = z.infer; + +const HttpsUrlSchema = z + .string() + .url() + .superRefine((value, ctx) => { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.username || parsed.password) { + ctx.addIssue({ code: "custom", message: "offer URL must be credential-free HTTPS" }); + } + }); + +const PercentBenefitSchema = z + .object({ + kind: z.literal("percent_off"), + basisPoints: z.number().int().min(1).max(10_000), + }) + .strict(); + +const CreditBenefitSchema = z + .object({ + kind: z.literal("credit"), + amountMinor: z.number().int().positive(), + currency: z.string().regex(/^[A-Z]{3}$/), + }) + .strict(); + +const TrialBenefitSchema = z + .object({ + kind: z.literal("trial_days"), + days: z.number().int().min(1).max(3_650), + }) + .strict(); + +export const RadarOfferBenefitSchema = z.discriminatedUnion("kind", [ + PercentBenefitSchema, + CreditBenefitSchema, + TrialBenefitSchema, +]); + +export type RadarOfferBenefit = z.infer; + +function isStrictlyBetter(benefit: RadarOfferBenefit, publicBenefit: RadarOfferBenefit): boolean { + if (benefit.kind !== publicBenefit.kind) return false; + if (benefit.kind === "percent_off" && publicBenefit.kind === "percent_off") { + return benefit.basisPoints > publicBenefit.basisPoints; + } + if (benefit.kind === "trial_days" && publicBenefit.kind === "trial_days") { + return benefit.days > publicBenefit.days; + } + if (benefit.kind === "credit" && publicBenefit.kind === "credit") { + return ( + benefit.currency === publicBenefit.currency && benefit.amountMinor > publicBenefit.amountMinor + ); + } + return false; +} + +export const RadarOfferSchema = z + .object({ + id: z.string().regex(OFFER_ID_PATTERN), + provider: z.string().regex(PROVIDER_ID_PATTERN), + title: RadarOfferLocalizedTextSchema, + description: RadarOfferLocalizedTextSchema, + benefit: RadarOfferBenefitSchema, + publicBenefit: RadarOfferBenefitSchema.nullable(), + conditions: RadarOfferLocalizedTextSchema, + validUntil: z.string().datetime().nullable(), + url: HttpsUrlSchema, + partner: z.boolean(), + }) + .strict() + .superRefine((offer, ctx) => { + if (!offer.partner && offer.publicBenefit !== null) { + ctx.addIssue({ + code: "custom", + path: ["publicBenefit"], + message: "official offer has no partner baseline", + }); + return; + } + if ( + offer.partner && + (offer.publicBenefit === null || !isStrictlyBetter(offer.benefit, offer.publicBenefit)) + ) { + ctx.addIssue({ + code: "custom", + path: ["publicBenefit"], + message: "partner benefit must be strictly better than a comparable public benefit", + }); + } + }); + +export type RadarOffer = z.infer; + +export const RadarOffersFeedSchema = z + .object({ + feed: z.literal("omniroute-radar-offers"), + schemaVersion: z.literal(1), + version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + generatedAt: z.string().datetime(), + tier: z.literal("live"), + count: z.number().int().nonnegative(), + offers: z.array(RadarOfferSchema), + }) + .strict() + .superRefine((feed, ctx) => { + if (feed.count !== feed.offers.length) { + ctx.addIssue({ code: "custom", path: ["count"], message: "offer count mismatch" }); + } + }); + +export type RadarOffersFeed = z.infer; + +export function filterActiveRadarOffers( + offers: readonly RadarOffer[], + now: Date = new Date() +): RadarOffer[] { + const nowMs = now.getTime(); + return offers.filter( + (offer) => offer.validUntil === null || Date.parse(offer.validUntil) > nowMs + ); +} + +export function localizeRadarOfferText(text: RadarOfferLocalizedText, locale: string): string { + return locale.toLowerCase().startsWith("pt") && text.pt ? text.pt : text.en; +} diff --git a/src/lib/radar/offersSync.ts b/src/lib/radar/offersSync.ts new file mode 100644 index 0000000000..05faa803ae --- /dev/null +++ b/src/lib/radar/offersSync.ts @@ -0,0 +1,152 @@ +/** + * Server-side sync for the separately signed, supporter-only Radar offers feed. + * Every failure preserves the last verified local cache. + */ + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { RadarOffersFeedSchema, type RadarOffersFeed } from "./offersFeedSchema"; +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 OffersSyncStatus = + | { 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 RadarOffersCacheEntry { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt?: string; +} + +export interface OffersSyncDeps { + fetch?: typeof globalThis.fetch; + now?: () => Date; + getFlag?: (key: string) => boolean; + getSettings?: () => RadarSettingsSnapshot; + getCache?: () => RadarOffersCacheEntry | null; + setCache?: (entry: RadarOffersCacheEntry) => void; +} + +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))); +} + +export async function syncRadarOffers(deps: OffersSyncDeps = {}): Promise { + const { + fetch: fetchFn = globalThis.fetch, + now = () => new Date(), + getFlag = isFeatureFlagEnabled, + getSettings: getSettingsFn, + getCache: getCacheFn, + setCache: setCacheFn, + } = 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/offers/latest`, { + method: "GET", + headers: { Authorization: `Bearer ${settings.supporterKey}` }, + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!response.ok) { + return { + status: "error", + reason: `Offers 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: RadarOffersFeed; + try { + feed = RadarOffersFeedSchema.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")).getRadarOffersCache(); + if (existing && compareVersions(feed.version, existing.version) <= 0) { + return { status: "stale" }; + } + + const cacheEntry: RadarOffersCacheEntry = { + version: feed.version, + tier: "live", + payload: rawBytes.toString("utf8"), + signature, + fetchedAt: now().toISOString(), + }; + if (setCacheFn) { + setCacheFn(cacheEntry); + } else { + (await import("@/lib/db/radar")).setRadarOffersCache(cacheEntry); + } + + return { status: "updated", version: feed.version }; + } catch (error: unknown) { + const reason = (sanitizeErrorMessage(error) || "Radar offers sync failed").replace( + /omr_[a-f0-9]{40}/gi, + "[REDACTED]" + ); + return { status: "error", reason }; + } +} diff --git a/tests/fixtures/radar-offers-canonical.json b/tests/fixtures/radar-offers-canonical.json new file mode 100644 index 0000000000..3764fe6680 --- /dev/null +++ b/tests/fixtures/radar-offers-canonical.json @@ -0,0 +1,63 @@ +{ + "feed": "omniroute-radar-offers", + "schemaVersion": 1, + "version": "2026.08.09.1", + "generatedAt": "2026-08-09T12:00:00.000Z", + "tier": "live", + "count": 2, + "offers": [ + { + "id": "example-official-trial", + "provider": "example", + "title": { + "en": "Official trial", + "pt": "Teste oficial" + }, + "description": { + "en": "Canonical official-offer fixture", + "pt": "Fixture canônico de oferta oficial" + }, + "benefit": { + "kind": "trial_days", + "days": 14 + }, + "publicBenefit": null, + "conditions": { + "en": "Fixture only; not a real offer", + "pt": "Somente fixture; não é uma oferta real" + }, + "validUntil": "2099-12-31T23:59:59.000Z", + "url": "https://provider.example/official-trial", + "partner": false + }, + { + "id": "example-partner-credit", + "provider": "example", + "title": { + "en": "Partner credit", + "pt": "Crédito de parceiro" + }, + "description": { + "en": "Canonical partner-offer fixture", + "pt": "Fixture canônico de oferta de parceiro" + }, + "benefit": { + "kind": "credit", + "amountMinor": 1000, + "currency": "USD" + }, + "publicBenefit": { + "kind": "credit", + "amountMinor": 500, + "currency": "USD" + }, + "conditions": { + "en": "Fixture only; not a real offer", + "pt": "Somente fixture; não é uma oferta real" + }, + "validUntil": null, + "url": "https://provider.example/partner-credit", + "partner": true + } + ] +} diff --git a/tests/unit/radar-offers-accessor.test.ts b/tests/unit/radar-offers-accessor.test.ts new file mode 100644 index 0000000000..98c92b1087 --- /dev/null +++ b/tests/unit/radar-offers-accessor.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { getRadarOffers } from "../../src/lib/radar/index.ts"; + +async function fixturePayload(): Promise { + return readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url), "utf8"); +} + +test("offers accessor short-circuits before cache when Radar is disabled", () => { + let reads = 0; + const result = getRadarOffers({ + getFlag: () => false, + getCache: () => { + reads += 1; + throw new Error("cache must not be read"); + }, + }); + + assert.deepEqual(result, { offers: [], meta: null }); + assert.equal(reads, 0); +}); + +test("offers accessor fails closed for missing, corrupt, or non-live cache", () => { + for (const cache of [ + null, + { version: "x", tier: "live", payload: "not-json", fetchedAt: "now" }, + { version: "x", tier: "community", payload: "{}", fetchedAt: "now" }, + ]) { + assert.deepEqual(getRadarOffers({ getFlag: () => true, getCache: () => cache }), { + offers: [], + meta: null, + }); + } +}); + +test("offers accessor revalidates the cache and removes expired entries", async () => { + const payload = await fixturePayload(); + const result = getRadarOffers({ + getFlag: () => true, + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload, + fetchedAt: "2026-08-09T12:05:00.000Z", + }), + now: () => new Date("2100-01-01T00:00:00.000Z"), + }); + + assert.deepEqual( + result.offers.map(({ id }) => id), + ["example-partner-credit"] + ); + assert.deepEqual(result.meta, { + version: "2026.08.09.1", + tier: "live", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); +}); diff --git a/tests/unit/radar-offers-contract.test.ts b/tests/unit/radar-offers-contract.test.ts new file mode 100644 index 0000000000..101bed3354 --- /dev/null +++ b/tests/unit/radar-offers-contract.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { + RadarOfferSchema, + RadarOffersFeedSchema, + filterActiveRadarOffers, + localizeRadarOfferText, +} from "../../src/lib/radar/offersFeedSchema.ts"; + +const EXPECTED_FIXTURE_HASH = "f01a4c03a72adbffa944b4bcc8610ad2fec31dc500feaed18bdd9d1af4f06216"; + +async function canonicalFixture(): Promise { + return readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url)); +} + +test("offers contract fixture is byte-identical to the private server contract", async () => { + const bytes = await canonicalFixture(); + assert.equal(createHash("sha256").update(bytes).digest("hex"), EXPECTED_FIXTURE_HASH); + + const feed = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))); + assert.equal(feed.count, 2); + assert.deepEqual( + feed.offers.map(({ id, partner }) => ({ id, partner })), + [ + { id: "example-official-trial", partner: false }, + { id: "example-partner-credit", partner: true }, + ] + ); +}); + +test("partner offer must be strictly better than a comparable public benefit", async () => { + const bytes = await canonicalFixture(); + const partner = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))).offers[1]!; + + assert.equal( + RadarOfferSchema.safeParse({ + ...partner, + benefit: { kind: "credit", amountMinor: 500, currency: "USD" }, + }).success, + false + ); + assert.equal( + RadarOfferSchema.safeParse({ + ...partner, + publicBenefit: { kind: "trial_days", days: 30 }, + }).success, + false + ); +}); + +test("active projection filters expired offers and localizes with English fallback", async () => { + const bytes = await canonicalFixture(); + const feed = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))); + const expired = { + ...feed.offers[0]!, + id: "expired", + validUntil: "2026-08-01T00:00:00.000Z", + }; + + assert.deepEqual( + filterActiveRadarOffers([...feed.offers, expired], new Date("2026-08-09T12:00:00.000Z")).map( + ({ id }) => id + ), + ["example-official-trial", "example-partner-credit"] + ); + assert.equal(localizeRadarOfferText({ en: "English", pt: "Português" }, "pt-BR"), "Português"); + assert.equal(localizeRadarOfferText({ en: "English" }, "de"), "English"); +}); diff --git a/tests/unit/radar-offers-db.test.ts b/tests/unit/radar-offers-db.test.ts new file mode 100644 index 0000000000..fa93225ffa --- /dev/null +++ b/tests/unit/radar-offers-db.test.ts @@ -0,0 +1,85 @@ +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-offers-db-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-offers-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 }); + delete process.env.STORAGE_ENCRYPTION_KEY; +}); + +test("Radar offers cache migration creates a single-row byte-preserving store", () => { + const db = core.getDbInstance(); + assert.equal(radar.getRadarOffersCache(), null); + + radar.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload: '{"byte":"exact"}\n', + signature: "signed", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + radar.setRadarOffersCache({ + version: "2026.08.09.2", + tier: "live", + payload: '{"replacement":true}', + signature: "signed-again", + fetchedAt: "2026-08-09T12:10:00.000Z", + }); + + assert.deepEqual(radar.getRadarOffersCache(), { + version: "2026.08.09.2", + tier: "live", + payload: '{"replacement":true}', + signature: "signed-again", + fetchedAt: "2026-08-09T12:10:00.000Z", + }); + const row = db.prepare("SELECT COUNT(*) AS count FROM radar_offers_cache").get() as { + count: number; + }; + assert.equal(row.count, 1); +}); + +test("changing the supporter key atomically invalidates every entitlement-sensitive cache", () => { + const db = core.getDbInstance(); + 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.setRadarKey(`omr_${"a".repeat(40)}`); + + assert.equal(radar.getRadarCache(), null); + assert.equal(radar.getRadarReferralsCache(), null); + assert.equal(radar.getRadarOffersCache(), null); + const stored = db + .prepare("SELECT supporter_key_encrypted AS key FROM radar_settings WHERE id = 1") + .get() as { key: string }; + assert.ok(!stored.key.includes("omr_"), "supporter key must stay encrypted at rest"); +}); diff --git a/tests/unit/radar-offers-page.test.ts b/tests/unit/radar-offers-page.test.ts new file mode 100644 index 0000000000..a6d093ca57 --- /dev/null +++ b/tests/unit/radar-offers-page.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const pagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/offers/page.tsx"); +const radarPagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +function pageSource(): string { + return fs.existsSync(pagePath) ? fs.readFileSync(pagePath, "utf8") : ""; +} + +test("Radar links to a dedicated supporter offers page", () => { + assert.ok(fs.existsSync(pagePath), "missing /dashboard/radar/offers page"); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/offers"/); +}); + +test("offers page uses only local settings, sync, and cache routes", () => { + const source = pageSource(); + assert.match(source, /fetch\("\/api\/radar\/settings"\)/); + assert.match(source, /fetch\("\/api\/radar\/offers\/sync",\s*\{\s*method:\s*"POST"/); + assert.match(source, /fetch\("\/api\/radar\/offers"\)/); + assert.doesNotMatch(source, /RADAR_FEED_URL|radar\.omniroute\.online|localDb|getDbInstance/); +}); + +test("offers UI is live-key gated, filters expiry, localizes, and labels partnerships", () => { + const source = pageSource(); + assert.match(source, /hasSupporterKey/); + assert.match(source, /filterActiveRadarOffers/); + assert.match(source, /localizeRadarOfferText/); + assert.match(source, /offer\.partner/); + assert.match(source, /t\("partnerBadge"\)/); + assert.match(source, /target="_blank"/); + assert.match(source, /rel="noopener noreferrer"/); +}); + +test("every locale carries the complete Radar offers namespace", () => { + const requiredKeys = [ + "title", + "subtitle", + "backToRadar", + "loading", + "refresh", + "refreshing", + "loadFailed", + "empty", + "keyRequiredTitle", + "keyRequiredDescription", + "contributorButton", + "supporterButton", + "partnerBadge", + "officialBadge", + "conditionsLabel", + "validUntil", + "noExpiry", + "openOffer", + "trialDays", + ]; + const messagesDir = path.resolve(process.cwd(), "src/i18n/messages"); + const files = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + + for (const file of files) { + const messages = JSON.parse(fs.readFileSync(path.join(messagesDir, file), "utf8")) as { + radarOffersPage?: Record; + }; + for (const key of requiredKeys) { + const value = messages.radarOffersPage?.[key]; + assert.equal(typeof value, "string", `${file}: missing radarOffersPage.${key}`); + assert.ok((value as string).trim().length > 0, `${file}: empty radarOffersPage.${key}`); + } + } +}); diff --git a/tests/unit/radar-offers-routes.test.ts b/tests/unit/radar-offers-routes.test.ts new file mode 100644 index 0000000000..1ff65c20ff --- /dev/null +++ b/tests/unit/radar-offers-routes.test.ts @@ -0,0 +1,120 @@ +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-offers-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-offers-routes-32b!"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-offers-routes"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-offers-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 resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function request( + pathname: string, + method: "GET" | "POST", + headers: Record = {}, + body?: unknown +) { + return new Request(`http://localhost:20128${pathname}`, { + method, + headers: { ...headers, ...(body === undefined ? {} : { "content-type": "application/json" }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.RADAR_ENABLED; + delete process.env.STORAGE_ENCRYPTION_KEY; +}); + +test("offers routes are inert before auth when the feature flag is off", async () => { + resetStorage(); + delete process.env.RADAR_ENABLED; + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + assert.equal((await GET(request("/api/radar/offers", "GET"))).status, 404); + assert.equal((await POST(request("/api/radar/offers/sync", "POST"))).status, 404); +}); + +test("offers routes require dashboard or management authentication", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + assert.equal((await GET(request("/api/radar/offers", "GET"))).status, 401); + assert.equal((await POST(request("/api/radar/offers/sync", "POST"))).status, 401); +}); + +test("GET offers returns only the local cache and never exposes supporter key material", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const payload = fs.readFileSync( + path.resolve(process.cwd(), "tests/fixtures/radar-offers-canonical.json"), + "utf8" + ); + radarDb.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload, + signature: "fixture-signature", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const response = await GET(request("/api/radar/offers", "GET", await authHeaders())); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.offers.length, 2); + assert.equal(body.meta.tier, "live"); + assert.ok(!JSON.stringify(body).includes("omr_")); +}); + +test("POST offers sync validates an empty body and gates a missing key without network", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + radarDb.setRadarOptIn(true); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + const invalid = await POST( + request("/api/radar/offers/sync", "POST", await authHeaders(), { provider: "groq" }) + ); + assert.equal(invalid.status, 400); + + const response = await POST(request("/api/radar/offers/sync", "POST", await authHeaders())); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: "no_key" }); +}); + +test("local offer routes never call the private server directly", () => { + for (const file of [ + "src/app/api/radar/offers/route.ts", + "src/app/api/radar/offers/sync/route.ts", + ]) { + const source = fs.readFileSync(path.resolve(process.cwd(), file), "utf8"); + assert.ok(!/fetch\(/.test(source), `${file} must stay local-only`); + } +}); diff --git a/tests/unit/radar-offers-sync.test.ts b/tests/unit/radar-offers-sync.test.ts new file mode 100644 index 0000000000..825390de2c --- /dev/null +++ b/tests/unit/radar-offers-sync.test.ts @@ -0,0 +1,185 @@ +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 offersSync = await import("../../src/lib/radar/offersSync.ts"); + +async function fixtureFeed(): Promise> { + const bytes = await readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url)); + return JSON.parse(bytes.toString("utf8")) as Record; +} + +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; +} + +function liveSettings(supporterKey: string | null = `omr_${"a".repeat(40)}`) { + return { optIn: true, supporterKey }; +} + +test("offers sync gates flag, opt-in, and missing supporter key before fetch", async () => { + for (const expected of ["disabled", "opt_out", "no_key"] as const) { + let fetched = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => expected !== "disabled", + getSettings: () => + expected === "opt_out" ? { optIn: false, supporterKey: null } : liveSettings(null), + fetch: (async () => { + fetched = true; + return response(Buffer.from("{}")); + }) as typeof fetch, + }); + assert.equal(result.status, expected); + assert.equal(fetched, false); + } +}); + +test("valid live offer feed sends Bearer server-side and caches exact signed bytes", async () => { + const feed = await fixtureFeed(); + const bytes = Buffer.from(JSON.stringify(feed)); + const signature = sign(bytes); + const writes: offersSync.RadarOffersCacheEntry[] = []; + let requestUrl = ""; + let authorization = ""; + + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => null, + setCache: (entry) => writes.push(entry), + fetch: (async (input, init) => { + requestUrl = String(input); + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return response(bytes, { + "x-omniroute-feed-signature": signature, + "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(requestUrl, "https://radar.omniroute.online/v1/offers/latest"); + assert.equal(authorization, `Bearer omr_${"a".repeat(40)}`); + assert.equal(writes[0]!.payload, bytes.toString("utf8")); + assert.equal(writes[0]!.signature, signature); + assert.equal(writes[0]!.tier, "live"); +}); + +test("signature, schema, and live-tier failures preserve the last good cache", async () => { + const feed = await fixtureFeed(); + const validBytes = Buffer.from(JSON.stringify(feed)); + const cases: Array<{ expected: string; bytes: Buffer; signature: string; tier: string | null }> = + [ + { expected: "invalid_signature", bytes: validBytes, signature: "invalid", tier: "live" }, + { + expected: "invalid_schema", + bytes: Buffer.from('{"feed":"wrong"}'), + signature: "valid-for-case", + tier: "live", + }, + { expected: "wrong_tier", bytes: validBytes, signature: "valid-for-case", tier: null }, + { expected: "wrong_tier", bytes: validBytes, signature: "valid-for-case", tier: "community" }, + ]; + + for (const item of cases) { + item.signature = item.expected === "invalid_signature" ? item.signature : sign(item.bytes); + let written = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => ({ + version: "2026.08.08.1", + tier: "live", + payload: "last-good", + signature: "old", + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(item.bytes, { + "x-omniroute-feed-signature": item.signature, + ...(item.tier ? { "x-omniroute-feed-tier": item.tier } : {}), + })) as typeof fetch, + }); + assert.equal(result.status, item.expected); + assert.equal(written, false); + } +}); + +test("same or older signed offer versions are rejected as stale", async () => { + const feed = await fixtureFeed(); + const bytes = Buffer.from(JSON.stringify(feed)); + let written = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload: "last-good", + signature: "old", + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(bytes, { + "x-omniroute-feed-signature": sign(bytes), + "x-omniroute-feed-tier": "live", + })) as typeof fetch, + }); + + assert.equal(result.status, "stale"); + assert.equal(written, false); +}); + +test("oversized and sanitized network failures never overwrite the cache or leak the key", async () => { + let written = false; + const tooLarge = await offersSync.syncRadarOffers({ + 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(tooLarge.status, "too_large"); + + const secret = `omr_${"b".repeat(40)}`; + const failed = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(secret), + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => { + throw new Error(`upstream failed for ${secret}\n at /private/path.ts:1:1`); + }) as typeof fetch, + }); + assert.equal(failed.status, "error"); + assert.ok(!("reason" in failed) || !failed.reason.includes(secret)); + assert.ok(!("reason" in failed) || !failed.reason.includes("/private/path")); + assert.equal(written, false); +});