diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 1e36a4e228..a95e1d8ee6 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") && ( { + 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}`); + } + } +});