From 340edbc997ee777a6469068a2333860ed4184221 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 20:02:59 -0300 Subject: [PATCH 001/134] feat(radar): persist local catalog state --- docs/frameworks/RADAR.md | 57 ++- .../dashboard/radar/RadarCatalogTable.tsx | 361 ++++++++++++++++++ src/app/(dashboard)/dashboard/radar/page.tsx | 196 ++-------- src/app/api/radar/local-model-state/route.ts | 165 ++++++++ src/i18n/messages/ar.json | 14 +- src/i18n/messages/az.json | 14 +- src/i18n/messages/bg.json | 14 +- src/i18n/messages/bn.json | 14 +- src/i18n/messages/cs.json | 14 +- src/i18n/messages/da.json | 14 +- src/i18n/messages/de.json | 14 +- src/i18n/messages/en.json | 14 +- src/i18n/messages/es.json | 14 +- src/i18n/messages/fa.json | 14 +- src/i18n/messages/fi.json | 14 +- src/i18n/messages/fr.json | 14 +- src/i18n/messages/gu.json | 14 +- src/i18n/messages/he.json | 14 +- src/i18n/messages/hi.json | 14 +- src/i18n/messages/hu.json | 14 +- src/i18n/messages/id.json | 14 +- src/i18n/messages/in.json | 14 +- src/i18n/messages/it.json | 14 +- src/i18n/messages/ja.json | 14 +- src/i18n/messages/ko.json | 14 +- src/i18n/messages/mr.json | 14 +- src/i18n/messages/ms.json | 14 +- src/i18n/messages/nl.json | 14 +- src/i18n/messages/no.json | 14 +- src/i18n/messages/phi.json | 14 +- src/i18n/messages/pl.json | 14 +- src/i18n/messages/pt-BR.json | 14 +- src/i18n/messages/pt.json | 14 +- src/i18n/messages/ro.json | 14 +- src/i18n/messages/ru.json | 14 +- src/i18n/messages/sk.json | 14 +- src/i18n/messages/sv.json | 14 +- src/i18n/messages/sw.json | 14 +- src/i18n/messages/ta.json | 14 +- src/i18n/messages/te.json | 14 +- src/i18n/messages/th.json | 14 +- src/i18n/messages/tr.json | 14 +- src/i18n/messages/uk-UA.json | 14 +- src/i18n/messages/ur.json | 14 +- src/i18n/messages/vi.json | 14 +- src/i18n/messages/zh-CN.json | 14 +- src/i18n/messages/zh-TW.json | 14 +- .../143_radar_local_model_state.sql | 16 + src/lib/db/radar.ts | 242 ++++++++++++ src/lib/localDb.ts | 16 +- src/lib/radar/index.ts | 26 +- tests/unit/radar-apply-feed.test.ts | 1 + tests/unit/radar-local-state-db.test.ts | 175 +++++++++ tests/unit/radar-local-state-route.test.ts | 155 ++++++++ tests/unit/radar-local-state-ui.test.ts | 67 ++++ 55 files changed, 1849 insertions(+), 230 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx create mode 100644 src/app/api/radar/local-model-state/route.ts create mode 100644 src/lib/db/migrations/143_radar_local_model_state.sql create mode 100644 tests/unit/radar-local-state-db.test.ts create mode 100644 tests/unit/radar-local-state-route.test.ts create mode 100644 tests/unit/radar-local-state-ui.test.ts diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 3a239157d8..1d1d1015d7 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -15,11 +15,12 @@ catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in 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](#read-time-overlay-merge-rules) -below. +**Nothing that is free today stops being free because of the remote feed.** Radar never +paywalls a baseline entry; it only refreshes limits/status fields at read time and can +layer in newly-discovered free models between releases. An operator can still hide a +model locally, and can restore it from the same dashboard. The baseline catalog itself +is never mutated on disk — see +[Read-time overlay merge rules](#read-time-overlay-merge-rules) below. --- @@ -31,7 +32,7 @@ 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. | +| 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. | | 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. | @@ -48,7 +49,7 @@ Radar is gated end-to-end by the `RADAR_ENABLED` feature flag **When the flag is off, the surface does not exist:** -- `GET /api/radar/catalog`, `POST /api/radar/sync`, `POST /api/radar/settings` all +- 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 `notFound()`. @@ -271,6 +272,24 @@ Four rules, in order of precedence: entry (`tombstones` set), the feed re-adding that `provider:modelId` in a later version does not bring it back. +The editable fields and tombstones are persisted in +`radar_local_model_state` (migration `143_radar_local_model_state.sql`). The public DB +adapter (`src/lib/db/radar.ts`) converts those rows into the `localOverrides` map and +`tombstones` set used by `applyFeed()`; production `getRadarCatalog()` loads that state +after the flag, cache, and schema gates pass. Only `displayName` and `enabled` are +operator-editable. Provider/model identity, feed provenance, quota, capabilities, ToS, +and setup data cannot be written through this surface. + +The dashboard exposes four local actions: + +- **Edit** changes the local display name and enabled state. +- **Reset local changes** clears both editable fields without changing a tombstone. +- **Hide** creates a tombstone, so later feed updates cannot recreate the row. +- **Restore** removes the tombstone; any separately-saved override remains in effect. + +A feed `enabled: false` remains the safety exception: it wins over a stale local +`enabled: true`, keeps the merged entry disabled, and records `disabledBy: "radar"`. + ### Provenance markers Every merged entry carries an `origin` field the UI renders as a badge: @@ -284,15 +303,19 @@ Every merged entry carries an `origin` field the UI renders as a badge: ## Local surfaces — never a feed proxy -Five local routes back the UI, all under `src/app/api/radar/`: +Six local endpoints back the UI, all under `src/app/api/radar/`: -| Route | Method | Purpose | -| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | -| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | -| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | -| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | -| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | -| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| Route | Method | Purpose | +| ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | +| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | +| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | +| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | +| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| `/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 @@ -300,14 +323,14 @@ to the local OmniRoute server. The two modules that touch the Radar service are 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 +All six endpoints return `404` when `RADAR_ENABLED` is off (see [Flag](#flag-radar_enabled-default-off) above), and route error responses through `buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule (`docs/security/ERROR_SANITIZATION.md`). ### Authentication -All five routes require authentication via `isAuthenticated()` +All six endpoints 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` diff --git a/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx new file mode 100644 index 0000000000..a9d4123101 --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; + +export interface RadarMergedEntry { + provider: string; + modelId: string; + displayName: string; + monthlyTokens: number; + creditTokens: number; + freeType: string; + poolKey: string | null; + tos: string; + trainsOnPrompts?: boolean; + enabled?: boolean; + origin: "baseline" | "radar" | "local"; + disabledBy?: "radar"; + contextWindow?: number | null; + capabilities?: { tools: boolean; vision: boolean; thinking: boolean }; + budget?: { kind: string; tokensPerMonth?: number; poolId?: string }; + limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null }; + setup?: { keyUrl: string | null; steps: string[] } | null; +} + +interface RadarLocalModelState { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; + updatedAt: string; +} + +interface RadarCatalogTableProps { + entries: RadarMergedEntry[]; + refreshCatalog: () => Promise; + onError: (message: string) => void; +} + +function formatTokens(value: number): string { + if (value === 0) return "rate-only"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`; + return String(value); +} + +function budgetLabel(entry: RadarMergedEntry): string { + if (entry.budget?.kind === "shared_pool") { + return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`; + } + if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only"; + return `${formatTokens(entry.monthlyTokens)}/mo`; +} + +export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCatalogTableProps) { + const t = useTranslations("radarPage"); + const [states, setStates] = useState([]); + const [editingKey, setEditingKey] = useState(null); + const [displayName, setDisplayName] = useState(""); + const [enabled, setEnabled] = useState(true); + const [saving, setSaving] = useState(false); + + const loadState = useCallback(async () => { + try { + const response = await fetch("/api/radar/local-model-state"); + if (!response.ok) return; + const payload = await response.json(); + setStates(Array.isArray(payload.states) ? payload.states : []); + } catch { + onError(t("errorLoading")); + } + }, [onError, t]); + + useEffect(() => { + void loadState(); + }, [loadState]); + + const stateByKey = useMemo( + () => new Map(states.map((state) => [`${state.provider}:${state.modelId}`, state])), + [states] + ); + const hiddenModels = useMemo(() => states.filter((state) => state.tombstoned), [states]); + + const applyResponse = useCallback(async (response: Response) => { + if (!response.ok) throw new Error("save_failed"); + const payload = await response.json(); + setStates(Array.isArray(payload.states) ? payload.states : []); + }, []); + + const mutate = useCallback( + async (operation: () => Promise) => { + setSaving(true); + onError(""); + try { + await applyResponse(await operation()); + setEditingKey(null); + await refreshCatalog(); + } catch { + onError(t("localStateSaveFailed")); + } finally { + setSaving(false); + } + }, + [applyResponse, onError, refreshCatalog, t] + ); + + const beginEdit = useCallback((entry: RadarMergedEntry) => { + setEditingKey(`${entry.provider}:${entry.modelId}`); + setDisplayName(entry.displayName); + setEnabled(entry.enabled !== false); + }, []); + + const saveOverride = useCallback( + (entry: RadarMergedEntry) => + mutate(() => + fetch("/api/radar/local-model-state", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: entry.provider, + modelId: entry.modelId, + displayName, + enabled, + }), + }) + ), + [displayName, enabled, mutate] + ); + + const resetOverride = useCallback( + (entry: Pick) => { + const query = new URLSearchParams({ provider: entry.provider, modelId: entry.modelId }); + return mutate(() => + fetch(`/api/radar/local-model-state?${query.toString()}`, { method: "DELETE" }) + ); + }, + [mutate] + ); + + const setTombstone = useCallback( + (provider: string, modelId: string, tombstoned: boolean) => + mutate(() => + fetch("/api/radar/local-model-state", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, modelId, tombstoned }), + }) + ), + [mutate] + ); + + return ( + <> + +
+ + + + + + + + + + + + + + {entries.map((entry) => { + const key = `${entry.provider}:${entry.modelId}`; + const localState = stateByKey.get(key); + const hasOverride = + localState && (localState.displayName !== null || localState.enabled !== null); + return ( + + + + + + + + + + ); + })} + +
{t("colProvider")}{t("colModel")}{t("colQuota")}{t("colContext")}{t("colCapabilities")}{t("colTos")}{t("colActions")}
+
+ {entry.provider} + {entry.origin === "radar" && ( + + {t("newBadge")} + + )} + {entry.origin === "local" && ( + + {t("localBadge")} + + )} + {entry.setup?.keyUrl && ( + + ⚙ + + )} +
+ {entry.enabled === false && entry.disabledBy === "radar" && ( +

{t("disabledByFeed")}

+ )} +
+ {editingKey === key ? ( + setDisplayName(event.target.value)} + aria-label={t("modelDisplayName")} + maxLength={160} + className="w-full min-w-[180px] px-2 py-1 rounded border border-border bg-transparent text-text-main focus:outline-none focus:ring-2 focus:ring-violet-500" + /> + ) : ( + {entry.displayName} + )} + {budgetLabel(entry)} + {entry.contextWindow ? `${(entry.contextWindow / 1000).toFixed(0)}K` : "—"} + +
+ {entry.capabilities?.tools && ( + + {t("capTools")} + + )} + {entry.capabilities?.vision && ( + + {t("capVision")} + + )} + {entry.capabilities?.thinking && ( + + {t("capThinking")} + + )} +
+
+ + {entry.tos} + + + {editingKey === key ? ( +
+ + + +
+ ) : ( +
+ + {hasOverride && ( + + )} + +
+ )} +
+
+
+ + {hiddenModels.length > 0 && ( + +
+

{t("hiddenModelsTitle")}

+ {hiddenModels.map((state) => ( +
+
+ {state.provider} + + {state.displayName ?? state.modelId} + +
+ +
+ ))} +
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index be0e20aa03..9153ba254d 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -3,10 +3,10 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { notFound } from "next/navigation"; -import Link from "next/link"; import { Card } from "@/shared/components"; import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync"; import { isValidSupporterKeyFormat } from "@/lib/radar/supporterKey"; +import { RadarCatalogTable, type RadarMergedEntry } from "./RadarCatalogTable"; // --------------------------------------------------------------------------- // Types @@ -18,27 +18,6 @@ interface RadarMeta { fetchedAt: string; } -interface RadarMergedEntry { - provider: string; - modelId: string; - displayName: string; - monthlyTokens: number; - creditTokens: number; - freeType: string; - poolKey: string | null; - tos: string; - trainsOnPrompts?: boolean; - enabled?: boolean; - origin: "baseline" | "radar" | "local"; - disabledBy?: "radar"; - // Extended feed fields (present when origin=radar) - contextWindow?: number | null; - capabilities?: { tools: boolean; vision: boolean; thinking: boolean }; - budget?: { kind: string; tokensPerMonth?: number; poolId?: string }; - limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null }; - setup?: { keyUrl: string | null; steps: string[] } | null; -} - type PageState = "flag_off" | "optin_pending" | "empty" | "populated"; /** D28 — referral links / free credits. Client-side mirror of RadarReferral. */ @@ -67,7 +46,7 @@ type RadarTabId = "catalog" | "referrals"; export function resolveRadarPageState( flagOn: boolean, optedIn: boolean, - hasEntries: boolean, + hasEntries: boolean ): PageState { if (!flagOn) return "flag_off"; if (!optedIn) return "optin_pending"; @@ -90,23 +69,6 @@ function relativeTime(isoDate: string): string { return `${days}d ago`; } -/** Format token count as human-readable. */ -function formatTokens(n: number): string { - if (n === 0) return "rate-only"; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; - return String(n); -} - -/** Budget display string. */ -function budgetLabel(entry: RadarMergedEntry): string { - if (entry.budget?.kind === "shared_pool") { - return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`; - } - if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only"; - return `${formatTokens(entry.monthlyTokens)}/mo`; -} - // --------------------------------------------------------------------------- // Page Component // --------------------------------------------------------------------------- @@ -144,31 +106,34 @@ export default function RadarPage() { const [hasSupporterKey, setHasSupporterKey] = useState(false); const [supporterKeyMasked, setSupporterKeyMasked] = useState(null); const [showKeyForm, setShowKeyForm] = useState(false); - // Fetch catalog - const fetchCatalog = useCallback(async () => { - setLoading(true); - setError(""); - try { - const res = await fetch("/api/radar/catalog"); - if (res.status === 404) { - // Flag off — treat as not found - setOptIn(false); - setEntries([]); - setMeta(null); - setLoading(false); - return; + const fetchCatalog = useCallback( + async (showLoading = true) => { + if (showLoading) setLoading(true); + setError(""); + try { + const res = await fetch("/api/radar/catalog"); + if (res.status === 404) { + // Flag off — treat as not found + setOptIn(false); + setEntries([]); + setMeta(null); + if (showLoading) setLoading(false); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setEntries(data.entries || []); + setMeta(data.meta || null); + } catch (err) { + setError(err instanceof Error ? err.message : t("errorLoading")); + } finally { + if (showLoading) setLoading(false); } - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setEntries(data.entries || []); - setMeta(data.meta || null); - } catch (err) { - setError(err instanceof Error ? err.message : t("errorLoading")); - } finally { - setLoading(false); - } - }, [t]); + }, + [t] + ); + const refreshCatalogSilently = useCallback(() => fetchCatalog(false), [fetchCatalog]); // D28 — fetch the referral links section ("Pegue seus créditos grátis"). // Best-effort: flag off => 404, no cache => empty shape; either way this @@ -204,7 +169,7 @@ export default function RadarPage() { setOptIn(settingsData.optIn === true); setHasSupporterKey(settingsData.hasSupporterKey === true); setSupporterKeyMasked( - typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null, + typeof settingsData.supporterKeyMasked === "string" ? settingsData.supporterKeyMasked : null ); // F4/T7 — best-effort: keep whatever we already had if the field is // absent (older cached response shape), never fall back to a literal. @@ -334,7 +299,7 @@ export default function RadarPage() { const pageState = resolveRadarPageState( optIn !== false, // if we got a 404, optIn=false => flag off optIn === true, - entries.length > 0 && meta !== null, + meta !== null ); // Flag off — render not-found @@ -379,9 +344,7 @@ export default function RadarPage() { )} - {error && ( -
{error}
- )} + {error &&
{error}
} {loading ? (
@@ -625,98 +588,11 @@ export default function RadarPage() { {/* Populated catalog table */} {pageState === "populated" && activeTab === "catalog" && ( - -
- - - - - - - - - - - - - {entries.map((entry) => ( - - - - - - - - - ))} - -
{t("colProvider")}{t("colModel")}{t("colQuota")}{t("colContext")}{t("colCapabilities")}{t("colTos")}
-
- {entry.provider} - {entry.origin === "radar" && ( - - {t("newBadge")} - - )} - {entry.setup?.keyUrl && ( - - ⚙ - - )} -
- {entry.enabled === false && entry.disabledBy === "radar" && ( -

{t("disabledByFeed")}

- )} -
- {entry.displayName} - {budgetLabel(entry)} - {entry.contextWindow - ? `${(entry.contextWindow / 1000).toFixed(0)}K` - : "—"} - -
- {entry.capabilities?.tools && ( - - {t("capTools")} - - )} - {entry.capabilities?.vision && ( - - {t("capVision")} - - )} - {entry.capabilities?.thinking && ( - - {t("capThinking")} - - )} -
-
- - {entry.tos} - -
-
-
+ )} )} diff --git a/src/app/api/radar/local-model-state/route.ts b/src/app/api/radar/local-model-state/route.ts new file mode 100644 index 0000000000..e569526ac0 --- /dev/null +++ b/src/app/api/radar/local-model-state/route.ts @@ -0,0 +1,165 @@ +/** + * Local-only Radar model overrides and tombstones. + * + * The browser never sends these settings to the private Radar server. The + * route is management-authenticated and exists only while RADAR_ENABLED is on. + */ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { + clearRadarLocalModelOverride, + listRadarLocalModelState, + setRadarLocalModelOverride, + setRadarModelTombstone, +} from "@/lib/db/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const providerSchema = z + .string() + .trim() + .regex(/^[a-z0-9][a-z0-9._-]{0,99}$/i); +const modelIdSchema = z + .string() + .trim() + .min(1) + .max(200) + .refine((value) => !/[\u0000-\u001f\u007f]/.test(value)); +const identityShape = { provider: providerSchema, modelId: modelIdSchema }; + +const overrideSchema = z + .object({ + ...identityShape, + displayName: z + .string() + .trim() + .min(1) + .max(160) + .refine((value) => !/[\u0000-\u001f\u007f]/.test(value)) + .nullable() + .optional(), + enabled: z.boolean().nullable().optional(), + }) + .strict() + .refine( + (value) => + Object.prototype.hasOwnProperty.call(value, "displayName") || + Object.prototype.hasOwnProperty.call(value, "enabled") + ); + +const tombstoneSchema = z.object({ ...identityShape, tombstoned: z.boolean() }).strict(); +const identitySchema = z.object(identityShape).strict(); + +function json(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { + status, + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); +} + +function error(status: number, message: string): NextResponse { + return json(buildErrorBody(status, message), status); +} + +async function authorize(request: Request): Promise { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) return error(404, "Not found"); + if (!(await isAuthenticated(request))) return error(401, "Unauthorized"); + return null; +} + +async function readJson(request: Request): Promise { + try { + return await request.json(); + } catch { + return null; + } +} + +function stateResponse(): NextResponse { + return json({ states: listRadarLocalModelState() }); +} + +function internalError(cause: unknown): NextResponse { + return error(500, sanitizeErrorMessage(cause) || "Failed to update Radar local state"); +} + +export async function OPTIONS(): Promise { + return handleCorsOptions(); +} + +export async function GET(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + try { + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function PATCH(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const parsed = overrideSchema.safeParse(await readJson(request)); + if (!parsed.success) return error(400, "Invalid Radar local override"); + + try { + const { provider, modelId, displayName, enabled } = parsed.data; + const patch: { displayName?: string | null; enabled?: boolean | null } = {}; + if (Object.prototype.hasOwnProperty.call(parsed.data, "displayName")) { + patch.displayName = displayName; + } + if (Object.prototype.hasOwnProperty.call(parsed.data, "enabled")) patch.enabled = enabled; + if (!setRadarLocalModelOverride(provider, modelId, patch)) { + return error(400, "Invalid Radar local override"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function PUT(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const parsed = tombstoneSchema.safeParse(await readJson(request)); + if (!parsed.success) return error(400, "Invalid Radar tombstone"); + + try { + const { provider, modelId, tombstoned } = parsed.data; + if (!setRadarModelTombstone(provider, modelId, tombstoned)) { + return error(400, "Invalid Radar tombstone"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function DELETE(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const url = new URL(request.url); + const parsed = identitySchema.safeParse({ + provider: url.searchParams.get("provider"), + modelId: url.searchParams.get("modelId"), + }); + if (!parsed.success) return error(400, "provider and modelId are required"); + + try { + if (!clearRadarLocalModelOverride(parsed.data.provider, parsed.data.modelId)) { + return error(400, "Invalid Radar local override"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 9e98994974..538ad5c46c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "إعداد المزود", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 47a03d8d7c..cc769f7cc0 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Təchizatçı Quraşdırması", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index cf8c6bc8f1..96673988c8 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Настройка на доставчика", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e483932fa4..b8d43decf1 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "প্রদানকারী সেটআপ", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 0609644a0e..ffa479e6b3 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Nastavení poskytovatele", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 380bc7cf83..3e4de43df6 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Udbyder Opsætning", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index aa515ce09a..863d26f17d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Anbieter-Einrichtung", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5ee194979c..2456b1b239 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index afb6a8317c..0307eae7e6 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configuración del proveedor", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index f8939025e2..64f1043b66 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "راه‌اندازی تأمین‌کننده", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index fa32b59a5e..beb0015714 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Palveluntarjoajan asennus", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 06c7424641..2070135c6c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configuration du fournisseur", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 024bd04ff2..6658097ec5 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "પ્રદાતા સેટઅપ", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index b6dc7a1b22..49ad5b30d6 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "הגדרת ספק", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index bc0a978377..1bafea2d5b 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 777acfc59d..ff33c18cbe 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Szolgáltató beállítása", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 41dbb6c12a..d973c59b57 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Pengaturan Penyedia", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index d856f0511d..7b9d909c05 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index f7cdce01d4..95e685a9ee 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configurazione del fornitore", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 13503227f4..d94486178e 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "プロバイダーセットアップ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 258e5b9c7d..d0a54efa24 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "제공자 설정", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index e64bf7fbbe..3d6f33eb93 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 14971e35d7..3255cd29e3 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Penyediaan Penyedia", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3063a4237a..a241e177c8 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverancier Configuratie", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index eb14cdadbe..4db9cb01ee 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverandøroppsett", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index ffe828d87b..282d0cf0dd 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index ee53ffade7..7dcb363c91 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Konfiguracja dostawcy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index f85fbba50d..0669292f6e 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Ações", + "localBadge": "local", + "editModel": "Editar", + "saveModel": "Salvar", + "cancelEdit": "Cancelar", + "resetModel": "Limpar alterações locais", + "hideModel": "Ocultar", + "restoreModel": "Restaurar", + "hiddenModelsTitle": "Modelos ocultos", + "modelDisplayName": "Nome local do modelo", + "modelEnabled": "Ativado localmente", + "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar" }, "radarSetupPage": { "title": "Configuração do Provedor", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ea789f8b49..61d984efe3 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Campanhas por tempo limitado", "campaignsEmpty": "Sem campanhas ativas de momento — volta mais tarde.", "campaignsUpsellCommunity": "As campanhas por tempo limitado são um extra de supporter. Tudo o que está nos links fixos desta página continua grátis para todos.", - "campaignsValidUntil": "Válido até {date}" + "campaignsValidUntil": "Válido até {date}", + "colActions": "Ações", + "localBadge": "local", + "editModel": "Editar", + "saveModel": "Guardar", + "cancelEdit": "Cancelar", + "resetModel": "Repor alterações locais", + "hideModel": "Ocultar", + "restoreModel": "Restaurar", + "hiddenModelsTitle": "Modelos ocultos", + "modelDisplayName": "Nome de exibição local", + "modelEnabled": "Ativado localmente", + "localStateSaveFailed": "Falha ao guardar as definições locais do Radar" }, "radarSetupPage": { "title": "Configuração do Fornecedor", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index d0c93437d6..afe16e648c 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configurare Furnizor", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 86be068570..4f0930ade8 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Настройка провайдера", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index de0085e0f6..ee62183ce8 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Nastavenie poskytovateľa", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index ece012880e..88d9275ec3 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverantörsinstallation", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 1dbc109a28..5d44c4b8b1 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverantörsinstallation", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 46844ad9e1..31351d2de4 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "வழங்குநர் அமைப்பு", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 0945649c7d..8b12e89546 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "ప్రొవైడర్ సెటప్", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index e408c398f9..36f622709a 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "การตั้งค่าผู้ให้บริการ", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index f70d112a9f..9b7461b08e 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Sağlayıcı Kurulumu", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index dbd6010a4a..30401ee1b9 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Налаштування постачальника", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index e6b409cff0..260e7dd57c 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "فراہم کنندہ سیٹ اپ", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 8cb05728d6..f985b77f74 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Thiết lập nhà cung cấp", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 3d8de6f6b2..74896f1944 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "提供者设置", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 488312f384..d30f4e2f30 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13124,7 +13124,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "提供者設置", diff --git a/src/lib/db/migrations/143_radar_local_model_state.sql b/src/lib/db/migrations/143_radar_local_model_state.sql new file mode 100644 index 0000000000..eec63c86f4 --- /dev/null +++ b/src/lib/db/migrations/143_radar_local_model_state.sql @@ -0,0 +1,16 @@ +-- 143_radar_local_model_state.sql +-- Operator-owned Radar catalog state. +-- +-- Overrides are intentionally limited to display_name and enabled. A +-- tombstone is stored independently so clearing an override cannot +-- accidentally resurrect a model the operator explicitly hid. + +CREATE TABLE IF NOT EXISTS radar_local_model_state ( + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + display_name TEXT, + enabled INTEGER CHECK (enabled IS NULL OR enabled IN (0, 1)), + tombstoned INTEGER NOT NULL DEFAULT 0 CHECK (tombstoned IN (0, 1)), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, model_id) +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index 6b76630362..7a03854ef7 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -13,6 +13,10 @@ * (`GET /v1/referrals/latest` — a separate, always-current artifact from * the catalog feed, see `src/lib/radar/referralsSync.ts`). * + * Tables (migration 143): + * - radar_local_model_state: operator-owned display/enabled overrides and + * deletion tombstones, keyed by provider + model ID. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -47,6 +51,34 @@ export interface RadarReferralsCache { fetchedAt: string; } +export interface RadarLocalModelState { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; + updatedAt: string; +} + +export interface RadarLocalModelOverridePatch { + displayName?: string | null; + enabled?: boolean | null; +} + +export interface RadarLocalMergeState { + localOverrides: Map; + tombstones: Set; +} + +interface RadarLocalModelStateRow { + provider: string; + model_id: string; + display_name: string | null; + enabled: number | null; + tombstoned: number; + updated_at: string; +} + // --------------------------------------------------------------------------- // radar_feed_cache // --------------------------------------------------------------------------- @@ -193,3 +225,213 @@ export function setRadarReferralsCache(entry: { fetched_at = excluded.fetched_at` ).run(entry.generatedAt, entry.tier, entry.payload, entry.signature, fetchedAt); } + +// --------------------------------------------------------------------------- +// radar_local_model_state +// --------------------------------------------------------------------------- + +const RADAR_PROVIDER_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/i; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +function normalizeRadarIdentity( + provider: unknown, + modelId: unknown +): { provider: string; modelId: string } | null { + const normalizedProvider = typeof provider === "string" ? provider.trim() : ""; + const normalizedModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!RADAR_PROVIDER_PATTERN.test(normalizedProvider)) return null; + if ( + normalizedModelId.length < 1 || + normalizedModelId.length > 200 || + CONTROL_CHARACTER_PATTERN.test(normalizedModelId) + ) { + return null; + } + return { provider: normalizedProvider, modelId: normalizedModelId }; +} + +function normalizeDisplayName(value: unknown): string | null | undefined { + if (value === undefined || value === null) return value; + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + if ( + normalized.length < 1 || + normalized.length > 160 || + CONTROL_CHARACTER_PATTERN.test(normalized) + ) { + return undefined; + } + return normalized; +} + +function rowToRadarLocalModelState(row: RadarLocalModelStateRow): RadarLocalModelState { + return { + provider: row.provider, + modelId: row.model_id, + displayName: row.display_name, + enabled: row.enabled === null ? null : row.enabled === 1, + tombstoned: row.tombstoned === 1, + updatedAt: row.updated_at, + }; +} + +function readRadarLocalModelStateRow( + provider: string, + modelId: string +): RadarLocalModelStateRow | null { + return ( + (getDbInstance() + .prepare( + `SELECT provider, model_id, display_name, enabled, tombstoned, updated_at + FROM radar_local_model_state WHERE provider = ? AND model_id = ?` + ) + .get(provider, modelId) as RadarLocalModelStateRow | undefined) ?? null + ); +} + +function persistRadarLocalModelState(input: { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; +}): void { + const db = getDbInstance(); + if (input.displayName === null && input.enabled === null && !input.tombstoned) { + db.prepare("DELETE FROM radar_local_model_state WHERE provider = ? AND model_id = ?").run( + input.provider, + input.modelId + ); + return; + } + + db.prepare( + `INSERT INTO radar_local_model_state + (provider, model_id, display_name, enabled, tombstoned, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(provider, model_id) DO UPDATE SET + display_name = excluded.display_name, + enabled = excluded.enabled, + tombstoned = excluded.tombstoned, + updated_at = excluded.updated_at` + ).run( + input.provider, + input.modelId, + input.displayName, + input.enabled === null ? null : input.enabled ? 1 : 0, + input.tombstoned ? 1 : 0 + ); +} + +/** List every persisted override/tombstone for UI editing and restore controls. */ +export function listRadarLocalModelState(): RadarLocalModelState[] { + const rows = getDbInstance() + .prepare( + `SELECT provider, model_id, display_name, enabled, tombstoned, updated_at + FROM radar_local_model_state ORDER BY provider, model_id` + ) + .all() as RadarLocalModelStateRow[]; + return rows.map(rowToRadarLocalModelState); +} + +/** + * Merge a validated partial override into the existing row. `null` clears a + * field; `undefined` preserves it. Tombstone state is never changed here. + */ +export function setRadarLocalModelOverride( + provider: unknown, + modelId: unknown, + patch: RadarLocalModelOverridePatch +): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity || !patch || typeof patch !== "object") return false; + const keys = Object.keys(patch); + if ( + keys.length === 0 || + keys.some((key) => key !== "displayName" && key !== "enabled") || + (Object.hasOwn(patch, "enabled") && + patch.enabled !== null && + typeof patch.enabled !== "boolean") + ) { + return false; + } + + const normalizedDisplayName = normalizeDisplayName(patch.displayName); + if (Object.hasOwn(patch, "displayName") && normalizedDisplayName === undefined) return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: Object.hasOwn(patch, "displayName") + ? (normalizedDisplayName ?? null) + : (current?.display_name ?? null), + enabled: Object.hasOwn(patch, "enabled") + ? (patch.enabled ?? null) + : current?.enabled === null || current?.enabled === undefined + ? null + : current.enabled === 1, + tombstoned: current?.tombstoned === 1, + }); + })(); + return true; +} + +/** Clear both editable fields while preserving an independent tombstone. */ +export function clearRadarLocalModelOverride(provider: unknown, modelId: unknown): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity) return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: null, + enabled: null, + tombstoned: current?.tombstoned === 1, + }); + })(); + return true; +} + +/** Hide or restore one model without modifying its editable local fields. */ +export function setRadarModelTombstone( + provider: unknown, + modelId: unknown, + tombstoned: boolean +): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity || typeof tombstoned !== "boolean") return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: current?.display_name ?? null, + enabled: + current?.enabled === null || current?.enabled === undefined ? null : current.enabled === 1, + tombstoned, + }); + })(); + return true; +} + +/** Convert persisted rows into the exact read-time merge structures. */ +export function getRadarLocalMergeState(): RadarLocalMergeState { + const localOverrides = new Map(); + const tombstones = new Set(); + + for (const state of listRadarLocalModelState()) { + const key = `${state.provider}:${state.modelId}`; + const override: { displayName?: string; enabled?: boolean } = {}; + if (state.displayName !== null) override.displayName = state.displayName; + if (state.enabled !== null) override.enabled = state.enabled; + if (Object.keys(override).length > 0) localOverrides.set(key, override); + if (state.tombstoned) tombstones.add(key); + } + + return { localOverrides, tombstones }; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 184fa744eb..2fcd61c5e9 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -819,6 +819,20 @@ export { getRadarSettings, setRadarOptIn, setRadarKey, + getRadarReferralsCache, + setRadarReferralsCache, + listRadarLocalModelState, + setRadarLocalModelOverride, + clearRadarLocalModelOverride, + setRadarModelTombstone, + getRadarLocalMergeState, +} from "./db/radar"; +export type { + RadarCache, + RadarSettings, + RadarReferralsCache, + RadarLocalModelState, + RadarLocalModelOverridePatch, + RadarLocalMergeState, } from "./db/radar"; -export type { RadarCache, RadarSettings } from "./db/radar"; export * from "./db/conductorBridge"; // OmniConductor hub mirror — SSE cursor (PRD Conductor RF1) diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index d5a781378b..1ae06486f0 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -15,7 +15,12 @@ import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFe import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { getRadarCache, getRadarReferralsCache } from "@/lib/db/radar"; +import { + getRadarCache, + getRadarLocalMergeState, + getRadarReferralsCache, + type RadarLocalMergeState, +} from "@/lib/db/radar"; // --------------------------------------------------------------------------- // Types @@ -39,6 +44,7 @@ export interface GetRadarCatalogDeps { baseline?: MergedEntry[]; localOverrides?: Map>; tombstones?: Set; + getLocalState?: () => RadarLocalMergeState; } // --------------------------------------------------------------------------- @@ -49,9 +55,7 @@ export interface GetRadarCatalogDeps { * Convert the static `FreeModelBudget[]` into `MergedEntry[]` so the * merge function has a uniform input shape. */ -export function baselineToMergedEntries( - budgets: typeof FREE_MODEL_BUDGETS, -): MergedEntry[] { +export function baselineToMergedEntries(budgets: typeof FREE_MODEL_BUDGETS): MergedEntry[] { return budgets.map((b) => ({ provider: b.provider, modelId: b.modelId, @@ -86,8 +90,9 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes getFlag = isFeatureFlagEnabled, getCache: getCacheFn = getRadarCache, baseline: baselineInput, - localOverrides = new Map(), - tombstones = new Set(), + localOverrides, + tombstones, + getLocalState: getLocalStateFn = getRadarLocalMergeState, } = deps; // Resolve baseline @@ -114,12 +119,15 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes return { entries: baseline, meta: null }; } + const persistedState = + localOverrides === undefined || tombstones === undefined ? getLocalStateFn() : null; + // Apply overlay const entries = applyFeed({ baseline, feed: feed.models as FeedModel[], - localOverrides, - tombstones, + localOverrides: localOverrides ?? persistedState?.localOverrides ?? new Map(), + tombstones: tombstones ?? persistedState?.tombstones ?? new Set(), }); return { @@ -195,7 +203,7 @@ export function getRadarReferrals(deps: GetRadarReferralsDeps = {}): RadarReferr */ export function getDefaultReferralFor( provider: string, - deps: GetRadarReferralsDeps = {}, + deps: GetRadarReferralsDeps = {} ): RadarReferral | null { const { fixed } = getRadarReferrals(deps); return findDefaultReferral(fixed, provider); diff --git a/tests/unit/radar-apply-feed.test.ts b/tests/unit/radar-apply-feed.test.ts index a5fd8b07d4..868d0cd76a 100644 --- a/tests/unit/radar-apply-feed.test.ts +++ b/tests/unit/radar-apply-feed.test.ts @@ -589,6 +589,7 @@ test("getRadarCatalog: corrupt payload returns baseline without throwing", () => test("getRadarCatalog: valid cache returns merged entries with meta", () => { const result = getRadarCatalog({ getFlag: () => true, + getLocalState: () => ({ localOverrides: new Map(), tombstones: new Set() }), getCache: () => ({ version: "2026.08.01.1", tier: "community", diff --git a/tests/unit/radar-local-state-db.test.ts b/tests/unit/radar-local-state-db.test.ts new file mode 100644 index 0000000000..fd30dd47d4 --- /dev/null +++ b/tests/unit/radar-local-state-db.test.ts @@ -0,0 +1,175 @@ +/** + * Persistent local Radar overrides and tombstones. + * + * These tests exercise the real migration-backed DB module and the production + * getRadarCatalog() wiring. They deliberately do not inject local merge state. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-local-state-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.RADAR_ENABLED = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const { + clearRadarLocalModelOverride, + getRadarLocalMergeState, + listRadarLocalModelState, + setRadarLocalModelOverride, + setRadarModelTombstone, + setRadarCache, +} = await import("../../src/lib/db/radar.ts"); +const { getRadarCatalog } = await import("../../src/lib/radar/index.ts"); + +async function resetStorage(): Promise { + 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.DATA_DIR; + delete process.env.RADAR_ENABLED; +}); + +test("migration 143 creates the closed local model state schema", () => { + const db = core.getDbInstance(); + const columns = db.prepare("PRAGMA table_info(radar_local_model_state)").all() as Array<{ + name: string; + }>; + + assert.deepEqual( + columns.map((column) => column.name), + ["provider", "model_id", "display_name", "enabled", "tombstoned", "updated_at"] + ); +}); + +test("local overrides round-trip, merge partial updates, and clear without stale fields", () => { + assert.equal( + setRadarLocalModelOverride(" groq ", " llama-3.3-70b-versatile ", { + displayName: " My Groq model ", + enabled: false, + }), + true + ); + + const initial = listRadarLocalModelState(); + assert.equal(initial.length, 1); + assert.deepEqual( + { ...initial[0], updatedAt: undefined }, + { + provider: "groq", + modelId: "llama-3.3-70b-versatile", + displayName: "My Groq model", + enabled: false, + tombstoned: false, + updatedAt: undefined, + } + ); + assert.match(initial[0].updatedAt, /^\d{4}-\d{2}-\d{2}/); + + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { enabled: true }), + true + ); + const updated = listRadarLocalModelState()[0]; + assert.equal(updated.displayName, "My Groq model", "partial updates preserve the other field"); + assert.equal(updated.enabled, true); + + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { displayName: null }), + true + ); + assert.equal(listRadarLocalModelState()[0].displayName, null, "null explicitly clears a field"); + + assert.equal(clearRadarLocalModelOverride("groq", "llama-3.3-70b-versatile"), true); + assert.deepEqual(listRadarLocalModelState(), [], "an empty non-tombstoned row is deleted"); +}); + +test("tombstones survive override resets and restoring the last field removes the row", () => { + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { + displayName: "Local name", + }), + true + ); + assert.equal(setRadarModelTombstone("groq", "llama-3.3-70b-versatile", true), true); + assert.equal(clearRadarLocalModelOverride("groq", "llama-3.3-70b-versatile"), true); + + const hidden = listRadarLocalModelState()[0]; + assert.equal(hidden.tombstoned, true); + assert.equal(hidden.displayName, null); + assert.equal(hidden.enabled, null); + + const mergeState = getRadarLocalMergeState(); + assert.deepEqual([...mergeState.localOverrides], []); + assert.deepEqual([...mergeState.tombstones], ["groq:llama-3.3-70b-versatile"]); + + assert.equal(setRadarModelTombstone("groq", "llama-3.3-70b-versatile", false), true); + assert.deepEqual(listRadarLocalModelState(), []); +}); + +test("invalid identities and empty override patches fail closed", () => { + assert.equal(setRadarLocalModelOverride("", "model", { displayName: "name" }), false); + assert.equal(setRadarLocalModelOverride("groq", "", { displayName: "name" }), false); + assert.equal(setRadarLocalModelOverride("groq", "model", {}), false); + assert.equal(setRadarLocalModelOverride("groq", "model", { displayName: " " }), false); + assert.equal(setRadarModelTombstone("bad provider", "model", true), false); + assert.deepEqual(listRadarLocalModelState(), []); +}); + +test("production getRadarCatalog loads persisted overrides and tombstones", () => { + const fixturePath = path.join(process.cwd(), "tests/fixtures/radar-feed-canonical.json"); + const payload = fs.readFileSync(fixturePath, "utf8"); + const fixture = JSON.parse(payload) as { + version: string; + tier: string; + models: Array<{ provider: string; modelId: string; enabled: boolean }>; + }; + const visible = fixture.models.find((model) => model.enabled); + const hidden = fixture.models.find( + (model) => + model.enabled && + `${model.provider}:${model.modelId}` !== `${visible?.provider}:${visible?.modelId}` + ); + assert.ok(visible && hidden, "fixture must contain two enabled models"); + + setRadarCache({ + version: fixture.version, + tier: fixture.tier, + payload, + signature: "test-signature", + }); + assert.equal( + setRadarLocalModelOverride(visible.provider, visible.modelId, { + displayName: "Persisted local name", + enabled: false, + }), + true + ); + assert.equal(setRadarModelTombstone(hidden.provider, hidden.modelId, true), true); + + const catalog = getRadarCatalog(); + const overridden = catalog.entries.find( + (entry) => entry.provider === visible.provider && entry.modelId === visible.modelId + ); + assert.ok(overridden); + assert.equal(overridden.displayName, "Persisted local name"); + assert.equal(overridden.enabled, false); + assert.equal(overridden.origin, "local"); + assert.equal( + catalog.entries.some( + (entry) => entry.provider === hidden.provider && entry.modelId === hidden.modelId + ), + false, + "a persisted tombstone must remove the model from the production catalog" + ); +}); diff --git a/tests/unit/radar-local-state-route.test.ts b/tests/unit/radar-local-state-route.test.ts new file mode 100644 index 0000000000..29dd7664e4 --- /dev/null +++ b/tests/unit/radar-local-state-route.test.ts @@ -0,0 +1,155 @@ +/** API contract for persisted Radar local overrides and tombstones. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-state-api-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-local-state"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-local-state"; + +const core = await import("../../src/lib/db/core.ts"); +const route = await import("../../src/app/api/radar/local-model-state/route.ts"); + +async function authHeaders(): Promise> { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(secret); + return { Cookie: `auth_token=${token}` }; +} + +function request(method: string, body?: unknown, headers: Record = {}): Request { + return new Request("http://localhost:20128/api/radar/local-model-state", { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function resetStorage(): Promise { + 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.DATA_DIR; + delete process.env.RADAR_ENABLED; + delete process.env.JWT_SECRET; + delete process.env.INITIAL_PASSWORD; +}); + +test("flag-off gate runs before authentication", async () => { + delete process.env.RADAR_ENABLED; + const response = await route.GET(request("GET")); + const text = await response.text(); + + assert.equal(response.status, 404); + assert.ok(!text.includes("at /")); +}); + +test("all local-state mutations require authentication when Radar is enabled", async () => { + process.env.RADAR_ENABLED = "true"; + const calls = [ + route.GET(request("GET")), + route.PATCH(request("PATCH", { provider: "groq", modelId: "model", enabled: false })), + route.PUT(request("PUT", { provider: "groq", modelId: "model", tombstoned: true })), + route.DELETE(request("DELETE")), + ]; + + for (const response of await Promise.all(calls)) { + assert.equal(response.status, 401); + assert.ok(!(await response.text()).includes("at /")); + } +}); + +test("authenticated CRUD persists overrides and tombstones without conflating them", async () => { + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + + const patch = await route.PATCH( + request( + "PATCH", + { + provider: "groq", + modelId: "llama-3.3-70b-versatile", + displayName: "My local Groq", + enabled: false, + }, + headers + ) + ); + assert.equal(patch.status, 200); + const patched = await patch.json(); + assert.equal(patched.states[0].displayName, "My local Groq"); + assert.equal(patched.states[0].enabled, false); + assert.equal(patched.states[0].tombstoned, false); + + const hide = await route.PUT( + request( + "PUT", + { provider: "groq", modelId: "llama-3.3-70b-versatile", tombstoned: true }, + headers + ) + ); + assert.equal(hide.status, 200); + assert.equal((await hide.json()).states[0].tombstoned, true); + + const removeOverrideUrl = new URL("http://localhost:20128/api/radar/local-model-state"); + removeOverrideUrl.searchParams.set("provider", "groq"); + removeOverrideUrl.searchParams.set("modelId", "llama-3.3-70b-versatile"); + const remove = await route.DELETE(new Request(removeOverrideUrl, { method: "DELETE", headers })); + assert.equal(remove.status, 200); + const removed = await remove.json(); + assert.equal(removed.states[0].displayName, null); + assert.equal(removed.states[0].enabled, null); + assert.equal(removed.states[0].tombstoned, true, "clearing overrides must not restore a model"); + + const restore = await route.PUT( + request( + "PUT", + { provider: "groq", modelId: "llama-3.3-70b-versatile", tombstoned: false }, + headers + ) + ); + assert.equal(restore.status, 200); + assert.deepEqual((await restore.json()).states, []); +}); + +test("strict schemas reject arbitrary fields, empty patches, and control characters", async () => { + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const invalidBodies = [ + { provider: "groq", modelId: "model" }, + { provider: "groq", modelId: "model", enabled: true, origin: "local" }, + { provider: "groq", modelId: "bad\nmodel", enabled: true }, + { provider: "groq", modelId: "model", displayName: " " }, + ]; + + for (const body of invalidBodies) { + const response = await route.PATCH(request("PATCH", body, headers)); + const text = await response.text(); + assert.equal(response.status, 400); + assert.ok(!text.includes("at /")); + assert.ok(!text.includes(".ts:")); + } +}); + +test("GET returns no-store local state for restore controls", async () => { + process.env.RADAR_ENABLED = "true"; + const response = await route.GET(request("GET", undefined, await authHeaders())); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { states: [] }); +}); diff --git a/tests/unit/radar-local-state-ui.test.ts b/tests/unit/radar-local-state-ui.test.ts new file mode 100644 index 0000000000..9cbccfeece --- /dev/null +++ b/tests/unit/radar-local-state-ui.test.ts @@ -0,0 +1,67 @@ +/** Source contract for the Radar local edit/hide/restore controls. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const pageSource = fs.readFileSync( + path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"), + "utf8" +); +const controlsSource = fs.readFileSync( + path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx"), + "utf8" +); + +test("Radar catalog UI reads and mutates the dedicated local-state endpoint", () => { + assert.match(pageSource, / { + for (const key of [ + "editModel", + "saveModel", + "resetModel", + "hideModel", + "restoreModel", + "hiddenModelsTitle", + "localBadge", + ]) { + assert.match( + controlsSource, + new RegExp(`t\\(\\"${key}\\"`), + `missing UI translation key ${key}` + ); + } + assert.match(controlsSource, /aria-label=\{t\("modelDisplayName"\)\}/); + assert.match(controlsSource, /type="checkbox"/); +}); + +test("English and Brazilian Portuguese catalogs include the local state copy", () => { + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8") + ) as { radarPage: Record }; + for (const key of [ + "colActions", + "editModel", + "saveModel", + "cancelEdit", + "resetModel", + "hideModel", + "restoreModel", + "hiddenModelsTitle", + "modelDisplayName", + "modelEnabled", + "localBadge", + "localStateSaveFailed", + ]) { + assert.equal(typeof messages.radarPage[key], "string", `${locale} missing radarPage.${key}`); + assert.ok(messages.radarPage[key].length > 0); + } + } +}); From 2ebab9c686d64d457f7b71b46022927bdb77c986 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 20:05:49 -0300 Subject: [PATCH 002/134] docs(changelog): add Radar local state entry --- changelog.d/features/9830-radar-local-model-state.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/features/9830-radar-local-model-state.md diff --git a/changelog.d/features/9830-radar-local-model-state.md b/changelog.d/features/9830-radar-local-model-state.md new file mode 100644 index 0000000000..a6df34c7e7 --- /dev/null +++ b/changelog.d/features/9830-radar-local-model-state.md @@ -0,0 +1 @@ +- **feat(radar):** Persist local model display-name/enabled overrides and hide/restore tombstones, with authenticated catalog controls and feed safety precedence ([#9830](https://github.com/diegosouzapw/OmniRoute/pull/9830)) From 86caa94baaa412ce41f91a1aaf23284bb556f03e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 20:33:48 -0300 Subject: [PATCH 003/134] feat(radar): build guided combo suggestions --- src/lib/radar/applyFeed.ts | 7 + src/lib/radar/comboSuggestions.ts | 164 +++++++++++++++++++++ tests/unit/radar-apply-feed.test.ts | 34 +++++ tests/unit/radar-combo-suggestions.test.ts | 134 +++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 src/lib/radar/comboSuggestions.ts create mode 100644 tests/unit/radar-combo-suggestions.test.ts diff --git a/src/lib/radar/applyFeed.ts b/src/lib/radar/applyFeed.ts index 55ba532099..a8e9f07ce7 100644 --- a/src/lib/radar/applyFeed.ts +++ b/src/lib/radar/applyFeed.ts @@ -27,6 +27,8 @@ export interface MergedEntry { provider: string; modelId: string; displayName: string; + /** Curated cross-provider model family used for Radar combo suggestions. */ + familyId?: string | null; monthlyTokens: number; creditTokens: number; freeType: @@ -259,6 +261,9 @@ function mergeOne( if (!overriddenKeys.has("displayName")) { result.displayName = feed.displayName; } + if (!overriddenKeys.has("familyId")) { + result.familyId = feed.familyId; + } if (!overriddenKeys.has("monthlyTokens")) { result.monthlyTokens = feedBudgetToMonthlyTokens(feed.budget); } @@ -293,6 +298,7 @@ function mergeOne( // Apply local overrides (rule 1: they win) if (overrides) { if (overrides.displayName !== undefined) result.displayName = overrides.displayName; + if (overrides.familyId !== undefined) result.familyId = overrides.familyId; if (overrides.monthlyTokens !== undefined) result.monthlyTokens = overrides.monthlyTokens; if (overrides.creditTokens !== undefined) result.creditTokens = overrides.creditTokens; if (overrides.freeType !== undefined) result.freeType = overrides.freeType; @@ -330,6 +336,7 @@ function feedModelToMerged( provider: feed.provider, modelId: feed.modelId, displayName: overrides?.displayName ?? feed.displayName, + familyId: overrides?.familyId ?? feed.familyId, monthlyTokens: overrides?.monthlyTokens ?? feedBudgetToMonthlyTokens(feed.budget), creditTokens: overrides?.creditTokens ?? 0, freeType: overrides?.freeType ?? feed.freeType, diff --git a/src/lib/radar/comboSuggestions.ts b/src/lib/radar/comboSuggestions.ts new file mode 100644 index 0000000000..f3fbdf7547 --- /dev/null +++ b/src/lib/radar/comboSuggestions.ts @@ -0,0 +1,164 @@ +import type { ComboBuilderProviderOption } from "../combos/builderOptions"; + +import type { MergedEntry } from "./applyFeed"; + +export interface RadarComboSuggestionModel { + providerId: string; + providerName: string; + modelId: string; + qualifiedModel: string; + displayName: string; + monthlyTokens: number; +} + +export interface RadarComboSuggestionPayload { + name: string; + strategy: "priority"; + models: Array<{ + kind: "model"; + providerId: string; + model: string; + weight: 0; + }>; +} + +export interface RadarComboSuggestion { + familyId: string; + name: string; + alreadyExists: boolean; + models: RadarComboSuggestionModel[]; + payload: RadarComboSuggestionPayload; +} + +export interface BuildRadarComboSuggestionsInput { + entries: readonly MergedEntry[]; + providers: readonly ComboBuilderProviderOption[]; + existingComboNames: Iterable; +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function normalizedIdentity(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function resolveProvider( + providerIdentity: string, + providers: readonly ComboBuilderProviderOption[] +): ComboBuilderProviderOption | null { + const identity = normalizedIdentity(providerIdentity); + if (!identity) return null; + + const active = providers.filter((provider) => provider.activeConnectionCount > 0); + const selectors: Array<(provider: ComboBuilderProviderOption) => string | null | undefined> = [ + (provider) => provider.providerId, + (provider) => provider.alias, + (provider) => provider.prefix, + ]; + + for (const select of selectors) { + const matches = active.filter((provider) => normalizedIdentity(select(provider)) === identity); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return null; + } + + return null; +} + +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function comboNameForFamily(familyId: string): string { + const safeFamily = + familyId + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "family"; + const fullName = `radar-${safeFamily}`; + if (fullName.length <= 100) return fullName; + + const hash = stableHash(familyId); + const availableFamilyLength = 100 - "radar-".length - 1 - hash.length; + return `radar-${safeFamily.slice(0, availableFamilyLength)}-${hash}`; +} + +function compareModels(left: RadarComboSuggestionModel, right: RadarComboSuggestionModel): number { + return ( + right.monthlyTokens - left.monthlyTokens || + compareText(left.providerId, right.providerId) || + compareText(left.modelId, right.modelId) + ); +} + +/** Build pure, deterministic combo proposals from curated Radar families and live provider options. */ +export function buildRadarComboSuggestions( + input: BuildRadarComboSuggestionsInput +): RadarComboSuggestion[] { + const families = new Map>(); + + for (const entry of input.entries) { + const familyId = entry.familyId?.trim(); + if (!familyId || entry.enabled === false) continue; + + const provider = resolveProvider(entry.provider, input.providers); + if (!provider) continue; + const model = provider.models.find((candidate) => candidate.id === entry.modelId); + if (!model) continue; + + const candidate: RadarComboSuggestionModel = { + providerId: provider.providerId, + providerName: provider.displayName, + modelId: entry.modelId, + qualifiedModel: model.qualifiedModel, + displayName: entry.displayName, + monthlyTokens: entry.monthlyTokens, + }; + const byProvider = families.get(familyId) ?? new Map(); + const current = byProvider.get(provider.providerId); + if (!current || compareModels(candidate, current) < 0) { + byProvider.set(provider.providerId, candidate); + } + families.set(familyId, byProvider); + } + + const existingNames = new Set( + [...input.existingComboNames].map((name) => normalizedIdentity(name)).filter(Boolean) + ); + const suggestions: RadarComboSuggestion[] = []; + + for (const familyId of [...families.keys()].sort(compareText)) { + const models = [...(families.get(familyId)?.values() ?? [])].sort(compareModels); + if (models.length < 2) continue; + + const name = comboNameForFamily(familyId); + suggestions.push({ + familyId, + name, + alreadyExists: existingNames.has(normalizedIdentity(name)), + models, + payload: { + name, + strategy: "priority", + models: models.map((model) => ({ + kind: "model", + providerId: model.providerId, + model: model.qualifiedModel, + weight: 0, + })), + }, + }); + } + + return suggestions; +} diff --git a/tests/unit/radar-apply-feed.test.ts b/tests/unit/radar-apply-feed.test.ts index 868d0cd76a..cd1a3fc012 100644 --- a/tests/unit/radar-apply-feed.test.ts +++ b/tests/unit/radar-apply-feed.test.ts @@ -746,6 +746,40 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi }); }); +test("F3 mergeOne path: familyId survives the feed merge over a baseline entry", () => { + const result = applyFeed({ + baseline: makeBaseline(), + feed: [ + makeFeedModel({ + provider: "groq", + modelId: "llama-3.3-70b-versatile", + familyId: "llama-3.3-70b", + }), + ], + localOverrides: new Map(), + tombstones: new Set(), + }); + + assert.equal(result.find((entry) => entry.provider === "groq")?.familyId, "llama-3.3-70b"); +}); + +test("F3 feedModelToMerged path: familyId survives for a feed-only entry", () => { + const result = applyFeed({ + baseline: [], + feed: [ + makeFeedModel({ + provider: "new-provider", + modelId: "shared-model", + familyId: "shared-family", + }), + ], + localOverrides: new Map(), + tombstones: new Set(), + }); + + assert.equal(result[0]?.familyId, "shared-family"); +}); + // =========================================================================== // Feed `enabled:false` is the safety exception to local override precedence: // a model confirmed dead upstream must not be resurrected locally. diff --git a/tests/unit/radar-combo-suggestions.test.ts b/tests/unit/radar-combo-suggestions.test.ts new file mode 100644 index 0000000000..9a83b3555b --- /dev/null +++ b/tests/unit/radar-combo-suggestions.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ComboBuilderProviderOption } from "../../src/lib/combos/builderOptions.ts"; +import type { MergedEntry } from "../../src/lib/radar/applyFeed.ts"; +import { buildRadarComboSuggestions } from "../../src/lib/radar/comboSuggestions.ts"; + +function entry( + overrides: Partial & Pick +): MergedEntry { + return { + provider: overrides.provider, + modelId: overrides.modelId, + displayName: overrides.displayName ?? overrides.modelId, + monthlyTokens: overrides.monthlyTokens ?? 100, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: true, + origin: "radar", + familyId: "shared-family", + ...overrides, + }; +} + +function provider( + providerId: string, + modelId: string, + overrides: Partial = {} +): ComboBuilderProviderOption { + return { + providerId, + providerType: providerId, + displayName: providerId.toUpperCase(), + alias: providerId, + icon: "api", + color: "#000000", + source: "system", + acceptsArbitraryModel: false, + connectionCount: 1, + activeConnectionCount: 1, + modelCount: 1, + connections: [], + models: [ + { + id: modelId, + qualifiedModel: `${providerId}/${modelId}`, + name: modelId, + source: "system", + sources: ["system"], + }, + ], + ...overrides, + }; +} + +test("two active providers in one family create one deterministic priority suggestion", () => { + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "groq", modelId: "llama", monthlyTokens: 200 }), + entry({ provider: "cerebras", modelId: "llama", monthlyTokens: 300 }), + ], + providers: [provider("groq", "llama"), provider("cerebras", "llama")], + existingComboNames: [], + }); + + assert.equal(suggestions.length, 1); + assert.equal(suggestions[0].familyId, "shared-family"); + assert.equal(suggestions[0].name, "radar-shared-family"); + assert.equal(suggestions[0].alreadyExists, false); + assert.deepEqual(suggestions[0].payload, { + name: "radar-shared-family", + strategy: "priority", + models: [ + { kind: "model", providerId: "cerebras", model: "cerebras/llama", weight: 0 }, + { kind: "model", providerId: "groq", model: "groq/llama", weight: 0 }, + ], + }); +}); + +test("ineligible entries fail closed while alias and prefix match exact provider models", () => { + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "gq", modelId: "llama", monthlyTokens: 500 }), + entry({ provider: "cb", modelId: "llama", monthlyTokens: 400 }), + entry({ provider: "inactive", modelId: "llama", monthlyTokens: 900 }), + entry({ provider: "disabled", modelId: "llama", enabled: false }), + entry({ provider: "missing-model", modelId: "other" }), + entry({ provider: "singleton", modelId: "solo", familyId: "solo-family" }), + ], + providers: [ + provider("groq", "llama", { alias: "gq" }), + provider("cerebras", "llama", { prefix: "cb" }), + provider("inactive", "llama", { activeConnectionCount: 0 }), + provider("disabled", "llama"), + provider("missing-model", "llama"), + provider("singleton", "solo"), + ], + existingComboNames: new Set(["RADAR-SHARED-FAMILY"]), + }); + + assert.equal(suggestions.length, 1); + assert.equal(suggestions[0].alreadyExists, true); + assert.deepEqual( + suggestions[0].models.map((model) => model.providerId), + ["groq", "cerebras"] + ); +}); + +test("ambiguous provider aliases, duplicate providers, empty families and unsafe names are closed", () => { + const longFamily = `Family / ${"x".repeat(120)}`; + const suggestions = buildRadarComboSuggestions({ + entries: [ + entry({ provider: "ambiguous", modelId: "m", familyId: "ambiguous-family" }), + entry({ provider: "one", modelId: "m", familyId: longFamily, monthlyTokens: 200 }), + entry({ provider: "two", modelId: "m", familyId: longFamily, monthlyTokens: 100 }), + entry({ provider: "one", modelId: "m", familyId: longFamily, monthlyTokens: 50 }), + entry({ provider: "one", modelId: "blank", familyId: " " }), + ], + providers: [ + provider("ambiguous-a", "m", { alias: "ambiguous" }), + provider("ambiguous-b", "m", { alias: "ambiguous" }), + provider("one", "m"), + provider("two", "m"), + ], + existingComboNames: [], + }); + + assert.equal(suggestions.length, 1); + assert.ok(suggestions[0].name.length <= 100); + assert.match(suggestions[0].name, /^[a-zA-Z0-9_/.\-\[\] ]+$/); + assert.equal(new Set(suggestions[0].models.map((model) => model.providerId)).size, 2); +}); From a801abc8e98af46dc43ab9b856d93c8ed1ece8fc Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 20:41:59 -0300 Subject: [PATCH 004/134] feat(radar): add guided combos page --- .../dashboard/radar/combos/page.tsx | 201 ++++++++++++++++++ src/app/(dashboard)/dashboard/radar/page.tsx | 29 ++- src/i18n/messages/ar.json | 20 +- src/i18n/messages/az.json | 20 +- src/i18n/messages/bg.json | 20 +- src/i18n/messages/bn.json | 20 +- src/i18n/messages/cs.json | 20 +- src/i18n/messages/da.json | 20 +- src/i18n/messages/de.json | 20 +- src/i18n/messages/en.json | 20 +- src/i18n/messages/es.json | 20 +- src/i18n/messages/fa.json | 20 +- src/i18n/messages/fi.json | 20 +- src/i18n/messages/fr.json | 20 +- src/i18n/messages/gu.json | 20 +- src/i18n/messages/he.json | 20 +- src/i18n/messages/hi.json | 20 +- src/i18n/messages/hu.json | 20 +- src/i18n/messages/id.json | 20 +- src/i18n/messages/in.json | 20 +- src/i18n/messages/it.json | 20 +- src/i18n/messages/ja.json | 20 +- src/i18n/messages/ko.json | 20 +- src/i18n/messages/mr.json | 20 +- src/i18n/messages/ms.json | 20 +- src/i18n/messages/nl.json | 20 +- src/i18n/messages/no.json | 20 +- src/i18n/messages/phi.json | 20 +- src/i18n/messages/pl.json | 20 +- src/i18n/messages/pt-BR.json | 20 +- src/i18n/messages/pt.json | 20 +- src/i18n/messages/ro.json | 20 +- src/i18n/messages/ru.json | 20 +- src/i18n/messages/sk.json | 20 +- src/i18n/messages/sv.json | 20 +- src/i18n/messages/sw.json | 20 +- src/i18n/messages/ta.json | 20 +- src/i18n/messages/te.json | 20 +- src/i18n/messages/th.json | 20 +- src/i18n/messages/tr.json | 20 +- src/i18n/messages/uk-UA.json | 20 +- src/i18n/messages/ur.json | 20 +- src/i18n/messages/vi.json | 20 +- src/i18n/messages/zh-CN.json | 20 +- src/i18n/messages/zh-TW.json | 20 +- tests/unit/radar-combos-page.test.ts | 90 ++++++++ 46 files changed, 1128 insertions(+), 52 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/radar/combos/page.tsx create mode 100644 tests/unit/radar-combos-page.test.ts diff --git a/src/app/(dashboard)/dashboard/radar/combos/page.tsx b/src/app/(dashboard)/dashboard/radar/combos/page.tsx new file mode 100644 index 0000000000..04cb3f3e98 --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/combos/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import type { ComboBuilderOptionsPayload } from "@/lib/combos/builderOptions"; +import type { MergedEntry } from "@/lib/radar/applyFeed"; +import { + buildRadarComboSuggestions, + type RadarComboSuggestion, +} from "@/lib/radar/comboSuggestions"; +import { Card } from "@/shared/components"; + +interface RadarCatalogPayload { + entries?: MergedEntry[]; + meta?: unknown; +} + +function comboNames(payload: ComboBuilderOptionsPayload): Set { + return new Set(payload.comboRefs.map((combo) => combo.name)); +} + +export default function RadarCombosPage() { + const t = useTranslations("radarCombosPage"); + const [entries, setEntries] = useState([]); + const [providers, setProviders] = useState([]); + const [existingNames, setExistingNames] = useState>(new Set()); + const [createdNames, setCreatedNames] = useState>(new Set()); + const [hasCatalog, setHasCatalog] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [loading, setLoading] = useState(true); + const [creatingName, setCreatingName] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + async function load() { + try { + const [catalogResponse, optionsResponse] = await Promise.all([ + fetch("/api/radar/catalog"), + fetch("/api/combos/builder/options"), + ]); + if (catalogResponse.status === 404) { + setFlagOff(true); + return; + } + if (!catalogResponse.ok || !optionsResponse.ok) throw new Error("load_failed"); + + const catalog = (await catalogResponse.json()) as RadarCatalogPayload; + const options = (await optionsResponse.json()) as ComboBuilderOptionsPayload; + if (!Array.isArray(catalog.entries) || !Array.isArray(options.providers)) { + throw new Error("invalid_shape"); + } + + setEntries(catalog.entries); + setProviders(options.providers); + setExistingNames(comboNames(options)); + setHasCatalog(catalog.meta != null); + } catch { + setError(t("loadFailed")); + } finally { + setLoading(false); + } + } + + void load(); + }, [t]); + + const suggestions = useMemo( + () => buildRadarComboSuggestions({ entries, providers, existingComboNames: existingNames }), + [entries, providers, existingNames] + ); + + const refreshExistingName = useCallback(async (name: string): Promise => { + try { + const response = await fetch("/api/combos/builder/options"); + if (!response.ok) return false; + const options = (await response.json()) as ComboBuilderOptionsPayload; + if (!Array.isArray(options.comboRefs)) return false; + const names = comboNames(options); + if (![...names].some((candidate) => candidate.toLowerCase() === name.toLowerCase())) { + return false; + } + setExistingNames(names); + return true; + } catch { + return false; + } + }, []); + + const createSuggestion = useCallback( + async (suggestion: RadarComboSuggestion) => { + setCreatingName(suggestion.name); + setError(""); + try { + const response = await fetch("/api/combos", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(suggestion.payload), + }); + if (!response.ok) { + if (response.status === 400 && (await refreshExistingName(suggestion.name))) return; + throw new Error("create_failed"); + } + setExistingNames((current) => new Set([...current, suggestion.name])); + setCreatedNames((current) => new Set([...current, suggestion.name])); + } catch { + setError(t("createFailed")); + } finally { + setCreatingName(null); + } + }, + [refreshExistingName, t] + ); + + if (flagOff) notFound(); + + return ( +
+
+ + ← {t("backToRadar")} + +
+

{t("title")}

+

{t("subtitle")}

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

{t("catalogRequired")}

+
+ ) : suggestions.length === 0 ? ( + +

{t("noSuggestions")}

+
+ ) : ( +
+ {suggestions.map((suggestion) => { + const creating = creatingName === suggestion.name; + const created = createdNames.has(suggestion.name); + return ( + +
+
+ + {t("familyLabel")} + +

{suggestion.familyId}

+

{t("strategyReason")}

+
+ +
+ {t("modelsLabel")} + {suggestion.models.map((model) => ( +
+ {model.providerName} + + {model.qualifiedModel} + +
+ ))} +
+ + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 9153ba254d..75803dc17a 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { notFound } from "next/navigation"; import { Card } from "@/shared/components"; import { shouldAutoSyncOnOpen } from "@/lib/radar/autoSync"; @@ -315,15 +316,25 @@ export default function RadarPage() {

{t("title")}

{t("subtitle")}

- {pageState === "populated" && ( - - )} +
+ {(pageState === "empty" || pageState === "populated") && ( + + {t("guidedCombos")} + + )} + {pageState === "populated" && ( + + )} +
{/* Feed freshness header */} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 538ad5c46c..ad91aeb603 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "إعداد المزود", @@ -13636,5 +13637,22 @@ "hint": "عند التمكين، تظهر النماذج المكتشفة في اختيارات المزود عبر OmniRoute.", "updateFailed": "فشل في التحديث (HTTP {status})", "networkError": "خطأ في الشبكة - لم يتمكن من تحديث إعدادات تعرض المزود" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index cc769f7cc0..ce03ed2392 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Təchizatçı Quraşdırması", @@ -13636,5 +13637,22 @@ "hint": "Aktiv edildikdə, aşkar edilmiş modellər OmniRoute boyunca təminatçı seçimlərində görünür.", "updateFailed": "Yeniləmə baş tutmadı (HTTP {status})", "networkError": "Şəbəkə xətası — təminatçı açıq parametrlərini yeniləmək mümkün olmadı" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 96673988c8..4736caa0d6 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Настройка на доставчика", @@ -13636,5 +13637,22 @@ "hint": "Когато е активирано, откритите модели се появяват в селекциите на доставчика в OmniRoute.", "updateFailed": "Неуспешно обновяване (HTTP {status})", "networkError": "Грешка в мрежата — не можа да актуализира настройката за експозиция на доставчика" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index b8d43decf1..352692e434 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "প্রদানকারী সেটআপ", @@ -13636,5 +13637,22 @@ "hint": "যখন সক্ষম করা হয়, আবিষ্কৃত মডেলগুলি OmniRoute জুড়ে প্রদানকারী নির্বাচনে প্রদর্শিত হয়।", "updateFailed": "আপডেট করতে ব্যর্থ (HTTP {status})", "networkError": "নেটওয়ার্ক ত্রুটি — প্রদানকারী এক্সপোজার সেটিং আপডেট করা যায়নি" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index ffa479e6b3..4e19b3d39b 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Nastavení poskytovatele", @@ -13636,5 +13637,22 @@ "hint": "Když je povoleno, objevené modely se zobrazují v selektorech poskytovatele napříč OmniRoute.", "updateFailed": "Nepodařilo se aktualizovat (HTTP {status})", "networkError": "Chyba sítě — nelze aktualizovat nastavení expozice poskytovatele" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 3e4de43df6..9d25536704 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Udbyder Opsætning", @@ -13636,5 +13637,22 @@ "hint": "Når aktiveret, vises opdagede modeller i udbydervælgerne på tværs af OmniRoute.", "updateFailed": "Mislykkedes at opdatere (HTTP {status})", "networkError": "Netværksfejl — kunne ikke opdatere udbyderens eksponeringsindstilling" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 863d26f17d..69fb44c9b3 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Anbieter-Einrichtung", @@ -13636,5 +13637,22 @@ "hint": "Wenn aktiviert, erscheinen entdeckte Modelle in den Anbieterauswahlen über OmniRoute.", "updateFailed": "Fehler beim Aktualisieren (HTTP {status})", "networkError": "Netzwerkfehler — konnte die Bereitstellungseinstellung nicht aktualisieren" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2456b1b239..8a573313d3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -13136,7 +13136,25 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 0307eae7e6..ccd75384a6 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuración del proveedor", @@ -13636,5 +13637,22 @@ "hint": "Cuando está habilitado, los modelos descubiertos aparecen en las selecciones de proveedores a través de OmniRoute.", "updateFailed": "Error al actualizar (HTTP {status})", "networkError": "Error de red: no se pudo actualizar la configuración de exposición del proveedor" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 64f1043b66..46bcc60631 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "راه‌اندازی تأمین‌کننده", @@ -13636,5 +13637,22 @@ "hint": "زمانی که فعال شود، مدل‌های کشف‌شده در انتخاب‌های ارائه‌دهنده در سرتاسر OmniRoute ظاهر می‌شوند.", "updateFailed": "به‌روزرسانی ناموفق بود (HTTP {status})", "networkError": "خطای شبکه — نمی‌توان تنظیمات نمایان‌سازی ارائه‌دهنده را به‌روزرسانی کرد" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index beb0015714..1389d8b673 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Palveluntarjoajan asennus", @@ -13636,5 +13637,22 @@ "hint": "Kun käytössä, löydetyt mallit näkyvät tarjoajien valinnoissa OmniRoutessa.", "updateFailed": "Päivitys epäonnistui (HTTP {status})", "networkError": "Verkkovirhe — ei voitu päivittää tarjoajan altistusasetusta" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 2070135c6c..86c5a692b4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuration du fournisseur", @@ -13636,5 +13637,22 @@ "hint": "Lorsqu'il est activé, les modèles découverts apparaissent dans les sélecteurs de fournisseur à travers OmniRoute.", "updateFailed": "Échec de la mise à jour (HTTP {status})", "networkError": "Erreur réseau — impossible de mettre à jour le paramètre d'exposition du fournisseur" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 6658097ec5..019df17eb6 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "પ્રદાતા સેટઅપ", @@ -13636,5 +13637,22 @@ "hint": "જ્યારે સક્રિય કરવામાં આવે છે, ત્યારે શોધાયેલા મોડલ્સ ઓમ્નીરૂટમાં પ્રદાતા પસંદગીઓમાં દેખાય છે.", "updateFailed": "અપડેટ કરવામાં નિષ્ફળ (HTTP {status})", "networkError": "નેટવર્ક ભૂલ — પ્રદાતા એક્સપોઝર સેટિંગને અપડેટ કરી શકતા નથી" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 49ad5b30d6..eb033e6971 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "הגדרת ספק", @@ -13636,5 +13637,22 @@ "hint": "כאשר זה מופעל, מודלים שהתגלו מופיעים בבחירות ספקים ברחבי OmniRoute.", "updateFailed": "נכשל בעדכון (HTTP {status})", "networkError": "שגיאת רשת — לא ניתן לעדכן את הגדרת החשיפה של הספק" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 1bafea2d5b..df164546aa 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -13636,5 +13637,22 @@ "hint": "जब सक्षम किया जाता है, तो खोजे गए मॉडल OmniRoute में प्रदाता चयन में दिखाई देते हैं।", "updateFailed": "अपडेट करने में विफल (HTTP {status})", "networkError": "नेटवर्क त्रुटि — प्रदाता एक्सपोज़र सेटिंग अपडेट नहीं कर सका" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index ff33c18cbe..a93c208b84 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Szolgáltató beállítása", @@ -13636,5 +13637,22 @@ "hint": "Ha engedélyezve van, a felfedezett modellek megjelennek a szolgáltató kiválasztásokban az OmniRoute-ban.", "updateFailed": "Sikertelen frissítés (HTTP {status})", "networkError": "Hálózati hiba — nem sikerült frissíteni a szolgáltató láthatósági beállítását" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index d973c59b57..07339e1544 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Pengaturan Penyedia", @@ -13636,5 +13637,22 @@ "hint": "Saat diaktifkan, model yang ditemukan muncul di pemilih penyedia di seluruh OmniRoute.", "updateFailed": "Gagal memperbarui (HTTP {status})", "networkError": "Kesalahan jaringan — tidak dapat memperbarui pengaturan eksposur penyedia" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 7b9d909c05..b8b3e8bfe5 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -13636,5 +13637,22 @@ "hint": "Saat diaktifkan, model yang ditemukan muncul di pemilih penyedia di seluruh OmniRoute.", "updateFailed": "Gagal memperbarui (HTTP {status})", "networkError": "Kesalahan jaringan — tidak dapat memperbarui pengaturan eksposur penyedia" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 95e685a9ee..538d987741 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configurazione del fornitore", @@ -13636,5 +13637,22 @@ "hint": "Quando abilitato, i modelli scoperti appaiono nei selettori del provider in OmniRoute.", "updateFailed": "Impossibile aggiornare (HTTP {status})", "networkError": "Errore di rete — impossibile aggiornare l'impostazione di esposizione del provider" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d94486178e..b935d3e0d1 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "プロバイダーセットアップ", @@ -13636,5 +13637,22 @@ "hint": "有効にすると、発見されたモデルがOmniRoute全体のプロバイダーセレクトに表示されます。", "updateFailed": "更新に失敗しました (HTTP {status})", "networkError": "ネットワークエラー — プロバイダーの露出設定を更新できませんでした" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d0a54efa24..234827376a 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "제공자 설정", @@ -13636,5 +13637,22 @@ "hint": "활성화되면 발견된 모델이 OmniRoute 전역의 공급자 선택 목록에 나타납니다.", "updateFailed": "업데이트 실패 (HTTP {status})", "networkError": "네트워크 오류 — 공급자 노출 설정을 업데이트할 수 없습니다." + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 3d6f33eb93..8d2b3f0715 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "प्रदाता सेटअप", @@ -13636,5 +13637,22 @@ "hint": "सक्रिय केल्यास, शोधलेले मॉडेल OmniRoute मध्ये प्रदाता निवडींमध्ये दिसतात.", "updateFailed": "अद्यतन करण्यात अयशस्वी (HTTP {status})", "networkError": "नेटवर्क त्रुटी — प्रदाता एक्सपोजर सेटिंग अद्यतनित करता येत नाही" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 3255cd29e3..1297c18ce4 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Penyediaan Penyedia", @@ -13636,5 +13637,22 @@ "hint": "Apabila diaktifkan, model yang ditemui akan muncul dalam pilihan penyedia di seluruh OmniRoute.", "updateFailed": "Gagal untuk mengemas kini (HTTP {status})", "networkError": "Ralat rangkaian — tidak dapat mengemas kini tetapan pendedahan penyedia" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index a241e177c8..db583a3a0c 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverancier Configuratie", @@ -13636,5 +13637,22 @@ "hint": "Wanneer ingeschakeld, verschijnen ontdekte modellen in providerselecties in OmniRoute.", "updateFailed": "Kon niet bijwerken (HTTP {status})", "networkError": "Netwerkfout — kon de blootstellingsinstelling van de provider niet bijwerken" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 4db9cb01ee..4ddd4aeff5 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverandøroppsett", @@ -13636,5 +13637,22 @@ "hint": "Når aktivert, vises oppdagede modeller i leverandørvalg på tvers av OmniRoute.", "updateFailed": "Feilet å oppdatere (HTTP {status})", "networkError": "Nettverksfeil — kunne ikke oppdatere leverandørens eksponeringsinnstilling" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 282d0cf0dd..828bf4b827 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Provider Setup", @@ -13636,5 +13637,22 @@ "hint": "Kapag naka-enable, ang mga natuklasang modelo ay lilitaw sa mga provider select sa buong OmniRoute.", "updateFailed": "Nabigong i-update (HTTP {status})", "networkError": "Error sa Network — hindi ma-update ang setting ng provider exposure" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 7dcb363c91..aa40d399f7 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Konfiguracja dostawcy", @@ -13636,5 +13637,22 @@ "hint": "Gdy jest włączone, odkryte modele pojawiają się w wyborach dostawcy w całym OmniRoute.", "updateFailed": "Nie udało się zaktualizować (HTTP {status})", "networkError": "Błąd sieci — nie można zaktualizować ustawienia ekspozycji dostawcy" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0669292f6e..2d67930a43 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -13136,7 +13136,25 @@ "hiddenModelsTitle": "Modelos ocultos", "modelDisplayName": "Nome local do modelo", "modelEnabled": "Ativado localmente", - "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar" + "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar", + "guidedCombos": "Combos guiados" + }, + "radarCombosPage": { + "title": "Combos guiados pelo Radar", + "subtitle": "Crie fallbacks prioritários com modelos gratuitos equivalentes curados pelo Radar.", + "backToRadar": "Voltar ao Radar", + "loading": "Carregando sugestões de combos...", + "familyLabel": "Família de modelos", + "modelsLabel": "Ordem de prioridade", + "strategyReason": "A estratégia de prioridade usa primeiro a maior franquia gratuita recorrente e depois tenta outros provedores ativos.", + "generateButton": "Gerar combo", + "generating": "Gerando...", + "alreadyCreated": "Já criado", + "created": "Combo criado", + "noSuggestions": "Nenhuma família elegível tem conexões ativas com pelo menos dois provedores no momento.", + "catalogRequired": "Sincronize primeiro o catálogo Radar para carregar as famílias de modelos curadas.", + "loadFailed": "Falha ao carregar as sugestões de combos do Radar.", + "createFailed": "Falha ao criar o combo. Revise as conexões dos provedores e tente novamente." }, "radarSetupPage": { "title": "Configuração do Provedor", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 61d984efe3..6a9876f985 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Modelos ocultos", "modelDisplayName": "Nome de exibição local", "modelEnabled": "Ativado localmente", - "localStateSaveFailed": "Falha ao guardar as definições locais do Radar" + "localStateSaveFailed": "Falha ao guardar as definições locais do Radar", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configuração do Fornecedor", @@ -13643,5 +13644,22 @@ "hint": "Quando ativado, os modelos descobertos aparecem nas seleções do provedor em todo o OmniRoute.", "updateFailed": "Falha ao atualizar (HTTP {status})", "networkError": "Erro de rede — não foi possível atualizar a configuração de exposição do fornecedor" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index afe16e648c..7bcc5e315f 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Configurare Furnizor", @@ -13636,5 +13637,22 @@ "hint": "Când este activat, modelele descoperite apar în selecțiile furnizorului din OmniRoute.", "updateFailed": "Actualizarea a eșuat (HTTP {status})", "networkError": "Eroare de rețea — nu s-a putut actualiza setarea de expunere a furnizorului" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 4f0930ade8..418881b8e8 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Настройка провайдера", @@ -13636,5 +13637,22 @@ "hint": "Когда включено, обнаруженные модели появляются в выборках провайдеров по всему OmniRoute.", "updateFailed": "Не удалось обновить (HTTP {status})", "networkError": "Ошибка сети — не удалось обновить настройки экспозиции провайдера" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index ee62183ce8..339fce26a8 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Nastavenie poskytovateľa", @@ -13636,5 +13637,22 @@ "hint": "Keď je povolené, objavené modely sa zobrazia v selektoroch poskytovateľa naprieč OmniRoute.", "updateFailed": "Nepodarilo sa aktualizovať (HTTP {status})", "networkError": "Chyba siete — nepodarilo sa aktualizovať nastavenie expozície poskytovateľa" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 88d9275ec3..36019731e9 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverantörsinstallation", @@ -13636,5 +13637,22 @@ "hint": "När den är aktiverad visas upptäckta modeller i leverantörsvalen över hela OmniRoute.", "updateFailed": "Misslyckades med att uppdatera (HTTP {status})", "networkError": "Nätverksfel — kunde inte uppdatera leverantörens exponeringinställning" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 5d44c4b8b1..132c727dfa 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Leverantörsinstallation", @@ -13636,5 +13637,22 @@ "hint": "Wakati imewezeshwa, mifano iliyogunduliwa inaonekana katika chaguo za mtoa huduma katika OmniRoute.", "updateFailed": "Imeshindikana kuboresha (HTTP {status})", "networkError": "Kosa la mtandao — haiwezekani kuboresha mipangilio ya kufichua mtoa huduma" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 31351d2de4..3f01034d2e 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "வழங்குநர் அமைப்பு", @@ -13636,5 +13637,22 @@ "hint": "இது செயல்படுத்தப்பட்டால், கண்டறியப்பட்ட மாதிரிகள் OmniRoute இல் வழங்குநர் தேர்வுகளில் தோன்றும்.", "updateFailed": "புதுப்பிக்க முடியவில்லை (HTTP {status})", "networkError": "நெட்வொர்க் பிழை — வழங்குநர் வெளிப்பாடு அமைப்பை புதுப்பிக்க முடியவில்லை" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 8b12e89546..62fcb82896 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "ప్రొవైడర్ సెటప్", @@ -13636,5 +13637,22 @@ "hint": "చాలా సులభంగా, కనుగొనబడిన మోడల్స్ OmniRoute లో ప్రొవైడర్ ఎంపికలలో కనిపిస్తాయి.", "updateFailed": "అప్‌డేట్ చేయడంలో విఫలమైంది (HTTP {status})", "networkError": "నెట్‌వర్క్ లోపం — ప్రొవైడర్ ఎక్స్‌పోజర్ సెటింగ్‌ను నవీకరించలేకపోయింది" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 36f622709a..c88974227d 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "การตั้งค่าผู้ให้บริการ", @@ -13636,5 +13637,22 @@ "hint": "เมื่อเปิดใช้งาน โมเดลที่ค้นพบจะปรากฏในตัวเลือกผู้ให้บริการทั่วทั้ง OmniRoute.", "updateFailed": "ไม่สามารถอัปเดตได้ (HTTP {status})", "networkError": "ข้อผิดพลาดของเครือข่าย — ไม่สามารถอัปเดตการตั้งค่าการเปิดเผยผู้ให้บริการได้" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 9b7461b08e..073bf6a181 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Sağlayıcı Kurulumu", @@ -13636,5 +13637,22 @@ "hint": "Etkinleştirildiğinde, keşfedilen modeller OmniRoute genelinde sağlayıcı seçimlerinde görünür.", "updateFailed": "Güncelleme başarısız oldu (HTTP {status})", "networkError": "Ağ hatası — sağlayıcı maruziyet ayarı güncellenemedi" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 30401ee1b9..0b00f346f7 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Налаштування постачальника", @@ -13636,5 +13637,22 @@ "hint": "Коли увімкнено, виявлені моделі з'являються у виборах постачальника в OmniRoute.", "updateFailed": "Не вдалося оновити (HTTP {status})", "networkError": "Помилка мережі — не вдалося оновити налаштування експозиції провайдера" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 260e7dd57c..36df27ef27 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "فراہم کنندہ سیٹ اپ", @@ -13636,5 +13637,22 @@ "hint": "جب فعال ہو تو، دریافت کردہ ماڈل OmniRoute میں فراہم کنندہ کے انتخاب میں ظاہر ہوتے ہیں۔", "updateFailed": "اپ ڈیٹ کرنے میں ناکامی (HTTP {status})", "networkError": "نیٹ ورک کی خرابی — فراہم کنندہ کی نمائش کی ترتیب کو اپ ڈیٹ نہیں کر سکا" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f985b77f74..1aa6535869 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "Thiết lập nhà cung cấp", @@ -13636,5 +13637,22 @@ "hint": "Khi được kích hoạt, các mô hình đã phát hiện sẽ xuất hiện trong các lựa chọn nhà cung cấp trên toàn bộ OmniRoute.", "updateFailed": "Cập nhật không thành công (HTTP {status})", "networkError": "Lỗi mạng — không thể cập nhật cài đặt hiển thị nhà cung cấp" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 74896f1944..eeff735bc5 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "提供者设置", @@ -13636,5 +13637,22 @@ "hint": "启用后,发现的模型会出现在 OmniRoute 的提供者选择中。", "updateFailed": "更新失败 (HTTP {status})", "networkError": "网络错误 — 无法更新提供者曝光设置" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d30f4e2f30..4ef8615b26 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -13136,7 +13136,8 @@ "hiddenModelsTitle": "Hidden models", "modelDisplayName": "Local display name", "modelEnabled": "Enabled locally", - "localStateSaveFailed": "Failed to save local Radar settings" + "localStateSaveFailed": "Failed to save local Radar settings", + "guidedCombos": "Guided combos" }, "radarSetupPage": { "title": "提供者設置", @@ -13636,5 +13637,22 @@ "hint": "啟用後,發現的模型將出現在 OmniRoute 的提供者選擇中。", "updateFailed": "更新失敗 (HTTP {status})", "networkError": "網絡錯誤 — 無法更新提供者曝光設置" + }, + "radarCombosPage": { + "title": "Radar guided combos", + "subtitle": "Create priority fallbacks from equivalent free models curated by Radar.", + "backToRadar": "Back to Radar", + "loading": "Loading combo suggestions...", + "familyLabel": "Model family", + "modelsLabel": "Priority order", + "strategyReason": "Priority uses the largest recurring free budget first, then falls back across active providers.", + "generateButton": "Generate combo", + "generating": "Generating...", + "alreadyCreated": "Already created", + "created": "Combo created", + "noSuggestions": "No eligible model family currently has active connections to at least two providers.", + "catalogRequired": "Sync the Radar catalog first to load curated model families.", + "loadFailed": "Failed to load Radar combo suggestions.", + "createFailed": "Failed to create the combo. Review your provider connections and try again." } } diff --git a/tests/unit/radar-combos-page.test.ts b/tests/unit/radar-combos-page.test.ts new file mode 100644 index 0000000000..de41e5d86a --- /dev/null +++ b/tests/unit/radar-combos-page.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const pagePath = path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/combos/page.tsx"); +const radarPagePath = path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +function pageSource(): string { + return fs.existsSync(pagePath) ? fs.readFileSync(pagePath, "utf8") : ""; +} + +test("Radar exposes the guided combos page from its catalog", () => { + assert.ok(fs.existsSync(pagePath), "missing /dashboard/radar/combos page"); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/combos"/); +}); + +test("guided combos reuse only the local catalog, builder options, and combo writer", () => { + const source = pageSource(); + assert.match(source, /fetch\("\/api\/radar\/catalog"\)/); + assert.match(source, /fetch\("\/api\/combos\/builder\/options"\)/); + assert.match(source, /fetch\("\/api\/combos",\s*\{/); + assert.match(source, /method:\s*"POST"/); + assert.doesNotMatch(source, /\/api\/radar\/sync/); + assert.doesNotMatch(source, /localDb|getDbInstance|createCombo\(/); +}); + +test("guided combos render family, provider models, strategy reason and created state", () => { + const source = pageSource(); + assert.match(source, /buildRadarComboSuggestions/); + for (const key of [ + "familyLabel", + "modelsLabel", + "strategyReason", + "generateButton", + "alreadyCreated", + "noSuggestions", + "catalogRequired", + "loadFailed", + "createFailed", + ]) { + assert.match(source, new RegExp(`t\\("${key}"`), `missing UI key ${key}`); + } +}); + +test("every locale carries the Radar combos namespace and English/pt-BR have real copy", () => { + const messagesDir = path.join(process.cwd(), "src/i18n/messages"); + const files = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json")); + const requiredKeys = [ + "title", + "subtitle", + "backToRadar", + "loading", + "familyLabel", + "modelsLabel", + "strategyReason", + "generateButton", + "generating", + "alreadyCreated", + "created", + "noSuggestions", + "catalogRequired", + "loadFailed", + "createFailed", + ]; + + for (const file of files) { + const messages = JSON.parse(fs.readFileSync(path.join(messagesDir, file), "utf8")) as { + radarCombosPage?: Record; + }; + for (const key of requiredKeys) { + const value = messages.radarCombosPage?.[key]; + assert.equal(typeof value, "string", `${file}: missing radarCombosPage.${key}`); + assert.ok((value as string).trim().length > 0, `${file}: empty radarCombosPage.${key}`); + } + } + + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.join(messagesDir, `${locale}.json`), "utf8") + ) as { radarCombosPage: Record }; + for (const key of requiredKeys) { + assert.doesNotMatch( + messages.radarCombosPage[key], + /^__MISSING__:/, + `${locale}: placeholder at radarCombosPage.${key}` + ); + } + } +}); From ea95cc2937e5db29da3c961954fd8ee4567b680e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 21:07:04 -0300 Subject: [PATCH 005/134] feat(mcp): expose Radar catalog tool --- .../__tests__/essentialTools.test.ts | 8 +- .../__tests__/radarCatalogTool.test.ts | 151 ++++++++++++++++++ open-sse/mcp-server/radarCatalog.ts | 117 ++++++++++++++ open-sse/mcp-server/schemas/index.ts | 3 + open-sse/mcp-server/schemas/tools.ts | 70 +++++++- open-sse/mcp-server/server.ts | 38 +++++ src/shared/constants/mcpScopes.ts | 2 + 7 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 open-sse/mcp-server/__tests__/radarCatalogTool.test.ts create mode 100644 open-sse/mcp-server/radarCatalog.ts diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index 1e6fd76c93..b08b7f21ba 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for MCP Essential Tools (Phase 1) * - * Tests all 10 essential tool handlers via the tool handler functions. + * Tests the essential tool handlers via the tool handler functions. * The omniroute_web_search tests use InMemoryTransport + Client to exercise * the actual registered handler (not mockFetch directly). */ @@ -22,10 +22,10 @@ describe("MCP Essential Tools", () => { }); describe("Tool schema validation", () => { - it("should have exactly 12 essential tools (includes web_search + web_fetch + tool_search)", () => { - // 11 -> 12: #8925 shipped omniroute_create_combo as a phase-1 tool. + it("should have exactly 13 essential tools (including Radar catalog)", () => { + // 12 -> 13: F3 shipped omniroute_radar_catalog as a phase-1 read-only tool. const schemas = MCP_ESSENTIAL_TOOLS; - expect(schemas).toHaveLength(12); + expect(schemas).toHaveLength(13); }); it("all tools should have omniroute_ prefix", () => { diff --git a/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts new file mode 100644 index 0000000000..d30a59be7e --- /dev/null +++ b/open-sse/mcp-server/__tests__/radarCatalogTool.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { MCP_SCOPE_LIST, MCP_TOOL_SCOPES } from "../../../src/shared/constants/mcpScopes.ts"; +import { evaluateToolScopes } from "../scopeEnforcement.ts"; +import { getMcpRadarCatalog } from "../radarCatalog.ts"; +import { MCP_ESSENTIAL_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +vi.mock("../audit.ts", () => ({ + logToolCall: vi.fn().mockResolvedValue(undefined), +})); + +const catalog = { + entries: [ + { + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: true, + origin: "radar", + capabilities: { tools: true, vision: false, thinking: false }, + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + setup: { keyUrl: "https://secret.example/key", steps: ["do not expose"] }, + }, + { + provider: "cerebras", + modelId: "llama", + displayName: "Llama on Cerebras", + familyId: "llama-family", + monthlyTokens: 300, + creditTokens: 0, + freeType: "recurring-daily", + poolKey: null, + tos: "ok", + enabled: false, + disabledBy: "radar", + origin: "radar", + capabilities: { tools: true, vision: false, thinking: true }, + limits: { rpm: null, rpd: 100, tpm: null, tpd: null }, + }, + ], + meta: { version: "2026.08.08.1", tier: "community", fetchedAt: "2026-08-08T20:00:00Z" }, +}; + +describe("omniroute_radar_catalog", () => { + it("is a phase-1 read-only registry tool with the dedicated Radar scope", () => { + const definition = MCP_TOOL_MAP.omniroute_radar_catalog; + expect(definition).toBeDefined(); + expect(definition.phase).toBe(1); + expect(definition.scopes).toEqual(["read:radar"]); + expect(definition.auditLevel).toBe("none"); + expect(definition.sourceEndpoints).toEqual(["/api/radar/catalog"]); + expect(MCP_ESSENTIAL_TOOLS).toContain(definition); + expect(MCP_SCOPE_LIST).toContain("read:radar"); + expect(MCP_TOOL_SCOPES.omniroute_radar_catalog).toEqual(["read:radar"]); + }); + + it("reads only the local catalog and returns a closed filtered projection", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + const result = await getMcpRadarCatalog( + { provider: "groq", familyId: "llama-family", enabledOnly: true }, + { fetchJson } + ); + + expect(fetchJson).toHaveBeenCalledOnce(); + expect(fetchJson).toHaveBeenCalledWith("/api/radar/catalog"); + expect(result.models).toHaveLength(1); + expect(result.models[0]).toEqual({ + provider: "groq", + modelId: "llama", + displayName: "Llama on Groq", + familyId: "llama-family", + quota: { + monthlyTokens: 200, + creditTokens: 0, + freeType: "recurring-daily", + limits: { rpm: 30, rpd: null, tpm: null, tpd: null }, + }, + capabilities: { tools: true, vision: false, thinking: false }, + enabled: true, + origin: "radar", + disabledBy: null, + }); + expect(JSON.stringify(result)).not.toContain("secret.example"); + expect(JSON.stringify(result)).not.toContain("setup"); + }); + + it("defaults enabledOnly to true and includes disabled models only when explicitly requested", async () => { + const fetchJson = vi.fn().mockResolvedValue(catalog); + expect((await getMcpRadarCatalog({}, { fetchJson })).models).toHaveLength(1); + expect((await getMcpRadarCatalog({ enabledOnly: false }, { fetchJson })).models).toHaveLength( + 2 + ); + }); + + it("allows read:radar and read:* but denies a missing scope when enforcement is active", () => { + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:radar"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", ["read:*"], true).allowed).toBe(true); + expect(evaluateToolScopes("omniroute_radar_catalog", [], true)).toMatchObject({ + allowed: false, + reason: "missing_scopes", + missing: ["read:radar"], + }); + }); +}); + +describe("omniroute_radar_catalog MCP dispatch", () => { + const mockFetch = vi.fn(); + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "radar-catalog-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + vi.unstubAllGlobals(); + }); + + it("registers and dispatches a real read without sync or write", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => catalog }); + + const listed = await client.listTools(); + expect(listed.tools.some((tool) => tool.name === "omniroute_radar_catalog")).toBe(true); + + const result = await client.callTool({ + name: "omniroute_radar_catalog", + arguments: { enabledOnly: false }, + }); + expect(result.isError).toBeFalsy(); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch.mock.calls[0][0]).toContain("/api/radar/catalog"); + expect(mockFetch.mock.calls[0][1]).not.toMatchObject({ method: "POST" }); + const body = JSON.parse((result.content[0] as { text: string }).text); + expect(body.models).toHaveLength(2); + }); +}); diff --git a/open-sse/mcp-server/radarCatalog.ts b/open-sse/mcp-server/radarCatalog.ts new file mode 100644 index 0000000000..0feea50899 --- /dev/null +++ b/open-sse/mcp-server/radarCatalog.ts @@ -0,0 +1,117 @@ +type JsonRecord = Record; + +export interface McpRadarCatalogArgs { + provider?: string; + familyId?: string; + enabledOnly?: boolean; +} + +interface McpRadarCatalogDeps { + fetchJson?: (path: string) => Promise; +} + +function record(value: unknown): JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function number(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function normalizeMeta( + value: unknown +): { version: string; tier: string; fetchedAt: string } | null { + const meta = record(value); + if ( + typeof meta.version !== "string" || + typeof meta.tier !== "string" || + typeof meta.fetchedAt !== "string" + ) { + return null; + } + return { version: meta.version, tier: meta.tier, fetchedAt: meta.fetchedAt }; +} + +function normalizeEntry(value: unknown) { + const entry = record(value); + const provider = text(entry.provider).trim(); + const modelId = text(entry.modelId).trim(); + if (!provider || !modelId) return null; + + const capabilities = record(entry.capabilities); + const limits = record(entry.limits); + const origin = + entry.origin === "radar" || entry.origin === "local" ? entry.origin : ("baseline" as const); + return { + provider, + modelId, + displayName: text(entry.displayName, modelId), + familyId: typeof entry.familyId === "string" ? entry.familyId : null, + quota: { + monthlyTokens: number(entry.monthlyTokens), + creditTokens: number(entry.creditTokens), + freeType: text(entry.freeType, "unknown"), + limits: + Object.keys(limits).length > 0 + ? { + rpm: nullableNumber(limits.rpm), + rpd: nullableNumber(limits.rpd), + tpm: nullableNumber(limits.tpm), + tpd: nullableNumber(limits.tpd), + } + : null, + }, + capabilities: + Object.keys(capabilities).length > 0 + ? { + tools: capabilities.tools === true, + vision: capabilities.vision === true, + thinking: capabilities.thinking === true, + } + : null, + enabled: entry.enabled !== false, + origin, + disabledBy: entry.disabledBy === "radar" ? ("radar" as const) : null, + }; +} + +function compareEntries( + left: NonNullable>, + right: NonNullable> +): number { + return left.provider.localeCompare(right.provider) || left.modelId.localeCompare(right.modelId); +} + +/** Read and project the local Radar catalog without exposing setup or secret-bearing state. */ +export async function getMcpRadarCatalog( + args: McpRadarCatalogArgs, + deps: McpRadarCatalogDeps = {} +) { + const fetchJson = + deps.fetchJson ?? + ((path: string) => import("./server.ts").then((module) => module.omniRouteFetch(path))); + const raw = record(await fetchJson("/api/radar/catalog")); + const providerFilter = args.provider?.trim().toLowerCase(); + const familyFilter = args.familyId?.trim().toLowerCase(); + const enabledOnly = args.enabledOnly !== false; + const entries = Array.isArray(raw.entries) ? raw.entries : []; + const models = entries + .map(normalizeEntry) + .filter((entry): entry is NonNullable => entry !== null) + .filter((entry) => !enabledOnly || entry.enabled) + .filter((entry) => !providerFilter || entry.provider.toLowerCase() === providerFilter) + .filter((entry) => !familyFilter || entry.familyId?.toLowerCase() === familyFilter) + .sort(compareEntries); + + return { meta: normalizeMeta(raw.meta), models }; +} diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index fe9df69ff2..1bc0f83b07 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -35,6 +35,9 @@ export { listModelsCatalogInput, listModelsCatalogOutput, listModelsCatalogTool, + radarCatalogInput, + radarCatalogOutput, + radarCatalogTool, // Phase 2: Advanced tool schemas simulateRouteInput, simulateRouteOutput, diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 26a91f435b..9450bca4a9 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1,5 +1,5 @@ /** - * MCP Tool Schemas — Contracts for all 23 core and advanced OmniRoute MCP tools. + * MCP Tool Schemas — Contracts for the canonical OmniRoute MCP tools. * * Defines input/output Zod schemas, descriptions, scopes, and audit levels * for both essential (Phase 1) and advanced (Phase 2) MCP tools. @@ -27,7 +27,7 @@ import type { McpToolDefinition } from "./toolDefinition.ts"; export { pickFastestModelInput, pickFastestModelOutput } from "./pickFastestModel.ts"; export * from "./ccrTools.ts"; -// ============ Phase 1: Essential Tools (8) ============ +// ============ Phase 1: Essential Tools ============ // --- Tool 1: omniroute_get_health --- export const getHealthInput = z.object({}).describe("No parameters required"); @@ -440,7 +440,70 @@ export const listModelsCatalogTool: McpToolDefinition< sourceEndpoints: ["/api/models/catalog", "/v1/models"], }; -// --- Tool 9: omniroute_web_search --- +// --- Tool 9: omniroute_radar_catalog --- +export const radarCatalogInput = z.object({ + provider: z.string().trim().min(1).max(100).optional().describe("Filter by provider id"), + familyId: z.string().trim().min(1).max(120).optional().describe("Filter by curated family id"), + enabledOnly: z.boolean().default(true).describe("Exclude models disabled by the Radar feed"), +}); + +const radarLimitOutput = z.object({ + rpm: z.number().nullable(), + rpd: z.number().nullable(), + tpm: z.number().nullable(), + tpd: z.number().nullable(), +}); + +export const radarCatalogOutput = z.object({ + meta: z + .object({ + version: z.string(), + tier: z.string(), + fetchedAt: z.string(), + }) + .nullable(), + models: z.array( + z.object({ + provider: z.string(), + modelId: z.string(), + displayName: z.string(), + familyId: z.string().nullable(), + quota: z.object({ + monthlyTokens: z.number(), + creditTokens: z.number(), + freeType: z.string(), + limits: radarLimitOutput.nullable(), + }), + capabilities: z + .object({ + tools: z.boolean(), + vision: z.boolean(), + thinking: z.boolean(), + }) + .nullable(), + enabled: z.boolean(), + origin: z.enum(["baseline", "radar", "local"]), + disabledBy: z.literal("radar").nullable(), + }) + ), +}); + +export const radarCatalogTool: McpToolDefinition< + typeof radarCatalogInput, + typeof radarCatalogOutput +> = { + name: "omniroute_radar_catalog", + description: + "Reads the local signed Radar catalog with optional provider and curated-family filters. Never syncs or writes data.", + inputSchema: radarCatalogInput, + outputSchema: radarCatalogOutput, + scopes: ["read:radar"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/radar/catalog"], +}; + +// --- Tool 10: omniroute_web_search --- export const webSearchInput = z.object({ query: z .string() @@ -1519,6 +1582,7 @@ export const MCP_TOOLS = [ routeRequestTool, costReportTool, listModelsCatalogTool, + radarCatalogTool, webSearchTool, webFetchTool, simulateRouteTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 48366ce405..6d138664e6 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -17,6 +17,8 @@ import { routeRequestInput, costReportInput, listModelsCatalogInput, + radarCatalogInput, + radarCatalogOutput, webSearchInput, webFetchInput, simulateRouteInput, @@ -93,7 +95,9 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; +import { getMcpRadarCatalog } from "./radarCatalog.ts"; export { getMcpModelsCatalog } from "./catalog.ts"; +export { getMcpRadarCatalog } from "./radarCatalog.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; @@ -597,6 +601,29 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s } } +async function handleRadarCatalog(args: { + provider?: string; + familyId?: string; + enabledOnly: boolean; +}) { + const start = Date.now(); + try { + const result = radarCatalogOutput.parse(await getMcpRadarCatalog(args)); + await logToolCall( + "omniroute_radar_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to read Radar catalog"; + await logToolCall("omniroute_radar_catalog", args, null, Date.now() - start, false, message); + return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; + } +} + async function handleWebSearch(args: { query: string; max_results?: number; @@ -842,6 +869,17 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool( + "omniroute_radar_catalog", + { + description: "Reads the local signed Radar catalog with optional provider and family filters", + inputSchema: radarCatalogInput, + }, + withScopeEnforcement("omniroute_radar_catalog", (args) => + handleRadarCatalog(radarCatalogInput.parse(args)) + ) + ); + server.registerTool( "omniroute_simulate_route", { diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index d5babbb63c..c03e4e5797 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -15,6 +15,7 @@ export const MCP_SCOPE_LIST = [ "read:quota", "read:usage", "read:models", + "read:radar", "execute:completions", "execute:search", "write:budget", @@ -44,6 +45,7 @@ export const MCP_TOOL_SCOPES: Record = { omniroute_web_fetch: ["execute:search"], omniroute_cost_report: ["read:usage"], omniroute_list_models_catalog: ["read:models"], + omniroute_radar_catalog: ["read:radar"], // Phase 2: Advanced Tools omniroute_simulate_route: ["read:health", "read:combos"], From b6bba896b3e289b0c73f1a57ba3195fc2012bb50 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 21:07:54 -0300 Subject: [PATCH 006/134] docs(radar): document guided combos and MCP --- AGENTS.md | 30 +++++++++++------------ README.md | 8 +++--- docs/architecture/QUALITY_GATES.md | 8 +++--- docs/frameworks/MCP-SERVER.md | 25 +++++++++---------- docs/frameworks/RADAR.md | 26 +++++++++++++++++--- open-sse/mcp-server/README.md | 27 +++++++++++--------- scripts/check/check-docs-counts-sync.mjs | 5 ++-- tests/unit/check-docs-counts-sync.test.ts | 21 ++++++++-------- 8 files changed, 87 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59be68c2c8..9992c1b479 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,20 +48,20 @@ Repository map and Reference Documentation sections below. **OmniRoute** — unified AI proxy/router. One endpoint, 339 LLM providers, auto-fallback. -| Layer | Location | Purpose | -| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | -| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | -| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | -| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | -| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | -| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (145 migrations) | -| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | -| MCP Server | `open-sse/mcp-server/` | 105 tools (43 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes | -| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | -| Skills | `src/lib/skills/` | Extensible skill framework | -| Memory | `src/lib/memory/` | Persistent conversational memory | +| Layer | Location | Purpose | +| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | +| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | +| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | +| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | +| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | +| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | +| Database | `src/lib/db/` | SQLite domain modules (146 migrations) | +| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | +| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | +| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | +| Skills | `src/lib/skills/` | Extensible skill framework | +| Memory | `src/lib/memory/` | Persistent conversational memory | Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point). @@ -679,7 +679,7 @@ the stale-enforcement added in Fase 6A.3. causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit - && git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição +&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate `check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma. diff --git a/README.md b/README.md index e31e2710a6..ecf5be2aab 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 105 tools, A2A, memory, guardrails, evals — 25,000+ tests). +The Promise — One endpoint. 339 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 339 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 40+ free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests).

@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 339 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 105 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. +What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 339 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -748,7 +748,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo - + @@ -1135,7 +1135,7 @@ same process on one port, so there is no separate CLI-only package today. - + diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 63fbdea72f..42d3ee7908 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -205,10 +205,10 @@ Runs on pull requests only. Runs after `build`. Blocks merge on failure. -| Suite | Validates | Blocking | -| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `test:vitest` | MCP server (105 tools), autoCombo, cache — vitest runner | Yes | -| `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job | +| Suite | Validates | Blocking | +| ---------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `test:vitest` | MCP server (109 tools), autoCombo, cache — vitest runner | Yes | +| `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job | ### Nightly workflows (scheduled, advisory) diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index ba25f55b13..67e946528f 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -1,14 +1,14 @@ --- title: "OmniRoute MCP Server Documentation" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.50 +lastUpdated: 2026-08-08 --- # OmniRoute MCP Server Documentation -> Model Context Protocol server with 105 tools across routing, cache, compression, memory, skills, proxy, pool, and context source operations. +> Model Context Protocol server with 109 tools across routing, cache, compression, memory, skills, proxy, pool, Radar, and context source operations. > -> Source of truth: `open-sse/mcp-server/server.ts` computes **105 unique tools** with `countUniqueMcpTools()`: 42 canonical definitions (including the six CCR lifecycle tools and the agent-skills trio), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), and two RTK-only compression tools. +> Source of truth: `open-sse/mcp-server/server.ts` computes **109 unique tools** with `countUniqueMcpTools()`: 44 canonical definitions (including the six CCR lifecycle tools, the agent-skills trio, and `omniroute_radar_catalog`), plus memory (3), skills (4), GitHub skills (3), pool (6), gamification (8), plugins (8), Notion (6), Obsidian (22), local corpus (3), and two RTK-only compression tools. ## Installation @@ -64,7 +64,7 @@ Cursor, Cline, and compatible MCP client setup. --- -## Essential Tools (8) — Phase 1 +## Essential Tools (13) — Phase 1 | Tool | Scopes | Description | | :------------------------------ | :-------------------- | :------------------------------------------------------------ | @@ -72,16 +72,15 @@ Cursor, Cline, and compatible MCP client setup. | `omniroute_list_combos` | `read:combos` | All configured combos with strategies (optional metrics) | | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo | +| `omniroute_create_combo` | `write:combos` | Create a validated combo through the existing combo API | | `omniroute_check_quota` | `read:quota` | Quota used/total, percent remaining, reset time, token health | | `omniroute_route_request` | `execute:completions` | Send a chat completion through OmniRoute routing | | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) | | `omniroute_list_models_catalog` | `read:models` | Full model catalog with capabilities, status, pricing | - -## Phase 1 — Search - -| Tool | Scopes | Description | -| :--------------------- | :--------------- | :--------------------------------------------------------------------------------------------------------------------------------- | -| `omniroute_web_search` | `execute:search` | Web search through OmniRoute search gateway (Serper/Brave/Perplexity/Exa/Tavily/Google PSE/Linkup/SearchAPI/SearXNG) with failover | +| `omniroute_radar_catalog` | `read:radar` | Local signed Radar catalog; optional provider/family filters | +| `omniroute_tool_search` | `read:tools` | Discover tools from the registered MCP catalog | +| `omniroute_web_search` | `execute:search` | Web search through the configured search providers | +| `omniroute_web_fetch` | `execute:search` | Fetch web content through the configured fetch providers | ## Advanced Tools (11) — Phase 2 @@ -227,7 +226,7 @@ See [AGENT-SKILLS.md](./AGENT-SKILLS.md) for the full catalog and how external a ## Related Frameworks (v3.8.0) -computed = 105, computed by `countUniqueMcpTools()`) is intentionally +The MCP tool inventory above (109 unique tools, computed by `countUniqueMcpTools()`) is intentionally scoped to runtime routing/cache/compression/memory/skills/proxy/context-source operations. Two adjacent frameworks ship alongside the MCP server in v3.8.0 and are documented separately: @@ -369,7 +368,7 @@ MCP tool, prompt, and resource registries can compress descriptions at registrat Description compression shrinks each tool's metadata; **tool-cardinality reduction** goes one step further by reducing _how many_ tools are announced at all. Advertising fewer tools in the `tools/list` manifest cuts the per-request token cost the client's model pays for the tool catalog ("layer 5" compression). The implementation is a pure, stateless filter in `open-sse/mcp-server/toolCardinality.ts` (`reduceToolManifest`), wired into the registration loop in `createMcpServer()` (`open-sse/mcp-server/server.ts`). -**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 105 tools are announced unchanged. +**Opt-in, off by default.** The filter only runs when at least one of two environment variables is set; with neither set, all 109 tools are announced unchanged. | Variable | Mode | | :--------------- | :-------------------------------------------------------------------------------------- | diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 1d1d1015d7..1e36a4e228 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -290,6 +290,26 @@ The dashboard exposes four local actions: A feed `enabled: false` remains the safety exception: it wins over a stale local `enabled: true`, keeps the merged entry disabled, and records `disabledBy: "radar"`. +### Guided combos and MCP access + +Confirmed `familyId` values survive the read-time overlay and drive the pure +`buildRadarComboSuggestions()` module (`src/lib/radar/comboSuggestions.ts`). A family is suggested +only when at least two distinct providers have active connections and expose the exact curated model +ID. Disabled models, inactive providers, missing model IDs, singleton families, and ambiguous +alias/prefix matches fail closed. Suggestions use the existing `priority` strategy, ordering the +largest recurring monthly budget first; the UI creates them only through `POST /api/combos`. + +The guided UI lives at `/dashboard/radar/combos`. It reads only the local +`GET /api/radar/catalog` and `GET /api/combos/builder/options` endpoints. It never triggers Radar sync, +reads provider credentials, or writes directly to the combo database. + +MCP clients can read the same local projection with `omniroute_radar_catalog` (`read:radar`). The +optional `provider`, `familyId`, and `enabledOnly` filters are evaluated after one local +`GET /api/radar/catalog` read. Its closed output includes catalog metadata plus provider/model, +display name, `familyId`, quota, capabilities, enabled state, origin, and `disabledBy`; setup URLs, +steps, connections, e-mail addresses, keys, and referral data are never returned. This tool is +read-only and never invokes `/api/radar/sync`. + ### Provenance markers Every merged entry carries an `origin` field the UI renders as a badge: @@ -303,7 +323,7 @@ Every merged entry carries an `origin` field the UI renders as a badge: ## Local surfaces — never a feed proxy -Six local endpoints back the UI, all under `src/app/api/radar/`: +The local Radar route families below back the UI under `src/app/api/radar/`: | Route | Method | Purpose | | ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------- | @@ -323,14 +343,14 @@ to the local OmniRoute server. The two modules that touch the Radar service are always run server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. -All six endpoints return `404` when `RADAR_ENABLED` is off (see +All Radar endpoints return `404` when `RADAR_ENABLED` is off (see [Flag](#flag-radar_enabled-default-off) above), and route error responses through `buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule (`docs/security/ERROR_SANITIZATION.md`). ### Authentication -All six endpoints require authentication via `isAuthenticated()` +All Radar endpoints 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` diff --git a/open-sse/mcp-server/README.md b/open-sse/mcp-server/README.md index 85fab9e49b..cf3d5ce748 100644 --- a/open-sse/mcp-server/README.md +++ b/open-sse/mcp-server/README.md @@ -1,6 +1,6 @@ # OmniRoute MCP Server -> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **107 tools** for AI agents. +> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **109 tools** for AI agents. > > **Source of truth for the full tool catalog and REST surface:** [`docs/frameworks/MCP-SERVER.md`](../../docs/frameworks/MCP-SERVER.md). This README focuses on architecture, configuration, and integration examples; the catalog below is a summary subset. @@ -20,7 +20,7 @@ The MCP Server allows any AI agent (Claude Desktop, Cursor, VS Code Copilot, cus ┌──────────────────────────────────────────────────────────────────┐ │ OmniRoute MCP Server │ │ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │ -│ │ Scope │ │ 107 MCP Tools │ │ Audit Logger │ │ +│ │ Scope │ │ 109 MCP Tools │ │ Audit Logger │ │ │ │ Enforcement │──│ (core + memory │──│ (SHA-256/SQLite) │ │ │ │ │ │ + skills + …) │ │ │ │ │ └──────────────┘ └────────┬────────┘ └────────────────────┘ │ @@ -120,18 +120,23 @@ omniroute --mcp ## Tool Reference -### Phase 1: Essential Tools (8) +### Phase 1: Essential Tools (13) | # | Tool | Scopes | Description | | --- | ------------------------------- | --------------------- | -------------------------------------------------------------------------- | -| 1 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats | -| 2 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | -| 3 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | -| 4 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | -| 5 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | -| 6 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | -| 7 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | -| 8 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | +| 1 | `omniroute_tool_search` | `read:tools` | Discover tools from the registered MCP catalog | +| 2 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats | +| 3 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | +| 4 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | +| 5 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | +| 6 | `omniroute_create_combo` | `write:combos` | Create a validated combo through the existing combo API | +| 7 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | +| 8 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | +| 9 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | +| 10 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | +| 11 | `omniroute_radar_catalog` | `read:radar` | Read the local signed Radar catalog with provider/family filters | +| 12 | `omniroute_web_search` | `execute:search` | Search the web through configured search providers | +| 13 | `omniroute_web_fetch` | `execute:search` | Fetch web content through configured fetch providers | ### Phase 2: Advanced Tools (8) diff --git a/scripts/check/check-docs-counts-sync.mjs b/scripts/check/check-docs-counts-sync.mjs index 61077b8cf9..47366deee6 100644 --- a/scripts/check/check-docs-counts-sync.mjs +++ b/scripts/check/check-docs-counts-sync.mjs @@ -158,6 +158,7 @@ function readCodeFacts() { 'import {pluginTools} from "./open-sse/mcp-server/tools/pluginTools.ts";', 'import {notionTools} from "./open-sse/mcp-server/tools/notionTools.ts";', 'import {obsidianTools} from "./open-sse/mcp-server/tools/obsidianTools.ts";', + 'import {localCorpusTools} from "./open-sse/mcp-server/tools/localCorpusTools.ts";', 'import {compressionTools} from "./open-sse/mcp-server/tools/compressionTools.ts";', // Live provider total — the SAME collections gen-provider-reference.ts unions, so the // doc-vs-live check below cannot drift from the generator's definition of "provider". @@ -169,7 +170,7 @@ function readCodeFacts() { "const pids=new Set();", "for(const c of provCols)for(const p of Object.values(c||{}))if(p&&p.id)pids.add(p.id);", "const cols={MCP_TOOLS,memoryTools,skillTools,agentSkillTools,githubSkillTools,poolTools,", - "gamificationTools,pluginTools,notionTools,obsidianTools,compressionTools};", + "gamificationTools,pluginTools,notionTools,obsidianTools,localCorpusTools,compressionTools};", "const sc=new Set();", "for(const col of Object.values(cols))for(const t of Object.values(col))", "for(const x of (t?.scopes||[]))sc.add(x);", @@ -452,7 +453,7 @@ export function buildChecks() { // total ("33 tools (25 CLI Code's …)") are not the MCP aggregate // per-module rows read "… tool definitions (N tools" / "… management tools // (N tools" — the word tool(s)/definitions sits right before the paren. The - // aggregate ("MCP Server (104 tools", "all 104 tools") never does. + // aggregate ("MCP Server (109 tools", "all 109 tools") never does. skipBefore: /(tools?|definitions?)\s*\(\s*$/i, skipAfter: /^\s*\(\d+ CLI/, }, diff --git a/tests/unit/check-docs-counts-sync.test.ts b/tests/unit/check-docs-counts-sync.test.ts index e91e18ba58..6fb605cab9 100644 --- a/tests/unit/check-docs-counts-sync.test.ts +++ b/tests/unit/check-docs-counts-sync.test.ts @@ -146,10 +146,9 @@ test("free-tier gate passes when a file carries no headline at all", () => { assert.equal(checkHeadline("no figures here", TOTALS).ok, true); }); - // --- Generic numeric-claim gate (engines / MCP tools / scopes / CLI) -------- // Extends the same drift guard to the counts that silently drifted in v3.8.49: -// 11→12 engines, 94→107 MCP tools, 30→32 scopes, 26→33 CLI tools. +// 11→12 engines, 94→109 MCP tools, 30→33 scopes, 26→33 CLI tools. import { makeNumberClaimValidator } from "../../scripts/check/check-docs-counts-sync.mjs"; const makeValidator = makeNumberClaimValidator as ( @@ -158,19 +157,19 @@ const makeValidator = makeNumberClaimValidator as ( ) => (content: string) => { ok: boolean; detail: string }; test("MCP-tools gate accepts the aggregate and rejects a stale one", () => { - const v = makeValidator(107, { + const v = makeValidator(109, { what: "MCP tools", pattern: /(\d+) tools/gi, skipBefore: /(tools?|definitions?)\s*\(\s*$/i, skipAfter: /^\s*\(\d+ CLI/, }); - assert.equal(v("MCP Server (107 tools)").ok, true); - assert.equal(v("with 107 tools total").ok, true); + assert.equal(v("MCP Server (109 tools)").ok, true); + assert.equal(v("with 109 tools total").ok, true); assert.equal(v("MCP Server (94 tools)").ok, false); }); test("MCP-tools gate ignores per-module counts and the CLI catalog total", () => { - const v = makeValidator(107, { + const v = makeValidator(109, { what: "MCP tools", pattern: /(\d+) tools/gi, skipBefore: /(tools?|definitions?)\s*\(\s*$/i, @@ -245,18 +244,18 @@ test("package.json description validator catches a stale provider count", () => }); test("migrations claim validator accepts the real count and rejects stale styles", () => { - const v = makeValidator(144, { what: "migrations", pattern: /(\d+)\+? migrations?\b/gi }); - assert.equal(v("SQLite domain modules (144 migrations)").ok, true); + const v = makeValidator(146, { what: "migrations", pattern: /(\d+)\+? migrations?\b/gi }); + assert.equal(v("SQLite domain modules (146 migrations)").ok, true); assert.equal(v("local, zero-config, 110+ migrations").ok, false); assert.equal(v("(130 migrations)").ok, false); }); -const SVG_EXPECTED = { providers: 338, mcpTools: 105, strategies: 19, pools: 42 }; +const SVG_EXPECTED = { providers: 339, mcpTools: 109, strategies: 19, pools: 41 }; test("SVG gate accepts canonical numbers in text and aria-label claims", () => { const good = - 'aria-label="338 AI providers, 19 routing strategies, MCP with 105 tools, ' + - '42 provider pools" 338 providersMCP (105'; + 'aria-label="339 AI providers, 19 routing strategies, MCP with 109 tools, ' + + '41 provider pools" 339 providersMCP (109'; assert.equal(checkSvg(good, SVG_EXPECTED).ok, true); }); From ee6695acad706f945c56dbcf059ac383918ca893 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 23:12:00 -0300 Subject: [PATCH 007/134] refactor(mcp): modularize Radar catalog tool --- open-sse/mcp-server/radarCatalog.ts | 53 +++++++++++++++++ open-sse/mcp-server/schemas/index.ts | 5 +- open-sse/mcp-server/schemas/radarCatalog.ts | 65 ++++++++++++++++++++ open-sse/mcp-server/schemas/tools.ts | 66 +-------------------- open-sse/mcp-server/server.ts | 45 +------------- open-sse/mcp-server/toolResult.ts | 4 ++ 6 files changed, 128 insertions(+), 110 deletions(-) create mode 100644 open-sse/mcp-server/schemas/radarCatalog.ts create mode 100644 open-sse/mcp-server/toolResult.ts diff --git a/open-sse/mcp-server/radarCatalog.ts b/open-sse/mcp-server/radarCatalog.ts index 0feea50899..e17ce724bb 100644 --- a/open-sse/mcp-server/radarCatalog.ts +++ b/open-sse/mcp-server/radarCatalog.ts @@ -1,5 +1,19 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { logToolCall } from "./audit.ts"; +import { radarCatalogInput, radarCatalogOutput } from "./schemas/radarCatalog.ts"; +import type { McpToolExtraLike } from "./scopeEnforcement.ts"; +import type { TextToolResult } from "./toolResult.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + type JsonRecord = Record; +type ScopeEnforcer = ( + toolName: string, + handler: (args: unknown, extra?: McpToolExtraLike) => Promise, + toolScopes?: readonly string[] +) => (args: unknown, extra?: McpToolExtraLike) => Promise; + export interface McpRadarCatalogArgs { provider?: string; familyId?: string; @@ -115,3 +129,42 @@ export async function getMcpRadarCatalog( return { meta: normalizeMeta(raw.meta), models }; } + +async function handleRadarCatalog(args: { + provider?: string; + familyId?: string; + enabledOnly: boolean; +}): Promise { + const start = Date.now(); + try { + const result = radarCatalogOutput.parse(await getMcpRadarCatalog(args)); + await logToolCall( + "omniroute_radar_catalog", + args, + { modelCount: result.models.length }, + Date.now() - start, + true + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + const message = sanitizeErrorMessage(error) || "Failed to read Radar catalog"; + await logToolCall("omniroute_radar_catalog", args, null, Date.now() - start, false, message); + return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; + } +} + +export function registerRadarCatalogTool( + server: McpServer, + withScopeEnforcement: ScopeEnforcer +): void { + server.registerTool( + "omniroute_radar_catalog", + { + description: "Reads the local signed Radar catalog with optional provider and family filters", + inputSchema: radarCatalogInput, + }, + withScopeEnforcement("omniroute_radar_catalog", (args) => + handleRadarCatalog(radarCatalogInput.parse(args)) + ) + ); +} diff --git a/open-sse/mcp-server/schemas/index.ts b/open-sse/mcp-server/schemas/index.ts index 1bc0f83b07..1c3cbdc029 100644 --- a/open-sse/mcp-server/schemas/index.ts +++ b/open-sse/mcp-server/schemas/index.ts @@ -35,9 +35,6 @@ export { listModelsCatalogInput, listModelsCatalogOutput, listModelsCatalogTool, - radarCatalogInput, - radarCatalogOutput, - radarCatalogTool, // Phase 2: Advanced tool schemas simulateRouteInput, simulateRouteOutput, @@ -94,6 +91,8 @@ export { ccrStatsTool, } from "./tools.ts"; +export { radarCatalogInput, radarCatalogOutput, radarCatalogTool } from "./radarCatalog.ts"; + // A2A schemas export { AgentCardSchema, diff --git a/open-sse/mcp-server/schemas/radarCatalog.ts b/open-sse/mcp-server/schemas/radarCatalog.ts new file mode 100644 index 0000000000..1bfb4040e2 --- /dev/null +++ b/open-sse/mcp-server/schemas/radarCatalog.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import type { McpToolDefinition } from "./toolDefinition.ts"; + +export const radarCatalogInput = z.object({ + provider: z.string().trim().min(1).max(100).optional().describe("Filter by provider id"), + familyId: z.string().trim().min(1).max(120).optional().describe("Filter by curated family id"), + enabledOnly: z.boolean().default(true).describe("Exclude models disabled by the Radar feed"), +}); + +const radarLimitOutput = z.object({ + rpm: z.number().nullable(), + rpd: z.number().nullable(), + tpm: z.number().nullable(), + tpd: z.number().nullable(), +}); + +export const radarCatalogOutput = z.object({ + meta: z + .object({ + version: z.string(), + tier: z.string(), + fetchedAt: z.string(), + }) + .nullable(), + models: z.array( + z.object({ + provider: z.string(), + modelId: z.string(), + displayName: z.string(), + familyId: z.string().nullable(), + quota: z.object({ + monthlyTokens: z.number(), + creditTokens: z.number(), + freeType: z.string(), + limits: radarLimitOutput.nullable(), + }), + capabilities: z + .object({ + tools: z.boolean(), + vision: z.boolean(), + thinking: z.boolean(), + }) + .nullable(), + enabled: z.boolean(), + origin: z.enum(["baseline", "radar", "local"]), + disabledBy: z.literal("radar").nullable(), + }) + ), +}); + +export const radarCatalogTool: McpToolDefinition< + typeof radarCatalogInput, + typeof radarCatalogOutput +> = { + name: "omniroute_radar_catalog", + description: + "Reads the local signed Radar catalog with optional provider and curated-family filters. Never syncs or writes data.", + inputSchema: radarCatalogInput, + outputSchema: radarCatalogOutput, + scopes: ["read:radar"], + auditLevel: "none", + phase: 1, + sourceEndpoints: ["/api/radar/catalog"], +}; diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 9450bca4a9..88d427e041 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -13,11 +13,11 @@ import { z } from "zod"; import { toolSearchTool } from "./toolSearch.ts"; import { pickFastestModelTool } from "./pickFastestModel.ts"; import { CCR_MCP_TOOLS } from "./ccrTools.ts"; +import { radarCatalogTool } from "./radarCatalog.ts"; import { AUTO_ROUTING_STRATEGY_VALUES, ROUTING_STRATEGY_VALUES, } from "../../../src/shared/constants/routingStrategies.ts"; - // ============ Shared Types ============ // AuditLevel + McpToolDefinition live in the leaf ./toolDefinition.ts so that // toolSearch.ts can import the type without forming a tools.ts ↔ toolSearch.ts cycle. @@ -26,7 +26,6 @@ export type { AuditLevel, McpToolDefinition } from "./toolDefinition.ts"; import type { McpToolDefinition } from "./toolDefinition.ts"; export { pickFastestModelInput, pickFastestModelOutput } from "./pickFastestModel.ts"; export * from "./ccrTools.ts"; - // ============ Phase 1: Essential Tools ============ // --- Tool 1: omniroute_get_health --- @@ -440,69 +439,6 @@ export const listModelsCatalogTool: McpToolDefinition< sourceEndpoints: ["/api/models/catalog", "/v1/models"], }; -// --- Tool 9: omniroute_radar_catalog --- -export const radarCatalogInput = z.object({ - provider: z.string().trim().min(1).max(100).optional().describe("Filter by provider id"), - familyId: z.string().trim().min(1).max(120).optional().describe("Filter by curated family id"), - enabledOnly: z.boolean().default(true).describe("Exclude models disabled by the Radar feed"), -}); - -const radarLimitOutput = z.object({ - rpm: z.number().nullable(), - rpd: z.number().nullable(), - tpm: z.number().nullable(), - tpd: z.number().nullable(), -}); - -export const radarCatalogOutput = z.object({ - meta: z - .object({ - version: z.string(), - tier: z.string(), - fetchedAt: z.string(), - }) - .nullable(), - models: z.array( - z.object({ - provider: z.string(), - modelId: z.string(), - displayName: z.string(), - familyId: z.string().nullable(), - quota: z.object({ - monthlyTokens: z.number(), - creditTokens: z.number(), - freeType: z.string(), - limits: radarLimitOutput.nullable(), - }), - capabilities: z - .object({ - tools: z.boolean(), - vision: z.boolean(), - thinking: z.boolean(), - }) - .nullable(), - enabled: z.boolean(), - origin: z.enum(["baseline", "radar", "local"]), - disabledBy: z.literal("radar").nullable(), - }) - ), -}); - -export const radarCatalogTool: McpToolDefinition< - typeof radarCatalogInput, - typeof radarCatalogOutput -> = { - name: "omniroute_radar_catalog", - description: - "Reads the local signed Radar catalog with optional provider and curated-family filters. Never syncs or writes data.", - inputSchema: radarCatalogInput, - outputSchema: radarCatalogOutput, - scopes: ["read:radar"], - auditLevel: "none", - phase: 1, - sourceEndpoints: ["/api/radar/catalog"], -}; - // --- Tool 10: omniroute_web_search --- export const webSearchInput = z.object({ query: z diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 6d138664e6..7f346ffd3e 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -17,8 +17,6 @@ import { routeRequestInput, costReportInput, listModelsCatalogInput, - radarCatalogInput, - radarCatalogOutput, webSearchInput, webFetchInput, simulateRouteInput, @@ -95,9 +93,9 @@ import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { getMcpModelsCatalog } from "./catalog.ts"; -import { getMcpRadarCatalog } from "./radarCatalog.ts"; +import { registerRadarCatalogTool } from "./radarCatalog.ts"; +import type { TextToolResult } from "./toolResult.ts"; export { getMcpModelsCatalog } from "./catalog.ts"; -export { getMcpRadarCatalog } from "./radarCatalog.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const MCP_ENFORCE_SCOPES = process.env.OMNIROUTE_MCP_ENFORCE_SCOPES === "true"; @@ -150,11 +148,6 @@ function readMcpAccessibilityConfig(): McpAccessibilityConfig { } } -type TextToolResult = { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; -}; - function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -601,29 +594,6 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s } } -async function handleRadarCatalog(args: { - provider?: string; - familyId?: string; - enabledOnly: boolean; -}) { - const start = Date.now(); - try { - const result = radarCatalogOutput.parse(await getMcpRadarCatalog(args)); - await logToolCall( - "omniroute_radar_catalog", - args, - { modelCount: result.models.length }, - Date.now() - start, - true - ); - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } catch (error) { - const message = sanitizeErrorMessage(error) || "Failed to read Radar catalog"; - await logToolCall("omniroute_radar_catalog", args, null, Date.now() - start, false, message); - return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true }; - } -} - async function handleWebSearch(args: { query: string; max_results?: number; @@ -869,16 +839,7 @@ export function createMcpServer(): McpServer { ) ); - server.registerTool( - "omniroute_radar_catalog", - { - description: "Reads the local signed Radar catalog with optional provider and family filters", - inputSchema: radarCatalogInput, - }, - withScopeEnforcement("omniroute_radar_catalog", (args) => - handleRadarCatalog(radarCatalogInput.parse(args)) - ) - ); + registerRadarCatalogTool(server, withScopeEnforcement); server.registerTool( "omniroute_simulate_route", diff --git a/open-sse/mcp-server/toolResult.ts b/open-sse/mcp-server/toolResult.ts new file mode 100644 index 0000000000..ea8535f545 --- /dev/null +++ b/open-sse/mcp-server/toolResult.ts @@ -0,0 +1,4 @@ +export type TextToolResult = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; From b11d55332bb02f38fe49c33fb51b91b0065a3ead Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 23:14:22 -0300 Subject: [PATCH 008/134] docs(changelog): record Radar guided combos --- changelog.d/features/9836-radar-guided-combos.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/features/9836-radar-guided-combos.md diff --git a/changelog.d/features/9836-radar-guided-combos.md b/changelog.d/features/9836-radar-guided-combos.md new file mode 100644 index 0000000000..c813289b35 --- /dev/null +++ b/changelog.d/features/9836-radar-guided-combos.md @@ -0,0 +1 @@ +- **feat(radar):** add curated-family combo suggestions, a guided combo page, and the read-only Radar MCP catalog tool ([#9836](https://github.com/diegosouzapw/OmniRoute/pull/9836)) From e2e589c79c64884625b467e5f3da7df17f6679db Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 02:22:57 -0300 Subject: [PATCH 009/134] feat(radar): sync signed supporter offers --- src/app/api/radar/offers/route.ts | 41 ++++ src/app/api/radar/offers/sync/route.ts | 55 ++++++ .../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-routes.test.ts | 120 ++++++++++++ tests/unit/radar-offers-sync.test.ts | 185 ++++++++++++++++++ 14 files changed, 1097 insertions(+), 1 deletion(-) 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-routes.test.ts create mode 100644 tests/unit/radar-offers-sync.test.ts diff --git a/src/app/api/radar/offers/route.ts b/src/app/api/radar/offers/route.ts new file mode 100644 index 0000000000..11bca3b756 --- /dev/null +++ b/src/app/api/radar/offers/route.ts @@ -0,0 +1,41 @@ +/** GET the verified local Radar offers cache. Never proxies the private service. */ + +import { NextResponse } from "next/server"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { getRadarOffers } from "@/lib/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + try { + return NextResponse.json(getRadarOffers(), { + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar offers"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/offers/sync/route.ts b/src/app/api/radar/offers/sync/route.ts new file mode 100644 index 0000000000..33c40e906d --- /dev/null +++ b/src/app/api/radar/offers/sync/route.ts @@ -0,0 +1,55 @@ +/** POST a server-side Radar offers sync. The browser never receives the supporter key. */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { syncRadarOffers } from "@/lib/radar/offersSync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const SyncBodySchema = z.object({}).strict().optional(); + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + if (!SyncBodySchema.safeParse(body).success) { + return NextResponse.json(buildErrorBody(400, "Invalid request body"), { + status: 400, + headers: CORS_HEADERS, + }); + } + + try { + return NextResponse.json(await syncRadarOffers(), { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar offers sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/lib/db/migrations/144_radar_offers_cache.sql b/src/lib/db/migrations/144_radar_offers_cache.sql new file mode 100644 index 0000000000..d01861b02e --- /dev/null +++ b/src/lib/db/migrations/144_radar_offers_cache.sql @@ -0,0 +1,11 @@ +-- 144_radar_offers_cache.sql +-- Single-row cache for the separately signed, live-only Radar offers feed. + +CREATE TABLE IF NOT EXISTS radar_offers_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + tier TEXT NOT NULL CHECK (tier = 'live'), + payload TEXT NOT NULL, + signature TEXT NOT NULL, + fetched_at TEXT NOT NULL +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index 7a03854ef7..fda649fb00 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -17,6 +17,9 @@ * - radar_local_model_state: operator-owned display/enabled overrides and * deletion tombstones, keyed by provider + model ID. * + * Tables (migration 144): + * - radar_offers_cache: single-row signed live offers feed cache. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -51,6 +54,14 @@ export interface RadarReferralsCache { fetchedAt: string; } +export interface RadarOffersCache { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt: string; +} + export interface RadarLocalModelState { provider: string; modelId: string; @@ -168,14 +179,16 @@ export function setRadarKey(key: string | null): void { ); const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); + const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1"); db.transaction(() => { 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 2fcd61c5e9..dcdd985664 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -821,6 +821,8 @@ export { setRadarKey, getRadarReferralsCache, setRadarReferralsCache, + getRadarOffersCache, + setRadarOffersCache, listRadarLocalModelState, setRadarLocalModelOverride, clearRadarLocalModelOverride, @@ -831,6 +833,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-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); +}); From f806740a2fddc1e74c33713356941231fa3d85fd Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 02:23:06 -0300 Subject: [PATCH 010/134] feat(radar): add supporter offers dashboard --- 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/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 | 26 +- 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 +- tests/unit/radar-offers-page.test.ts | 72 +++++ 49 files changed, 1402 insertions(+), 62 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/radar/offers/page.tsx create mode 100644 tests/unit/radar-offers-page.test.ts 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 ede96d13e5..b6e8bff502 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1354,7 +1354,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}`); + } + } +}); From fa7bf4df7b1fec6de21a1729c04743a82064e7a7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 04:26:15 -0300 Subject: [PATCH 011/134] docs(radar): format offers route table --- docs/frameworks/RADAR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index a95e1d8ee6..b090f49357 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -335,8 +335,8 @@ 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/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 }`. | From b8a65574490d33047cbc2f992a5445fd07dd17b2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 04:29:50 -0300 Subject: [PATCH 012/134] docs(changelog): record Radar supporter offers --- changelog.d/features/9912-radar-supporter-offers.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/features/9912-radar-supporter-offers.md 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)) From 7859c843def797576be3241871481b3a570a0e21 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:06:41 -0300 Subject: [PATCH 013/134] feat(radar): sync signed Intel insights --- src/app/api/radar/intel/route.ts | 42 ++++ src/app/api/radar/intel/sync/route.ts | 56 +++++ src/app/api/radar/status/route.ts | 70 ++++++ src/app/api/radar/sync-all/route.ts | 47 ++++ .../db/migrations/145_radar_intel_cache.sql | 11 + src/lib/db/radar.ts | 61 +++++ src/lib/localDb.ts | 3 + src/lib/radar/index.ts | 45 ++++ src/lib/radar/intelFeedSchema.ts | 73 ++++++ src/lib/radar/intelSync.ts | 173 +++++++++++++++ src/lib/radar/scheduler.ts | 37 ++- tests/fixtures/radar-intel-canonical.json | 42 ++++ tests/unit/radar-intel-db.test.ts | 74 ++++++ tests/unit/radar-intel-routes.test.ts | 107 +++++++++ tests/unit/radar-intel-sync.test.ts | 210 ++++++++++++++++++ tests/unit/radar-scheduler.test.ts | 188 ++++++++++------ 16 files changed, 1174 insertions(+), 65 deletions(-) create mode 100644 src/app/api/radar/intel/route.ts create mode 100644 src/app/api/radar/intel/sync/route.ts create mode 100644 src/app/api/radar/status/route.ts create mode 100644 src/app/api/radar/sync-all/route.ts create mode 100644 src/lib/db/migrations/145_radar_intel_cache.sql create mode 100644 src/lib/radar/intelFeedSchema.ts create mode 100644 src/lib/radar/intelSync.ts create mode 100644 tests/fixtures/radar-intel-canonical.json create mode 100644 tests/unit/radar-intel-db.test.ts create mode 100644 tests/unit/radar-intel-routes.test.ts create mode 100644 tests/unit/radar-intel-sync.test.ts diff --git a/src/app/api/radar/intel/route.ts b/src/app/api/radar/intel/route.ts new file mode 100644 index 0000000000..5afcd0cd23 --- /dev/null +++ b/src/app/api/radar/intel/route.ts @@ -0,0 +1,42 @@ +/** GET the verified local Radar Intel cache. Never proxies the private service. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { getRadarIntel } from "@/lib/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + return NextResponse.json(getRadarIntel(), { + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar Intel"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/intel/sync/route.ts b/src/app/api/radar/intel/sync/route.ts new file mode 100644 index 0000000000..ca09377a0a --- /dev/null +++ b/src/app/api/radar/intel/sync/route.ts @@ -0,0 +1,56 @@ +/** POST a server-side Radar Intel sync. The browser never receives the supporter key. */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { syncRadarIntel } from "@/lib/radar/intelSync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const SyncBodySchema = z.object({}).strict().optional(); + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + if (!SyncBodySchema.safeParse(body).success) { + return NextResponse.json(buildErrorBody(400, "Invalid request body"), { + status: 400, + headers: CORS_HEADERS, + }); + } + try { + return NextResponse.json(await syncRadarIntel(), { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar Intel sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/status/route.ts b/src/app/api/radar/status/route.ts new file mode 100644 index 0000000000..b4d3a8caec --- /dev/null +++ b/src/app/api/radar/status/route.ts @@ -0,0 +1,70 @@ +/** Read-only aggregate status of local Radar state. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { + getRadarCache, + getRadarIntelCache, + getRadarOffersCache, + getRadarReferralsCache, + getRadarSettings, +} from "@/lib/db/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +function cacheStatus( + cache: { version?: string; generatedAt?: string; tier: string; fetchedAt: string } | null +) { + if (!cache) return { available: false }; + return { + available: true, + version: cache.version ?? cache.generatedAt, + tier: cache.tier, + fetchedAt: cache.fetchedAt, + }; +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + const settings = getRadarSettings(); + return NextResponse.json( + { + settings: { optIn: settings.optIn, hasSupporterKey: settings.supporterKey !== null }, + feeds: { + catalog: cacheStatus(getRadarCache()), + referrals: cacheStatus(getRadarReferralsCache()), + offers: cacheStatus(getRadarOffersCache()), + intel: cacheStatus(getRadarIntelCache()), + }, + }, + { headers: { ...CORS_HEADERS, "Cache-Control": "no-store" } } + ); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar status"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/sync-all/route.ts b/src/app/api/radar/sync-all/route.ts new file mode 100644 index 0000000000..807dabb5d7 --- /dev/null +++ b/src/app/api/radar/sync-all/route.ts @@ -0,0 +1,47 @@ +/** Aggregate local trigger for every Radar feed sync. */ + +import { NextResponse } from "next/server"; + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { syncRadarIntel } from "@/lib/radar/intelSync"; +import { syncRadarOffers } from "@/lib/radar/offersSync"; +import { syncRadarReferrals } from "@/lib/radar/referralsSync"; +import { syncRadar } from "@/lib/radar/sync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + try { + const catalog = await syncRadar(); + const referrals = await syncRadarReferrals(); + const offers = await syncRadarOffers(); + const intel = await syncRadarIntel(); + return NextResponse.json({ catalog, referrals, offers, intel }, { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar aggregate sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/lib/db/migrations/145_radar_intel_cache.sql b/src/lib/db/migrations/145_radar_intel_cache.sql new file mode 100644 index 0000000000..63b4201a8c --- /dev/null +++ b/src/lib/db/migrations/145_radar_intel_cache.sql @@ -0,0 +1,11 @@ +-- Signed, live-only Radar Intel feed cache. The supporter identity is a +-- one-way SHA-256 marker (`radar:<64 hex>`) and never contains the raw key. +CREATE TABLE IF NOT EXISTS radar_intel_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + tier TEXT NOT NULL CHECK (tier = 'live'), + payload TEXT NOT NULL, + signature TEXT NOT NULL, + supporter_identity TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index fda649fb00..a9aee3b504 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -20,6 +20,10 @@ * Tables (migration 144): * - radar_offers_cache: single-row signed live offers feed cache. * + * Tables (migration 145): + * - radar_intel_cache: single-row signed live Intel feed cache plus a + * one-way supporter identity used for local recognition. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -62,6 +66,15 @@ export interface RadarOffersCache { fetchedAt: string; } +export interface RadarIntelCache { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt: string; +} + export interface RadarLocalModelState { provider: string; modelId: string; @@ -180,6 +193,7 @@ export function setRadarKey(key: string | null): void { const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1"); + const clearIntelCache = db.prepare("DELETE FROM radar_intel_cache WHERE id = 1"); db.transaction(() => { updateKey.run(encrypted); @@ -189,6 +203,7 @@ export function setRadarKey(key: string | null): void { clearCatalogCache.run(); clearReferralsCache.run(); clearOffersCache.run(); + clearIntelCache.run(); })(); } @@ -276,6 +291,52 @@ export function setRadarOffersCache(entry: { .run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); } +// --------------------------------------------------------------------------- +// radar_intel_cache +// --------------------------------------------------------------------------- + +export function getRadarIntelCache(): RadarIntelCache | null { + const row = getDbInstance() + .prepare( + "SELECT version, tier, payload, signature, supporter_identity AS supporterIdentity, " + + "fetched_at AS fetchedAt FROM radar_intel_cache WHERE id = 1" + ) + .get() as RadarIntelCache | undefined; + return row ?? null; +} + +export function setRadarIntelCache(entry: { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +}): void { + const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); + getDbInstance() + .prepare( + `INSERT INTO radar_intel_cache + (id, version, tier, payload, signature, supporter_identity, fetched_at) + VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + supporter_identity = excluded.supporter_identity, + fetched_at = excluded.fetched_at` + ) + .run( + entry.version, + entry.tier, + entry.payload, + entry.signature, + entry.supporterIdentity, + fetchedAt + ); +} + // --------------------------------------------------------------------------- // radar_local_model_state // --------------------------------------------------------------------------- diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index dcdd985664..70e7eb04c3 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -823,6 +823,8 @@ export { setRadarReferralsCache, getRadarOffersCache, setRadarOffersCache, + getRadarIntelCache, + setRadarIntelCache, listRadarLocalModelState, setRadarLocalModelOverride, clearRadarLocalModelOverride, @@ -834,6 +836,7 @@ export type { RadarSettings, RadarReferralsCache, RadarOffersCache, + RadarIntelCache, RadarLocalModelState, RadarLocalModelOverridePatch, RadarLocalMergeState, diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index ec06fa1340..f6b05c5fd7 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -17,6 +17,7 @@ import { RadarOffersFeedSchema, type RadarOffer, } from "./offersFeedSchema"; +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; @@ -24,6 +25,7 @@ import { getRadarCache, getRadarLocalMergeState, getRadarOffersCache, + getRadarIntelCache, getRadarReferralsCache, type RadarLocalMergeState, } from "@/lib/db/radar"; @@ -261,8 +263,51 @@ export function getRadarOffers(deps: GetRadarOffersDeps = {}): RadarOffersResult } } +export interface RadarIntelResult { + intel: RadarIntelFeed | null; + meta: { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; + } | null; +} + +export interface GetRadarIntelDeps { + getFlag?: (key: string) => boolean; + getCache?: typeof getRadarIntelCache; +} + +const EMPTY_INTEL: RadarIntelResult = { intel: null, meta: null }; + +/** Return only a defensively revalidated live Intel cache. */ +export function getRadarIntel(deps: GetRadarIntelDeps = {}): RadarIntelResult { + const { getFlag = isFeatureFlagEnabled, getCache: getCacheFn = getRadarIntelCache } = deps; + if (!getFlag("RADAR_ENABLED")) return EMPTY_INTEL; + const cache = getCacheFn(); + if (!cache || cache.tier !== "live" || !/^radar:[a-f0-9]{64}$/.test(cache.supporterIdentity)) { + return EMPTY_INTEL; + } + try { + const feed = RadarIntelFeedSchema.parse(JSON.parse(cache.payload)); + if (feed.version !== cache.version || feed.tier !== "live") return EMPTY_INTEL; + return { + intel: feed, + meta: { + version: cache.version, + tier: "live", + fetchedAt: cache.fetchedAt, + supporterVerified: true, + }, + }; + } catch { + return EMPTY_INTEL; + } +} + // 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"; +export type { RadarIntelFeed, RadarIntelRanking, RadarIntelCatalog } from "./intelFeedSchema"; diff --git a/src/lib/radar/intelFeedSchema.ts b/src/lib/radar/intelFeedSchema.ts new file mode 100644 index 0000000000..c189aacc76 --- /dev/null +++ b/src/lib/radar/intelFeedSchema.ts @@ -0,0 +1,73 @@ +/** Closed client mirror of the private Radar Intel feed contract. */ + +import { z } from "zod"; + +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; +const CATEGORY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,239}$/; + +export const RadarIntelRankingSchema = z + .object({ + rank: z.number().int().positive(), + provider: z.string().regex(ID_PATTERN), + modelId: z.string().regex(MODEL_ID_PATTERN), + category: z.string().regex(CATEGORY_PATTERN), + rating: z.number().int(), + matches: z.number().int().nonnegative(), + wins: z.number().int().nonnegative(), + losses: z.number().int().nonnegative(), + draws: z.number().int().nonnegative(), + }) + .strict() + .superRefine((ranking, ctx) => { + if (ranking.matches !== ranking.wins + ranking.losses + ranking.draws) { + ctx.addIssue({ code: "custom", path: ["matches"], message: "match counters disagree" }); + } + }); + +const CatalogDeltaSchema = z + .object({ + current: z.number().int().nonnegative(), + added: z.number().int().nonnegative(), + removed: z.number().int().nonnegative(), + }) + .strict(); + +export const RadarIntelCatalogSchema = z + .object({ + currentVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + previousVersion: z + .string() + .regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/) + .nullable(), + currentGeneratedAt: z.string().datetime(), + ageDays: z.number().int().nonnegative(), + freshness: z.enum(["fresh", "aging", "stale"]), + providers: CatalogDeltaSchema, + models: CatalogDeltaSchema, + trend: z.enum(["growing", "stable", "shrinking"]), + }) + .strict(); + +export const RadarIntelFeedSchema = z + .object({ + feed: z.literal("omniroute-radar-intel"), + schemaVersion: z.literal(1), + version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + generatedAt: z.string().datetime(), + tier: z.literal("live"), + methodology: z + .object({ + kind: z.literal("elo"), + initialRating: z.literal(1000), + kFactor: z.literal(32), + }) + .strict(), + rankings: z.array(RadarIntelRankingSchema), + catalog: RadarIntelCatalogSchema, + }) + .strict(); + +export type RadarIntelFeed = z.infer; +export type RadarIntelRanking = z.infer; +export type RadarIntelCatalog = z.infer; diff --git a/src/lib/radar/intelSync.ts b/src/lib/radar/intelSync.ts new file mode 100644 index 0000000000..b039e772af --- /dev/null +++ b/src/lib/radar/intelSync.ts @@ -0,0 +1,173 @@ +/** Server-side sync for the signed, supporter-only Radar Intel feed. */ + +import crypto from "node:crypto"; + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; +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 IntelSyncStatus = + | { 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 RadarIntelCacheEntry { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +} + +export interface IntelSyncDeps { + fetch?: typeof globalThis.fetch; + now?: () => Date; + getFlag?: (key: string) => boolean; + getSettings?: () => RadarSettingsSnapshot; + getCache?: () => RadarIntelCacheEntry | null; + setCache?: (entry: RadarIntelCacheEntry) => void; + recognizeSupporter?: (identity: string) => Promise; +} + +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))); +} + +function supporterIdentity(key: string): string { + return `radar:${crypto.createHash("sha256").update(key, "utf8").digest("hex")}`; +} + +async function recognizeVerifiedSupporter(identity: string): Promise { + const { emitGamificationEvent } = await import("@/lib/gamification/events"); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); +} + +export async function syncRadarIntel(deps: IntelSyncDeps = {}): Promise { + const { + fetch: fetchFn = globalThis.fetch, + now = () => new Date(), + getFlag = isFeatureFlagEnabled, + getSettings: getSettingsFn, + getCache: getCacheFn, + setCache: setCacheFn, + recognizeSupporter = recognizeVerifiedSupporter, + } = 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/intel/latest`, { + method: "GET", + headers: { Authorization: `Bearer ${settings.supporterKey}` }, + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!response.ok) { + return { + status: "error", + reason: `Intel 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: RadarIntelFeed; + try { + feed = RadarIntelFeedSchema.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")).getRadarIntelCache(); + if (existing && compareVersions(feed.version, existing.version) <= 0) { + return { status: "stale" }; + } + + const identity = supporterIdentity(settings.supporterKey); + const cacheEntry: RadarIntelCacheEntry = { + version: feed.version, + tier: "live", + payload: rawBytes.toString("utf8"), + signature, + supporterIdentity: identity, + fetchedAt: now().toISOString(), + }; + if (setCacheFn) setCacheFn(cacheEntry); + else (await import("@/lib/db/radar")).setRadarIntelCache(cacheEntry); + + // Recognition is local and best-effort. It runs only after the signed live + // bytes have been accepted and persisted, and never changes sync success. + await recognizeSupporter(identity).catch(() => undefined); + return { status: "updated", version: feed.version }; + } catch (error: unknown) { + const reason = (sanitizeErrorMessage(error) || "Radar Intel sync failed").replace( + /omr_[a-f0-9]{40}/gi, + "[REDACTED]" + ); + return { status: "error", reason }; + } +} + +export const RADAR_INTEL_STALE_MS = 24 * 60 * 60 * 1000; + +export function shouldSyncRadarIntel(fetchedAt: string | null, nowMs = Date.now()): boolean { + if (!fetchedAt) return true; + const fetchedMs = Date.parse(fetchedAt); + return !Number.isFinite(fetchedMs) || nowMs - fetchedMs >= RADAR_INTEL_STALE_MS; +} diff --git a/src/lib/radar/scheduler.ts b/src/lib/radar/scheduler.ts index 33de6cf52e..15e33cf782 100644 --- a/src/lib/radar/scheduler.ts +++ b/src/lib/radar/scheduler.ts @@ -24,7 +24,15 @@ */ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { getRadarCache, getRadarSettings, getRadarReferralsCache } from "@/lib/db/radar"; +import { + getRadarCache, + getRadarIntelCache, + getRadarOffersCache, + getRadarSettings, + getRadarReferralsCache, +} from "@/lib/db/radar"; +import { shouldSyncRadarIntel, syncRadarIntel, type IntelSyncStatus } from "./intelSync"; +import { syncRadarOffers, type OffersSyncStatus } from "./offersSync"; import { nextSyncTime, syncRadar, type SyncStatus } from "./sync"; import { syncRadarReferrals, @@ -49,6 +57,10 @@ export interface RadarSchedulerDeps { getReferralsCache?: () => { fetchedAt: string } | null; /** Referrals sync — separate from `sync` (the catalog sync). */ syncReferrals?: () => Promise; + getOffersCache?: () => { fetchedAt: string } | null; + syncOffers?: () => Promise; + getIntelCache?: () => { fetchedAt: string } | null; + syncIntel?: () => Promise; now?: () => number; setIntervalFn?: typeof setInterval; clearIntervalFn?: typeof clearInterval; @@ -73,6 +85,28 @@ async function maybeSyncReferrals(deps: RadarSchedulerDeps, nowMs: number): Prom } } +async function maybeSyncSupporterFeeds(deps: RadarSchedulerDeps, nowMs: number): Promise { + try { + const offersCache = (deps.getOffersCache ?? getRadarOffersCache)(); + if (nowMs >= nextSyncTime(offersCache?.fetchedAt ?? null).getTime()) { + await (deps.syncOffers ?? syncRadarOffers)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Offers side-sync failed (non-fatal):", msg); + } + + try { + const intelCache = (deps.getIntelCache ?? getRadarIntelCache)(); + if (shouldSyncRadarIntel(intelCache?.fetchedAt ?? null, nowMs)) { + await (deps.syncIntel ?? syncRadarIntel)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Intel side-sync failed (non-fatal):", msg); + } +} + /** * One scheduler evaluation. Exported for tests and for the immediate * post-start tick. @@ -92,6 +126,7 @@ export async function radarSchedulerTick(deps: RadarSchedulerDeps = {}): Promise // Referrals sync on their own staleness window — independent of the // catalog's due-ness below, same tick. await maybeSyncReferrals(deps, nowMs); + await maybeSyncSupporterFeeds(deps, nowMs); const cache = (deps.getCache ?? getRadarCache)(); if (nowMs < nextSyncTime(cache?.fetchedAt ?? null).getTime()) { diff --git a/tests/fixtures/radar-intel-canonical.json b/tests/fixtures/radar-intel-canonical.json new file mode 100644 index 0000000000..0112d568cb --- /dev/null +++ b/tests/fixtures/radar-intel-canonical.json @@ -0,0 +1,42 @@ +{ + "feed": "omniroute-radar-intel", + "schemaVersion": 1, + "version": "2026.08.09.1", + "generatedAt": "2026-08-09T12:00:00.000Z", + "tier": "live", + "methodology": { "kind": "elo", "initialRating": 1000, "kFactor": 32 }, + "rankings": [ + { + "rank": 1, + "provider": "example-a", + "modelId": "example-model-a", + "category": "general", + "rating": 1016, + "matches": 1, + "wins": 1, + "losses": 0, + "draws": 0 + }, + { + "rank": 2, + "provider": "example-b", + "modelId": "example-model-b", + "category": "general", + "rating": 984, + "matches": 1, + "wins": 0, + "losses": 1, + "draws": 0 + } + ], + "catalog": { + "currentVersion": "2026.08.09.1", + "previousVersion": "2026.08.08.1", + "currentGeneratedAt": "2026-08-09T11:00:00.000Z", + "ageDays": 0, + "freshness": "fresh", + "providers": { "current": 2, "added": 1, "removed": 0 }, + "models": { "current": 2, "added": 1, "removed": 0 }, + "trend": "growing" + } +} diff --git a/tests/unit/radar-intel-db.test.ts b/tests/unit/radar-intel-db.test.ts new file mode 100644 index 0000000000..d5f80496af --- /dev/null +++ b/tests/unit/radar-intel-db.test.ts @@ -0,0 +1,74 @@ +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-intel-db-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-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 }); +}); + +test("Intel migration provides a byte-preserving single-row cache", () => { + assert.equal(radar.getRadarIntelCache(), null); + radar.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + assert.deepEqual(radar.getRadarIntelCache(), { + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); +}); + +test("changing supporter key invalidates catalog, referrals, offers, and Intel atomically", () => { + 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.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "d", + supporterIdentity: `radar:${"a".repeat(64)}`, + }); + + radar.setRadarKey(`omr_${"b".repeat(40)}`); + + assert.equal(radar.getRadarCache(), null); + assert.equal(radar.getRadarReferralsCache(), null); + assert.equal(radar.getRadarOffersCache(), null); + assert.equal(radar.getRadarIntelCache(), null); +}); diff --git a/tests/unit/radar-intel-routes.test.ts b/tests/unit/radar-intel-routes.test.ts new file mode 100644 index 0000000000..9de90bac74 --- /dev/null +++ b/tests/unit/radar-intel-routes.test.ts @@ -0,0 +1,107 @@ +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-intel-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-routes-32b!"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-intel-routes"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-intel-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 request(pathname: string, method: "GET" | "POST", headers: Record = {}) { + return new Request(`http://localhost:20128${pathname}`, { method, headers }); +} + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.RADAR_ENABLED; +}); + +test("Intel, status, and aggregate sync routes are 404 before auth when flag is off", async () => { + resetStorage(); + delete process.env.RADAR_ENABLED; + const intel = await import("../../src/app/api/radar/intel/route.ts"); + const intelSync = await import("../../src/app/api/radar/intel/sync/route.ts"); + const status = await import("../../src/app/api/radar/status/route.ts"); + const syncAll = await import("../../src/app/api/radar/sync-all/route.ts"); + + assert.equal((await intel.GET(request("/api/radar/intel", "GET"))).status, 404); + assert.equal((await intelSync.POST(request("/api/radar/intel/sync", "POST"))).status, 404); + assert.equal((await status.GET(request("/api/radar/status", "GET"))).status, 404); + assert.equal((await syncAll.POST(request("/api/radar/sync-all", "POST"))).status, 404); +}); + +test("verified local Intel is returned without supporter identity or key material", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const payload = fs.readFileSync( + path.resolve(process.cwd(), "tests/fixtures/radar-intel-canonical.json"), + "utf8" + ); + radarDb.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload, + signature: "fixture-signature", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + + const { GET } = await import("../../src/app/api/radar/intel/route.ts"); + const response = await GET(request("/api/radar/intel", "GET", await authHeaders())); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.intel.rankings.length, 2); + assert.equal(body.meta.supporterVerified, true); + assert.ok(!JSON.stringify(body).includes("radar:")); + assert.ok(!JSON.stringify(body).includes("omr_")); +}); + +test("Radar status is read-only and aggregate sync reports each feed separately", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const statusRoute = await import("../../src/app/api/radar/status/route.ts"); + const status = await statusRoute.GET(request("/api/radar/status", "GET", headers)); + const statusBody = await status.json(); + assert.deepEqual(statusBody.settings, { optIn: false, hasSupporterKey: false }); + assert.deepEqual(Object.keys(statusBody.feeds).sort(), [ + "catalog", + "intel", + "offers", + "referrals", + ]); + + const syncAllRoute = await import("../../src/app/api/radar/sync-all/route.ts"); + const synced = await syncAllRoute.POST(request("/api/radar/sync-all", "POST", headers)); + const syncBody = await synced.json(); + assert.deepEqual(syncBody, { + catalog: { status: "opt_out" }, + referrals: { status: "opt_out" }, + offers: { status: "opt_out" }, + intel: { status: "opt_out" }, + }); +}); diff --git a/tests/unit/radar-intel-sync.test.ts b/tests/unit/radar-intel-sync.test.ts new file mode 100644 index 0000000000..f9fa31e6ff --- /dev/null +++ b/tests/unit/radar-intel-sync.test.ts @@ -0,0 +1,210 @@ +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 intelSync = await import("../../src/lib/radar/intelSync.ts"); +const { RadarIntelFeedSchema } = await import("../../src/lib/radar/intelFeedSchema.ts"); + +async function fixtureBytes(): Promise { + return readFile(new URL("../fixtures/radar-intel-canonical.json", import.meta.url)); +} + +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; +} + +const supporterKey = `omr_${"a".repeat(40)}`; +const liveSettings = { optIn: true, supporterKey }; + +test("canonical Intel fixture is byte-identical to the private contract", async () => { + const bytes = await fixtureBytes(); + assert.equal(bytes.byteLength, 1024); + assert.equal( + crypto.createHash("sha256").update(bytes).digest("hex"), + "c36aaa6ad53942afa0325d6b0fad0aa048ef66f24c805b743b9815446b0e6176" + ); + assert.equal(RadarIntelFeedSchema.parse(JSON.parse(bytes.toString("utf8"))).tier, "live"); +}); + +test("Intel schema rejects telemetry and inconsistent ranking counters", async () => { + const feed = JSON.parse((await fixtureBytes()).toString("utf8")); + assert.equal(RadarIntelFeedSchema.safeParse({ ...feed, uptime: 99.9 }).success, false); + feed.rankings[0].matches = 2; + assert.equal(RadarIntelFeedSchema.safeParse(feed).success, false); +}); + +test("Intel sync gates before fetch and only accepts exact signed live bytes", async () => { + for (const expected of ["disabled", "opt_out", "no_key"] as const) { + let fetched = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => expected !== "disabled", + getSettings: () => + expected === "opt_out" + ? { optIn: false, supporterKey: null } + : { optIn: true, supporterKey: null }, + fetch: (async () => { + fetched = true; + return response(Buffer.from("{}")); + }) as typeof fetch, + }); + assert.equal(result.status, expected); + assert.equal(fetched, false); + } + + const bytes = await fixtureBytes(); + const writes: intelSync.RadarIntelCacheEntry[] = []; + const supporterIdentities: string[] = []; + let authorization = ""; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: (entry) => writes.push(entry), + recognizeSupporter: async (identity) => supporterIdentities.push(identity), + fetch: (async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return response(bytes, { + "x-omniroute-feed-signature": sign(bytes), + "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(authorization, `Bearer ${supporterKey}`); + assert.equal(writes[0]?.payload, bytes.toString("utf8")); + assert.equal(writes[0]?.tier, "live"); + assert.match(writes[0]?.supporterIdentity ?? "", /^radar:[a-f0-9]{64}$/); + assert.deepEqual(supporterIdentities, [writes[0]?.supporterIdentity]); + assert.ok(!writes[0]?.supporterIdentity.includes(supporterKey)); +}); + +test("Intel sync preserves the good cache on signature, tier, schema, replay, and size failures", async () => { + const bytes = await fixtureBytes(); + const validSignature = sign(bytes); + const cases = [ + { expected: "invalid_signature", body: bytes, signature: "bad", tier: "live" }, + { expected: "wrong_tier", body: bytes, signature: validSignature, tier: "community" }, + { + expected: "invalid_schema", + body: Buffer.from('{"feed":"wrong"}'), + signature: "", + tier: "live", + }, + ]; + + for (const item of cases) { + const signature = item.expected === "invalid_schema" ? sign(item.body) : item.signature; + let written = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.08.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(item.body, { + "x-omniroute-feed-signature": signature, + "x-omniroute-feed-tier": item.tier, + })) as typeof fetch, + }); + assert.equal(result.status, item.expected); + assert.equal(written, false); + } + + let written = false; + const stale = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(bytes, { + "x-omniroute-feed-signature": validSignature, + "x-omniroute-feed-tier": "live", + })) as typeof fetch, + }); + assert.equal(stale.status, "stale"); + + const oversized = await intelSync.syncRadarIntel({ + 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(oversized.status, "too_large"); + assert.equal(written, false); +}); + +test("Intel sync enforces the byte cap while reading streamed chunks", async () => { + let cancelled = false; + let written = false; + const firstChunk = new Uint8Array(6 * 1024 * 1024); + const secondChunk = new Uint8Array(5 * 1024 * 1024); + const chunks = [firstChunk, secondChunk]; + let chunkIndex = 0; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[chunkIndex]); + chunkIndex += 1; + }, + cancel() { + cancelled = true; + }, + }); + + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + new Response(body, { + status: 200, + headers: { "x-omniroute-feed-tier": "live" }, + })) as typeof fetch, + }); + + assert.equal(result.status, "too_large"); + assert.equal(cancelled, true); + assert.equal(written, false); +}); diff --git a/tests/unit/radar-scheduler.test.ts b/tests/unit/radar-scheduler.test.ts index 5f7275ab5c..e51e09784f 100644 --- a/tests/unit/radar-scheduler.test.ts +++ b/tests/unit/radar-scheduler.test.ts @@ -46,10 +46,14 @@ function fakeTimers() { function deps(overrides: Record = {}) { const syncCalls: number[] = []; const referralsSyncCalls: number[] = []; + const offersSyncCalls: number[] = []; + const intelSyncCalls: number[] = []; const timers = fakeTimers(); return { syncCalls, referralsSyncCalls, + offersSyncCalls, + intelSyncCalls, timers, d: { getFlag: () => true, @@ -66,7 +70,21 @@ function deps(overrides: Record = {}) { getReferralsCache: () => ({ fetchedAt: REFERRALS_FRESH }), syncReferrals: async () => { referralsSyncCalls.push(1); - return { status: "updated", generatedAt: "2026-08-06T12:00:00.000Z", tier: "live" } as const; + return { + status: "updated", + generatedAt: "2026-08-06T12:00:00.000Z", + tier: "live", + } as const; + }, + getOffersCache: () => ({ fetchedAt: FRESH }), + syncOffers: async () => { + offersSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; + }, + getIntelCache: () => ({ fetchedAt: FRESH }), + syncIntel: async () => { + intelSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; }, now: () => NOW, setIntervalFn: timers.setIntervalFn, @@ -126,18 +144,21 @@ test("radar sync scheduler", async (t) => { assert.equal(syncCalls.length, 1); }); - await t.test("ensure: registers one hourly timer, fires an immediate tick, idempotent", async () => { - const { d, timers, syncCalls } = deps(); - assert.equal(ensureRadarSyncScheduler(d), true); - assert.equal(timers.registered.length, 1); - assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); - // The immediate tick is fire-and-forget; give the microtask queue a turn. - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); - // Second ensure is a no-op — no second timer. - assert.equal(ensureRadarSyncScheduler(d), false); - assert.equal(timers.registered.length, 1); - }); + await t.test( + "ensure: registers one hourly timer, fires an immediate tick, idempotent", + async () => { + const { d, timers, syncCalls } = deps(); + assert.equal(ensureRadarSyncScheduler(d), true); + assert.equal(timers.registered.length, 1); + assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); + // The immediate tick is fire-and-forget; give the microtask queue a turn. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); + // Second ensure is a no-op — no second timer. + assert.equal(ensureRadarSyncScheduler(d), false); + assert.equal(timers.registered.length, 1); + } + ); await t.test("init: flag off => never arms (flag-off boot stays timer-free)", () => { const { d, timers } = deps({ getFlag: () => false }); @@ -174,61 +195,100 @@ test("radar sync scheduler", async (t) => { // only) so the catalog-sync result shape/assertions above stay unchanged. // ------------------------------------------------------------------------- - await t.test("tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", async () => { - const { d, syncCalls, referralsSyncCalls } = deps(); - const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "catalog was due and must still sync as before"); - assert.equal(syncCalls.length, 1); - assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); - }); + await t.test( + "tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps(); + const result = await radarSchedulerTick(d); + assert.equal(result.action, "synced", "catalog was due and must still sync as before"); + assert.equal(syncCalls.length, 1); + assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); + } + ); - await t.test("tick: referrals cache stale => referrals sync called, independent of catalog due-ness", async () => { - const { d, syncCalls, referralsSyncCalls } = deps({ - getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due - }); - const result = await radarSchedulerTick(d); - assert.deepEqual(result, { action: "skipped", reason: "not_due" }, "catalog result shape must stay unchanged"); - assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); - assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); - }); + await t.test( + "tick: referrals cache stale => referrals sync called, independent of catalog due-ness", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due + }); + const result = await radarSchedulerTick(d); + assert.deepEqual( + result, + { action: "skipped", reason: "not_due" }, + "catalog result shape must stay unchanged" + ); + assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); + assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); + } + ); - await t.test("tick: referrals cache missing => referrals sync called (missing counts as stale)", async () => { - const { d, referralsSyncCalls } = deps({ + await t.test( + "tick: referrals cache missing => referrals sync called (missing counts as stale)", + async () => { + const { d, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), + getReferralsCache: () => null, + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 1); + } + ); + + await t.test( + "tick: flag off => referrals sync NOT called (stopped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getFlag: () => false, + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: opt-in off => referrals sync NOT called (skipped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getSettings: () => ({ optIn: false }), + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: referrals sync throwing => swallowed, catalog tick still completes normally", + async () => { + const { d, syncCalls } = deps({ + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), + syncReferrals: async () => { + throw new Error("referrals upstream exploded"); + }, + }); + const result = await radarSchedulerTick(d); + assert.equal( + result.action, + "synced", + "a throwing referrals sync must never break the catalog tick" + ); + assert.equal(syncCalls.length, 1); + } + ); + + await t.test("tick: offers and Intel use independent staleness gates", async () => { + const { d, syncCalls, offersSyncCalls, intelSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), - getReferralsCache: () => null, - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 1); - }); - - await t.test("tick: flag off => referrals sync NOT called (stopped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getFlag: () => false, - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: opt-in off => referrals sync NOT called (skipped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getSettings: () => ({ optIn: false }), - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: referrals sync throwing => swallowed, catalog tick still completes normally", async () => { - const { d, syncCalls } = deps({ - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), - syncReferrals: async () => { - throw new Error("referrals upstream exploded"); - }, + getOffersCache: () => ({ fetchedAt: STALE }), + getIntelCache: () => null, }); const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "a throwing referrals sync must never break the catalog tick"); - assert.equal(syncCalls.length, 1); + assert.deepEqual(result, { action: "skipped", reason: "not_due" }); + assert.equal(syncCalls.length, 0); + assert.equal(offersSyncCalls.length, 1); + assert.equal(intelSyncCalls.length, 1); }); }); From a45076fa2ac568f013eb64655438e871950ee824 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:07:05 -0300 Subject: [PATCH 014/134] feat(radar): recognize supporters and add Intel UI --- .../dashboard/radar/intel/page.tsx | 197 ++++++++++++++++++ src/app/(dashboard)/dashboard/radar/page.tsx | 8 + src/i18n/messages/en.json | 41 +++- src/i18n/messages/pt-BR.json | 41 +++- src/i18n/messages/vi.json | 41 +++- src/lib/gamification/badges.ts | 10 + src/lib/gamification/events.ts | 23 +- tests/unit/radar-intel-page.test.ts | 53 +++++ .../unit/radar-supporter-gamification.test.ts | 42 ++++ 9 files changed, 447 insertions(+), 9 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/radar/intel/page.tsx create mode 100644 tests/unit/radar-intel-page.test.ts create mode 100644 tests/unit/radar-supporter-gamification.test.ts diff --git a/src/app/(dashboard)/dashboard/radar/intel/page.tsx b/src/app/(dashboard)/dashboard/radar/intel/page.tsx new file mode 100644 index 0000000000..7c933b053d --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/intel/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import type { RadarIntelFeed } from "@/lib/radar/intelFeedSchema"; +import { Card } from "@/shared/components"; + +interface IntelMeta { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; +} + +export default function RadarIntelPage() { + const t = useTranslations("radarIntelPage"); + const [intel, setIntel] = useState(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + const response = await fetch("/api/radar/intel"); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_load_failed"); + const body = (await response.json()) as { + intel?: RadarIntelFeed | null; + meta?: IntelMeta | null; + }; + setIntel(body.intel ?? null); + setMeta(body.meta ?? null); + }, []); + + const sync = useCallback(async () => { + setSyncing(true); + setError(""); + try { + const response = await fetch("/api/radar/intel/sync", { method: "POST" }); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_sync_failed"); + const status = (await response.json()) as { status?: string }; + if ( + ["error", "invalid_signature", "invalid_schema", "wrong_tier", "too_large"].includes( + status.status ?? "" + ) + ) { + setError(t("loadFailed")); + } + await load(); + } catch { + setError(t("loadFailed")); + await load().catch(() => undefined); + } finally { + setSyncing(false); + } + }, [load, t]); + + useEffect(() => { + load() + .catch(() => setError(t("loadFailed"))) + .finally(() => setLoading(false)); + }, [load, t]); + + if (flagOff) notFound(); + + return ( +
+
+
+ + ← {t("backToRadar")} + +

{t("title")}

+

{t("subtitle")}

+
+
+ {meta?.supporterVerified === true && ( + + {t("supporterBadge")} + + )} + +
+
+ + {error &&
{error}
} + + {loading ? ( +
+ {t("loading")} +
+ ) : !intel || !meta ? ( + +

{t("empty")}

+
+ ) : ( + <> +
+ +

{t("methodology")}

+

+ {t("eloMethod", { + initial: intel.methodology.initialRating, + factor: intel.methodology.kFactor, + })} +

+
+ +

{t("freshness")}

+

+ {t(`freshnessValues.${intel.catalog.freshness}`)} +

+

+ {t("ageDays", { days: intel.catalog.ageDays })} +

+
+ +

{t("trend")}

+

{t(`trendValues.${intel.catalog.trend}`)}

+

+ {t("modelDelta", { + current: intel.catalog.models.current, + added: intel.catalog.models.added, + removed: intel.catalog.models.removed, + })} +

+
+
+ + +
+

{t("ranking")}

+ {meta.version} +
+ {intel.rankings.length === 0 ? ( +

{t("noRankings")}

+ ) : ( +
+
InterfaceEndpoint / commandUse it for
🧰 MCP (stdio)omniroute --mcpPlug into Claude Desktop, Cursor, any MCP client
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 105 tools, 31 scopes, full audit trail
🌊 MCP (HTTP)/api/mcp/streamRemote MCP — 109 tools, 33 scopes, full audit trail
📡 MCP (SSE)/api/mcp/sseStreaming MCP transport
🤝 A2A/.well-known/agent.jsonAgent-to-agent, JSON-RPC 2.0 + SSE, 6 skills
🌐 REST API/v1/*OpenAI-compatible — chat, embeddings, images, audio, OCR
DocumentDescription
API ReferenceAll endpoints with examples
OpenAPI SpecOpenAPI 3.0 specification
MCP Server105 MCP tools, IDE configs, Python/TS/Go clients
MCP Server109 MCP tools, IDE configs, Python/TS/Go clients
MCP Server GuideMCP installation, transports, and tool reference
A2A ServerJSON-RPC 2.0 protocol, skills, streaming, task mgmt
A2A Server GuideA2A agent card, tasks, skills, and streaming
+ + + + + + + + + + + {intel.rankings.map((ranking) => ( + + + + + + + + ))} + +
#{t("model")}{t("category")}{t("rating")}{t("matches")}
{ranking.rank} + {ranking.provider}/{ranking.modelId} + {ranking.category}{ranking.rating}{ranking.matches}
+ + )} + + + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 7f209202a8..203d06e9b4 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("intel")} + + )} {(pageState === "empty" || pageState === "populated") && ( [] = [ criteria: JSON.stringify({ type: "threshold", metric: "uptime", threshold: 100, window: 7 }), hidden: 0, }, + { + id: "radar-supporter", + name: "Radar Supporter", + description: "Verified a live OmniRoute Radar supporter feed", + icon: "radar", + category: "contribution", + rarity: "rare", + criteria: JSON.stringify({ type: "action_count", action: "radar_supporter", threshold: 1 }), + hidden: 0, + }, // ── Streak (Engagement) ────────────────────────────────────────────────── { diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index cccb2474b0..cde799f5c0 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -25,7 +25,8 @@ export async function emitGamificationEvent(params: { | "combo_use" | "token_share" | "invite_redeem" - | "daily_login"; + | "daily_login" + | "radar_supporter"; metadata?: Record; }): Promise { const { apiKeyId, action, metadata } = params; @@ -33,6 +34,13 @@ export async function emitGamificationEvent(params: { if (!apiKeyId) return; // Skip if no API key try { + // A verified Radar supporter is a recognition event, not an XP or + // leaderboard action. The caller supplies only a one-way key identity. + if (action === "radar_supporter") { + await checkAndUnlockBadge(apiKeyId, "radar-supporter", false); + return; + } + // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { @@ -87,7 +95,7 @@ export async function emitGamificationEvent(params: { } catch (err) { // Never throw — gamification must not break the request pipeline log.error("events.error", { - apiKeyId, + ...(action === "radar_supporter" ? {} : { apiKeyId }), action, error: err instanceof Error ? err.message : String(err), }); @@ -114,22 +122,25 @@ function getXpForAction(action: string): number { /** * Check and unlock a specific badge. */ -async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise { +async function checkAndUnlockBadge( + apiKeyId: string, + badgeId: string, + logIdentity = true +): Promise { const { unlockBadge, hasBadge } = await import("../db/gamification"); // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on // every request. if (!hasBadge(apiKeyId, badgeId)) { unlockBadge(apiKeyId, badgeId); - log.info("events.badge_unlocked", { apiKeyId, badgeId }); + log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId }); // Look up badge details from badge_definitions const { getDbInstance } = await import("../db/core"); const badgeRow = getDbInstance() .prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?") .get(badgeId) as - | { name: string; description: string | null; icon: string | null; rarity: string } - | undefined; + { name: string; description: string | null; icon: string | null; rarity: string } | undefined; // Record notification for SSE toast const { recordBadgeUnlock } = await import("./notifications"); diff --git a/tests/unit/radar-intel-page.test.ts b/tests/unit/radar-intel-page.test.ts new file mode 100644 index 0000000000..d4891ee8f0 --- /dev/null +++ b/tests/unit/radar-intel-page.test.ts @@ -0,0 +1,53 @@ +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/intel/page.tsx"); +const radarPagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +test("Radar links to a dedicated local-only Intel page", () => { + assert.ok(fs.existsSync(pagePath)); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/intel"/); + const source = fs.readFileSync(pagePath, "utf8"); + assert.match(source, /fetch\("\/api\/radar\/intel"\)/); + assert.match(source, /fetch\("\/api\/radar\/intel\/sync",\s*\{\s*method:\s*"POST"/); + assert.doesNotMatch(source, /RADAR_FEED_URL|radar\.omniroute\.online|omr_|getDbInstance/); +}); + +test("Intel page exposes methodology, ranking, freshness, trend, and verified supporter badge only", () => { + const source = fs.readFileSync(pagePath, "utf8"); + for (const marker of [ + "methodology", + "rankings", + "freshness", + "trend", + "supporterVerified", + "radar-supporter", + ]) { + assert.match(source, new RegExp(marker)); + } + assert.doesNotMatch(source, /\bhealth\b|\buptime\b|\blatency\b|\btelemetry\b/i); +}); + +test("Intel UI strings exist in English and Brazilian Portuguese", () => { + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8") + ) as { radarIntelPage?: Record; radarPage?: Record }; + for (const key of [ + "title", + "subtitle", + "methodology", + "supporterBadge", + "ranking", + "freshness", + "trend", + "empty", + "loadFailed", + ]) { + assert.equal(typeof messages.radarIntelPage?.[key], "string", `${locale}: ${key}`); + } + assert.equal(typeof messages.radarPage?.intel, "string", `${locale}: radarPage.intel`); + } +}); diff --git a/tests/unit/radar-supporter-gamification.test.ts b/tests/unit/radar-supporter-gamification.test.ts new file mode 100644 index 0000000000..a3b1d4b100 --- /dev/null +++ b/tests/unit/radar-supporter-gamification.test.ts @@ -0,0 +1,42 @@ +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-supporter-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { BUILTIN_BADGES } = await import("../../src/lib/gamification/badges.ts"); +const { emitGamificationEvent } = await import("../../src/lib/gamification/events.ts"); + +test.after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Radar supporter has a dedicated badge and zero-XP idempotent action", async () => { + const identity = `radar:${"a".repeat(64)}`; + const badge = BUILTIN_BADGES.find((item) => item.id === "radar-supporter"); + assert.ok(badge); + assert.equal(JSON.parse(badge.criteria).action, "radar_supporter"); + + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + + const db = getDbInstance(); + const userBadges = db + .prepare("SELECT badge_id AS badgeId FROM user_badges WHERE api_key_id = ?") + .all(identity) as Array<{ badgeId: string }>; + const xpRows = db + .prepare("SELECT action FROM xp_audit_log WHERE api_key_id = ?") + .all(identity) as Array<{ action: string }>; + const scoreRows = db + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ?") + .all(identity) as Array<{ score: number }>; + + assert.deepEqual(userBadges, [{ badgeId: "radar-supporter" }]); + assert.deepEqual(xpRows, []); + assert.deepEqual(scoreRows, []); +}); From 1a55b20963f65a7248c91c229e70e6981b0e477f Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:07:18 -0300 Subject: [PATCH 015/134] feat(cli): add Radar status and sync commands --- bin/cli/commands/radar.mjs | 76 +++++++++++++++++++++ bin/cli/commands/registry.mjs | 2 + bin/cli/locales/en.json | 5 ++ bin/cli/locales/pt-BR.json | 5 ++ tests/unit/cli-radar-commands.test.ts | 97 +++++++++++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 bin/cli/commands/radar.mjs create mode 100644 tests/unit/cli-radar-commands.test.ts diff --git a/bin/cli/commands/radar.mjs b/bin/cli/commands/radar.mjs new file mode 100644 index 0000000000..1455efebba --- /dev/null +++ b/bin/cli/commands/radar.mjs @@ -0,0 +1,76 @@ +import { apiFetch } from "../api.mjs"; +import { t } from "../i18n.mjs"; +import { emit } from "../output.mjs"; + +const statusSchema = [ + { key: "feed", header: "Feed" }, + { key: "available", header: "Available" }, + { key: "version", header: "Version" }, + { key: "tier", header: "Tier" }, + { key: "fetchedAt", header: "Fetched" }, +]; + +const syncSchema = [ + { key: "feed", header: "Feed" }, + { key: "status", header: "Status" }, + { key: "version", header: "Version" }, + { key: "reason", header: "Reason" }, +]; + +function exitCodeFor(response) { + return Number.isInteger(response.exitCode) ? response.exitCode : response.status === 401 ? 4 : 1; +} + +export async function runRadarStatusCommand(opts = {}) { + const response = await apiFetch("/api/radar/status", { acceptNotOk: true }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data.feeds ?? {}).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { available: false }), + })); + emit(rows, opts, statusSchema); + return 0; +} + +export async function runRadarSyncCommand(opts = {}) { + const response = await apiFetch("/api/radar/sync-all", { + method: "POST", + body: {}, + acceptNotOk: true, + }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { status: "error" }), + })); + emit(rows, opts, syncSchema); + return 0; +} + +export function registerRadar(program) { + const radar = program.command("radar").description(t("radar.description")); + radar + .command("status") + .description(t("radar.status")) + .action(async (_opts, command) => { + const code = await runRadarStatusCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); + radar + .command("sync") + .description(t("radar.sync")) + .action(async (_opts, command) => { + const code = await runRadarSyncCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 84c71bdf06..53dd2787e8 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -78,6 +78,7 @@ import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerPlugin } from "./plugin.mjs"; +import { registerRadar } from "./radar.mjs"; export function registerCommands(program) { registerMemory(program); @@ -161,4 +162,5 @@ export function registerCommands(program) { registerConfigure(program); registerApiCommands(program); registerPlugin(program); + registerRadar(program); } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index d0d4808e16..0cfef17b65 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -921,6 +921,11 @@ "model": "Filter by model" } }, + "radar": { + "description": "Inspect and synchronize the local Radar catalog feeds", + "status": "Show local Radar settings and feed cache status", + "sync": "Synchronize catalog, referrals, offers, and Intel through the local server" + }, "resilience": { "description": "Inspect and manage resilience mechanisms", "status": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index a951491d63..204ce1f4b7 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -918,6 +918,11 @@ "model": "Filtrar por model" } }, + "radar": { + "description": "Inspecionar e sincronizar os feeds locais do catálogo Radar", + "status": "Mostrar configurações locais e estado dos caches do Radar", + "sync": "Sincronizar catálogo, indicações, ofertas e Intel pelo servidor local" + }, "resilience": { "description": "Inspecionar e gerenciar mecanismos de resiliência", "status": { diff --git a/tests/unit/cli-radar-commands.test.ts b/tests/unit/cli-radar-commands.test.ts new file mode 100644 index 0000000000..ca37a389de --- /dev/null +++ b/tests/unit/cli-radar-commands.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +function makeResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => data, + text: async () => JSON.stringify(data), + } as Response; +} + +async function captureStdout(fn: () => Promise): Promise<{ output: string; code: number }> { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + if (typeof chunk === "string") chunks.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + const code = await fn(); + return { output: chunks.join(""), code }; + } finally { + process.stdout.write = original; + } +} + +test("radar status is GET-only, read-only, and prints no secret", async () => { + const originalFetch = globalThis.fetch; + let method = "GET"; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + settings: { optIn: true, hasSupporterKey: true }, + feeds: { catalog: { available: true, version: "2026.08.09.1", tier: "live" } }, + }); + }) as typeof fetch; + try { + const { runRadarStatusCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarStatusCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/status$/); + assert.equal(method, "GET"); + assert.ok(!result.output.includes("omr_")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("radar sync posts only to the local aggregate route and prints per-feed results", async () => { + const originalFetch = globalThis.fetch; + let method = ""; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + catalog: { status: "updated", version: "2026.08.09.1" }, + referrals: { status: "stale" }, + offers: { status: "no_key" }, + intel: { status: "no_key" }, + }); + }) as typeof fetch; + try { + const { runRadarSyncCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarSyncCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/sync-all$/); + assert.equal(method, "POST"); + const parsed = JSON.parse(result.output) as Record; + assert.deepEqual(Object.keys(parsed).sort(), ["catalog", "intel", "offers", "referrals"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CLI registry exposes nested radar status and sync commands with EN/PT strings", async () => { + const { createProgram } = await import("../../bin/cli/program.mjs"); + const program = createProgram(); + const radar = program.commands.find((command) => command.name() === "radar"); + assert.ok(radar); + assert.deepEqual(radar.commands.map((command) => command.name()).sort(), ["status", "sync"]); + + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `bin/cli/locales/${locale}.json`), "utf8") + ) as { radar?: Record }; + assert.equal(typeof messages.radar?.description, "string"); + assert.equal(typeof messages.radar?.status, "string"); + assert.equal(typeof messages.radar?.sync, "string"); + } +}); From e80ac605bc0cae2ad2bf2e4e53eae3a766bfb12c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:07:30 -0300 Subject: [PATCH 016/134] docs(radar): document Intel and CLI contract --- docs/frameworks/RADAR.md | 49 ++++++++++++++++++++++++++++++----- docs/reference/ENVIRONMENT.md | 2 +- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index b090f49357..467269b04f 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -34,9 +34,10 @@ 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 server-side sync. Changing or clearing the key invalidates all three 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 four 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. | +| Intel and supporter recognition | Implemented as a strict signed live-only feed with Radar-owned ELO, factual catalog freshness/trend, a verified local supporter badge, dashboard page, and local-only CLI status/sync commands. | | 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. | @@ -53,7 +54,7 @@ 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`, - `/dashboard/radar/combos`, `/dashboard/radar/offers`) render + `/dashboard/radar/combos`, `/dashboard/radar/offers`, `/dashboard/radar/intel`) render `notFound()`. - `getRadarCatalog()` (`src/lib/radar/index.ts`) returns the untouched baseline — same entry count, same values, every entry tagged `origin: "baseline"` — and never @@ -87,9 +88,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 three server-side network paths: + [Security model](#security-model)). Radar has exactly four server-side network paths: `syncRadar()` for the catalog, `syncRadarReferrals()` for referrals, and - `syncRadarOffers()` for supporter-only offers. + `syncRadarOffers()` / `syncRadarIntel()` for supporter-only offers and Intel. The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) that lets the feed service decide which tier to serve (see @@ -99,7 +100,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 the catalog, referrals, and offers caches. The +- Changing or clearing it atomically invalidates the catalog, referrals, offers, and Intel 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 @@ -337,15 +338,20 @@ The local Radar route families below back the UI under `src/app/api/radar/`: | `/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/intel` | GET | Returns verified local live Intel plus a supporter-recognition boolean; never an identity or key. | +| `/api/radar/intel/sync` | POST | Triggers the server-side, live-key-only `syncRadarIntel()` pipeline. | +| `/api/radar/status` | GET | Returns read-only local settings/cache status for catalog, referrals, offers, and Intel, without secrets. | +| `/api/radar/sync-all` | POST | Runs all four server-side sync modules and returns a separate status for each feed. | | `/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 three modules that touch the Radar service are +to the local OmniRoute server. The four 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 +`src/lib/radar/offersSync.ts` (offers) plus `src/lib/radar/intelSync.ts` (Intel); 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 @@ -395,6 +401,29 @@ this release. --- +## Radar Intel, supporter badge, and CLI + +Intel is a signed artifact at `GET /v1/intel/latest`. The closed `RadarIntelFeedSchema` accepts +only Radar-owned ELO rankings derived by the private curator from confirmed comparisons and factual +catalog age/count deltas derived from signed catalog snapshots. The methodology is fixed at initial +rating 1000 and K=32. An empty ranking is valid when no comparison has been confirmed; the client +never synthesizes one. + +`syncRadarIntel()` applies the same server-side Bearer, 30-second timeout, 10 MiB streamed cap, +exact-byte Ed25519 verification, strict schema, `live` body/header requirement, version floor, and +last-good-cache preservation as offers. After a verified live snapshot is persisted, the client +derives `radar:`, stores only that one-way identity, and emits the dedicated +`radar_supporter` recognition event. Its `radar-supporter` badge is idempotent and awards zero XP; +it never updates leaderboards or reuses `token_share`. `/dashboard/radar/intel` renders the badge +only from verified local cache metadata. + +The CLI exposes `omniroute radar status` and `omniroute radar sync`. Both communicate only with the +local OmniRoute API. `status` performs a read-only `GET /api/radar/status`; `sync` sends one +`POST /api/radar/sync-all` and prints a result per feed. Neither command reads, accepts, or prints +the supporter key, and neither contacts the Radar service directly. + +--- + ## Referral links (free credits) Referral links are served from a **standalone, always-current** feed — @@ -582,6 +611,12 @@ Supporter offers are another optional artifact. To serve them, implement endpoint keeps the catalog/referrals behavior unchanged; offer refresh fails non-destructively and the last verified local offer cache remains available. +Intel is optional in the same way. A self-hoster can serve `GET /v1/intel/latest` using +`RadarIntelFeedSchema` (`src/lib/radar/intelFeedSchema.ts`), require live entitlement, return +`x-omniroute-feed-tier: live`, and sign the exact bytes with the shared Ed25519 key. Omitting the +endpoint leaves catalog, referrals, and offers unchanged; Intel refresh preserves any last verified +local snapshot. + --- ## Related docs diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b6e8bff502..d1af873ab3 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1354,7 +1354,7 @@ module doc. | Variable | Default | Source File | Description | | -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| `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_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync,intelSync}.ts` | Base URL shared by the separately signed catalog, referrals, supporter-offers, and Intel 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). | From 6f48ac4bfe540fea2fa0a650b7c29d848688b1ff Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 07:08:48 -0300 Subject: [PATCH 017/134] docs(changelog): add Radar Intel fragment --- changelog.d/features/9923-radar-intel.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/features/9923-radar-intel.md diff --git a/changelog.d/features/9923-radar-intel.md b/changelog.d/features/9923-radar-intel.md new file mode 100644 index 0000000000..033b3fc4b3 --- /dev/null +++ b/changelog.d/features/9923-radar-intel.md @@ -0,0 +1 @@ +- **feat(radar):** add signed Intel insights, supporter recognition, and local Radar CLI commands ([#9923](https://github.com/diegosouzapw/OmniRoute/pull/9923)) From d0aff219f51c0e279634e5e5ea75e512bd97fca2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 09:06:10 -0300 Subject: [PATCH 018/134] feat(radar): prepare launch news surface --- README.md | 19 ++ docs/architecture/REPOSITORY_MAP.md | 3 +- docs/frameworks/RADAR.md | 23 ++ docs/ops/RELEASE_CHECKLIST.md | 17 ++ docs/reference/ENVIRONMENT.md | 5 + news.json | 47 +++- .../(dashboard)/dashboard/HomePageClient.tsx | 33 --- src/app/(dashboard)/dashboard/NewsBanner.tsx | 119 +++++++++ .../changelog/components/NewsViewer.tsx | 103 ++++---- src/app/(dashboard)/home/page.tsx | 2 + src/shared/utils/releaseNotes.ts | 242 ++++++++++++++++-- tests/unit/news-feed-contract.test.ts | 33 +++ tests/unit/release-notes.test.ts | 165 +++++++++++- 13 files changed, 705 insertions(+), 106 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/NewsBanner.tsx create mode 100644 tests/unit/news-feed-contract.test.ts diff --git a/README.md b/README.md index ecf5be2aab..c1f3be248f 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,25 @@ Pix copia-e-cola:
+## 📡 OmniRoute Radar + +The main free-tier headline remains **~1.53B tokens/month** from the documented, +pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first +month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher +free-model availability between OmniRoute releases; the community catalog and every existing free +feature remain free. + +Supporters can receive the live catalog and additional provider opportunities. Its separate, +mutable ceiling is **approximately 3B tokens/month at most**, depending on provider availability. +That ceiling is not a guarantee: providers can change quotas, eligibility, models, or regions at +any time. + +Radar is opt-in and GET-only. The OmniRoute client does not upload prompts, traffic, provider +configuration, usage telemetry, or local announcement-dismiss state. Learn about eligibility and +the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute.online/planos)**. + +
+
## ✨ What's New diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 5cee8f04a0..ec973d9e1b 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -102,7 +102,7 @@ OmniRoute/ | **.gitleaks.toml** | gitleaks secret-scan ruleset | | **.zizmor.yml** | zizmor GitHub-Actions security-lint config | | **socket.yml** | Socket.dev supply-chain config | -| **news.json** | In-app release-notes feed (read by `src/shared/utils/releaseNotes.ts`) | +| **news.json** | Localized v2 announcement feed; Radar launch item ships inactive | | **flake.nix** / **flake.lock** | Nix dev-shell definition + lock | | **.env** | Local secrets (gitignored — generated from `.env.example`) | @@ -256,6 +256,7 @@ src/ | `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) | | `utils/apiAuth.ts` | API key validation, scope checking | | `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch | +| `utils/releaseNotes.ts` | Closed v2/legacy announcement parser, localization and ID dismissal | --- diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 467269b04f..eeac6b6044 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -43,6 +43,29 @@ or external integration is currently available. --- +## Public announcement reader + +The generic announcement reader is separate from the Radar feature flag. The dashboard Home and +Changelog viewer fetch the repository's public `news.json` through a plain `GET` to +`NEWS_JSON_URL` (`src/shared/utils/releaseNotes.ts`). They send no Radar setting, prompt, provider +configuration, usage record, or local dismissal state. + +`news.json` uses the closed v2 schema implemented by `parseNewsPayload()`: + +- `schemaVersion: 2` and a bounded `items[]` collection; +- stable, unique announcement `id` values; +- explicit `active` and ISO `publishedAt` fields; +- required English copy with optional localized copy; +- optional credential-free HTTPS links and an allowlisted icon; +- newest-active-first selection, locale fallback to English, and per-ID local dismissal. + +The parser temporarily accepts the former singular `{ active, title, message, ... }` shape so +older forks can migrate without a broken Changelog view. Invalid feeds are inert. The Radar launch +entry ships with `active: false`; changing it to `true` is a separate post-merge, post-deploy +release action and does not change `RADAR_ENABLED` or the independent feed-sync opt-in. + +--- + ## Flag: `RADAR_ENABLED` (default off) Radar is gated end-to-end by the `RADAR_ENABLED` feature flag diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index 32717a5fc0..b800d6fc22 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -65,6 +65,7 @@ directly from anywhere — CI can only stage; only the owner's 2FA releases. as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents window and never as the first move. Docker: never rewrite a version tag — rollback is repointing `latest` to the last good digest. + ## Hotfix Fast-Lane (label `hotfix`) A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet, @@ -276,6 +277,22 @@ Deploy skills use the light rsync flow — no `npm pack`, no `npm i -g`: - [ ] Open milestone for next version - [ ] If critical: pin discussion or post in `news.json` for in-app banner +### Radar public-launch gate + +The Radar announcement is intentionally committed with `active: false`. Activation is a separate +change after every item below is evidenced: + +- [ ] All stacked Radar PRs are merged and the release-tip CI is green +- [ ] Deploy and smoke the OSS Radar routes with `RADAR_ENABLED` still off by default +- [ ] Smoke `GET /planos`, `/termos`, `/privacidade`, and `/reembolso` on the named Radar host +- [ ] Record operator identity/contact/address and owner-approved legal review in the private service +- [ ] Exercise Stripe Checkout and the signed webhook in test mode only +- [ ] Exercise one encrypted transactional-email delivery with the approved sender/domain +- [ ] Prove backup restore and one supervised, budget-capped research run +- [ ] Approve the BRL/PIX review policy before accepting donation evidence +- [ ] Enable public Checkout only after the preceding gates, then activate the new `news.json` ID +- [ ] Verify the Home banner uses localized copy and a new ID reappears after an older ID is dismissed + ## Embedded Services smoke (v3.8.4+) Before shipping any release that includes embedded services changes, verify: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index d1af873ab3..3ea7ace9f5 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1352,6 +1352,11 @@ self-hosted or forked feed / supporter-key flow instead of the default OmniRoute Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full module doc. +The generic Home/Changelog announcement reader is not configured by an environment +variable and does not depend on the RADAR_ENABLED feature flag. It reads the public repository +`news.json` URL declared in `src/shared/utils/releaseNotes.ts` by +GET only; dismissal IDs remain in browser local storage. + | Variable | Default | Source File | Description | | -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | | `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync,intelSync}.ts` | Base URL shared by the separately signed catalog, referrals, supporter-offers, and Intel feeds. Override to point at a self-hosted or forked service. | diff --git a/news.json b/news.json index bf4077df7d..16e2de2c47 100644 --- a/news.json +++ b/news.json @@ -1,8 +1,43 @@ { - "active": false, - "title": "Novidade no Omniverse", - "message": "Está lançado hoje o tOmni, o terminal interativo múltiplo para Agentes de AI! Experimente a nova interface focada em produtividade para desenvolvedores.", - "link": "https://github.com/diegosouzapw/tOmni", - "linkLabel": "Conhecer o tOmni", - "icon": "campaign" + "schemaVersion": 2, + "items": [ + { + "id": "radar-launch-2026-08", + "active": false, + "publishedAt": "2026-08-09T00:00:00.000Z", + "text": { + "en": { + "title": "OmniRoute Radar", + "message": "An opt-in, GET-only free-model catalog overlay with no telemetry from the OmniRoute client.", + "linkLabel": "Learn about Radar" + }, + "pt-BR": { + "title": "OmniRoute Radar", + "message": "Um catálogo opcional de modelos gratuitos, somente GET e sem telemetria enviada pelo cliente OmniRoute.", + "linkLabel": "Conheça o Radar" + } + }, + "link": "https://radar.omniroute.online/planos", + "icon": "radar" + }, + { + "id": "tomni-launch-2026-07", + "active": false, + "publishedAt": "2026-07-01T00:00:00.000Z", + "text": { + "en": { + "title": "New in the Omniverse", + "message": "tOmni is an interactive multi-agent terminal focused on developer productivity.", + "linkLabel": "Meet tOmni" + }, + "pt-BR": { + "title": "Novidade no Omniverse", + "message": "O tOmni é um terminal interativo para múltiplos agentes, focado na produtividade de desenvolvedores.", + "linkLabel": "Conhecer o tOmni" + } + }, + "link": "https://github.com/diegosouzapw/tOmni", + "icon": "campaign" + } + ] } diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index dc64977a86..31823cbce6 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -22,7 +22,6 @@ import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; import { shouldShowProviderTopologyOnHome } from "./homeAppearance"; const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); -import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; type UpdateStep = { step: string; @@ -37,7 +36,6 @@ type VersionInfo = { channel: string; autoUpdateSupported: boolean; autoUpdateError?: string | null; - news?: NewsAnnouncement | null; }; type HomePageClientProps = { @@ -1047,37 +1045,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
)}
- - {/* News Notification Banner */} - {versionInfo?.news && ( -
-
-
- - {versionInfo.news.icon || "campaign"} - -
-
-

{versionInfo.news.title}

-

- {versionInfo.news.message} -

-
-
- - {versionInfo.news.link && ( - - {versionInfo.news.linkLabel || t("readMore")} - arrow_forward - - )} -
- )} )} diff --git a/src/app/(dashboard)/dashboard/NewsBanner.tsx b/src/app/(dashboard)/dashboard/NewsBanner.tsx new file mode 100644 index 0000000000..10922cbb24 --- /dev/null +++ b/src/app/(dashboard)/dashboard/NewsBanner.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useLocale, useTranslations } from "next-intl"; + +import { + NEWS_DISMISS_EVENT, + NEWS_DISMISS_STORAGE_KEY, + fetchNewsPayload, + parseDismissedNewsIds, + selectActiveNews, + serializeDismissedNewsIds, +} from "@/shared/utils/releaseNotes"; + +function subscribeToDismissals(callback: () => void) { + window.addEventListener("storage", callback); + window.addEventListener(NEWS_DISMISS_EVENT, callback); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener(NEWS_DISMISS_EVENT, callback); + }; +} + +function readDismissedIds(): string { + try { + return localStorage.getItem(NEWS_DISMISS_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +function getServerDismissedIds(): string { + return ""; +} + +/** + * Generic, fail-silent reader for the public announcement feed. Fetching the + * static JSON is GET-only and does not send product state or telemetry. + */ +export default function NewsBanner() { + const locale = useLocale(); + const t = useTranslations("common"); + const [payload, setPayload] = useState(null); + const dismissedSnapshot = useSyncExternalStore( + subscribeToDismissals, + readDismissedIds, + getServerDismissedIds + ); + const dismissedIds = parseDismissedNewsIds(dismissedSnapshot); + const announcement = selectActiveNews(payload, locale, dismissedIds); + + useEffect(() => { + const controller = new AbortController(); + + void fetchNewsPayload(fetch, controller.signal).then((value) => { + if (value !== null) setPayload(value); + }); + + return () => controller.abort(); + }, []); + + if (!announcement) return null; + + const dismiss = () => { + dismissedIds.add(announcement.id); + try { + localStorage.setItem(NEWS_DISMISS_STORAGE_KEY, serializeDismissedNewsIds(dismissedIds)); + } catch { + // Storage is optional; the next announcement fetch remains functional. + } + window.dispatchEvent(new Event(NEWS_DISMISS_EVENT)); + }; + + return ( +
+
+
+ +
+
+

{announcement.title}

+

{announcement.message}

+
+
+ +
+ {announcement.link && ( + + {announcement.linkLabel ?? announcement.title} + + + )} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx index 44e7997874..764565eb61 100644 --- a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx +++ b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx @@ -1,39 +1,39 @@ "use client"; -import { useState, useEffect } from "react"; -import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; + import { Button } from "@/shared/components"; import { - NEWS_JSON_URL, - parseActiveNewsPayload, + fetchNewsPayload, + listActiveNews, type NewsAnnouncement, } from "@/shared/utils/releaseNotes"; export default function NewsViewer() { + const locale = useLocale(); const t = useTranslations("changelogPage"); - const [news, setNews] = useState(null); + const [news, setNews] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); useEffect(() => { - async function fetchNews() { - try { - const res = await fetch(NEWS_JSON_URL, { cache: "no-store" }); - if (res.ok) { - const data = await res.json(); - setNews(parseActiveNewsPayload(data)); - } else { - setError(true); + const controller = new AbortController(); + + void fetchNewsPayload(fetch, controller.signal) + .then((payload) => { + if (payload === null) { + if (!controller.signal.aborted) setError(true); + return; } - } catch (err) { - console.error("Failed to fetch news:", err); - setError(true); - } finally { - setLoading(false); - } - } - fetchNews(); - }, []); + setNews(listActiveNews(payload, locale)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [locale]); if (loading) { return ( @@ -48,7 +48,7 @@ export default function NewsViewer() { if (error) { return (
- + error_outline

{t("announcementsLoadFailed")}

@@ -56,10 +56,10 @@ export default function NewsViewer() { ); } - if (!news || !news.active) { + if (news.length === 0) { return (
- + notifications_off

{t("noAnnouncements")}

@@ -68,30 +68,37 @@ export default function NewsViewer() { } return ( -
-
-
- - {news.icon || "campaign"} - -
- -
-

{news.title}

-

{news.message}

-
- - {news.link && ( -
- - - +
+ {news.map((announcement) => ( +
+
+ + {announcement.icon} +
- )} -
+ +
+

{announcement.title}

+

+ {announcement.message} +

+
+ + {announcement.link && ( + + )} + + ))}
); } diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index 1926f8fd32..92d336a71e 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -4,6 +4,7 @@ import { getSettings } from "@/lib/localDb"; import HomePageClient from "../dashboard/HomePageClient"; import BootstrapBanner from "../dashboard/BootstrapBanner"; import KimiSponsorBanner from "../dashboard/KimiSponsorBanner"; +import NewsBanner from "../dashboard/NewsBanner"; export const dynamic = "force-dynamic"; @@ -18,6 +19,7 @@ export default async function HomePage() { <> {isBootstrapped && } + ); diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts index a1e25b3ad9..bdea01d498 100644 --- a/src/shared/utils/releaseNotes.ts +++ b/src/shared/utils/releaseNotes.ts @@ -6,34 +6,242 @@ export const CHANGELOG_RAW_URL = "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md"; export const CHANGELOG_GITHUB_URL = "https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md"; +export const NEWS_DISMISS_STORAGE_KEY = "omniroute-news-dismissed-v2"; +export const NEWS_DISMISS_EVENT = "omniroute:news-dismissed"; -const activeNewsSchema = z.object({ - active: z.literal(true), - title: z.string().trim().min(1).max(120), - message: z.string().trim().min(1).max(600), - link: z.string().url().optional(), - linkLabel: z.string().trim().min(1).max(80).optional(), - icon: z - .string() - .trim() - .regex(/^[a-z0-9_]+$/) - .optional(), +const NEWS_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/; +const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-[A-Z]{2}|-[0-9]{3})?$/; +const MAX_DISMISSED_IDS = 50; + +const newsIconSchema = z.enum(["campaign", "celebration", "info", "new_releases", "radar"]); +const localizedTextSchema = z + .object({ + title: z.string().trim().min(1).max(120), + message: z.string().trim().min(1).max(600), + linkLabel: z.string().trim().min(1).max(80).optional(), + }) + .strict(); + +const httpsUrlSchema = z + .string() + .url() + .max(500) + .refine((value) => { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + }, "Announcement links must use HTTPS without embedded credentials"); + +const localizedTextMapSchema = z.record(localizedTextSchema).superRefine((value, context) => { + if (!value.en) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" }); + } + for (const locale of Object.keys(value)) { + if (!LOCALE_PATTERN.test(locale)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid locale: ${locale}`, + }); + } + } }); -const inactiveNewsSchema = z +const newsFeedItemSchema = z + .object({ + id: z.string().regex(NEWS_ID_PATTERN), + active: z.boolean(), + publishedAt: z.string().datetime({ offset: true }), + text: localizedTextMapSchema, + link: httpsUrlSchema.optional(), + icon: newsIconSchema, + }) + .strict(); + +const newsFeedSchema = z + .object({ + schemaVersion: z.literal(2), + items: z.array(newsFeedItemSchema).max(50), + }) + .strict() + .superRefine(({ items }, context) => { + const ids = new Set(); + for (const [index, item] of items.entries()) { + if (ids.has(item.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate announcement id: ${item.id}`, + path: ["items", index, "id"], + }); + } + ids.add(item.id); + } + }); + +const legacyActiveNewsSchema = z + .object({ + active: z.literal(true), + title: z.string().trim().min(1).max(120), + message: z.string().trim().min(1).max(600), + link: httpsUrlSchema.optional(), + linkLabel: z.string().trim().min(1).max(80).optional(), + icon: newsIconSchema.optional(), + }) + .strict(); + +const legacyInactiveNewsSchema = z .object({ active: z.literal(false), }) .passthrough(); -const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]); +const legacyNewsSchema = z.discriminatedUnion("active", [ + legacyActiveNewsSchema, + legacyInactiveNewsSchema, +]); -export type NewsAnnouncement = z.infer; +export type NewsFeedItem = z.infer; +export type NewsIcon = z.infer; + +export type NewsAnnouncement = { + id: string; + active: true; + publishedAt: string; + title: string; + message: string; + link?: string; + linkLabel?: string; + icon: NewsIcon; +}; + +type NewsFetchResponse = Pick; +type NewsFetch = (url: string, init: RequestInit) => Promise; + +export async function fetchNewsPayload( + fetchNews: NewsFetch = fetch, + signal?: AbortSignal +): Promise { + try { + const response = await fetchNews(NEWS_JSON_URL, { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + signal, + }); + return response.ok ? await response.json() : null; + } catch { + return null; + } +} + +function stableHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function normalizeLegacyNews(payload: unknown): NewsFeedItem[] { + const parsed = legacyNewsSchema.safeParse(payload); + if (!parsed.success || !parsed.data.active) return []; + + const item = parsed.data; + return [ + { + id: `legacy-${stableHash(`${item.title}\n${item.message}\n${item.link ?? ""}`)}`, + active: true, + publishedAt: "1970-01-01T00:00:00.000Z", + text: { + en: { + title: item.title, + message: item.message, + ...(item.linkLabel ? { linkLabel: item.linkLabel } : {}), + }, + }, + ...(item.link ? { link: item.link } : {}), + icon: item.icon ?? "campaign", + }, + ]; +} + +export function parseNewsPayload(payload: unknown): NewsFeedItem[] { + const feed = newsFeedSchema.safeParse(payload); + if (feed.success) return feed.data.items; + return normalizeLegacyNews(payload); +} + +function resolveLocalizedText(item: NewsFeedItem, locale: string) { + const normalizedLocale = locale.trim().replace("_", "-"); + const exactKey = Object.keys(item.text).find( + (key) => key.toLowerCase() === normalizedLocale.toLowerCase() + ); + if (exactKey) return item.text[exactKey]; + + const language = normalizedLocale.split("-")[0]?.toLowerCase(); + const languageKey = Object.keys(item.text).find((key) => key.toLowerCase() === language); + return (languageKey && item.text[languageKey]) || item.text.en; +} + +export function listActiveNews( + payload: unknown, + locale = "en", + now = new Date() +): NewsAnnouncement[] { + const nowMs = now.getTime(); + if (!Number.isFinite(nowMs)) return []; + + return parseNewsPayload(payload) + .filter((item) => item.active && Date.parse(item.publishedAt) <= nowMs) + .sort( + (left, right) => + Date.parse(right.publishedAt) - Date.parse(left.publishedAt) || + left.id.localeCompare(right.id) + ) + .map((item) => { + const text = resolveLocalizedText(item, locale); + return { + id: item.id, + active: true as const, + publishedAt: item.publishedAt, + title: text.title, + message: text.message, + ...(item.link ? { link: item.link } : {}), + ...(text.linkLabel ? { linkLabel: text.linkLabel } : {}), + icon: item.icon, + }; + }); +} + +export function selectActiveNews( + payload: unknown, + locale = "en", + dismissedIds: ReadonlySet = new Set(), + now = new Date() +): NewsAnnouncement | null { + return listActiveNews(payload, locale, now).find((item) => !dismissedIds.has(item.id)) ?? null; +} export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null { - const parsed = newsPayloadSchema.safeParse(payload); - if (!parsed.success || parsed.data.active !== true) return null; - return parsed.data; + return selectActiveNews(payload, "en"); +} + +export function parseDismissedNewsIds(raw: string | null): Set { + if (!raw) return new Set(); + try { + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) return new Set(); + const ids = value.filter( + (id): id is string => typeof id === "string" && NEWS_ID_PATTERN.test(id) + ); + return new Set(ids.slice(-MAX_DISMISSED_IDS)); + } catch { + return new Set(); + } +} + +export function serializeDismissedNewsIds(ids: Iterable): string { + const sanitized = [...new Set(ids)].filter((id) => NEWS_ID_PATTERN.test(id)); + return JSON.stringify(sanitized.slice(-MAX_DISMISSED_IDS)); } export function getLatestChangelogMarkdown(markdown: string, limit = 10): string { diff --git a/tests/unit/news-feed-contract.test.ts b/tests/unit/news-feed-contract.test.ts new file mode 100644 index 0000000000..4508eef200 --- /dev/null +++ b/tests/unit/news-feed-contract.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const root = new URL("../../", import.meta.url); + +test("news.json ships Radar inactive in the localized v2 feed without commercial details", async () => { + const source = await readFile(new URL("news.json", root), "utf8"); + const payload = JSON.parse(source); + const radar = payload.items.find((item: { id?: string }) => item.id === "radar-launch-2026-08"); + + assert.equal(payload.schemaVersion, 2); + assert.equal(radar.active, false); + assert.equal(radar.link, "https://radar.omniroute.online/planos"); + assert.match(radar.text.en.message, /opt-in/i); + assert.match(radar.text.en.message, /GET-only/i); + assert.match(radar.text.en.message, /no telemetry/i); + assert.doesNotMatch(source, /R\$|US\$|coupon|cupom|discount|desconto/i); +}); + +test("the generic banner is ID-dismissable and independent from the Radar feature flag", async () => { + const source = await readFile( + new URL("src/app/(dashboard)/dashboard/NewsBanner.tsx", root), + "utf8" + ); + + assert.match(source, /selectActiveNews/); + assert.match(source, /parseDismissedNewsIds/); + assert.match(source, /localStorage/); + assert.match(source, /announcement\.id/); + assert.doesNotMatch(source, /RADAR_ENABLED/); + assert.doesNotMatch(source, /method:\s*["']POST["']/); +}); diff --git a/tests/unit/release-notes.test.ts b/tests/unit/release-notes.test.ts index 6a90764bc1..19dfc38388 100644 --- a/tests/unit/release-notes.test.ts +++ b/tests/unit/release-notes.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; const releaseNotes = await import("../../src/shared/utils/releaseNotes.ts"); -test("parseActiveNewsPayload returns only valid active announcements", () => { +test("parseActiveNewsPayload keeps the legacy singular contract", () => { assert.deepEqual( releaseNotes.parseActiveNewsPayload({ active: true, @@ -14,7 +14,9 @@ test("parseActiveNewsPayload returns only valid active announcements", () => { icon: "campaign", }), { + id: "legacy-8d301d21", active: true, + publishedAt: "1970-01-01T00:00:00.000Z", title: "Launch", message: "A short announcement", link: "https://github.com/diegosouzapw/tOmni", @@ -35,6 +37,167 @@ test("parseActiveNewsPayload returns only valid active announcements", () => { ); }); +test("parseNewsPayload validates the closed v2 feed and preserves inactive entries", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "radar-launch-2026-08", + active: false, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { + en: { title: "Radar", message: "Opt-in catalog", linkLabel: "Learn more" }, + "pt-BR": { title: "Radar", message: "Catálogo opt-in", linkLabel: "Saiba mais" }, + }, + link: "https://radar.omniroute.online/planos", + icon: "radar", + }, + ], + }; + + assert.deepEqual(releaseNotes.parseNewsPayload(payload), payload.items); + assert.deepEqual(releaseNotes.parseNewsPayload({ ...payload, unexpected: true }), []); + assert.deepEqual( + releaseNotes.parseNewsPayload({ + ...payload, + items: [{ ...payload.items[0], link: "http://radar.omniroute.online/planos" }], + }), + [] + ); + assert.deepEqual( + releaseNotes.parseNewsPayload({ + ...payload, + items: [...payload.items, payload.items[0]], + }), + [] + ); +}); + +test("listActiveNews localizes, filters future entries and sorts newest first", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "older", + active: true, + publishedAt: "2026-08-08T12:00:00.000Z", + text: { en: { title: "Older", message: "English" } }, + icon: "info", + }, + { + id: "newer", + active: true, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { + en: { title: "Newer", message: "English" }, + "pt-BR": { title: "Mais recente", message: "Português" }, + }, + icon: "campaign", + }, + { + id: "future", + active: true, + publishedAt: "2026-08-11T12:00:00.000Z", + text: { en: { title: "Future", message: "Not published yet" } }, + icon: "campaign", + }, + { + id: "inactive", + active: false, + publishedAt: "2026-08-09T13:00:00.000Z", + text: { en: { title: "Inactive", message: "Hidden" } }, + icon: "campaign", + }, + ], + }; + + const news = releaseNotes.listActiveNews(payload, "pt-BR", new Date("2026-08-10T00:00:00.000Z")); + assert.deepEqual( + news.map((item: { id: string; title: string; message: string }) => [ + item.id, + item.title, + item.message, + ]), + [ + ["newer", "Mais recente", "Português"], + ["older", "Older", "English"], + ] + ); + assert.equal( + releaseNotes.listActiveNews(payload, "pt-PT", new Date("2026-08-10T00:00:00.000Z"))[0]?.message, + "English" + ); +}); + +test("selectActiveNews skips dismissed ids while retaining new announcements", () => { + const payload = { + schemaVersion: 2, + items: [ + { + id: "newer", + active: true, + publishedAt: "2026-08-09T12:00:00.000Z", + text: { en: { title: "Newer", message: "English" } }, + icon: "campaign", + }, + { + id: "older", + active: true, + publishedAt: "2026-08-08T12:00:00.000Z", + text: { en: { title: "Older", message: "English" } }, + icon: "campaign", + }, + ], + }; + + assert.equal( + releaseNotes.selectActiveNews( + payload, + "en", + new Set(["newer"]), + new Date("2026-08-10T00:00:00.000Z") + )?.id, + "older" + ); +}); + +test("dismissed announcement ids are sanitized, deduplicated and bounded", () => { + assert.deepEqual( + [...releaseNotes.parseDismissedNewsIds('["valid-id","valid-id","