docs/frameworks/RADAR.md
Source of truth:
src/lib/radar/,src/lib/db/radar.ts,src/app/api/radar/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
open-sse/config/freeModelCatalog.data.ts). It exists because the free-tier landscape moves
faster than release cadence — providers add, shrink, or discontinue free quotas between
releases, and the baseline catalog can only be refreshed when a new version ships.
Nothing that is free today stops being free. Radar never removes or paywalls a baseline entry; it only refreshes limits/status fields at read time and can layer in newly-discovered free models between releases. The baseline catalog itself is never mutated on disk — see Read-time overlay merge rules below.
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. |
RADAR_ENABLED (default off)Radar is gated end-to-end by the RADAR_ENABLED feature flag
(src/shared/constants/featureFlagDefinitions.ts, category policies,
defaultValue: "false").
When the flag is off, the surface does not exist:
GET /api/radar/catalog, POST /api/radar/sync, POST /api/radar/settings all
return 404 before touching any Radar module./dashboard/radar, /dashboard/radar/setup) 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.syncRadar() (src/lib/radar/sync.ts) returns
{ status: "disabled" } at step 1 without 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 routing or model selection — see the separate opt-in below.
Turning RADAR_ENABLED on only unlocks the UI. Syncing the feed requires a second,
independent opt-in stored in radar_settings.opt_in (src/lib/db/radar.ts,
migration 136_radar_cache_settings.sql). syncRadar() checks the flag and the
opt-in before making any network call:
Flag off → { status: "disabled" } — no network call
Opt-in false → { status: "opt_out" } — no network call
When both are on, the sync path is:
GET <feed base URL>/v1/catalog/latest with an optional Authorization: Bearer <supporter key> header (see below).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
Tiers). It is:
encrypt()/decrypt()
helpers (src/lib/db/encryption.ts) used for provider credentials.POST /api/radar/settings ({ supporterKey: "omr_" + 40 hex chars }) and
never echoed back — the response returns a masked form (omr_****abcd).The activation screen (/dashboard/radar) links out to two flows for obtaining a
supporter key. The OSS repo itself never issues one, never runs payment code, and
never states a price — pricing is decided and displayed entirely on the
destination pages, not in this repo (spec decision D14).
RADAR_CONTRIBUTOR_CLAIM_URL (default
https://radar.omniroute.online/auth/github), a GitHub OAuth claim flow hosted on
the private radar server. It verifies the visitor's GitHub account and grants a
supporter key to anyone with 5+ merged pull requests or a top-100 contributor spot
on the repo.RADAR_SUPPORTER_PLANS_URL (default
https://radar.omniroute.online/planos), the payment/plans page.Both URLs are resolved server-side (src/lib/radar/links.ts, same env-override
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). |
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
path: pasting a key and submitting sends POST /api/radar/settings
({ optIn: true, supporterKey }) in one call — pasting a key both sets it and opts in,
unlocking the screen. The format (omr_ + 40 hex chars) is checked client-side first
with the shared isValidSupporterKeyFormat() helper (src/lib/radar/supporterKey.ts)
as a UX nicety; the server's Zod schema is the authoritative check either way. Once a
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
who already has one activates it.
The feed payload is signed with Ed25519. verifyFeedBytes()
(src/lib/radar/verify.ts) verifies the signature over the exact response bytes
received over the wire — the payload is never re-serialized before verification, so a
byte-for-byte re-encoding cannot silently invalidate or bypass the signature check.
Verification failure (invalid_signature) aborts the sync before the payload is ever
parsed or cached.
The verifying public key is pinned in src/lib/radar/pinnedKeys.ts
(PINNED_FEED_PUBLIC_KEYS), an array so a new key can be prepended ahead of a
rotation while old cached feeds signed with a previous key remain valid until
re-synced.
Two env vars let forks and self-hosters point the client at their own feed instead of the default OmniRoute service — see How to self-host a feed below:
| Var | Purpose |
|---|---|
RADAR_FEED_URL | Overrides the feed base URL (default https://radar.omniroute.online). |
RADAR_FEED_PUBKEY | Overrides the pinned public key (base64-DER SPKI or PEM), replacing the built-in array with this single key. |
syncRadar() rejects a downloaded feed whose version is not strictly newer than the
currently cached version (compareVersions(), dotted YYYY.MM.DD.n comparison) —
{ status: "stale" }. This prevents a compromised or misconfigured feed endpoint from
rolling a client back to an older, differently-signed payload.
The downloaded bytes are parsed and validated against RadarFeedSchema
(src/lib/radar/feedSchema.ts, a Zod schema) after signature verification. A
schema mismatch returns { status: "invalid_schema" } and the cache is left
untouched. The cached payload is defensively re-validated again on every read
(getRadarCatalog()) — a corrupted or hand-edited cache row falls back to the
baseline rather than being served.
syncRadar() enforces a 10 MB hard cap on the feed response body — the signed
feed is a KB-scale JSON document, so anything past this points at a misconfigured or
hostile RADAR_FEED_URL (or an upstream serving garbage), not a legitimate catalog.
Enforcement is two-layered:
Content-Length preflight check skips reading the body entirely when the
header already declares a value over the cap.Content-Length is absent or understates the real size — the header is never
trusted on its own. Concatenating the accumulated chunks preserves the exact
bytes needed for the Ed25519 signature check afterward.Exceeding the cap returns { status: "too_large" } and leaves the cache untouched,
following the same non-destructive pattern as every other sync failure
(invalid_signature, invalid_schema, stale).
community and liveThe feed schema carries a tier: "community" | "live" field, decided server-side
by the feed service based on the request (presence and validity of the supporter key)
— the client never decides its own tier.
community — the free catalog delayed by roughly 30 days behind the freshest
data. This is what an unauthenticated or invalid-key request receives.live — the freshest catalog, served to requests carrying a valid supporter
key.An invalid or expired supporter key degrades to community — it is never an
error. The sync path only distinguishes signature/schema/version failures (all
recoverable, all non-fatal to the cached state) from a successful { status: "updated", version, tier }. There is no tier-specific error path a client needs to
handle.
The signed feed body's tier field is always "live" — the feed service ships
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:
x-omniroute-feed-tier with RadarTierSchema (Zod) — an absent header, or
a value that isn't exactly "community" or "live", is treated as not
present (never trusted into the cache/UI as-is; this also covers older feed
servers that predate the header).tier field (always "live") only when step 1
yields nothing.{ status: "updated", version, tier } — this is the value the dashboard shows, never the raw body
field.applyFeed() (src/lib/radar/applyFeed.ts) merges the cached feed over the
static baseline at read time, inside getRadarCatalog(). The baseline array
(FREE_MODEL_BUDGETS) is never mutated — a MergedEntry[] is computed fresh on every
call.
Four rules, in order of precedence:
localOverrides map, keyed provider:modelId),
the feed's value for that specific field is skipped — the operator's value wins.enabled: false disables the entry, with provenance. A feed entry that turns
an entry off sets enabled: false and disabledBy: "radar" on the merged result,
so the UI can explain why an entry went from available to disabled.tombstones set), the feed re-adding that provider:modelId in a later
version does not bring it back.Every merged entry carries an origin field the UI renders as a badge:
"baseline" — untouched from the static release catalog."radar" — one or more fields were refreshed by the feed."local" — the operator has at least one local override on this entry (local
overrides always win over the feed per rule 1, regardless of what the feed says).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 below. |
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.
All five routes return 404 when RADAR_ENABLED is off (see
Flag above), and route error responses through
buildErrorBody()/sanitizeErrorMessage() per the repo-wide error-sanitization rule
(docs/security/ERROR_SANITIZATION.md).
All five routes require authentication via isAuthenticated()
(src/shared/utils/apiAuth.ts) — a dashboard session cookie or a management-scoped
API key, the same gate that protects the rest of /api/settings/*. The flag-off
404 check always runs before the auth check, so an install with RADAR_ENABLED
off stays byte-identical (no auth prompt just to learn the surface doesn't exist);
once the flag is on, an unauthenticated request gets 401 before any DB read or
write. GET /api/radar/settings never returns the raw supporter key regardless of
auth state — only the masked form and a hasSupporterKey boolean.
Referral links are served from a standalone, always-current feed —
GET /v1/referrals/latest — separate from the catalog feed. This is deliberate: the
catalog feed on the community tier is a snapshot that can be up to 30 days old, so a
referral link extracted from it used to lag the server's real link list by the same
amount (a newly-added referral wouldn't reach a free/community user for up to a month).
The referrals feed removes that delay by syncing on its own, much shorter cadence.
// GET /v1/referrals/latest response body (Ed25519-signed, same pinned key as
// the catalog feed):
{
feed: "omniroute-radar-referrals",
schemaVersion: 1,
generatedAt: string, // ISO — deterministic: max(updatedAt) across referral
// links, so two identical requests produce the exact
// same signed bytes/signature
referrals: {
fixed: RadarReferral[], // present in EVERY tier, including no-auth/community
campaigns: RadarReferral[], // only populated for a valid live (supporter) Bearer
// key; no-auth/expired-key requests get []
},
}
// RadarReferral = { provider, url, kind: "fixo" | "campanha", validUntil,
// requiredAction, isDefault }
Unlike the catalog feed, this body carries no tier field at all — the server decides
what to include per-request based on the Authorization key, so the
x-omniroute-feed-tier response header is the ONLY source for the served tier
(referralsSync.ts::syncRadarReferrals); an absent/unrecognized header degrades to
"community", the least-privileged assumption. RadarReferralsFeedSchema
(src/lib/radar/referralsFeedSchema.ts) validates the whole body, reusing the same
per-referral RadarReferralSchema exported from feedSchema.ts so both feeds validate
individual referrals identically. Every RadarReferral.url must be https:// — a
http:// url fails schema validation.
The OLD catalog-embedded referrals field on RadarFeedSchema (feedSchema.ts) is
kept for backward-compat with already-cached catalog feeds, but getRadarReferrals()
no longer reads it — see Accessor below.
syncRadarReferrals() (src/lib/radar/referralsSync.ts) is the ONLY module that
touches the network for referrals, mirroring syncRadar()'s contract exactly: flag off
→ disabled; opt-in false → opt_out; downloads ${RADAR_FEED_URL}/v1/referrals/latest
(same RADAR_FEED_URL/RADAR_FEED_PUBKEY fork overrides as the catalog), verifies the
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 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:
GET /api/radar/referrals itself calls syncRadarReferrals()
inline whenever the cache is missing or older than REFERRALS_STALE_MS (1h,
shouldSyncReferralsOnRead()), before serving the response. This is what makes fixed
links "always current" for the very next dashboard load, without waiting on any
background timer.radarSchedulerTick() (scheduler.ts) independently
evaluates referrals staleness on the same hourly tick used for the catalog, calling
syncRadarReferrals() when due. This runs regardless of whether the catalog itself
was due that tick, and never affects RadarTickResult's shape (best-effort side
effect only, swallowed on error).src/lib/radar/index.ts exports two read-only accessors, both never throwing (same
defensive contract as getRadarCatalog() — flag off, no cache, or a corrupt cached
payload all resolve to the empty shape instead of an error):
getRadarReferrals() → { fixed: RadarReferral[], campaigns: RadarReferral[] },
reading from radar_referrals_cache (via getRadarReferralsCache()) and validating
through RadarReferralsFeedSchema — not the catalog cache.getDefaultReferralFor(provider) → the fixed referral with isDefault: true for
that provider, or null. Only looks at fixed — a campaign is never used as a
provider's "default" link.The actual "which referral is the default for a provider" rule lives in
findDefaultReferral() (src/lib/radar/referrals.ts), a small pure function with no
DB import — it is safe to import into a "use client" component. getRadarReferrals/
getDefaultReferralFor (in index.ts) pull in @/lib/db/radar and therefore stay
server-only; the providers dashboard imports referrals.ts directly instead of
index.ts (see below) to avoid bundling better-sqlite3 into the browser.
GET /api/radar/referralsFollows the exact same gate order as every other Radar route: RADAR_ENABLED off →
404 (checked first, byte-identical inertia); unauthenticated → 401; otherwise
triggers a sync-on-read (see above) when stale, then 200 with
{ fixed, campaigns, tier } — tier comes straight from the (possibly just-refreshed)
cache row and is purely informative (drives the UI's soft upsell copy below). Never
proxies the feed server directly — the route's own source contains no fetch( call;
the network only ever happens inside syncRadarReferrals(), same local-cache-only
principle as /api/radar/catalog.
/dashboard/radarReuses the existing Radar page (src/app/(dashboard)/dashboard/radar/page.tsx) as a
second tab instead of a new route — less routing/i18n surface for a feature that is a
variation on data the page already fetches. Once opted in, the tab bar offers
Catalog (existing table) and Free credits:
requiredAction (when present)
and a target="_blank" rel="noopener noreferrer" button to the referral URL.validUntil when present.campaigns is empty and the served tier is community, the UI shows a
short upsell note ("limited-time campaigns are a supporter extra") — this never
hides or gates the fixed links list, which stays fully populated for every tier. The
upsell is soft messaging only, never a block.ProviderPageHeader (src/app/(dashboard)/dashboard/providers/[id]/components/)
already linked the provider name to providerInfo.website when present, with one
precedent for a monetized link: the Kimi (Moonshot AI) partner-link note
(providers.kimiPartnerLinkNote i18n key). D28 reuses that exact same discreet-note
pattern for Radar default referrals instead of introducing a new key.
Loose coupling, by design:
resolveProviderHeaderLink() (src/app/(dashboard)/dashboard/providers/providerPageUtils.ts)
is a pure function — (staticWebsite, referralUrl) => { website, isReferralLink }
— with no dependency on @/lib/radar or @/lib/db/*. providerPageUtils.ts as a
whole stays free of those imports (asserted by
tests/unit/provider-header-referral-link.test.ts).ProviderDetailPageClient.tsx (a "use client" component) is the one place allowed
to fetch Radar data — via fetch("/api/radar/referrals"), the same local-route
pattern the Radar dashboard page itself uses — and it computes the default referral
client-side with findDefaultReferral() from the DB-free src/lib/radar/referrals.ts.RADAR_ENABLED off, the fetch 404s, referralUrl stays null, and
resolveProviderHeaderLink() returns the static catalog website unchanged — the
provider page is byte-identical to before this feature existed. Same outcome when
there is no cache yet or no default referral for that specific provider.ProviderPageHeader receives isReferralLink
and shows the same discreet note/tooltip as the Kimi partner link (reusing the
providers.kimiPartnerLinkNote key) — never a new, separate visual treatment.A fork or self-hoster that wants full control over the catalog can run their own feed service without touching client code:
GET /v1/catalog/latest endpoint returning a JSON body that satisfies
RadarFeedSchema (src/lib/radar/feedSchema.ts) — top-level feed: "omniroute-radar", schemaVersion: 1, version, tier, providers, models,
quirks, and totals.x-omniroute-feed-signature response header.RADAR_FEED_URL to the new base URL and RADAR_FEED_PUBKEY to the matching
public key (base64-DER SPKI or PEM) — see the
env var reference.RADAR_ENABLED and opt in via POST /api/radar/settings
({ optIn: true }).No other code changes are required — verifyFeedBytes() picks up the override
automatically (getFeedPublicKeys() in src/lib/radar/pinnedKeys.ts), and version
comparison, schema validation, and the merge rules apply identically to a self-hosted
feed.
Referral links (see Referral links (free credits)
above) are a separate, optional artifact: a fork that only serves /v1/catalog/latest
still works fully — syncRadarReferrals() degrades to { status: "error" } on a 404
from /v1/referrals/latest and the cache simply stays empty, so
GET /api/radar/referrals keeps returning { fixed: [], campaigns: [], tier: null }
instead of failing the rest of the page. To also offer referral links, serve
GET /v1/referrals/latest satisfying RadarReferralsFeedSchema
(src/lib/radar/referralsFeedSchema.ts) and sign it with the same Ed25519 key pair as
the catalog feed.
docs/security/ERROR_SANITIZATION.md — the
error-response pattern the five /api/radar/* routes follow.docs/reference/ENVIRONMENT.md
— RADAR_FEED_URL / RADAR_FEED_PUBKEY reference.