mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 08:42:15 +03:00
Compare commits
4 Commits
feat/modal
...
docs/radar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a583ddf6 | ||
|
|
f379e9215a | ||
|
|
4f21e663f0 | ||
|
|
f6708c78fb |
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal file
1
changelog.d/fixes/9776-radar-entitlement-refresh.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Radar Free-Model Catalog"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-07
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Radar Free-Model Catalog
|
||||
|
||||
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
|
||||
> **Last updated:** 2026-08-07 — v3.8.50
|
||||
> **Last updated:** 2026-08-08 — v3.8.50
|
||||
|
||||
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
|
||||
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
|
||||
@@ -23,6 +23,23 @@ below.
|
||||
|
||||
---
|
||||
|
||||
## Delivery status in v3.8.50
|
||||
|
||||
The following status distinguishes what this OSS release implements from later Radar
|
||||
workstreams. It is a code-level status, not a promise that a particular hosted deployment
|
||||
or external integration is currently available.
|
||||
|
||||
| Area | Status in this release |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. |
|
||||
| Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. |
|
||||
| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. |
|
||||
| Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. |
|
||||
| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. |
|
||||
| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. |
|
||||
|
||||
---
|
||||
|
||||
## Flag: `RADAR_ENABLED` (default off)
|
||||
|
||||
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
|
||||
@@ -67,7 +84,8 @@ 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)). Nothing else touches the network for Radar.
|
||||
[Security model](#security-model)). Radar has exactly two server-side network paths:
|
||||
`syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed.
|
||||
|
||||
The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`)
|
||||
that lets the feed service decide which tier to serve (see
|
||||
@@ -77,6 +95,9 @@ 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
|
||||
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
|
||||
key ever leaves the client.
|
||||
|
||||
@@ -102,10 +123,10 @@ pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
|
||||
`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the
|
||||
client component never reads `process.env` itself.
|
||||
|
||||
| Var | Purpose |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
| Var | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
|
||||
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
|
||||
|
||||
Once a visitor has a key (`omr_` + 40 hex chars), the activation screen
|
||||
(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary
|
||||
@@ -117,7 +138,7 @@ as a UX nicety; the server's Zod schema is the authoritative check either way. O
|
||||
key is set, the activation screen shows the masked form (`supporterKeyMasked` from
|
||||
`GET /api/radar/settings`) instead of an empty input, with a "change key" control to
|
||||
paste a new one — the raw key is never redisplayed. The two claim/plans buttons above
|
||||
remain the way to *obtain* a key in the first place; this input is where an operator
|
||||
remain the way to _obtain_ a key in the first place; this input is where an operator
|
||||
who already has one activates it.
|
||||
|
||||
---
|
||||
@@ -207,11 +228,11 @@ handle.
|
||||
### The served tier comes from a response header, not the signed body
|
||||
|
||||
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
|
||||
**one signed artifact per version**, so the body cannot carry a per-request tier
|
||||
without invalidating the Ed25519 signature (re-signing per request would defeat the
|
||||
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
|
||||
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
|
||||
decided server-side from the request's `Authorization` key.
|
||||
**two signed artifacts per version**: live includes current campaigns and community
|
||||
omits them. Each artifact is signed over its own exact bytes. The body still does not
|
||||
serve as the entitlement decision; the tier actually selected for a request is carried
|
||||
in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's
|
||||
`Authorization` key.
|
||||
|
||||
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
|
||||
that resolves the tier a client should trust:
|
||||
@@ -223,7 +244,7 @@ that resolves the tier a client should trust:
|
||||
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
|
||||
yields nothing.
|
||||
3. The resolved tier is what gets cached and returned as `{ status: "updated",
|
||||
version, tier }` — this is the value the dashboard shows, never the raw body
|
||||
version, tier }` — this is the value the dashboard shows, never the raw body
|
||||
field.
|
||||
|
||||
---
|
||||
@@ -265,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge:
|
||||
|
||||
Five local routes back the UI, all under `src/app/api/radar/`:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
| ----------------------- | ------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. |
|
||||
| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. |
|
||||
| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. |
|
||||
| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. |
|
||||
| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#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. |
|
||||
|
||||
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
|
||||
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
|
||||
that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
|
||||
server-side, never client-side. This keeps the feed URL and any supporter key
|
||||
to the local OmniRoute server. The two modules that touch the Radar service are
|
||||
`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both
|
||||
always run server-side, never client-side. This keeps the feed URL and any supporter key
|
||||
out of client-facing network traffic entirely.
|
||||
|
||||
All five routes return `404` when `RADAR_ENABLED` is off (see
|
||||
@@ -348,11 +369,13 @@ touches the network for referrals, mirroring `syncRadar()`'s contract exactly: f
|
||||
Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against
|
||||
`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table
|
||||
(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the
|
||||
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an
|
||||
incoming feed with a `generatedAt` no newer than the cached one is treated as `stale`
|
||||
and never overwrites the cache — guards against a replay of an older signed artifact)
|
||||
mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws —
|
||||
always returns a status object; errors never carry a stack trace in `reason`.
|
||||
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an
|
||||
incoming feed older than the cached one, guarding against replay of an older signed
|
||||
artifact. An equal timestamp is accepted: the server intentionally gives the community
|
||||
and live referral variants the same deterministic `generatedAt`, so the signed payload
|
||||
and served tier can change after a supporter-key change without the underlying link set
|
||||
changing. Never throws — always returns a status object; errors never carry a stack trace
|
||||
in `reason`.
|
||||
|
||||
Two triggers keep the referrals cache warm, both independent of the catalog's own
|
||||
24h cadence:
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import {
|
||||
firstProviderConnectionId,
|
||||
providerConnectionsRequestUrl,
|
||||
type RadarSetupConnection,
|
||||
} from "@/lib/radar/setupConnections";
|
||||
import type { RadarLocalizedText } from "@/lib/radar/feedSchema";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -14,17 +20,16 @@ import { Card } from "@/shared/components";
|
||||
* Localized text: either a plain string or an {en, pt?} object.
|
||||
* The renderer resolves the best locale with EN fallback (D25 compat).
|
||||
*/
|
||||
type LocalizedText = string | { en: string; pt?: string };
|
||||
|
||||
interface SetupInfo {
|
||||
keyUrl: string | null;
|
||||
steps: LocalizedText[];
|
||||
steps: RadarLocalizedText[];
|
||||
}
|
||||
|
||||
interface ProviderSetupData {
|
||||
provider: string;
|
||||
setup: SetupInfo | null;
|
||||
configured: boolean;
|
||||
connectionId: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -32,9 +37,9 @@ interface ProviderSetupData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a LocalizedText to a display string. */
|
||||
function resolveText(text: LocalizedText, locale: string): string {
|
||||
function resolveText(text: RadarLocalizedText, locale: string): string {
|
||||
if (typeof text === "string") return text;
|
||||
if (locale === "pt" && text.pt) return text.pt;
|
||||
if (locale.toLowerCase().startsWith("pt") && text.pt) return text.pt;
|
||||
return text.en;
|
||||
}
|
||||
|
||||
@@ -44,9 +49,9 @@ function resolveText(text: LocalizedText, locale: string): string {
|
||||
|
||||
export default function RadarSetupPage() {
|
||||
const t = useTranslations("radarSetupPage");
|
||||
const locale = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const provider = searchParams.get("provider");
|
||||
const locale = "en"; // Could be derived from next-intl locale later
|
||||
|
||||
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -63,18 +68,25 @@ export default function RadarSetupPage() {
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/radar/catalog");
|
||||
const [res, connectionsRes] = await Promise.all([
|
||||
fetch("/api/radar/catalog"),
|
||||
fetch(providerConnectionsRequestUrl(provider)),
|
||||
]);
|
||||
if (res.status === 404) {
|
||||
setError(t("flagDisabled"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!connectionsRes.ok) throw new Error(`HTTP ${connectionsRes.status}`);
|
||||
const data = await res.json();
|
||||
const connectionsData = (await connectionsRes.json()) as {
|
||||
connections?: RadarSetupConnection[];
|
||||
};
|
||||
|
||||
// Find ALL entries for this provider and extract setup from the first one that has it
|
||||
const providerEntries = data.entries.filter(
|
||||
(e: { provider: string }) => e.provider === provider,
|
||||
(e: { provider: string }) => e.provider === provider
|
||||
);
|
||||
|
||||
if (providerEntries.length === 0) {
|
||||
@@ -85,17 +97,19 @@ export default function RadarSetupPage() {
|
||||
|
||||
// Find setup info from feed entries (they carry the setup field)
|
||||
const entryWithSetup = providerEntries.find(
|
||||
(e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl),
|
||||
(e: { setup?: SetupInfo | null }) =>
|
||||
e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl)
|
||||
);
|
||||
|
||||
// Check if provider is configured (has connections)
|
||||
// We infer this from whether the provider exists in the catalog at all
|
||||
// The actual connection check would need a separate API — for now we show
|
||||
// the guide regardless
|
||||
const connectionId = firstProviderConnectionId(
|
||||
Array.isArray(connectionsData.connections) ? connectionsData.connections : [],
|
||||
provider
|
||||
);
|
||||
setSetupData({
|
||||
provider,
|
||||
setup: entryWithSetup?.setup ?? null,
|
||||
configured: false, // Will be enriched when connection-status API is available
|
||||
configured: connectionId !== null,
|
||||
connectionId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("loadFailed"));
|
||||
@@ -109,15 +123,11 @@ export default function RadarSetupPage() {
|
||||
|
||||
// Test connection — uses the EXISTING connection-test endpoint
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!provider) return;
|
||||
if (!setupData?.connectionId) return;
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
// The existing test endpoint is POST /api/providers/[id]/test
|
||||
// We need the connection ID — for now we use the provider ID as a proxy.
|
||||
// In a full implementation, the setup page would list connections for
|
||||
// this provider and test each one. Here we test the first connection.
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, {
|
||||
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
@@ -136,7 +146,7 @@ export default function RadarSetupPage() {
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [provider, t]);
|
||||
}, [setupData?.connectionId, t]);
|
||||
|
||||
if (!provider) {
|
||||
return (
|
||||
@@ -165,9 +175,7 @@ export default function RadarSetupPage() {
|
||||
<p className="text-sm text-text-muted mt-1">{t("setupSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
@@ -248,15 +256,13 @@ export default function RadarSetupPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
disabled={testing || !setupData.connectionId}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testing ? t("testing") : t("testButton")}
|
||||
</button>
|
||||
{testResult && (
|
||||
<span
|
||||
className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
<span className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -103,9 +103,7 @@ export function setRadarCache(entry: {
|
||||
export function getRadarSettings(): RadarSettings {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1"
|
||||
)
|
||||
.prepare("SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1")
|
||||
.get() as { opt_in: number; supporter_key_encrypted: string | null; updated_at: string };
|
||||
|
||||
return {
|
||||
@@ -120,9 +118,9 @@ export function getRadarSettings(): RadarSettings {
|
||||
*/
|
||||
export function setRadarOptIn(optIn: boolean): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
"UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1"
|
||||
).run(optIn ? 1 : 0);
|
||||
db.prepare("UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1").run(
|
||||
optIn ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,9 +131,20 @@ export function setRadarOptIn(optIn: boolean): void {
|
||||
export function setRadarKey(key: string | null): void {
|
||||
const db = getDbInstance();
|
||||
const encrypted = key !== null ? encrypt(key) : null;
|
||||
db.prepare(
|
||||
const updateKey = db.prepare(
|
||||
"UPDATE radar_settings SET supporter_key_encrypted = ?, updated_at = datetime('now') WHERE id = 1"
|
||||
).run(encrypted);
|
||||
);
|
||||
const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1");
|
||||
const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1");
|
||||
|
||||
db.transaction(() => {
|
||||
updateKey.run(encrypted);
|
||||
// Both 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();
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
* The baseline (`FREE_MODEL_BUDGETS`) is NEVER mutated.
|
||||
*
|
||||
* Merge rules:
|
||||
* 1. Feed never overwrites a local override.
|
||||
* 1. Feed never overwrites a local override, except the safety-critical
|
||||
* `enabled:false` signal for an upstream model confirmed unavailable.
|
||||
* 2. `enabled:false` disables the entry with `disabledBy: "radar"` provenance.
|
||||
* 3. User-added entry NOT in the feed survives untouched.
|
||||
* 4. User deletion tombstone prevents feed from resurrecting the entry.
|
||||
*/
|
||||
|
||||
import type { RadarLocalizedText } from "./feedSchema";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,7 +76,7 @@ export interface MergedEntry {
|
||||
/** Setup guide (key URL + steps) reported by the feed. Undefined for baseline-only entries. */
|
||||
setup?: {
|
||||
keyUrl: string | null;
|
||||
steps: string[];
|
||||
steps: RadarLocalizedText[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -107,7 +110,7 @@ export interface FeedModel {
|
||||
tosRisk: MergedEntry["tos"];
|
||||
setup: {
|
||||
keyUrl: string | null;
|
||||
steps: string[];
|
||||
steps: RadarLocalizedText[];
|
||||
} | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -127,9 +130,7 @@ function entryKey(provider: string, modelId: string): string {
|
||||
* Convert a FeedModel budget into a `monthlyTokens` number compatible
|
||||
* with the baseline catalog shape.
|
||||
*/
|
||||
function feedBudgetToMonthlyTokens(
|
||||
budget: FeedModel["budget"],
|
||||
): number {
|
||||
function feedBudgetToMonthlyTokens(budget: FeedModel["budget"]): number {
|
||||
if (budget.kind === "per_model") return budget.tokensPerMonth;
|
||||
if (budget.kind === "shared_pool") return budget.tokensPerMonth;
|
||||
return 0; // rate_only
|
||||
@@ -138,9 +139,7 @@ function feedBudgetToMonthlyTokens(
|
||||
/**
|
||||
* Convert a FeedModel budget into a `poolKey` compatible with the baseline.
|
||||
*/
|
||||
function feedBudgetToPoolKey(
|
||||
budget: FeedModel["budget"],
|
||||
): string | null {
|
||||
function feedBudgetToPoolKey(budget: FeedModel["budget"]): string | null {
|
||||
if (budget.kind === "shared_pool") return budget.poolId;
|
||||
return null;
|
||||
}
|
||||
@@ -242,7 +241,7 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] {
|
||||
function mergeOne(
|
||||
base: MergedEntry,
|
||||
feed: FeedModel,
|
||||
overrides: Partial<MergedEntry> | undefined,
|
||||
overrides: Partial<MergedEntry> | undefined
|
||||
): MergedEntry {
|
||||
// Start from baseline
|
||||
const result: MergedEntry = { ...base };
|
||||
@@ -307,6 +306,13 @@ function mergeOne(
|
||||
if (overrides.setup !== undefined) result.setup = overrides.setup;
|
||||
}
|
||||
|
||||
// Safety exception to rule 1: a model confirmed unavailable upstream is
|
||||
// never resurrected by a stale local enabled:true override.
|
||||
if (!feed.enabled) {
|
||||
result.enabled = false;
|
||||
result.disabledBy = "radar";
|
||||
}
|
||||
|
||||
// Origin: "local" if user has overrides, else "radar" (feed updated it)
|
||||
result.origin = overriddenKeys.size > 0 ? "local" : "radar";
|
||||
|
||||
@@ -318,7 +324,7 @@ function mergeOne(
|
||||
*/
|
||||
function feedModelToMerged(
|
||||
feed: FeedModel,
|
||||
overrides: Partial<MergedEntry> | undefined,
|
||||
overrides: Partial<MergedEntry> | undefined
|
||||
): MergedEntry {
|
||||
const entry: MergedEntry = {
|
||||
provider: feed.provider,
|
||||
@@ -329,8 +335,8 @@ function feedModelToMerged(
|
||||
freeType: overrides?.freeType ?? feed.freeType,
|
||||
poolKey: overrides?.poolKey ?? feedBudgetToPoolKey(feed.budget),
|
||||
tos: overrides?.tos ?? feed.tosRisk,
|
||||
trainsOnPrompts: overrides?.trainsOnPrompts ?? (feed.trainsOnPrompts ?? undefined),
|
||||
enabled: overrides?.enabled ?? feed.enabled,
|
||||
trainsOnPrompts: overrides?.trainsOnPrompts ?? feed.trainsOnPrompts ?? undefined,
|
||||
enabled: feed.enabled ? (overrides?.enabled ?? true) : false,
|
||||
origin: overrides ? "local" : "radar",
|
||||
contextWindow: overrides?.contextWindow ?? feed.contextWindow,
|
||||
capabilities: overrides?.capabilities ?? feed.capabilities,
|
||||
@@ -338,10 +344,8 @@ function feedModelToMerged(
|
||||
setup: overrides?.setup ?? feed.setup,
|
||||
};
|
||||
|
||||
// Rule 2 (feed disable) — but rule 1 (local override wins) takes precedence,
|
||||
// matching mergeOne(): only force-disable when the user has NOT explicitly
|
||||
// overridden `enabled` locally.
|
||||
if (!feed.enabled && overrides?.enabled === undefined) {
|
||||
// Rule 2 is the safety exception to local override precedence.
|
||||
if (!feed.enabled) {
|
||||
entry.enabled = false;
|
||||
entry.disabledBy = "radar";
|
||||
}
|
||||
|
||||
@@ -43,6 +43,16 @@ export type RadarTier = z.infer<typeof RadarTierSchema>;
|
||||
|
||||
const IntNullable = z.number().int().nullable();
|
||||
|
||||
/** D25: English is canonical; Portuguese is an optional localized companion. */
|
||||
export const RadarLocalizedTextSchema = z.union([
|
||||
z.string(), // compatibility with schema-v1 feeds published before D25
|
||||
z.object({
|
||||
en: z.string().min(1),
|
||||
pt: z.string().min(1).optional(),
|
||||
}),
|
||||
]);
|
||||
export type RadarLocalizedText = z.infer<typeof RadarLocalizedTextSchema>;
|
||||
|
||||
/**
|
||||
* Budget is a discriminated union on `kind`:
|
||||
* - per_model: tokensPerMonth (positive int)
|
||||
@@ -80,7 +90,7 @@ const CapabilitiesSchema = z.object({
|
||||
const SetupSchema = z
|
||||
.object({
|
||||
keyUrl: z.string().url().nullable(),
|
||||
steps: z.array(z.string()),
|
||||
steps: z.array(RadarLocalizedTextSchema),
|
||||
})
|
||||
.nullable();
|
||||
|
||||
@@ -167,8 +177,8 @@ const QuirkTargetSchema = z.object({
|
||||
|
||||
const QuirkSchema = z.object({
|
||||
slug: z.string(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
title: RadarLocalizedTextSchema,
|
||||
body: RadarLocalizedTextSchema,
|
||||
severity: SeverityEnum,
|
||||
targets: z.array(QuirkTargetSchema),
|
||||
});
|
||||
@@ -182,10 +192,9 @@ export const RadarFeedSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
version: z.string(),
|
||||
generatedAt: z.string().datetime(),
|
||||
// NOTE: this body field is ALWAYS "live", by design — the community tier
|
||||
// is the exact same signed bytes served from an older snapshot, and there
|
||||
// is only one signed artifact per version (rewriting this field
|
||||
// server-side per request would break the exact-bytes Ed25519 signature).
|
||||
// NOTE: this body field is not the entitlement decision. The server can
|
||||
// publish separate exact-byte live/community artifacts for one version,
|
||||
// while the selected request tier is still communicated by the header.
|
||||
// The tier ACTUALLY served is decided by the server per-request based on
|
||||
// the Authorization key, and is surfaced via the `x-omniroute-feed-tier`
|
||||
// response header instead. NEVER read this field for UI/display — use the
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
* Deps are injectable for testing.
|
||||
*/
|
||||
|
||||
import {
|
||||
RadarReferralsFeedSchema,
|
||||
type RadarReferralsFeed,
|
||||
} from "./referralsFeedSchema";
|
||||
import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema";
|
||||
import { RadarTierSchema, type RadarTier } from "./feedSchema";
|
||||
import { verifyFeedBytes } from "./verify";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
@@ -144,9 +141,10 @@ export function shouldSyncReferralsOnRead(
|
||||
* 4. Verify Ed25519 signature over exact bytes (same pinned key as the
|
||||
* catalog feed — one key pins both artifacts).
|
||||
* 5. Parse+validate with RadarReferralsFeedSchema.
|
||||
* 6. Replay guard: incoming `generatedAt` must be strictly newer than the
|
||||
* cache (a same/older `generatedAt` is a no-op — nothing changed, or a
|
||||
* stale replay — either way the cache is left untouched).
|
||||
* 6. Replay guard: reject an incoming `generatedAt` older than the cache.
|
||||
* Equal timestamps remain valid because the community and live referral
|
||||
* variants deliberately share one deterministic `generatedAt`; the
|
||||
* served tier can still change after the supporter key changes.
|
||||
* 7. Cache the result.
|
||||
*
|
||||
* @param deps - Injectable dependencies for testing.
|
||||
@@ -262,7 +260,8 @@ export async function syncRadarReferrals(
|
||||
return { status: "invalid_schema" };
|
||||
}
|
||||
|
||||
// Step 7: generatedAt floor — replay/no-op guard.
|
||||
// Step 7: generatedAt floor — reject older signed replays. Equal
|
||||
// timestamps are accepted because entitlement variants share generatedAt.
|
||||
let existingCache: RadarReferralsCacheEntry | null = null;
|
||||
if (getCacheFn) {
|
||||
existingCache = getCacheFn();
|
||||
@@ -274,7 +273,7 @@ export async function syncRadarReferrals(
|
||||
if (existingCache) {
|
||||
const existingMs = Date.parse(existingCache.generatedAt);
|
||||
const incomingMs = Date.parse(feed.generatedAt);
|
||||
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs <= existingMs) {
|
||||
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs < existingMs) {
|
||||
return { status: "stale" };
|
||||
}
|
||||
}
|
||||
@@ -282,7 +281,8 @@ export async function syncRadarReferrals(
|
||||
// Step 8: Resolve served tier — the body carries no `tier` field at all
|
||||
// for this feed, so the header is the only source; absent/garbage header
|
||||
// degrades to the least-privileged "community" default.
|
||||
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
|
||||
const servedTier =
|
||||
parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
|
||||
|
||||
// Step 9: Cache the result
|
||||
const cacheEntry: RadarReferralsCacheEntry = {
|
||||
|
||||
26
src/lib/radar/setupConnections.ts
Normal file
26
src/lib/radar/setupConnections.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface RadarSetupConnection {
|
||||
id: string;
|
||||
provider: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Reuse the authenticated provider-connections API with a bounded provider filter. */
|
||||
export function providerConnectionsRequestUrl(provider: string): string {
|
||||
return `/api/providers?provider=${encodeURIComponent(provider)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a concrete connection id for the setup test endpoint. Prefer an active
|
||||
* connection, then fall back to the first valid connection for the provider.
|
||||
*/
|
||||
export function firstProviderConnectionId(
|
||||
connections: readonly RadarSetupConnection[],
|
||||
provider: string
|
||||
): string | null {
|
||||
const matching = connections.filter(
|
||||
(connection) => connection.provider === provider && connection.id.length > 0
|
||||
);
|
||||
return (
|
||||
matching.find((connection) => connection.isActive !== false)?.id ?? matching[0]?.id ?? null
|
||||
);
|
||||
}
|
||||
@@ -266,7 +266,14 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
|
||||
return { status: "invalid_schema" };
|
||||
}
|
||||
|
||||
// Step 7: Version floor
|
||||
// Step 7: Resolve the served tier before the version floor. A single-use
|
||||
// supporter key deliberately transitions from live to community after its
|
||||
// first catalog pull, and the community snapshot can be older.
|
||||
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
|
||||
|
||||
// Step 8: Version floor. Same/older versions are rejected within a tier,
|
||||
// but a verified live -> community transition must replace the privileged
|
||||
// cache even when the community snapshot is older.
|
||||
let existingCache: RadarCacheEntry | null = null;
|
||||
if (getCacheFn) {
|
||||
existingCache = getCacheFn();
|
||||
@@ -275,17 +282,15 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
|
||||
existingCache = mod.getRadarCache();
|
||||
}
|
||||
|
||||
if (existingCache && compareVersions(feed.version, existingCache.version) <= 0) {
|
||||
const isEntitlementDowngrade = existingCache?.tier === "live" && servedTier === "community";
|
||||
if (
|
||||
existingCache &&
|
||||
!isEntitlementDowngrade &&
|
||||
compareVersions(feed.version, existingCache.version) <= 0
|
||||
) {
|
||||
return { status: "stale" };
|
||||
}
|
||||
|
||||
// Step 8: Resolve the served tier.
|
||||
// The `x-omniroute-feed-tier` header reflects the tier ACTUALLY served
|
||||
// (see `parseServedTierHeader`); fall back to the signed body's `tier`
|
||||
// field only when the header is absent or unrecognized — never trust an
|
||||
// arbitrary header value into the cache/UI.
|
||||
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
|
||||
|
||||
// Step 9: Cache the result
|
||||
const cacheEntry: RadarCacheEntry = {
|
||||
version: feed.version,
|
||||
|
||||
5
tests/fixtures/radar-feed-canonical.json
vendored
5
tests/fixtures/radar-feed-canonical.json
vendored
@@ -46,7 +46,10 @@
|
||||
"setup": {
|
||||
"keyUrl": "https://console.groq.com/keys",
|
||||
"steps": [
|
||||
"Crie uma conta gratuita no console da Groq",
|
||||
{
|
||||
"en": "Create a free account in the Groq console",
|
||||
"pt": "Crie uma conta gratuita no console da Groq"
|
||||
},
|
||||
"Gere uma API key na página de keys",
|
||||
"Adicione a key no OmniRoute com o provider groq"
|
||||
]
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyFeed,
|
||||
type MergedEntry,
|
||||
type FeedModel,
|
||||
} from "../../src/lib/radar/applyFeed.ts";
|
||||
import { applyFeed, type MergedEntry, type FeedModel } from "../../src/lib/radar/applyFeed.ts";
|
||||
import {
|
||||
getRadarCatalog,
|
||||
baselineToMergedEntries,
|
||||
@@ -70,7 +66,9 @@ function makeBaseline(): MergedEntry[] {
|
||||
];
|
||||
}
|
||||
|
||||
function makeFeedModel(overrides: Partial<FeedModel> & { provider: string; modelId: string }): FeedModel {
|
||||
function makeFeedModel(
|
||||
overrides: Partial<FeedModel> & { provider: string; modelId: string }
|
||||
): FeedModel {
|
||||
return {
|
||||
displayName: overrides.displayName ?? overrides.modelId,
|
||||
familyId: null,
|
||||
@@ -116,7 +114,7 @@ test("rule 1: feed does NOT overwrite a local override field", () => {
|
||||
});
|
||||
|
||||
const groq = result.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
)!;
|
||||
|
||||
// Local override fields must survive
|
||||
@@ -150,7 +148,7 @@ test("rule 2: feed enabled:false disables entry and carries disabledBy provenanc
|
||||
});
|
||||
|
||||
const groq = result.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
)!;
|
||||
|
||||
assert.equal(groq.enabled, false);
|
||||
@@ -191,9 +189,7 @@ test("rule 3: user-added entry not in feed survives untouched", () => {
|
||||
tombstones: new Set(),
|
||||
});
|
||||
|
||||
const custom = result.find(
|
||||
(e) => e.provider === "custom" && e.modelId === "my-local-model",
|
||||
)!;
|
||||
const custom = result.find((e) => e.provider === "custom" && e.modelId === "my-local-model")!;
|
||||
|
||||
assert.equal(custom.displayName, "My Local Model");
|
||||
assert.equal(custom.monthlyTokens, 50_000);
|
||||
@@ -242,7 +238,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
|
||||
});
|
||||
|
||||
const groq = result.filter(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
|
||||
// Should be deduplicated to ONE entry
|
||||
@@ -262,7 +258,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
|
||||
test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () => {
|
||||
// Baseline has an entry for gemini, but user deleted it
|
||||
const baseline = makeBaseline().filter(
|
||||
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash"),
|
||||
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash")
|
||||
);
|
||||
|
||||
const feed: FeedModel[] = [
|
||||
@@ -283,9 +279,7 @@ test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () =>
|
||||
tombstones,
|
||||
});
|
||||
|
||||
const gemini = result.find(
|
||||
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
|
||||
);
|
||||
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash");
|
||||
|
||||
// Must NOT be resurrected
|
||||
assert.equal(gemini, undefined);
|
||||
@@ -353,9 +347,7 @@ test("applyFeed: feed-only entry is added with origin 'radar'", () => {
|
||||
tombstones: new Set(),
|
||||
});
|
||||
|
||||
const added = result.find(
|
||||
(e) => e.provider === "new-provider" && e.modelId === "new-model",
|
||||
);
|
||||
const added = result.find((e) => e.provider === "new-provider" && e.modelId === "new-model");
|
||||
|
||||
assert.ok(added, "feed-only entry should be present");
|
||||
assert.equal(added.displayName, "Brand New Model");
|
||||
@@ -388,7 +380,7 @@ test("applyFeed: feed fields merge over baseline where no local override", () =>
|
||||
});
|
||||
|
||||
const groq = result.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
)!;
|
||||
|
||||
// Feed values win when no local override
|
||||
@@ -440,7 +432,7 @@ test("applyFeed: duplicate key (baseline + feed) produces single merged entry",
|
||||
});
|
||||
|
||||
const groqEntries = result.filter(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
|
||||
assert.equal(groqEntries.length, 1, "should be deduplicated to one entry");
|
||||
@@ -465,9 +457,7 @@ test("rule 4b: tombstoned entry removed even when baseline has it", () => {
|
||||
tombstones,
|
||||
});
|
||||
|
||||
const groq = result.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
);
|
||||
const groq = result.find((e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile");
|
||||
|
||||
assert.equal(groq, undefined, "tombstoned entry should be excluded");
|
||||
});
|
||||
@@ -494,9 +484,7 @@ test("origin switches to 'radar' when feed updates a baseline entry", () => {
|
||||
tombstones: new Set(),
|
||||
});
|
||||
|
||||
const gemini = result.find(
|
||||
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
|
||||
)!;
|
||||
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash")!;
|
||||
|
||||
assert.equal(gemini.origin, "radar");
|
||||
assert.equal(gemini.displayName, "Updated Gemini");
|
||||
@@ -612,7 +600,7 @@ test("getRadarCatalog: valid cache returns merged entries with meta", () => {
|
||||
|
||||
// The feed has groq:llama-3.3-70b-versatile, which merges over baseline
|
||||
const groq = result.entries.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
)!;
|
||||
|
||||
assert.equal(groq.displayName, "Feed Groq Name");
|
||||
@@ -714,7 +702,7 @@ test("FIX2 mergeOne path: contextWindow/capabilities/limits/setup survive merge
|
||||
});
|
||||
|
||||
const groq = result.find(
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
|
||||
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
|
||||
)!;
|
||||
|
||||
assert.equal(groq.contextWindow, 131072);
|
||||
@@ -758,13 +746,11 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// FIX 4 — feedModelToMerged() must honor an `enabled` local override instead
|
||||
// of unconditionally forcing `enabled:false` when the feed disables the model.
|
||||
// mergeOne() already gets this right (overrides applied AFTER rule 2); this
|
||||
// pins the same semantics on the feed-only path.
|
||||
// Feed `enabled:false` is the safety exception to local override precedence:
|
||||
// a model confirmed dead upstream must not be resurrected locally.
|
||||
// ===========================================================================
|
||||
|
||||
test("FIX4: feed-only entry with local override enabled:true wins over feed enabled:false", () => {
|
||||
test("rule 2: feed-only entry stays disabled even with local enabled:true", () => {
|
||||
const baseline = makeBaseline();
|
||||
const feed: FeedModel[] = [
|
||||
makeFeedModel({
|
||||
@@ -786,11 +772,11 @@ test("FIX4: feed-only entry with local override enabled:true wins over feed enab
|
||||
});
|
||||
|
||||
const entry = result.find(
|
||||
(e) => e.provider === "new-provider" && e.modelId === "disabled-model",
|
||||
(e) => e.provider === "new-provider" && e.modelId === "disabled-model"
|
||||
)!;
|
||||
|
||||
assert.equal(entry.enabled, true, "local override must win over feed disable");
|
||||
assert.equal(entry.disabledBy, undefined, "must not carry radar disabledBy when overridden on");
|
||||
assert.equal(entry.enabled, false, "a local override must not resurrect a dead upstream model");
|
||||
assert.equal(entry.disabledBy, "radar");
|
||||
});
|
||||
|
||||
test("FIX4: feed-only entry with NO override still gets disabled with disabledBy provenance", () => {
|
||||
@@ -811,7 +797,7 @@ test("FIX4: feed-only entry with NO override still gets disabled with disabledBy
|
||||
});
|
||||
|
||||
const entry = result.find(
|
||||
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2",
|
||||
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2"
|
||||
)!;
|
||||
|
||||
assert.equal(entry.enabled, false);
|
||||
|
||||
@@ -165,7 +165,9 @@ test("setRadarKey encrypts at rest and getRadarSettings decrypts", () => {
|
||||
assert.equal(settings.supporterKey, clearKey, "getRadarSettings must return the clear key");
|
||||
|
||||
// Direct DB query to prove encryption at rest
|
||||
interface SettingsRow { supporter_key_encrypted: string | null }
|
||||
interface SettingsRow {
|
||||
supporter_key_encrypted: string | null;
|
||||
}
|
||||
const row = db
|
||||
.prepare("SELECT supporter_key_encrypted FROM radar_settings WHERE id = 1")
|
||||
.get() as SettingsRow;
|
||||
@@ -197,6 +199,36 @@ test("setRadarKey(null) clears the key", () => {
|
||||
assert.equal(cleared.supporter_key_encrypted, null, "DB value must be null");
|
||||
});
|
||||
|
||||
test("changing the supporter key invalidates the entitlement-sensitive referrals cache", () => {
|
||||
radar.setRadarCache({
|
||||
version: "2026.08.07.1",
|
||||
tier: "live",
|
||||
payload: '{"models":[]}',
|
||||
signature: "catalog-live-signature",
|
||||
});
|
||||
radar.setRadarReferralsCache({
|
||||
generatedAt: "2026-08-07T12:00:00.000Z",
|
||||
tier: "live",
|
||||
payload: '{"referrals":{"fixed":[],"campaigns":[{"provider":"groq"}]}}',
|
||||
signature: "live-signature",
|
||||
});
|
||||
assert.ok(radar.getRadarReferralsCache(), "precondition: live referrals cache exists");
|
||||
assert.ok(radar.getRadarCache(), "precondition: live catalog cache exists");
|
||||
|
||||
radar.setRadarKey("omr_" + "d".repeat(40));
|
||||
|
||||
assert.equal(
|
||||
radar.getRadarReferralsCache(),
|
||||
null,
|
||||
"a new key must force the next referrals read to resolve entitlement server-side"
|
||||
);
|
||||
assert.equal(
|
||||
radar.getRadarCache(),
|
||||
null,
|
||||
"a new key must force the next catalog sync to resolve entitlement server-side"
|
||||
);
|
||||
});
|
||||
|
||||
test("setRadarKey uses existing AES-256-GCM encryption from encryption.ts", () => {
|
||||
const db = core.getDbInstance();
|
||||
const clearKey = "omr_" + "c".repeat(40);
|
||||
@@ -271,7 +303,9 @@ test("second setRadarReferralsCache REPLACES the row (still single row)", () =>
|
||||
assert.equal(result.tier, "live", "must have the second tier");
|
||||
assert.equal(result.payload, '{"new":true}', "must have the second payload");
|
||||
|
||||
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as { c: number };
|
||||
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as {
|
||||
c: number;
|
||||
};
|
||||
assert.equal(count.c, 1, "must have exactly one row");
|
||||
});
|
||||
|
||||
|
||||
45
tests/unit/radar-localized-feed.test.ts
Normal file
45
tests/unit/radar-localized-feed.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { RadarFeedSchema } from "../../src/lib/radar/feedSchema.ts";
|
||||
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("../fixtures/radar-feed-canonical.json", import.meta.url), "utf8")
|
||||
) as Record<string, unknown>;
|
||||
|
||||
test("canonical fixture carries D25 localized setup and accepts localized quirks", () => {
|
||||
const localized = structuredClone(fixture) as {
|
||||
models: Array<{ setup: { steps: unknown[] } | null }>;
|
||||
quirks: Array<{ title: unknown; body: unknown }>;
|
||||
};
|
||||
localized.quirks = [
|
||||
{
|
||||
slug: "shared-pool",
|
||||
title: { en: "Shared quota", pt: "Cota compartilhada" },
|
||||
body: { en: "Models share one pool." },
|
||||
severity: "info",
|
||||
targets: [{ provider: "groq", modelGlob: null }],
|
||||
},
|
||||
];
|
||||
const parsed = RadarFeedSchema.parse(localized);
|
||||
assert.deepEqual(parsed.models[0]!.setup!.steps[0], {
|
||||
en: "Create a free account in the Groq console",
|
||||
pt: "Crie uma conta gratuita no console da Groq",
|
||||
});
|
||||
});
|
||||
|
||||
test("RadarFeedSchema preserves schema-v1 legacy setup and quirk strings", () => {
|
||||
const legacy = structuredClone(fixture) as {
|
||||
models: Array<{ setup: { steps: unknown[] } | null }>;
|
||||
quirks: Array<{ title: unknown; body: unknown }>;
|
||||
};
|
||||
legacy.models[0]!.setup!.steps[0] = "Create an account";
|
||||
legacy.quirks[0]!.title = "Shared quota";
|
||||
legacy.quirks[0]!.body = "Models share one pool.";
|
||||
|
||||
const parsed = RadarFeedSchema.parse(legacy);
|
||||
assert.equal(parsed.models[0]!.setup!.steps[0], "Create an account");
|
||||
assert.equal(parsed.quirks[0]!.title, "Shared quota");
|
||||
assert.equal(parsed.quirks[0]!.body, "Models share one pool.");
|
||||
});
|
||||
@@ -118,7 +118,11 @@ test("RadarReferralsFeedSchema: rejects missing referrals section", () => {
|
||||
const feed = baseReferralsFeed();
|
||||
delete (feed as Record<string, unknown>).referrals;
|
||||
const result = referralsFeedSchema.RadarReferralsFeedSchema.safeParse(feed);
|
||||
assert.equal(result.success, false, "referrals section is required (no old-feed compat needed here)");
|
||||
assert.equal(
|
||||
result.success,
|
||||
false,
|
||||
"referrals section is required (no old-feed compat needed here)"
|
||||
);
|
||||
});
|
||||
|
||||
test("RadarReferralsFeedSchema: rejects a non-https referral url", () => {
|
||||
@@ -215,7 +219,10 @@ test("syncRadarReferrals: valid signature => cache updated, payload byte-identic
|
||||
},
|
||||
fetch: (() =>
|
||||
Promise.resolve(
|
||||
mockResponse(bytes, { "x-omniroute-feed-signature": sig, "x-omniroute-feed-tier": "community" })
|
||||
mockResponse(bytes, {
|
||||
"x-omniroute-feed-signature": sig,
|
||||
"x-omniroute-feed-tier": "community",
|
||||
})
|
||||
)) as unknown as typeof globalThis.fetch,
|
||||
now: () => new Date("2026-08-07T12:05:00.000Z"),
|
||||
});
|
||||
@@ -296,32 +303,47 @@ test("syncRadarReferrals: valid sig over garbage JSON => invalid_schema, cache u
|
||||
// syncRadarReferrals — generatedAt floor (replay/no-op guard)
|
||||
// ===========================================================================
|
||||
|
||||
test("syncRadarReferrals: same generatedAt as cache => stale, cache untouched", async () => {
|
||||
test("syncRadarReferrals: same generatedAt with a new served tier replaces the cache", async () => {
|
||||
const feed = baseReferralsFeed("2026-08-07T12:00:00.000Z");
|
||||
(feed.referrals as { campaigns: Array<Record<string, unknown>> }).campaigns = [
|
||||
{
|
||||
provider: "groq",
|
||||
url: "https://groq.com/?campaign=live",
|
||||
kind: "campanha",
|
||||
validUntil: null,
|
||||
requiredAction: null,
|
||||
isDefault: false,
|
||||
},
|
||||
];
|
||||
const bytes = feedBytes(feed);
|
||||
const sig = signBytes(bytes);
|
||||
let cacheWritten = false;
|
||||
const cacheStore: referralsSync.RadarReferralsCacheEntry[] = [];
|
||||
|
||||
const result = await referralsSync.syncRadarReferrals({
|
||||
getFlag: () => true,
|
||||
getSettings: () => ({ optIn: true, supporterKey: null }),
|
||||
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
|
||||
getCache: () => ({
|
||||
generatedAt: "2026-08-07T12:00:00.000Z",
|
||||
tier: "community",
|
||||
payload: "{}",
|
||||
signature: "old-sig",
|
||||
}),
|
||||
setCache: () => {
|
||||
cacheWritten = true;
|
||||
setCache: (entry) => {
|
||||
cacheStore.push(entry);
|
||||
},
|
||||
fetch: (() =>
|
||||
Promise.resolve(
|
||||
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
|
||||
mockResponse(bytes, {
|
||||
"x-omniroute-feed-signature": sig,
|
||||
"x-omniroute-feed-tier": "live",
|
||||
})
|
||||
)) as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "stale");
|
||||
assert.equal(cacheWritten, false);
|
||||
assert.equal(result.status, "updated");
|
||||
assert.equal(cacheStore.length, 1);
|
||||
assert.equal(cacheStore[0]!.tier, "live");
|
||||
assert.equal(cacheStore[0]!.payload, bytes.toString("utf-8"));
|
||||
});
|
||||
|
||||
test("syncRadarReferrals: older generatedAt than cache => stale (replay rejected)", async () => {
|
||||
@@ -487,7 +509,9 @@ test("syncRadarReferrals: header absent => falls back to 'community' (no body ti
|
||||
cacheStore.push(entry);
|
||||
},
|
||||
fetch: (() =>
|
||||
Promise.resolve(mockResponse(bytes, { "x-omniroute-feed-signature": sig }))) as unknown as typeof globalThis.fetch,
|
||||
Promise.resolve(
|
||||
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
|
||||
)) as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "updated");
|
||||
@@ -596,7 +620,9 @@ test("syncRadarReferrals: HTTP non-200 => error mentioning the status code", asy
|
||||
getFlag: () => true,
|
||||
getSettings: () => ({ optIn: true, supporterKey: null }),
|
||||
fetch: (() =>
|
||||
Promise.resolve(mockResponse(Buffer.from("Internal Server Error"), {}, 500))) as unknown as typeof globalThis.fetch,
|
||||
Promise.resolve(
|
||||
mockResponse(Buffer.from("Internal Server Error"), {}, 500)
|
||||
)) as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "error");
|
||||
@@ -646,7 +672,8 @@ test("FIX: oversized body without a trustworthy Content-Length header => too_lar
|
||||
setCache: () => {
|
||||
setCacheCalled = true;
|
||||
},
|
||||
fetch: (() => Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
|
||||
fetch: (() =>
|
||||
Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: "too_large" });
|
||||
|
||||
28
tests/unit/radar-setup-connections.test.ts
Normal file
28
tests/unit/radar-setup-connections.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
firstProviderConnectionId,
|
||||
providerConnectionsRequestUrl,
|
||||
} from "../../src/lib/radar/setupConnections.ts";
|
||||
|
||||
test("providerConnectionsRequestUrl filters the existing providers API", () => {
|
||||
assert.equal(
|
||||
providerConnectionsRequestUrl("openrouter/custom"),
|
||||
"/api/providers?provider=openrouter%2Fcustom"
|
||||
);
|
||||
});
|
||||
|
||||
test("firstProviderConnectionId selects a real connection id, never the provider slug", () => {
|
||||
assert.equal(
|
||||
firstProviderConnectionId(
|
||||
[
|
||||
{ id: "connection-disabled", provider: "groq", isActive: false },
|
||||
{ id: "connection-active", provider: "groq", isActive: true },
|
||||
],
|
||||
"groq"
|
||||
),
|
||||
"connection-active"
|
||||
);
|
||||
assert.equal(firstProviderConnectionId([], "groq"), null);
|
||||
});
|
||||
@@ -82,7 +82,7 @@ test("contract: fixture sha256 matches the server's canonical hash", () => {
|
||||
const hash = crypto.createHash("sha256").update(FIXTURE_BYTES).digest("hex");
|
||||
assert.equal(
|
||||
hash,
|
||||
"13992d27702071bcb240306b052d0fdfacb2a2c688056c5e5118e92957e28a3d",
|
||||
"80194e15a8add2a3be57eaef63b26ab75976c83e20b5589172aefa806eae72d3",
|
||||
"Fixture sha256 must match the server's canonical fixture. " +
|
||||
"If this fails, the fixture was modified or re-downloaded with different formatting."
|
||||
);
|
||||
@@ -409,6 +409,35 @@ test("syncRadar: version floor — incoming older => stale", async () => {
|
||||
assert.equal(cacheWritten, false, "cache must NOT be overwritten with older version");
|
||||
});
|
||||
|
||||
test("syncRadar: an entitlement downgrade replaces a newer live cache with community", async () => {
|
||||
const sig = signBytes(FIXTURE_BYTES);
|
||||
const cacheStore: syncMod.RadarCacheEntry[] = [];
|
||||
|
||||
const result = await syncMod.syncRadar({
|
||||
getFlag: () => true,
|
||||
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
|
||||
getCache: () => ({
|
||||
version: "2026.08.02.1",
|
||||
tier: "live",
|
||||
payload: "{}",
|
||||
signature: "old-live-sig",
|
||||
}),
|
||||
setCache: (entry) => cacheStore.push(entry),
|
||||
fetch: (() =>
|
||||
Promise.resolve(
|
||||
mockResponse(FIXTURE_BYTES, {
|
||||
"x-omniroute-feed-signature": sig,
|
||||
"x-omniroute-feed-tier": "community",
|
||||
})
|
||||
)) as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "updated");
|
||||
assert.equal(cacheStore.length, 1);
|
||||
assert.equal(cacheStore[0]!.tier, "community");
|
||||
assert.equal(cacheStore[0]!.version, "2026.08.01.1");
|
||||
});
|
||||
|
||||
test("syncRadar: version floor — incoming newer => updated", async () => {
|
||||
// Modify fixture to have a newer version
|
||||
const fixtureObj = JSON.parse(FIXTURE_STRING);
|
||||
|
||||
Reference in New Issue
Block a user