mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
feat(radar): add signed Intel insights and local CLI (#9923)
* feat(radar): sync signed Intel insights * feat(radar): recognize supporters and add Intel UI * feat(cli): add Radar status and sync commands * docs(radar): document Intel and CLI contract * docs(changelog): add Radar Intel fragment --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
12051f7edd
commit
17b4313b6e
76
bin/cli/commands/radar.mjs
Normal file
76
bin/cli/commands/radar.mjs
Normal file
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
1
changelog.d/features/9923-radar-intel.md
Normal file
1
changelog.d/features/9923-radar-intel.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(radar):** add signed Intel insights, supporter recognition, and local Radar CLI commands ([#9923](https://github.com/diegosouzapw/OmniRoute/pull/9923))
|
||||
@@ -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:<sha256(supporter key)>`, 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
|
||||
|
||||
@@ -1294,7 +1294,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). |
|
||||
|
||||
197
src/app/(dashboard)/dashboard/radar/intel/page.tsx
Normal file
197
src/app/(dashboard)/dashboard/radar/intel/page.tsx
Normal file
@@ -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<RadarIntelFeed | null>(null);
|
||||
const [meta, setMeta] = useState<IntelMeta | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/radar"
|
||||
className="text-sm text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
← {t("backToRadar")}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{meta?.supporterVerified === true && (
|
||||
<span
|
||||
data-badge-id="radar-supporter"
|
||||
className="rounded-full border border-violet-500 px-3 py-1 text-sm text-violet-300"
|
||||
>
|
||||
{t("supporterBadge")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sync()}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? t("syncing") : t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex min-h-48 items-center justify-center text-text-muted">
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : !intel || !meta ? (
|
||||
<Card>
|
||||
<p className="py-8 text-center text-text-muted">{t("empty")}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("methodology")}</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{t("eloMethod", {
|
||||
initial: intel.methodology.initialRating,
|
||||
factor: intel.methodology.kFactor,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("freshness")}</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{t(`freshnessValues.${intel.catalog.freshness}`)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("ageDays", { days: intel.catalog.ageDays })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("trend")}</p>
|
||||
<p className="mt-2 font-semibold">{t(`trendValues.${intel.catalog.trend}`)}</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("modelDelta", {
|
||||
current: intel.catalog.models.current,
|
||||
added: intel.catalog.models.added,
|
||||
removed: intel.catalog.models.removed,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("ranking")}</h2>
|
||||
<span className="text-xs text-text-muted">{meta.version}</span>
|
||||
</div>
|
||||
{intel.rankings.length === 0 ? (
|
||||
<p className="py-6 text-center text-text-muted">{t("noRankings")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-text-muted">
|
||||
<tr>
|
||||
<th className="pb-3">#</th>
|
||||
<th className="pb-3">{t("model")}</th>
|
||||
<th className="pb-3">{t("category")}</th>
|
||||
<th className="pb-3 text-right">{t("rating")}</th>
|
||||
<th className="pb-3 text-right">{t("matches")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intel.rankings.map((ranking) => (
|
||||
<tr
|
||||
key={`${ranking.category}:${ranking.provider}:${ranking.modelId}`}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="py-3">{ranking.rank}</td>
|
||||
<td className="py-3 font-mono">
|
||||
{ranking.provider}/{ranking.modelId}
|
||||
</td>
|
||||
<td className="py-3">{ranking.category}</td>
|
||||
<td className="py-3 text-right">{ranking.rating}</td>
|
||||
<td className="py-3 text-right">{ranking.matches}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -317,6 +317,14 @@ export default function RadarPage() {
|
||||
<p className="text-sm text-text-muted mt-1">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(pageState === "empty" || pageState === "populated") && (
|
||||
<Link
|
||||
href="/dashboard/radar/intel"
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-main hover:border-violet-500 hover:text-violet-400 transition-colors"
|
||||
>
|
||||
{t("intel")}
|
||||
</Link>
|
||||
)}
|
||||
{(pageState === "empty" || pageState === "populated") && (
|
||||
<Link
|
||||
href="/dashboard/radar/offers"
|
||||
|
||||
42
src/app/api/radar/intel/route.ts
Normal file
42
src/app/api/radar/intel/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/app/api/radar/intel/sync/route.ts
Normal file
56
src/app/api/radar/intel/sync/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/radar/status/route.ts
Normal file
70
src/app/api/radar/status/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/radar/sync-all/route.ts
Normal file
47
src/app/api/radar/sync-all/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Rare achievements"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Radar Supporter",
|
||||
"description": "Verified a live OmniRoute Radar supporter feed",
|
||||
"criteria": "Verify a signed live Radar supporter feed."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "First Token",
|
||||
"description": "Made your first API request",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Enabled locally",
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers"
|
||||
"offers": "Offers",
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Radar guided combos",
|
||||
@@ -12351,6 +12357,31 @@
|
||||
"addConnectionDescription": "Don't have a connection yet? Add one in the providers dashboard.",
|
||||
"addConnectionLink": "Go to providers →"
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Radar Intel",
|
||||
"subtitle": "Radar-owned ELO rankings and factual catalog movement.",
|
||||
"backToRadar": "Back to Radar",
|
||||
"loading": "Loading Intel...",
|
||||
"refresh": "Refresh Intel",
|
||||
"syncing": "Refreshing...",
|
||||
"loadFailed": "We could not refresh Intel. The last verified local cache is kept.",
|
||||
"empty": "No verified Intel snapshot is available yet.",
|
||||
"supporterBadge": "Radar Supporter",
|
||||
"methodology": "Methodology",
|
||||
"eloMethod": "ELO, initial {initial}, K={factor}",
|
||||
"freshness": "Catalog freshness",
|
||||
"ageDays": "{days, plural, one {# day old} other {# days old}}",
|
||||
"trend": "Catalog trend",
|
||||
"modelDelta": "{current} models, +{added} / -{removed}",
|
||||
"ranking": "Model ranking",
|
||||
"noRankings": "No confirmed comparisons are available yet.",
|
||||
"model": "Model",
|
||||
"category": "Category",
|
||||
"rating": "Rating",
|
||||
"matches": "Matches",
|
||||
"freshnessValues": { "fresh": "Fresh", "aging": "Aging", "stale": "Stale" },
|
||||
"trendValues": { "growing": "Growing", "stable": "Stable", "shrinking": "Shrinking" }
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Supporter offers",
|
||||
"subtitle": "Official discounts, credits, trials, and reviewed partner benefits from Radar.",
|
||||
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Conquistas raras"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Apoiador do Radar",
|
||||
"description": "Verificou um feed ativo de apoiador do OmniRoute Radar",
|
||||
"criteria": "Verifique um feed ativo e assinado de apoiador do Radar."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "Primeiro Token",
|
||||
"description": "Fez sua primeira requisição de API",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Ativado localmente",
|
||||
"localStateSaveFailed": "Falha ao salvar as configurações locais do Radar",
|
||||
"guidedCombos": "Combos guiados",
|
||||
"offers": "Ofertas"
|
||||
"offers": "Ofertas",
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Combos guiados pelo Radar",
|
||||
@@ -12351,6 +12357,31 @@
|
||||
"addConnectionDescription": "Ainda não tem uma conexão? Adicione uma no painel de provedores.",
|
||||
"addConnectionLink": "Ir para provedores →"
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Intel do Radar",
|
||||
"subtitle": "Ranking ELO próprio do Radar e evolução factual do catálogo.",
|
||||
"backToRadar": "Voltar ao Radar",
|
||||
"loading": "Carregando Intel...",
|
||||
"refresh": "Atualizar Intel",
|
||||
"syncing": "Atualizando...",
|
||||
"loadFailed": "Não foi possível atualizar o Intel. O último cache local verificado foi preservado.",
|
||||
"empty": "Ainda não há um snapshot Intel verificado.",
|
||||
"supporterBadge": "Apoiador do Radar",
|
||||
"methodology": "Metodologia",
|
||||
"eloMethod": "ELO, inicial {initial}, K={factor}",
|
||||
"freshness": "Atualidade do catálogo",
|
||||
"ageDays": "{days, plural, one {# dia} other {# dias}}",
|
||||
"trend": "Tendência do catálogo",
|
||||
"modelDelta": "{current} modelos, +{added} / -{removed}",
|
||||
"ranking": "Ranking de modelos",
|
||||
"noRankings": "Ainda não há comparações confirmadas.",
|
||||
"model": "Modelo",
|
||||
"category": "Categoria",
|
||||
"rating": "Pontuação",
|
||||
"matches": "Partidas",
|
||||
"freshnessValues": { "fresh": "Atual", "aging": "Envelhecendo", "stale": "Desatualizado" },
|
||||
"trendValues": { "growing": "Crescendo", "stable": "Estável", "shrinking": "Diminuindo" }
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Ofertas para apoiadores",
|
||||
"subtitle": "Descontos, créditos, testes oficiais e benefícios de parceiros revisados pelo Radar.",
|
||||
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Thành tích hiếm"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Người ủng hộ Radar",
|
||||
"description": "Đã xác minh nguồn dữ liệu trực tiếp dành cho người ủng hộ OmniRoute Radar",
|
||||
"criteria": "Xác minh nguồn dữ liệu Radar trực tiếp đã được ký dành cho người ủng hộ."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "Token đầu tiên",
|
||||
"description": "Đã thực hiện yêu cầu API đầu tiên",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Enabled locally",
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers"
|
||||
"offers": "Offers",
|
||||
"intel": "Thông tin chuyên sâu"
|
||||
},
|
||||
"radarSetupPage": {
|
||||
"title": "Thiết lập nhà cung cấp",
|
||||
@@ -12351,6 +12357,39 @@
|
||||
"loadFailed": "Failed to load Radar combo suggestions.",
|
||||
"createFailed": "Failed to create the combo. Review your provider connections and try again."
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Thông tin chuyên sâu Radar",
|
||||
"subtitle": "Xếp hạng ELO do Radar quản lý và biến động thực tế của danh mục.",
|
||||
"backToRadar": "Quay lại Radar",
|
||||
"loading": "Đang tải thông tin chuyên sâu...",
|
||||
"refresh": "Làm mới thông tin chuyên sâu",
|
||||
"syncing": "Đang làm mới...",
|
||||
"loadFailed": "Không thể làm mới thông tin chuyên sâu. Bộ nhớ đệm cục bộ đã xác minh gần nhất được giữ lại.",
|
||||
"empty": "Chưa có bản chụp thông tin chuyên sâu đã xác minh.",
|
||||
"supporterBadge": "Người ủng hộ Radar",
|
||||
"methodology": "Phương pháp",
|
||||
"eloMethod": "ELO, khởi tạo {initial}, K={factor}",
|
||||
"freshness": "Độ mới của danh mục",
|
||||
"ageDays": "{days, plural, one {# ngày tuổi} other {# ngày tuổi}}",
|
||||
"trend": "Xu hướng danh mục",
|
||||
"modelDelta": "{current} mô hình, +{added} / -{removed}",
|
||||
"ranking": "Xếp hạng mô hình",
|
||||
"noRankings": "Chưa có phép so sánh nào được xác nhận.",
|
||||
"model": "Mô hình",
|
||||
"category": "Danh mục",
|
||||
"rating": "Điểm",
|
||||
"matches": "Lượt so sánh",
|
||||
"freshnessValues": {
|
||||
"fresh": "Mới",
|
||||
"aging": "Đang cũ dần",
|
||||
"stale": "Đã cũ"
|
||||
},
|
||||
"trendValues": {
|
||||
"growing": "Đang tăng",
|
||||
"stable": "Ổn định",
|
||||
"shrinking": "Đang giảm"
|
||||
}
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Supporter offers",
|
||||
"subtitle": "Official discounts, credits, trials, and reviewed partner benefits from Radar.",
|
||||
|
||||
11
src/lib/db/migrations/145_radar_intel_cache.sql
Normal file
11
src/lib/db/migrations/145_radar_intel_cache.sql
Normal file
@@ -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'))
|
||||
);
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -161,6 +161,16 @@ export const BUILTIN_BADGES: Omit<BadgeDefinition, "createdAt">[] = [
|
||||
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) ──────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function emitGamificationEvent(params: {
|
||||
| "combo_use"
|
||||
| "token_share"
|
||||
| "invite_redeem"
|
||||
| "daily_login";
|
||||
| "daily_login"
|
||||
| "radar_supporter";
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
async function checkAndUnlockBadge(
|
||||
apiKeyId: string,
|
||||
badgeId: string,
|
||||
logIdentity = true
|
||||
): Promise<void> {
|
||||
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");
|
||||
|
||||
@@ -822,6 +822,8 @@ export {
|
||||
setRadarReferralsCache,
|
||||
getRadarOffersCache,
|
||||
setRadarOffersCache,
|
||||
getRadarIntelCache,
|
||||
setRadarIntelCache,
|
||||
listRadarLocalModelState,
|
||||
setRadarLocalModelOverride,
|
||||
clearRadarLocalModelOverride,
|
||||
@@ -833,6 +835,7 @@ export type {
|
||||
RadarSettings,
|
||||
RadarReferralsCache,
|
||||
RadarOffersCache,
|
||||
RadarIntelCache,
|
||||
RadarLocalModelState,
|
||||
RadarLocalModelOverridePatch,
|
||||
RadarLocalMergeState,
|
||||
|
||||
@@ -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";
|
||||
|
||||
73
src/lib/radar/intelFeedSchema.ts
Normal file
73
src/lib/radar/intelFeedSchema.ts
Normal file
@@ -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<typeof RadarIntelFeedSchema>;
|
||||
export type RadarIntelRanking = z.infer<typeof RadarIntelRankingSchema>;
|
||||
export type RadarIntelCatalog = z.infer<typeof RadarIntelCatalogSchema>;
|
||||
173
src/lib/radar/intelSync.ts
Normal file
173
src/lib/radar/intelSync.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
async function readBoundedBytes(response: Response): Promise<Buffer | null> {
|
||||
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<Uint8Array> | 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<void> {
|
||||
const { emitGamificationEvent } = await import("@/lib/gamification/events");
|
||||
await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" });
|
||||
}
|
||||
|
||||
export async function syncRadarIntel(deps: IntelSyncDeps = {}): Promise<IntelSyncStatus> {
|
||||
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;
|
||||
}
|
||||
@@ -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<ReferralsSyncStatus>;
|
||||
getOffersCache?: () => { fetchedAt: string } | null;
|
||||
syncOffers?: () => Promise<OffersSyncStatus>;
|
||||
getIntelCache?: () => { fetchedAt: string } | null;
|
||||
syncIntel?: () => Promise<IntelSyncStatus>;
|
||||
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<void> {
|
||||
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()) {
|
||||
|
||||
42
tests/fixtures/radar-intel-canonical.json
vendored
Normal file
42
tests/fixtures/radar-intel-canonical.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
97
tests/unit/cli-radar-commands.test.ts
Normal file
97
tests/unit/cli-radar-commands.test.ts
Normal file
@@ -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<number>): 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<string, unknown>;
|
||||
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<string, unknown> };
|
||||
assert.equal(typeof messages.radar?.description, "string");
|
||||
assert.equal(typeof messages.radar?.status, "string");
|
||||
assert.equal(typeof messages.radar?.sync, "string");
|
||||
}
|
||||
});
|
||||
74
tests/unit/radar-intel-db.test.ts
Normal file
74
tests/unit/radar-intel-db.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
53
tests/unit/radar-intel-page.test.ts
Normal file
53
tests/unit/radar-intel-page.test.ts
Normal file
@@ -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<string, unknown>; radarPage?: Record<string, unknown> };
|
||||
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`);
|
||||
}
|
||||
});
|
||||
107
tests/unit/radar-intel-routes.test.ts
Normal file
107
tests/unit/radar-intel-routes.test.ts
Normal file
@@ -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<Record<string, string>> {
|
||||
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<string, string> = {}) {
|
||||
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" },
|
||||
});
|
||||
});
|
||||
210
tests/unit/radar-intel-sync.test.ts
Normal file
210
tests/unit/radar-intel-sync.test.ts
Normal file
@@ -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<Buffer> {
|
||||
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<string, string> = {}, 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<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
@@ -46,10 +46,14 @@ function fakeTimers() {
|
||||
function deps(overrides: Record<string, unknown> = {}) {
|
||||
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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
42
tests/unit/radar-supporter-gamification.test.ts
Normal file
42
tests/unit/radar-supporter-gamification.test.ts
Normal file
@@ -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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user