diff --git a/bin/cli/commands/radar.mjs b/bin/cli/commands/radar.mjs new file mode 100644 index 0000000000..1455efebba --- /dev/null +++ b/bin/cli/commands/radar.mjs @@ -0,0 +1,76 @@ +import { apiFetch } from "../api.mjs"; +import { t } from "../i18n.mjs"; +import { emit } from "../output.mjs"; + +const statusSchema = [ + { key: "feed", header: "Feed" }, + { key: "available", header: "Available" }, + { key: "version", header: "Version" }, + { key: "tier", header: "Tier" }, + { key: "fetchedAt", header: "Fetched" }, +]; + +const syncSchema = [ + { key: "feed", header: "Feed" }, + { key: "status", header: "Status" }, + { key: "version", header: "Version" }, + { key: "reason", header: "Reason" }, +]; + +function exitCodeFor(response) { + return Number.isInteger(response.exitCode) ? response.exitCode : response.status === 401 ? 4 : 1; +} + +export async function runRadarStatusCommand(opts = {}) { + const response = await apiFetch("/api/radar/status", { acceptNotOk: true }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data.feeds ?? {}).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { available: false }), + })); + emit(rows, opts, statusSchema); + return 0; +} + +export async function runRadarSyncCommand(opts = {}) { + const response = await apiFetch("/api/radar/sync-all", { + method: "POST", + body: {}, + acceptNotOk: true, + }); + if (!response.ok) return exitCodeFor(response); + const data = await response.json(); + if (opts.output === "json") { + emit(data, opts); + return 0; + } + const rows = Object.entries(data).map(([feed, value]) => ({ + feed, + ...(value && typeof value === "object" ? value : { status: "error" }), + })); + emit(rows, opts, syncSchema); + return 0; +} + +export function registerRadar(program) { + const radar = program.command("radar").description(t("radar.description")); + radar + .command("status") + .description(t("radar.status")) + .action(async (_opts, command) => { + const code = await runRadarStatusCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); + radar + .command("sync") + .description(t("radar.sync")) + .action(async (_opts, command) => { + const code = await runRadarSyncCommand(command.optsWithGlobals()); + if (code !== 0) process.exitCode = code; + }); +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 84c71bdf06..53dd2787e8 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -78,6 +78,7 @@ import { registerTokens } from "./tokens.mjs"; import { registerConfigure } from "./configure.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerPlugin } from "./plugin.mjs"; +import { registerRadar } from "./radar.mjs"; export function registerCommands(program) { registerMemory(program); @@ -161,4 +162,5 @@ export function registerCommands(program) { registerConfigure(program); registerApiCommands(program); registerPlugin(program); + registerRadar(program); } diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index d0d4808e16..0cfef17b65 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -921,6 +921,11 @@ "model": "Filter by model" } }, + "radar": { + "description": "Inspect and synchronize the local Radar catalog feeds", + "status": "Show local Radar settings and feed cache status", + "sync": "Synchronize catalog, referrals, offers, and Intel through the local server" + }, "resilience": { "description": "Inspect and manage resilience mechanisms", "status": { diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index a951491d63..204ce1f4b7 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -918,6 +918,11 @@ "model": "Filtrar por model" } }, + "radar": { + "description": "Inspecionar e sincronizar os feeds locais do catálogo Radar", + "status": "Mostrar configurações locais e estado dos caches do Radar", + "sync": "Sincronizar catálogo, indicações, ofertas e Intel pelo servidor local" + }, "resilience": { "description": "Inspecionar e gerenciar mecanismos de resiliência", "status": { diff --git a/changelog.d/features/9923-radar-intel.md b/changelog.d/features/9923-radar-intel.md new file mode 100644 index 0000000000..033b3fc4b3 --- /dev/null +++ b/changelog.d/features/9923-radar-intel.md @@ -0,0 +1 @@ +- **feat(radar):** add signed Intel insights, supporter recognition, and local Radar CLI commands ([#9923](https://github.com/diegosouzapw/OmniRoute/pull/9923)) diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index b090f49357..467269b04f 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -34,9 +34,10 @@ or external integration is currently available. | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, persistent display/enabled overrides, reversible tombstones, scheduler, and dashboard. | | Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. | -| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by server-side sync. Changing or clearing the key invalidates all three entitlement-sensitive feed caches. | +| Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by server-side sync. Changing or clearing the key invalidates all four entitlement-sensitive feed caches. | | Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. | | Supporter offers | Implemented as a separate signed, live-only feed and dashboard page. The client revalidates the closed benefit schema, preserves the last good cache, filters expired entries, and labels partner offers explicitly. | +| Intel and supporter recognition | Implemented as a strict signed live-only feed with Radar-owned ELO, factual catalog freshness/trend, a verified local supporter badge, dashboard page, and local-only CLI status/sync commands. | | Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. | | Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. | @@ -53,7 +54,7 @@ Radar is gated end-to-end by the `RADAR_ENABLED` feature flag - All `/api/radar/*` endpoints, including local model-state reads and writes, return `404` before touching any Radar module. - The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`, - `/dashboard/radar/combos`, `/dashboard/radar/offers`) render + `/dashboard/radar/combos`, `/dashboard/radar/offers`, `/dashboard/radar/intel`) render `notFound()`. - `getRadarCatalog()` (`src/lib/radar/index.ts`) returns the untouched baseline — same entry count, same values, every entry tagged `origin: "baseline"` — and never @@ -87,9 +88,9 @@ When both are on, the sync path is: plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider configuration, or model traffic to the feed service. 3. The response is verified, validated, and cached locally (see - [Security model](#security-model)). Radar has exactly three server-side network paths: + [Security model](#security-model)). Radar has exactly four server-side network paths: `syncRadar()` for the catalog, `syncRadarReferrals()` for referrals, and - `syncRadarOffers()` for supporter-only offers. + `syncRadarOffers()` / `syncRadarIntel()` for supporter-only offers and Intel. The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`) that lets the feed service decide which tier to serve (see @@ -99,7 +100,7 @@ that lets the feed service decide which tier to serve (see helpers (`src/lib/db/encryption.ts`) used for provider credentials. - Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and **never echoed back** — the response returns a masked form (`omr_****abcd`). -- Changing or clearing it atomically invalidates the catalog, referrals, and offers caches. The +- Changing or clearing it atomically invalidates the catalog, referrals, offers, and Intel caches. The next sync/read resolves the new entitlement server-side; saving a key does not itself make a network request or consume a single-use activation key. - Sent to the feed service as a Bearer token on the sync GET — nothing else about the @@ -337,15 +338,20 @@ The local Radar route families below back the UI under `src/app/api/radar/`: | `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | | `/api/radar/offers` | GET | Returns active offers from the verified local live cache; never returns the supporter key. | | `/api/radar/offers/sync` | POST | Triggers the server-side, live-key-only `syncRadarOffers()` pipeline. | +| `/api/radar/intel` | GET | Returns verified local live Intel plus a supporter-recognition boolean; never an identity or key. | +| `/api/radar/intel/sync` | POST | Triggers the server-side, live-key-only `syncRadarIntel()` pipeline. | +| `/api/radar/status` | GET | Returns read-only local settings/cache status for catalog, referrals, offers, and Intel, without secrets. | +| `/api/radar/sync-all` | POST | Runs all four server-side sync modules and returns a separate status for each feed. | | `/api/radar/local-model-state` | GET | Lists persisted overrides and tombstones for edit/restore controls. | | `/api/radar/local-model-state` | PATCH | Sets or clears the validated `displayName`/`enabled` override fields. | | `/api/radar/local-model-state` | PUT | Creates or removes a tombstone with `{ provider, modelId, tombstoned }`. | | `/api/radar/local-model-state` | DELETE | Clears editable override fields while preserving any tombstone. | **Hard rule: these routes never proxy the feed service.** The browser only ever talks -to the local OmniRoute server. The three modules that touch the Radar service are +to the local OmniRoute server. The four modules that touch the Radar service are `src/lib/radar/sync.ts` (catalog), `src/lib/radar/referralsSync.ts` (referrals), and -`src/lib/radar/offersSync.ts` (offers); all run server-side, never client-side. This keeps +`src/lib/radar/offersSync.ts` (offers) plus `src/lib/radar/intelSync.ts` (Intel); all run +server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. All Radar endpoints return `404` when `RADAR_ENABLED` is off (see @@ -395,6 +401,29 @@ this release. --- +## Radar Intel, supporter badge, and CLI + +Intel is a signed artifact at `GET /v1/intel/latest`. The closed `RadarIntelFeedSchema` accepts +only Radar-owned ELO rankings derived by the private curator from confirmed comparisons and factual +catalog age/count deltas derived from signed catalog snapshots. The methodology is fixed at initial +rating 1000 and K=32. An empty ranking is valid when no comparison has been confirmed; the client +never synthesizes one. + +`syncRadarIntel()` applies the same server-side Bearer, 30-second timeout, 10 MiB streamed cap, +exact-byte Ed25519 verification, strict schema, `live` body/header requirement, version floor, and +last-good-cache preservation as offers. After a verified live snapshot is persisted, the client +derives `radar:`, stores only that one-way identity, and emits the dedicated +`radar_supporter` recognition event. Its `radar-supporter` badge is idempotent and awards zero XP; +it never updates leaderboards or reuses `token_share`. `/dashboard/radar/intel` renders the badge +only from verified local cache metadata. + +The CLI exposes `omniroute radar status` and `omniroute radar sync`. Both communicate only with the +local OmniRoute API. `status` performs a read-only `GET /api/radar/status`; `sync` sends one +`POST /api/radar/sync-all` and prints a result per feed. Neither command reads, accepts, or prints +the supporter key, and neither contacts the Radar service directly. + +--- + ## Referral links (free credits) Referral links are served from a **standalone, always-current** feed — @@ -582,6 +611,12 @@ Supporter offers are another optional artifact. To serve them, implement endpoint keeps the catalog/referrals behavior unchanged; offer refresh fails non-destructively and the last verified local offer cache remains available. +Intel is optional in the same way. A self-hoster can serve `GET /v1/intel/latest` using +`RadarIntelFeedSchema` (`src/lib/radar/intelFeedSchema.ts`), require live entitlement, return +`x-omniroute-feed-tier: live`, and sign the exact bytes with the shared Ed25519 key. Omitting the +endpoint leaves catalog, referrals, and offers unchanged; Intel refresh preserves any last verified +local snapshot. + --- ## Related docs diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index d8b49d3f58..69a9fe79af 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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). | diff --git a/src/app/(dashboard)/dashboard/radar/intel/page.tsx b/src/app/(dashboard)/dashboard/radar/intel/page.tsx new file mode 100644 index 0000000000..7c933b053d --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/intel/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import type { RadarIntelFeed } from "@/lib/radar/intelFeedSchema"; +import { Card } from "@/shared/components"; + +interface IntelMeta { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; +} + +export default function RadarIntelPage() { + const t = useTranslations("radarIntelPage"); + const [intel, setIntel] = useState(null); + const [meta, setMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [flagOff, setFlagOff] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + const response = await fetch("/api/radar/intel"); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_load_failed"); + const body = (await response.json()) as { + intel?: RadarIntelFeed | null; + meta?: IntelMeta | null; + }; + setIntel(body.intel ?? null); + setMeta(body.meta ?? null); + }, []); + + const sync = useCallback(async () => { + setSyncing(true); + setError(""); + try { + const response = await fetch("/api/radar/intel/sync", { method: "POST" }); + if (response.status === 404) { + setFlagOff(true); + return; + } + if (!response.ok) throw new Error("intel_sync_failed"); + const status = (await response.json()) as { status?: string }; + if ( + ["error", "invalid_signature", "invalid_schema", "wrong_tier", "too_large"].includes( + status.status ?? "" + ) + ) { + setError(t("loadFailed")); + } + await load(); + } catch { + setError(t("loadFailed")); + await load().catch(() => undefined); + } finally { + setSyncing(false); + } + }, [load, t]); + + useEffect(() => { + load() + .catch(() => setError(t("loadFailed"))) + .finally(() => setLoading(false)); + }, [load, t]); + + if (flagOff) notFound(); + + return ( +
+
+
+ + ← {t("backToRadar")} + +

{t("title")}

+

{t("subtitle")}

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

{t("empty")}

+
+ ) : ( + <> +
+ +

{t("methodology")}

+

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

+
+ +

{t("freshness")}

+

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

+

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

+
+ +

{t("trend")}

+

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

+

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

+
+
+ + +
+

{t("ranking")}

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

{t("noRankings")}

+ ) : ( +
+ + + + + + + + + + + + {intel.rankings.map((ranking) => ( + + + + + + + + ))} + +
#{t("model")}{t("category")}{t("rating")}{t("matches")}
{ranking.rank} + {ranking.provider}/{ranking.modelId} + {ranking.category}{ranking.rating}{ranking.matches}
+
+ )} +
+ + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 7f209202a8..203d06e9b4 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -317,6 +317,14 @@ export default function RadarPage() {

{t("subtitle")}

+ {(pageState === "empty" || pageState === "populated") && ( + + {t("intel")} + + )} {(pageState === "empty" || pageState === "populated") && ( `) and never contains the raw key. +CREATE TABLE IF NOT EXISTS radar_intel_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + tier TEXT NOT NULL CHECK (tier = 'live'), + payload TEXT NOT NULL, + signature TEXT NOT NULL, + supporter_identity TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index fda649fb00..a9aee3b504 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -20,6 +20,10 @@ * Tables (migration 144): * - radar_offers_cache: single-row signed live offers feed cache. * + * Tables (migration 145): + * - radar_intel_cache: single-row signed live Intel feed cache plus a + * one-way supporter identity used for local recognition. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -62,6 +66,15 @@ export interface RadarOffersCache { fetchedAt: string; } +export interface RadarIntelCache { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt: string; +} + export interface RadarLocalModelState { provider: string; modelId: string; @@ -180,6 +193,7 @@ export function setRadarKey(key: string | null): void { const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1"); + const clearIntelCache = db.prepare("DELETE FROM radar_intel_cache WHERE id = 1"); db.transaction(() => { updateKey.run(encrypted); @@ -189,6 +203,7 @@ export function setRadarKey(key: string | null): void { clearCatalogCache.run(); clearReferralsCache.run(); clearOffersCache.run(); + clearIntelCache.run(); })(); } @@ -276,6 +291,52 @@ export function setRadarOffersCache(entry: { .run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); } +// --------------------------------------------------------------------------- +// radar_intel_cache +// --------------------------------------------------------------------------- + +export function getRadarIntelCache(): RadarIntelCache | null { + const row = getDbInstance() + .prepare( + "SELECT version, tier, payload, signature, supporter_identity AS supporterIdentity, " + + "fetched_at AS fetchedAt FROM radar_intel_cache WHERE id = 1" + ) + .get() as RadarIntelCache | undefined; + return row ?? null; +} + +export function setRadarIntelCache(entry: { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +}): void { + const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); + getDbInstance() + .prepare( + `INSERT INTO radar_intel_cache + (id, version, tier, payload, signature, supporter_identity, fetched_at) + VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + supporter_identity = excluded.supporter_identity, + fetched_at = excluded.fetched_at` + ) + .run( + entry.version, + entry.tier, + entry.payload, + entry.signature, + entry.supporterIdentity, + fetchedAt + ); +} + // --------------------------------------------------------------------------- // radar_local_model_state // --------------------------------------------------------------------------- diff --git a/src/lib/gamification/badges.ts b/src/lib/gamification/badges.ts index 41120d8747..4111489d71 100644 --- a/src/lib/gamification/badges.ts +++ b/src/lib/gamification/badges.ts @@ -161,6 +161,16 @@ export const BUILTIN_BADGES: Omit[] = [ criteria: JSON.stringify({ type: "threshold", metric: "uptime", threshold: 100, window: 7 }), hidden: 0, }, + { + id: "radar-supporter", + name: "Radar Supporter", + description: "Verified a live OmniRoute Radar supporter feed", + icon: "radar", + category: "contribution", + rarity: "rare", + criteria: JSON.stringify({ type: "action_count", action: "radar_supporter", threshold: 1 }), + hidden: 0, + }, // ── Streak (Engagement) ────────────────────────────────────────────────── { diff --git a/src/lib/gamification/events.ts b/src/lib/gamification/events.ts index cccb2474b0..cde799f5c0 100644 --- a/src/lib/gamification/events.ts +++ b/src/lib/gamification/events.ts @@ -25,7 +25,8 @@ export async function emitGamificationEvent(params: { | "combo_use" | "token_share" | "invite_redeem" - | "daily_login"; + | "daily_login" + | "radar_supporter"; metadata?: Record; }): Promise { const { apiKeyId, action, metadata } = params; @@ -33,6 +34,13 @@ export async function emitGamificationEvent(params: { if (!apiKeyId) return; // Skip if no API key try { + // A verified Radar supporter is a recognition event, not an XP or + // leaderboard action. The caller supplies only a one-way key identity. + if (action === "radar_supporter") { + await checkAndUnlockBadge(apiKeyId, "radar-supporter", false); + return; + } + // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { @@ -87,7 +95,7 @@ export async function emitGamificationEvent(params: { } catch (err) { // Never throw — gamification must not break the request pipeline log.error("events.error", { - apiKeyId, + ...(action === "radar_supporter" ? {} : { apiKeyId }), action, error: err instanceof Error ? err.message : String(err), }); @@ -114,22 +122,25 @@ function getXpForAction(action: string): number { /** * Check and unlock a specific badge. */ -async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise { +async function checkAndUnlockBadge( + apiKeyId: string, + badgeId: string, + logIdentity = true +): Promise { const { unlockBadge, hasBadge } = await import("../db/gamification"); // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on // every request. if (!hasBadge(apiKeyId, badgeId)) { unlockBadge(apiKeyId, badgeId); - log.info("events.badge_unlocked", { apiKeyId, badgeId }); + log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId }); // Look up badge details from badge_definitions const { getDbInstance } = await import("../db/core"); const badgeRow = getDbInstance() .prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?") .get(badgeId) as - | { name: string; description: string | null; icon: string | null; rarity: string } - | undefined; + { name: string; description: string | null; icon: string | null; rarity: string } | undefined; // Record notification for SSE toast const { recordBadgeUnlock } = await import("./notifications"); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index d621129aa0..2c2f672c9c 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -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, diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index ec06fa1340..f6b05c5fd7 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -17,6 +17,7 @@ import { RadarOffersFeedSchema, type RadarOffer, } from "./offersFeedSchema"; +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; @@ -24,6 +25,7 @@ import { getRadarCache, getRadarLocalMergeState, getRadarOffersCache, + getRadarIntelCache, getRadarReferralsCache, type RadarLocalMergeState, } from "@/lib/db/radar"; @@ -261,8 +263,51 @@ export function getRadarOffers(deps: GetRadarOffersDeps = {}): RadarOffersResult } } +export interface RadarIntelResult { + intel: RadarIntelFeed | null; + meta: { + version: string; + tier: "live"; + fetchedAt: string; + supporterVerified: true; + } | null; +} + +export interface GetRadarIntelDeps { + getFlag?: (key: string) => boolean; + getCache?: typeof getRadarIntelCache; +} + +const EMPTY_INTEL: RadarIntelResult = { intel: null, meta: null }; + +/** Return only a defensively revalidated live Intel cache. */ +export function getRadarIntel(deps: GetRadarIntelDeps = {}): RadarIntelResult { + const { getFlag = isFeatureFlagEnabled, getCache: getCacheFn = getRadarIntelCache } = deps; + if (!getFlag("RADAR_ENABLED")) return EMPTY_INTEL; + const cache = getCacheFn(); + if (!cache || cache.tier !== "live" || !/^radar:[a-f0-9]{64}$/.test(cache.supporterIdentity)) { + return EMPTY_INTEL; + } + try { + const feed = RadarIntelFeedSchema.parse(JSON.parse(cache.payload)); + if (feed.version !== cache.version || feed.tier !== "live") return EMPTY_INTEL; + return { + intel: feed, + meta: { + version: cache.version, + tier: "live", + fetchedAt: cache.fetchedAt, + supporterVerified: true, + }, + }; + } catch { + return EMPTY_INTEL; + } +} + // Re-export merge types for convenience export { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; export { findDefaultReferral } from "./referrals"; export type { RadarReferral } from "./feedSchema"; export type { RadarOffer, RadarOfferBenefit, RadarOfferLocalizedText } from "./offersFeedSchema"; +export type { RadarIntelFeed, RadarIntelRanking, RadarIntelCatalog } from "./intelFeedSchema"; diff --git a/src/lib/radar/intelFeedSchema.ts b/src/lib/radar/intelFeedSchema.ts new file mode 100644 index 0000000000..c189aacc76 --- /dev/null +++ b/src/lib/radar/intelFeedSchema.ts @@ -0,0 +1,73 @@ +/** Closed client mirror of the private Radar Intel feed contract. */ + +import { z } from "zod"; + +const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; +const CATEGORY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,239}$/; + +export const RadarIntelRankingSchema = z + .object({ + rank: z.number().int().positive(), + provider: z.string().regex(ID_PATTERN), + modelId: z.string().regex(MODEL_ID_PATTERN), + category: z.string().regex(CATEGORY_PATTERN), + rating: z.number().int(), + matches: z.number().int().nonnegative(), + wins: z.number().int().nonnegative(), + losses: z.number().int().nonnegative(), + draws: z.number().int().nonnegative(), + }) + .strict() + .superRefine((ranking, ctx) => { + if (ranking.matches !== ranking.wins + ranking.losses + ranking.draws) { + ctx.addIssue({ code: "custom", path: ["matches"], message: "match counters disagree" }); + } + }); + +const CatalogDeltaSchema = z + .object({ + current: z.number().int().nonnegative(), + added: z.number().int().nonnegative(), + removed: z.number().int().nonnegative(), + }) + .strict(); + +export const RadarIntelCatalogSchema = z + .object({ + currentVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + previousVersion: z + .string() + .regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/) + .nullable(), + currentGeneratedAt: z.string().datetime(), + ageDays: z.number().int().nonnegative(), + freshness: z.enum(["fresh", "aging", "stale"]), + providers: CatalogDeltaSchema, + models: CatalogDeltaSchema, + trend: z.enum(["growing", "stable", "shrinking"]), + }) + .strict(); + +export const RadarIntelFeedSchema = z + .object({ + feed: z.literal("omniroute-radar-intel"), + schemaVersion: z.literal(1), + version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + generatedAt: z.string().datetime(), + tier: z.literal("live"), + methodology: z + .object({ + kind: z.literal("elo"), + initialRating: z.literal(1000), + kFactor: z.literal(32), + }) + .strict(), + rankings: z.array(RadarIntelRankingSchema), + catalog: RadarIntelCatalogSchema, + }) + .strict(); + +export type RadarIntelFeed = z.infer; +export type RadarIntelRanking = z.infer; +export type RadarIntelCatalog = z.infer; diff --git a/src/lib/radar/intelSync.ts b/src/lib/radar/intelSync.ts new file mode 100644 index 0000000000..b039e772af --- /dev/null +++ b/src/lib/radar/intelSync.ts @@ -0,0 +1,173 @@ +/** Server-side sync for the signed, supporter-only Radar Intel feed. */ + +import crypto from "node:crypto"; + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +import { RadarIntelFeedSchema, type RadarIntelFeed } from "./intelFeedSchema"; +import { compareVersions, type RadarSettingsSnapshot } from "./sync"; +import { verifyFeedBytes } from "./verify"; + +const DEFAULT_FEED_BASE_URL = "https://radar.omniroute.online"; +const SYNC_TIMEOUT_MS = 30_000; +const MAX_FEED_BYTES = 10 * 1024 * 1024; + +export type IntelSyncStatus = + | { status: "disabled" } + | { status: "opt_out" } + | { status: "no_key" } + | { status: "invalid_signature" } + | { status: "invalid_schema" } + | { status: "wrong_tier" } + | { status: "stale" } + | { status: "too_large" } + | { status: "updated"; version: string } + | { status: "error"; reason: string }; + +export interface RadarIntelCacheEntry { + version: string; + tier: "live"; + payload: string; + signature: string; + supporterIdentity: string; + fetchedAt?: string; +} + +export interface IntelSyncDeps { + fetch?: typeof globalThis.fetch; + now?: () => Date; + getFlag?: (key: string) => boolean; + getSettings?: () => RadarSettingsSnapshot; + getCache?: () => RadarIntelCacheEntry | null; + setCache?: (entry: RadarIntelCacheEntry) => void; + recognizeSupporter?: (identity: string) => Promise; +} + +async function readBoundedBytes(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const declared = Number(contentLength); + if (Number.isFinite(declared) && declared > MAX_FEED_BYTES) return null; + } + + const body = response.body as ReadableStream | null | undefined; + if (!body || typeof body.getReader !== "function") { + const buffered = Buffer.from(await response.arrayBuffer()); + return buffered.byteLength > MAX_FEED_BYTES ? null : buffered; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_FEED_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + +function supporterIdentity(key: string): string { + return `radar:${crypto.createHash("sha256").update(key, "utf8").digest("hex")}`; +} + +async function recognizeVerifiedSupporter(identity: string): Promise { + const { emitGamificationEvent } = await import("@/lib/gamification/events"); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); +} + +export async function syncRadarIntel(deps: IntelSyncDeps = {}): Promise { + const { + fetch: fetchFn = globalThis.fetch, + now = () => new Date(), + getFlag = isFeatureFlagEnabled, + getSettings: getSettingsFn, + getCache: getCacheFn, + setCache: setCacheFn, + recognizeSupporter = recognizeVerifiedSupporter, + } = deps; + + try { + if (!getFlag("RADAR_ENABLED")) return { status: "disabled" }; + const settings = getSettingsFn + ? getSettingsFn() + : (await import("@/lib/db/radar")).getRadarSettings(); + if (!settings.optIn) return { status: "opt_out" }; + if (!settings.supporterKey) return { status: "no_key" }; + + const baseUrl = (process.env.RADAR_FEED_URL || DEFAULT_FEED_BASE_URL).replace(/\/+$/, ""); + const response = await fetchFn(`${baseUrl}/v1/intel/latest`, { + method: "GET", + headers: { Authorization: `Bearer ${settings.supporterKey}` }, + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!response.ok) { + return { + status: "error", + reason: `Intel feed request failed with status ${response.status}`, + }; + } + + const rawBytes = await readBoundedBytes(response); + if (!rawBytes) return { status: "too_large" }; + + const signature = response.headers.get("x-omniroute-feed-signature") ?? ""; + if (!verifyFeedBytes(rawBytes, signature)) return { status: "invalid_signature" }; + + let feed: RadarIntelFeed; + try { + feed = RadarIntelFeedSchema.parse(JSON.parse(rawBytes.toString("utf8"))); + } catch { + return { status: "invalid_schema" }; + } + if (response.headers.get("x-omniroute-feed-tier") !== "live" || feed.tier !== "live") { + return { status: "wrong_tier" }; + } + + const existing = getCacheFn + ? getCacheFn() + : (await import("@/lib/db/radar")).getRadarIntelCache(); + if (existing && compareVersions(feed.version, existing.version) <= 0) { + return { status: "stale" }; + } + + const identity = supporterIdentity(settings.supporterKey); + const cacheEntry: RadarIntelCacheEntry = { + version: feed.version, + tier: "live", + payload: rawBytes.toString("utf8"), + signature, + supporterIdentity: identity, + fetchedAt: now().toISOString(), + }; + if (setCacheFn) setCacheFn(cacheEntry); + else (await import("@/lib/db/radar")).setRadarIntelCache(cacheEntry); + + // Recognition is local and best-effort. It runs only after the signed live + // bytes have been accepted and persisted, and never changes sync success. + await recognizeSupporter(identity).catch(() => undefined); + return { status: "updated", version: feed.version }; + } catch (error: unknown) { + const reason = (sanitizeErrorMessage(error) || "Radar Intel sync failed").replace( + /omr_[a-f0-9]{40}/gi, + "[REDACTED]" + ); + return { status: "error", reason }; + } +} + +export const RADAR_INTEL_STALE_MS = 24 * 60 * 60 * 1000; + +export function shouldSyncRadarIntel(fetchedAt: string | null, nowMs = Date.now()): boolean { + if (!fetchedAt) return true; + const fetchedMs = Date.parse(fetchedAt); + return !Number.isFinite(fetchedMs) || nowMs - fetchedMs >= RADAR_INTEL_STALE_MS; +} diff --git a/src/lib/radar/scheduler.ts b/src/lib/radar/scheduler.ts index 33de6cf52e..15e33cf782 100644 --- a/src/lib/radar/scheduler.ts +++ b/src/lib/radar/scheduler.ts @@ -24,7 +24,15 @@ */ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { getRadarCache, getRadarSettings, getRadarReferralsCache } from "@/lib/db/radar"; +import { + getRadarCache, + getRadarIntelCache, + getRadarOffersCache, + getRadarSettings, + getRadarReferralsCache, +} from "@/lib/db/radar"; +import { shouldSyncRadarIntel, syncRadarIntel, type IntelSyncStatus } from "./intelSync"; +import { syncRadarOffers, type OffersSyncStatus } from "./offersSync"; import { nextSyncTime, syncRadar, type SyncStatus } from "./sync"; import { syncRadarReferrals, @@ -49,6 +57,10 @@ export interface RadarSchedulerDeps { getReferralsCache?: () => { fetchedAt: string } | null; /** Referrals sync — separate from `sync` (the catalog sync). */ syncReferrals?: () => Promise; + getOffersCache?: () => { fetchedAt: string } | null; + syncOffers?: () => Promise; + getIntelCache?: () => { fetchedAt: string } | null; + syncIntel?: () => Promise; now?: () => number; setIntervalFn?: typeof setInterval; clearIntervalFn?: typeof clearInterval; @@ -73,6 +85,28 @@ async function maybeSyncReferrals(deps: RadarSchedulerDeps, nowMs: number): Prom } } +async function maybeSyncSupporterFeeds(deps: RadarSchedulerDeps, nowMs: number): Promise { + try { + const offersCache = (deps.getOffersCache ?? getRadarOffersCache)(); + if (nowMs >= nextSyncTime(offersCache?.fetchedAt ?? null).getTime()) { + await (deps.syncOffers ?? syncRadarOffers)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Offers side-sync failed (non-fatal):", msg); + } + + try { + const intelCache = (deps.getIntelCache ?? getRadarIntelCache)(); + if (shouldSyncRadarIntel(intelCache?.fetchedAt ?? null, nowMs)) { + await (deps.syncIntel ?? syncRadarIntel)(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[RADAR_SYNC] Intel side-sync failed (non-fatal):", msg); + } +} + /** * One scheduler evaluation. Exported for tests and for the immediate * post-start tick. @@ -92,6 +126,7 @@ export async function radarSchedulerTick(deps: RadarSchedulerDeps = {}): Promise // Referrals sync on their own staleness window — independent of the // catalog's due-ness below, same tick. await maybeSyncReferrals(deps, nowMs); + await maybeSyncSupporterFeeds(deps, nowMs); const cache = (deps.getCache ?? getRadarCache)(); if (nowMs < nextSyncTime(cache?.fetchedAt ?? null).getTime()) { diff --git a/tests/fixtures/radar-intel-canonical.json b/tests/fixtures/radar-intel-canonical.json new file mode 100644 index 0000000000..0112d568cb --- /dev/null +++ b/tests/fixtures/radar-intel-canonical.json @@ -0,0 +1,42 @@ +{ + "feed": "omniroute-radar-intel", + "schemaVersion": 1, + "version": "2026.08.09.1", + "generatedAt": "2026-08-09T12:00:00.000Z", + "tier": "live", + "methodology": { "kind": "elo", "initialRating": 1000, "kFactor": 32 }, + "rankings": [ + { + "rank": 1, + "provider": "example-a", + "modelId": "example-model-a", + "category": "general", + "rating": 1016, + "matches": 1, + "wins": 1, + "losses": 0, + "draws": 0 + }, + { + "rank": 2, + "provider": "example-b", + "modelId": "example-model-b", + "category": "general", + "rating": 984, + "matches": 1, + "wins": 0, + "losses": 1, + "draws": 0 + } + ], + "catalog": { + "currentVersion": "2026.08.09.1", + "previousVersion": "2026.08.08.1", + "currentGeneratedAt": "2026-08-09T11:00:00.000Z", + "ageDays": 0, + "freshness": "fresh", + "providers": { "current": 2, "added": 1, "removed": 0 }, + "models": { "current": 2, "added": 1, "removed": 0 }, + "trend": "growing" + } +} diff --git a/tests/unit/cli-radar-commands.test.ts b/tests/unit/cli-radar-commands.test.ts new file mode 100644 index 0000000000..ca37a389de --- /dev/null +++ b/tests/unit/cli-radar-commands.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +function makeResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + json: async () => data, + text: async () => JSON.stringify(data), + } as Response; +} + +async function captureStdout(fn: () => Promise): Promise<{ output: string; code: number }> { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + if (typeof chunk === "string") chunks.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + const code = await fn(); + return { output: chunks.join(""), code }; + } finally { + process.stdout.write = original; + } +} + +test("radar status is GET-only, read-only, and prints no secret", async () => { + const originalFetch = globalThis.fetch; + let method = "GET"; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + settings: { optIn: true, hasSupporterKey: true }, + feeds: { catalog: { available: true, version: "2026.08.09.1", tier: "live" } }, + }); + }) as typeof fetch; + try { + const { runRadarStatusCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarStatusCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/status$/); + assert.equal(method, "GET"); + assert.ok(!result.output.includes("omr_")); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("radar sync posts only to the local aggregate route and prints per-feed results", async () => { + const originalFetch = globalThis.fetch; + let method = ""; + let url = ""; + globalThis.fetch = (async (input, init) => { + url = String(input); + method = init?.method ?? "GET"; + return makeResponse({ + catalog: { status: "updated", version: "2026.08.09.1" }, + referrals: { status: "stale" }, + offers: { status: "no_key" }, + intel: { status: "no_key" }, + }); + }) as typeof fetch; + try { + const { runRadarSyncCommand } = await import("../../bin/cli/commands/radar.mjs"); + const result = await captureStdout(() => runRadarSyncCommand({ output: "json" })); + assert.equal(result.code, 0); + assert.match(url, /\/api\/radar\/sync-all$/); + assert.equal(method, "POST"); + const parsed = JSON.parse(result.output) as Record; + assert.deepEqual(Object.keys(parsed).sort(), ["catalog", "intel", "offers", "referrals"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CLI registry exposes nested radar status and sync commands with EN/PT strings", async () => { + const { createProgram } = await import("../../bin/cli/program.mjs"); + const program = createProgram(); + const radar = program.commands.find((command) => command.name() === "radar"); + assert.ok(radar); + assert.deepEqual(radar.commands.map((command) => command.name()).sort(), ["status", "sync"]); + + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `bin/cli/locales/${locale}.json`), "utf8") + ) as { radar?: Record }; + assert.equal(typeof messages.radar?.description, "string"); + assert.equal(typeof messages.radar?.status, "string"); + assert.equal(typeof messages.radar?.sync, "string"); + } +}); diff --git a/tests/unit/radar-intel-db.test.ts b/tests/unit/radar-intel-db.test.ts new file mode 100644 index 0000000000..d5f80496af --- /dev/null +++ b/tests/unit/radar-intel-db.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-intel-db-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-db-32b!"; + +const core = await import("../../src/lib/db/core.ts"); +const radar = await import("../../src/lib/db/radar.ts"); + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Intel migration provides a byte-preserving single-row cache", () => { + assert.equal(radar.getRadarIntelCache(), null); + radar.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + assert.deepEqual(radar.getRadarIntelCache(), { + version: "2026.08.09.1", + tier: "live", + payload: '{"exact":true}\n', + signature: "signed", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); +}); + +test("changing supporter key invalidates catalog, referrals, offers, and Intel atomically", () => { + radar.setRadarCache({ version: "2026.08.09.1", tier: "live", payload: "{}", signature: "a" }); + radar.setRadarReferralsCache({ + generatedAt: "2026-08-09T12:00:00.000Z", + tier: "live", + payload: "{}", + signature: "b", + }); + radar.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "c", + }); + radar.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "d", + supporterIdentity: `radar:${"a".repeat(64)}`, + }); + + radar.setRadarKey(`omr_${"b".repeat(40)}`); + + assert.equal(radar.getRadarCache(), null); + assert.equal(radar.getRadarReferralsCache(), null); + assert.equal(radar.getRadarOffersCache(), null); + assert.equal(radar.getRadarIntelCache(), null); +}); diff --git a/tests/unit/radar-intel-page.test.ts b/tests/unit/radar-intel-page.test.ts new file mode 100644 index 0000000000..d4891ee8f0 --- /dev/null +++ b/tests/unit/radar-intel-page.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const pagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/intel/page.tsx"); +const radarPagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"); + +test("Radar links to a dedicated local-only Intel page", () => { + assert.ok(fs.existsSync(pagePath)); + assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/intel"/); + const source = fs.readFileSync(pagePath, "utf8"); + assert.match(source, /fetch\("\/api\/radar\/intel"\)/); + assert.match(source, /fetch\("\/api\/radar\/intel\/sync",\s*\{\s*method:\s*"POST"/); + assert.doesNotMatch(source, /RADAR_FEED_URL|radar\.omniroute\.online|omr_|getDbInstance/); +}); + +test("Intel page exposes methodology, ranking, freshness, trend, and verified supporter badge only", () => { + const source = fs.readFileSync(pagePath, "utf8"); + for (const marker of [ + "methodology", + "rankings", + "freshness", + "trend", + "supporterVerified", + "radar-supporter", + ]) { + assert.match(source, new RegExp(marker)); + } + assert.doesNotMatch(source, /\bhealth\b|\buptime\b|\blatency\b|\btelemetry\b/i); +}); + +test("Intel UI strings exist in English and Brazilian Portuguese", () => { + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8") + ) as { radarIntelPage?: Record; radarPage?: Record }; + for (const key of [ + "title", + "subtitle", + "methodology", + "supporterBadge", + "ranking", + "freshness", + "trend", + "empty", + "loadFailed", + ]) { + assert.equal(typeof messages.radarIntelPage?.[key], "string", `${locale}: ${key}`); + } + assert.equal(typeof messages.radarPage?.intel, "string", `${locale}: radarPage.intel`); + } +}); diff --git a/tests/unit/radar-intel-routes.test.ts b/tests/unit/radar-intel-routes.test.ts new file mode 100644 index 0000000000..9de90bac74 --- /dev/null +++ b/tests/unit/radar-intel-routes.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-intel-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-intel-routes-32b!"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-intel-routes"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-intel-routes"; + +const core = await import("../../src/lib/db/core.ts"); +const radarDb = await import("../../src/lib/db/radar.ts"); + +async function authHeaders(): Promise> { + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + return { Cookie: `auth_token=${token}` }; +} + +function request(pathname: string, method: "GET" | "POST", headers: Record = {}) { + return new Request(`http://localhost:20128${pathname}`, { method, headers }); +} + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.RADAR_ENABLED; +}); + +test("Intel, status, and aggregate sync routes are 404 before auth when flag is off", async () => { + resetStorage(); + delete process.env.RADAR_ENABLED; + const intel = await import("../../src/app/api/radar/intel/route.ts"); + const intelSync = await import("../../src/app/api/radar/intel/sync/route.ts"); + const status = await import("../../src/app/api/radar/status/route.ts"); + const syncAll = await import("../../src/app/api/radar/sync-all/route.ts"); + + assert.equal((await intel.GET(request("/api/radar/intel", "GET"))).status, 404); + assert.equal((await intelSync.POST(request("/api/radar/intel/sync", "POST"))).status, 404); + assert.equal((await status.GET(request("/api/radar/status", "GET"))).status, 404); + assert.equal((await syncAll.POST(request("/api/radar/sync-all", "POST"))).status, 404); +}); + +test("verified local Intel is returned without supporter identity or key material", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const payload = fs.readFileSync( + path.resolve(process.cwd(), "tests/fixtures/radar-intel-canonical.json"), + "utf8" + ); + radarDb.setRadarIntelCache({ + version: "2026.08.09.1", + tier: "live", + payload, + signature: "fixture-signature", + supporterIdentity: `radar:${"a".repeat(64)}`, + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + + const { GET } = await import("../../src/app/api/radar/intel/route.ts"); + const response = await GET(request("/api/radar/intel", "GET", await authHeaders())); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.intel.rankings.length, 2); + assert.equal(body.meta.supporterVerified, true); + assert.ok(!JSON.stringify(body).includes("radar:")); + assert.ok(!JSON.stringify(body).includes("omr_")); +}); + +test("Radar status is read-only and aggregate sync reports each feed separately", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const statusRoute = await import("../../src/app/api/radar/status/route.ts"); + const status = await statusRoute.GET(request("/api/radar/status", "GET", headers)); + const statusBody = await status.json(); + assert.deepEqual(statusBody.settings, { optIn: false, hasSupporterKey: false }); + assert.deepEqual(Object.keys(statusBody.feeds).sort(), [ + "catalog", + "intel", + "offers", + "referrals", + ]); + + const syncAllRoute = await import("../../src/app/api/radar/sync-all/route.ts"); + const synced = await syncAllRoute.POST(request("/api/radar/sync-all", "POST", headers)); + const syncBody = await synced.json(); + assert.deepEqual(syncBody, { + catalog: { status: "opt_out" }, + referrals: { status: "opt_out" }, + offers: { status: "opt_out" }, + intel: { status: "opt_out" }, + }); +}); diff --git a/tests/unit/radar-intel-sync.test.ts b/tests/unit/radar-intel-sync.test.ts new file mode 100644 index 0000000000..f9fa31e6ff --- /dev/null +++ b/tests/unit/radar-intel-sync.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +process.env.RADAR_FEED_PUBKEY = publicKey + .export({ type: "spki", format: "der" }) + .toString("base64"); + +const intelSync = await import("../../src/lib/radar/intelSync.ts"); +const { RadarIntelFeedSchema } = await import("../../src/lib/radar/intelFeedSchema.ts"); + +async function fixtureBytes(): Promise { + return readFile(new URL("../fixtures/radar-intel-canonical.json", import.meta.url)); +} + +function sign(bytes: Buffer): string { + return crypto.sign(null, bytes, privateKey).toString("base64"); +} + +function response(body: Buffer, headers: Record = {}, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + } as Response; +} + +const supporterKey = `omr_${"a".repeat(40)}`; +const liveSettings = { optIn: true, supporterKey }; + +test("canonical Intel fixture is byte-identical to the private contract", async () => { + const bytes = await fixtureBytes(); + assert.equal(bytes.byteLength, 1024); + assert.equal( + crypto.createHash("sha256").update(bytes).digest("hex"), + "c36aaa6ad53942afa0325d6b0fad0aa048ef66f24c805b743b9815446b0e6176" + ); + assert.equal(RadarIntelFeedSchema.parse(JSON.parse(bytes.toString("utf8"))).tier, "live"); +}); + +test("Intel schema rejects telemetry and inconsistent ranking counters", async () => { + const feed = JSON.parse((await fixtureBytes()).toString("utf8")); + assert.equal(RadarIntelFeedSchema.safeParse({ ...feed, uptime: 99.9 }).success, false); + feed.rankings[0].matches = 2; + assert.equal(RadarIntelFeedSchema.safeParse(feed).success, false); +}); + +test("Intel sync gates before fetch and only accepts exact signed live bytes", async () => { + for (const expected of ["disabled", "opt_out", "no_key"] as const) { + let fetched = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => expected !== "disabled", + getSettings: () => + expected === "opt_out" + ? { optIn: false, supporterKey: null } + : { optIn: true, supporterKey: null }, + fetch: (async () => { + fetched = true; + return response(Buffer.from("{}")); + }) as typeof fetch, + }); + assert.equal(result.status, expected); + assert.equal(fetched, false); + } + + const bytes = await fixtureBytes(); + const writes: intelSync.RadarIntelCacheEntry[] = []; + const supporterIdentities: string[] = []; + let authorization = ""; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: (entry) => writes.push(entry), + recognizeSupporter: async (identity) => supporterIdentities.push(identity), + fetch: (async (_input, init) => { + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return response(bytes, { + "x-omniroute-feed-signature": sign(bytes), + "x-omniroute-feed-tier": "live", + }); + }) as typeof fetch, + now: () => new Date("2026-08-09T12:05:00.000Z"), + }); + + assert.deepEqual(result, { status: "updated", version: "2026.08.09.1" }); + assert.equal(authorization, `Bearer ${supporterKey}`); + assert.equal(writes[0]?.payload, bytes.toString("utf8")); + assert.equal(writes[0]?.tier, "live"); + assert.match(writes[0]?.supporterIdentity ?? "", /^radar:[a-f0-9]{64}$/); + assert.deepEqual(supporterIdentities, [writes[0]?.supporterIdentity]); + assert.ok(!writes[0]?.supporterIdentity.includes(supporterKey)); +}); + +test("Intel sync preserves the good cache on signature, tier, schema, replay, and size failures", async () => { + const bytes = await fixtureBytes(); + const validSignature = sign(bytes); + const cases = [ + { expected: "invalid_signature", body: bytes, signature: "bad", tier: "live" }, + { expected: "wrong_tier", body: bytes, signature: validSignature, tier: "community" }, + { + expected: "invalid_schema", + body: Buffer.from('{"feed":"wrong"}'), + signature: "", + tier: "live", + }, + ]; + + for (const item of cases) { + const signature = item.expected === "invalid_schema" ? sign(item.body) : item.signature; + let written = false; + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.08.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(item.body, { + "x-omniroute-feed-signature": signature, + "x-omniroute-feed-tier": item.tier, + })) as typeof fetch, + }); + assert.equal(result.status, item.expected); + assert.equal(written, false); + } + + let written = false; + const stale = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload: "last-good", + signature: "old", + supporterIdentity: `radar:${"b".repeat(64)}`, + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(bytes, { + "x-omniroute-feed-signature": validSignature, + "x-omniroute-feed-tier": "live", + })) as typeof fetch, + }); + assert.equal(stale.status, "stale"); + + const oversized = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + response(Buffer.from("ignored"), { + "content-length": String(10 * 1024 * 1024 + 1), + })) as typeof fetch, + }); + assert.equal(oversized.status, "too_large"); + assert.equal(written, false); +}); + +test("Intel sync enforces the byte cap while reading streamed chunks", async () => { + let cancelled = false; + let written = false; + const firstChunk = new Uint8Array(6 * 1024 * 1024); + const secondChunk = new Uint8Array(5 * 1024 * 1024); + const chunks = [firstChunk, secondChunk]; + let chunkIndex = 0; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(chunks[chunkIndex]); + chunkIndex += 1; + }, + cancel() { + cancelled = true; + }, + }); + + const result = await intelSync.syncRadarIntel({ + getFlag: () => true, + getSettings: () => liveSettings, + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + new Response(body, { + status: 200, + headers: { "x-omniroute-feed-tier": "live" }, + })) as typeof fetch, + }); + + assert.equal(result.status, "too_large"); + assert.equal(cancelled, true); + assert.equal(written, false); +}); diff --git a/tests/unit/radar-scheduler.test.ts b/tests/unit/radar-scheduler.test.ts index 5f7275ab5c..e51e09784f 100644 --- a/tests/unit/radar-scheduler.test.ts +++ b/tests/unit/radar-scheduler.test.ts @@ -46,10 +46,14 @@ function fakeTimers() { function deps(overrides: Record = {}) { const syncCalls: number[] = []; const referralsSyncCalls: number[] = []; + const offersSyncCalls: number[] = []; + const intelSyncCalls: number[] = []; const timers = fakeTimers(); return { syncCalls, referralsSyncCalls, + offersSyncCalls, + intelSyncCalls, timers, d: { getFlag: () => true, @@ -66,7 +70,21 @@ function deps(overrides: Record = {}) { getReferralsCache: () => ({ fetchedAt: REFERRALS_FRESH }), syncReferrals: async () => { referralsSyncCalls.push(1); - return { status: "updated", generatedAt: "2026-08-06T12:00:00.000Z", tier: "live" } as const; + return { + status: "updated", + generatedAt: "2026-08-06T12:00:00.000Z", + tier: "live", + } as const; + }, + getOffersCache: () => ({ fetchedAt: FRESH }), + syncOffers: async () => { + offersSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; + }, + getIntelCache: () => ({ fetchedAt: FRESH }), + syncIntel: async () => { + intelSyncCalls.push(1); + return { status: "updated", version: "2026.08.06.1" } as const; }, now: () => NOW, setIntervalFn: timers.setIntervalFn, @@ -126,18 +144,21 @@ test("radar sync scheduler", async (t) => { assert.equal(syncCalls.length, 1); }); - await t.test("ensure: registers one hourly timer, fires an immediate tick, idempotent", async () => { - const { d, timers, syncCalls } = deps(); - assert.equal(ensureRadarSyncScheduler(d), true); - assert.equal(timers.registered.length, 1); - assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); - // The immediate tick is fire-and-forget; give the microtask queue a turn. - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); - // Second ensure is a no-op — no second timer. - assert.equal(ensureRadarSyncScheduler(d), false); - assert.equal(timers.registered.length, 1); - }); + await t.test( + "ensure: registers one hourly timer, fires an immediate tick, idempotent", + async () => { + const { d, timers, syncCalls } = deps(); + assert.equal(ensureRadarSyncScheduler(d), true); + assert.equal(timers.registered.length, 1); + assert.equal(timers.registered[0].ms, RADAR_SCHEDULER_TICK_MS); + // The immediate tick is fire-and-forget; give the microtask queue a turn. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(syncCalls.length, 1, "immediate tick should have synced the stale cache"); + // Second ensure is a no-op — no second timer. + assert.equal(ensureRadarSyncScheduler(d), false); + assert.equal(timers.registered.length, 1); + } + ); await t.test("init: flag off => never arms (flag-off boot stays timer-free)", () => { const { d, timers } = deps({ getFlag: () => false }); @@ -174,61 +195,100 @@ test("radar sync scheduler", async (t) => { // only) so the catalog-sync result shape/assertions above stay unchanged. // ------------------------------------------------------------------------- - await t.test("tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", async () => { - const { d, syncCalls, referralsSyncCalls } = deps(); - const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "catalog was due and must still sync as before"); - assert.equal(syncCalls.length, 1); - assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); - }); + await t.test( + "tick: referrals cache fresh => referrals sync NOT called (catalog path unaffected)", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps(); + const result = await radarSchedulerTick(d); + assert.equal(result.action, "synced", "catalog was due and must still sync as before"); + assert.equal(syncCalls.length, 1); + assert.equal(referralsSyncCalls.length, 0, "referrals cache was fresh — must not sync"); + } + ); - await t.test("tick: referrals cache stale => referrals sync called, independent of catalog due-ness", async () => { - const { d, syncCalls, referralsSyncCalls } = deps({ - getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due - }); - const result = await radarSchedulerTick(d); - assert.deepEqual(result, { action: "skipped", reason: "not_due" }, "catalog result shape must stay unchanged"); - assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); - assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); - }); + await t.test( + "tick: referrals cache stale => referrals sync called, independent of catalog due-ness", + async () => { + const { d, syncCalls, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), // catalog NOT due + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), // referrals due + }); + const result = await radarSchedulerTick(d); + assert.deepEqual( + result, + { action: "skipped", reason: "not_due" }, + "catalog result shape must stay unchanged" + ); + assert.equal(syncCalls.length, 0, "catalog must not sync — it was not due"); + assert.equal(referralsSyncCalls.length, 1, "referrals were due and must sync independently"); + } + ); - await t.test("tick: referrals cache missing => referrals sync called (missing counts as stale)", async () => { - const { d, referralsSyncCalls } = deps({ + await t.test( + "tick: referrals cache missing => referrals sync called (missing counts as stale)", + async () => { + const { d, referralsSyncCalls } = deps({ + getCache: () => ({ fetchedAt: FRESH }), + getReferralsCache: () => null, + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 1); + } + ); + + await t.test( + "tick: flag off => referrals sync NOT called (stopped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getFlag: () => false, + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: opt-in off => referrals sync NOT called (skipped before any sync check)", + async () => { + const { d, referralsSyncCalls } = deps({ + getSettings: () => ({ optIn: false }), + getReferralsCache: () => null, // would be due if ever reached + }); + await radarSchedulerTick(d); + assert.equal(referralsSyncCalls.length, 0); + } + ); + + await t.test( + "tick: referrals sync throwing => swallowed, catalog tick still completes normally", + async () => { + const { d, syncCalls } = deps({ + getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), + syncReferrals: async () => { + throw new Error("referrals upstream exploded"); + }, + }); + const result = await radarSchedulerTick(d); + assert.equal( + result.action, + "synced", + "a throwing referrals sync must never break the catalog tick" + ); + assert.equal(syncCalls.length, 1); + } + ); + + await t.test("tick: offers and Intel use independent staleness gates", async () => { + const { d, syncCalls, offersSyncCalls, intelSyncCalls } = deps({ getCache: () => ({ fetchedAt: FRESH }), - getReferralsCache: () => null, - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 1); - }); - - await t.test("tick: flag off => referrals sync NOT called (stopped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getFlag: () => false, - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: opt-in off => referrals sync NOT called (skipped before any sync check)", async () => { - const { d, referralsSyncCalls } = deps({ - getSettings: () => ({ optIn: false }), - getReferralsCache: () => null, // would be due if ever reached - }); - await radarSchedulerTick(d); - assert.equal(referralsSyncCalls.length, 0); - }); - - await t.test("tick: referrals sync throwing => swallowed, catalog tick still completes normally", async () => { - const { d, syncCalls } = deps({ - getReferralsCache: () => ({ fetchedAt: REFERRALS_STALE }), - syncReferrals: async () => { - throw new Error("referrals upstream exploded"); - }, + getOffersCache: () => ({ fetchedAt: STALE }), + getIntelCache: () => null, }); const result = await radarSchedulerTick(d); - assert.equal(result.action, "synced", "a throwing referrals sync must never break the catalog tick"); - assert.equal(syncCalls.length, 1); + assert.deepEqual(result, { action: "skipped", reason: "not_due" }); + assert.equal(syncCalls.length, 0); + assert.equal(offersSyncCalls.length, 1); + assert.equal(intelSyncCalls.length, 1); }); }); diff --git a/tests/unit/radar-supporter-gamification.test.ts b/tests/unit/radar-supporter-gamification.test.ts new file mode 100644 index 0000000000..a3b1d4b100 --- /dev/null +++ b/tests/unit/radar-supporter-gamification.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-supporter-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts"); +const { BUILTIN_BADGES } = await import("../../src/lib/gamification/badges.ts"); +const { emitGamificationEvent } = await import("../../src/lib/gamification/events.ts"); + +test.after(() => { + resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Radar supporter has a dedicated badge and zero-XP idempotent action", async () => { + const identity = `radar:${"a".repeat(64)}`; + const badge = BUILTIN_BADGES.find((item) => item.id === "radar-supporter"); + assert.ok(badge); + assert.equal(JSON.parse(badge.criteria).action, "radar_supporter"); + + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" }); + + const db = getDbInstance(); + const userBadges = db + .prepare("SELECT badge_id AS badgeId FROM user_badges WHERE api_key_id = ?") + .all(identity) as Array<{ badgeId: string }>; + const xpRows = db + .prepare("SELECT action FROM xp_audit_log WHERE api_key_id = ?") + .all(identity) as Array<{ action: string }>; + const scoreRows = db + .prepare("SELECT score FROM leaderboard WHERE api_key_id = ?") + .all(identity) as Array<{ score: number }>; + + assert.deepEqual(userBadges, [{ badgeId: "radar-supporter" }]); + assert.deepEqual(xpRows, []); + assert.deepEqual(scoreRows, []); +});