Files
OmniRoute/src/lib/radar/sync.ts
Diego Rodrigues de Sa e Souza 1e15583f29 fix(radar): close audit gaps (auth, feed fields, opt-in state, sidebar gate, size cap) + daily sync scheduler (#9686)
* fix(radar): preserve extended feed fields and honor local enable override

applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.

feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.

* fix(radar): cap feed sync response body at 10MB

syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.

Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).

* fix(radar): gate the sidebar radar item behind RADAR_ENABLED

The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.

Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.

* fix(radar): require auth on management routes, add GET settings

GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.

Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).

* docs(radar): fix stale feed URL, document tier header/auth/size cap

- RADAR_FEED_URL default was documented as radar.omniroute.dev in
  ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
  other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
  freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
  re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
  signed artifact per version) and the actually-served tier comes from
  the `x-omniroute-feed-tier` response header, resolved with a Zod parse
  + fallback to the body field.
- Document that all four /api/radar/* routes now require auth
  (isAuthenticated(), same gate as /api/settings/*), the new
  GET /api/radar/settings route, and the new too_large sync status from
  the 10MB response cap.

* feat(radar): daily sync scheduler + auto-sync on page open

Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-07 08:26:18 -03:00

311 lines
10 KiB
TypeScript

/**
* sync.ts — Radar feed sync: download, verify, validate, cache.
*
* This is the ONLY module that touches the network for Radar.
* Every step is gated: flag off / opt-out / bad sig / bad schema /
* stale version all bail early without touching the cache.
*
* Errors never escape `syncRadar()` — always return a status object.
* Stack traces are never included in the `reason` field.
*
* Deps are injectable for testing.
*/
import { RadarFeedSchema, RadarTierSchema, type RadarFeed, type RadarTier } from "./feedSchema";
import { verifyFeedBytes } from "./verify";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* Default feed base URL. Forks and self-hosters point this at their own
* signed feed with the `RADAR_FEED_URL` env var (see docs/frameworks/RADAR.md).
*/
const DEFAULT_FEED_BASE_URL = "https://radar.omniroute.online";
const SYNC_TIMEOUT_MS = 30_000;
/**
* Hard cap on the Radar feed response body. The signed feed is a small JSON
* document (KB-scale) — anything past this is either a misconfigured/hostile
* `RADAR_FEED_URL` or an upstream serving garbage. Enforced both via a
* `Content-Length` preflight (skip reading the body entirely when the
* server already declares an oversized payload) AND a running-total check
* while reading the body (an absent/lying Content-Length must not bypass
* the cap).
*/
const MAX_FEED_BYTES = 10 * 1024 * 1024; // 10 MB
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SyncStatus =
| { status: "disabled" }
| { status: "opt_out" }
| { status: "invalid_signature" }
| { status: "invalid_schema" }
| { status: "stale" }
| { status: "too_large" }
| { status: "updated"; version: string; tier: string }
| { status: "error"; reason: string };
export interface RadarCacheEntry {
version: string;
tier: string;
payload: string;
signature: string;
fetchedAt?: string;
}
export interface RadarSettingsSnapshot {
optIn: boolean;
supporterKey: string | null;
}
export interface SyncDeps {
fetch?: typeof globalThis.fetch;
now?: () => Date;
getFlag?: (key: string) => boolean;
getSettings?: () => RadarSettingsSnapshot;
getCache?: () => RadarCacheEntry | null;
setCache?: (entry: RadarCacheEntry) => void;
}
// ---------------------------------------------------------------------------
// Served-tier header
// ---------------------------------------------------------------------------
/**
* Parse & validate the `x-omniroute-feed-tier` response header.
*
* This header is the AUTHORITATIVE source for which tier was actually
* served to this caller — the server decides per-request based on the
* `Authorization` key, and an invalid/expired key degrades to
* `"community"`. The signed body's `tier` field is always `"live"` by
* design (see `feedSchema.ts`) and must never be shown to the user.
*
* Returns `null` when the header is absent, or holds a value that is not
* exactly `"community"` or `"live"` — an arbitrary/garbage header string
* is never trusted into the UI/DB; callers must fall back to the body's
* `tier` field in that case (also covers older servers that predate this
* header).
*/
function parseServedTierHeader(value: string | null): RadarTier | null {
const result = RadarTierSchema.safeParse(value);
return result.success ? result.data : null;
}
// ---------------------------------------------------------------------------
// Version comparison
// ---------------------------------------------------------------------------
/**
* Compare two `YYYY.MM.DD.n` version strings numerically.
*
* @returns negative if a < b, 0 if equal, positive if a > b.
*/
export function compareVersions(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] ?? 0;
const nb = pb[i] ?? 0;
if (na !== nb) return na - nb;
}
return 0;
}
// ---------------------------------------------------------------------------
// Scheduling helper (exported for UI/route wiring later)
// ---------------------------------------------------------------------------
/**
* Compute the next sync time given the last successful sync timestamp.
* Returns a Date that is ~24h after `lastSyncAt`. If `lastSyncAt` is
* null, sync should happen immediately.
*/
export function nextSyncTime(lastSyncAt: string | null): Date {
if (!lastSyncAt) return new Date(0); // epoch = "sync now"
const last = new Date(lastSyncAt);
return new Date(last.getTime() + 24 * 60 * 60 * 1000);
}
// ---------------------------------------------------------------------------
// syncRadar
// ---------------------------------------------------------------------------
/**
* Download, verify, validate, and cache the Radar feed.
*
* Steps:
* 1. Feature flag off => `{status:"disabled"}`, no network.
* 2. Opt-in false => `{status:"opt_out"}`, no network.
* 3. GET feed with timeout.
* 4. Verify Ed25519 signature over exact bytes.
* 5. Parse+validate with RadarFeedSchema.
* 6. Version floor: incoming must be strictly newer than cache.
* 7. Cache the result.
*
* @param deps - Injectable dependencies for testing.
*/
export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
const {
fetch: fetchFn = globalThis.fetch,
now = () => new Date(),
getFlag = isFeatureFlagEnabled,
getSettings: getSettingsFn,
getCache: getCacheFn,
setCache: setCacheFn,
} = deps;
try {
// Step 1: Feature flag gate
const flagOn = getFlag("RADAR_ENABLED");
if (!flagOn) {
return { status: "disabled" };
}
// Step 2: Opt-in gate
let settings: RadarSettingsSnapshot;
if (getSettingsFn) {
settings = getSettingsFn();
} else {
const mod = await import("@/lib/db/radar");
settings = mod.getRadarSettings();
}
if (!settings.optIn) {
return { status: "opt_out" };
}
// Step 3: Download feed
const baseUrl = (process.env.RADAR_FEED_URL || DEFAULT_FEED_BASE_URL).replace(/\/+$/, "");
const url = `${baseUrl}/v1/catalog/latest`;
const headers: Record<string, string> = {};
if (settings.supporterKey) {
headers["Authorization"] = `Bearer ${settings.supporterKey}`;
}
const res = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok) {
return { status: "error", reason: `Feed request failed with status ${res.status}` };
}
// Step 3b: Content-Length preflight — skip reading an already-oversized
// body entirely. The header is untrusted (may be absent or wrong), so
// this is a fast-path only; the real enforcement is the running-total
// check below.
const contentLengthHeader = res.headers.get("content-length");
if (contentLengthHeader !== null) {
const declaredLength = Number(contentLengthHeader);
if (Number.isFinite(declaredLength) && declaredLength > MAX_FEED_BYTES) {
return { status: "too_large" };
}
}
// Step 4: Read exact bytes + signature header, enforcing MAX_FEED_BYTES
// while reading so an absent/lying Content-Length cannot bypass the cap.
// Concatenating the accumulated chunks preserves the exact bytes needed
// for signature verification below.
let rawBytes: Buffer;
const body = res.body as ReadableStream<Uint8Array> | null | undefined;
if (body && typeof body.getReader === "function") {
const reader = body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
let tooLarge = false;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
total += value.byteLength;
if (total > MAX_FEED_BYTES) {
tooLarge = true;
await reader.cancel().catch(() => {});
break;
}
chunks.push(value);
}
}
if (tooLarge) {
return { status: "too_large" };
}
rawBytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
} else {
const buffered = Buffer.from(await res.arrayBuffer());
if (buffered.byteLength > MAX_FEED_BYTES) {
return { status: "too_large" };
}
rawBytes = buffered;
}
const signature = res.headers.get("x-omniroute-feed-signature") ?? "";
// Step 5: Verify signature
const sigValid = verifyFeedBytes(rawBytes, signature);
if (!sigValid) {
return { status: "invalid_signature" };
}
// Step 6: Parse + validate
let feed: RadarFeed;
try {
const parsed = JSON.parse(rawBytes.toString("utf-8"));
feed = RadarFeedSchema.parse(parsed);
} catch {
return { status: "invalid_schema" };
}
// Step 7: Version floor
let existingCache: RadarCacheEntry | null = null;
if (getCacheFn) {
existingCache = getCacheFn();
} else {
const mod = await import("@/lib/db/radar");
existingCache = mod.getRadarCache();
}
if (existingCache && compareVersions(feed.version, existingCache.version) <= 0) {
return { status: "stale" };
}
// Step 8: Resolve the served tier.
// The `x-omniroute-feed-tier` header reflects the tier ACTUALLY served
// (see `parseServedTierHeader`); fall back to the signed body's `tier`
// field only when the header is absent or unrecognized — never trust an
// arbitrary header value into the cache/UI.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
// Step 9: Cache the result
const cacheEntry: RadarCacheEntry = {
version: feed.version,
tier: servedTier,
payload: rawBytes.toString("utf-8"),
signature,
fetchedAt: now().toISOString(),
};
if (setCacheFn) {
setCacheFn(cacheEntry);
} else {
const mod = await import("@/lib/db/radar");
mod.setRadarCache(cacheEntry);
}
return { status: "updated", version: feed.version, tier: servedTier };
} catch (err: unknown) {
const reason = sanitizeErrorMessage(err) || "Radar sync failed";
return { status: "error", reason };
}
}