diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md index 3a239157d8..1d1d1015d7 100644 --- a/docs/frameworks/RADAR.md +++ b/docs/frameworks/RADAR.md @@ -15,11 +15,12 @@ catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in faster than release cadence — providers add, shrink, or discontinue free quotas between releases, and the baseline catalog can only be refreshed when a new version ships. -**Nothing that is free today stops being free.** Radar never removes or paywalls a -baseline entry; it only refreshes limits/status fields at read time and can layer in -newly-discovered free models between releases. The baseline catalog itself is never -mutated on disk — see [Read-time overlay merge rules](#read-time-overlay-merge-rules) -below. +**Nothing that is free today stops being free because of the remote feed.** Radar never +paywalls a baseline entry; it only refreshes limits/status fields at read time and can +layer in newly-discovered free models between releases. An operator can still hide a +model locally, and can restore it from the same dashboard. The baseline catalog itself +is never mutated on disk — see +[Read-time overlay merge rules](#read-time-overlay-merge-rules) below. --- @@ -31,7 +32,7 @@ or external integration is currently available. | Area | Status in this release | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, non-destructive overlay, scheduler, and dashboard. | +| Signed catalog client | Implemented behind `RADAR_ENABLED`, with separate opt-in, Ed25519 verification, local encrypted settings/cache, persistent display/enabled overrides, reversible tombstones, scheduler, and dashboard. | | Contributor activation | The dashboard links to the server-hosted GitHub claim flow and accepts an existing `omr_…` key. Contributor eligibility is resolved by the private service; the OSS client contains no GitHub token or issuance logic. | | Supporter-key activation | Implemented. The raw key is validated, encrypted at rest, masked on reads, and sent only by the server-side sync. Changing or clearing the key invalidates both entitlement-sensitive feed caches. | | Referral links | Implemented as a separately signed, hourly-refreshed feed. Fixed links are available to the community tier immediately; limited campaigns remain live-tier data. | @@ -48,7 +49,7 @@ Radar is gated end-to-end by the `RADAR_ENABLED` feature flag **When the flag is off, the surface does not exist:** -- `GET /api/radar/catalog`, `POST /api/radar/sync`, `POST /api/radar/settings` all +- All `/api/radar/*` endpoints, including local model-state reads and writes, return `404` before touching any Radar module. - The dashboard screens (`/dashboard/radar`, `/dashboard/radar/setup`) render `notFound()`. @@ -271,6 +272,24 @@ Four rules, in order of precedence: entry (`tombstones` set), the feed re-adding that `provider:modelId` in a later version does not bring it back. +The editable fields and tombstones are persisted in +`radar_local_model_state` (migration `143_radar_local_model_state.sql`). The public DB +adapter (`src/lib/db/radar.ts`) converts those rows into the `localOverrides` map and +`tombstones` set used by `applyFeed()`; production `getRadarCatalog()` loads that state +after the flag, cache, and schema gates pass. Only `displayName` and `enabled` are +operator-editable. Provider/model identity, feed provenance, quota, capabilities, ToS, +and setup data cannot be written through this surface. + +The dashboard exposes four local actions: + +- **Edit** changes the local display name and enabled state. +- **Reset local changes** clears both editable fields without changing a tombstone. +- **Hide** creates a tombstone, so later feed updates cannot recreate the row. +- **Restore** removes the tombstone; any separately-saved override remains in effect. + +A feed `enabled: false` remains the safety exception: it wins over a stale local +`enabled: true`, keeps the merged entry disabled, and records `disabledBy: "radar"`. + ### Provenance markers Every merged entry carries an `origin` field the UI renders as a badge: @@ -284,15 +303,19 @@ Every merged entry carries an `origin` field the UI renders as a badge: ## Local surfaces — never a feed proxy -Five local routes back the UI, all under `src/app/api/radar/`: +Six local endpoints back the UI, all under `src/app/api/radar/`: -| Route | Method | Purpose | -| ---------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | -| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | -| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | -| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | -| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | -| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| Route | Method | Purpose | +| ------------------------------ | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `/api/radar/catalog` | GET | Returns the merged catalog (`getRadarCatalog()`) from the local cache. | +| `/api/radar/sync` | POST | Triggers `syncRadar()` server-side; returns the resulting status. | +| `/api/radar/settings` | GET | Returns `{ optIn, hasSupporterKey, supporterKeyMasked }` — never the raw key. | +| `/api/radar/settings` | POST | Sets opt-in and/or the (encrypted) supporter key. | +| `/api/radar/referrals` | GET | Returns `{ fixed, campaigns, tier }` from the local cache — see [Referral links](#referral-links-free-credits) below. | +| `/api/radar/local-model-state` | GET | Lists persisted overrides and tombstones for edit/restore controls. | +| `/api/radar/local-model-state` | PATCH | Sets or clears the validated `displayName`/`enabled` override fields. | +| `/api/radar/local-model-state` | PUT | Creates or removes a tombstone with `{ provider, modelId, tombstoned }`. | +| `/api/radar/local-model-state` | DELETE | Clears editable override fields while preserving any tombstone. | **Hard rule: these routes never proxy the feed service.** The browser only ever talks to the local OmniRoute server. The two modules that touch the Radar service are @@ -300,14 +323,14 @@ to the local OmniRoute server. The two modules that touch the Radar service are always run server-side, never client-side. This keeps the feed URL and any supporter key out of client-facing network traffic entirely. -All five routes return `404` when `RADAR_ENABLED` is off (see +All six endpoints return `404` when `RADAR_ENABLED` is off (see [Flag](#flag-radar_enabled-default-off) above), and route error responses through `buildErrorBody()`/`sanitizeErrorMessage()` per the repo-wide error-sanitization rule (`docs/security/ERROR_SANITIZATION.md`). ### Authentication -All five routes require authentication via `isAuthenticated()` +All six endpoints require authentication via `isAuthenticated()` (`src/shared/utils/apiAuth.ts`) — a dashboard session cookie or a management-scoped API key, the same gate that protects the rest of `/api/settings/*`. The flag-off `404` check always runs **before** the auth check, so an install with `RADAR_ENABLED` diff --git a/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx new file mode 100644 index 0000000000..a9d4123101 --- /dev/null +++ b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx @@ -0,0 +1,361 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; + +export interface RadarMergedEntry { + provider: string; + modelId: string; + displayName: string; + monthlyTokens: number; + creditTokens: number; + freeType: string; + poolKey: string | null; + tos: string; + trainsOnPrompts?: boolean; + enabled?: boolean; + origin: "baseline" | "radar" | "local"; + disabledBy?: "radar"; + contextWindow?: number | null; + capabilities?: { tools: boolean; vision: boolean; thinking: boolean }; + budget?: { kind: string; tokensPerMonth?: number; poolId?: string }; + limits?: { rpm: number | null; rpd: number | null; tpm: number | null; tpd: number | null }; + setup?: { keyUrl: string | null; steps: string[] } | null; +} + +interface RadarLocalModelState { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; + updatedAt: string; +} + +interface RadarCatalogTableProps { + entries: RadarMergedEntry[]; + refreshCatalog: () => Promise; + onError: (message: string) => void; +} + +function formatTokens(value: number): string { + if (value === 0) return "rate-only"; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`; + return String(value); +} + +function budgetLabel(entry: RadarMergedEntry): string { + if (entry.budget?.kind === "shared_pool") { + return `shared (${formatTokens(entry.budget.tokensPerMonth ?? entry.monthlyTokens)}/mo)`; + } + if (entry.budget?.kind === "rate_only" || entry.monthlyTokens === 0) return "rate-only"; + return `${formatTokens(entry.monthlyTokens)}/mo`; +} + +export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCatalogTableProps) { + const t = useTranslations("radarPage"); + const [states, setStates] = useState([]); + const [editingKey, setEditingKey] = useState(null); + const [displayName, setDisplayName] = useState(""); + const [enabled, setEnabled] = useState(true); + const [saving, setSaving] = useState(false); + + const loadState = useCallback(async () => { + try { + const response = await fetch("/api/radar/local-model-state"); + if (!response.ok) return; + const payload = await response.json(); + setStates(Array.isArray(payload.states) ? payload.states : []); + } catch { + onError(t("errorLoading")); + } + }, [onError, t]); + + useEffect(() => { + void loadState(); + }, [loadState]); + + const stateByKey = useMemo( + () => new Map(states.map((state) => [`${state.provider}:${state.modelId}`, state])), + [states] + ); + const hiddenModels = useMemo(() => states.filter((state) => state.tombstoned), [states]); + + const applyResponse = useCallback(async (response: Response) => { + if (!response.ok) throw new Error("save_failed"); + const payload = await response.json(); + setStates(Array.isArray(payload.states) ? payload.states : []); + }, []); + + const mutate = useCallback( + async (operation: () => Promise) => { + setSaving(true); + onError(""); + try { + await applyResponse(await operation()); + setEditingKey(null); + await refreshCatalog(); + } catch { + onError(t("localStateSaveFailed")); + } finally { + setSaving(false); + } + }, + [applyResponse, onError, refreshCatalog, t] + ); + + const beginEdit = useCallback((entry: RadarMergedEntry) => { + setEditingKey(`${entry.provider}:${entry.modelId}`); + setDisplayName(entry.displayName); + setEnabled(entry.enabled !== false); + }, []); + + const saveOverride = useCallback( + (entry: RadarMergedEntry) => + mutate(() => + fetch("/api/radar/local-model-state", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: entry.provider, + modelId: entry.modelId, + displayName, + enabled, + }), + }) + ), + [displayName, enabled, mutate] + ); + + const resetOverride = useCallback( + (entry: Pick) => { + const query = new URLSearchParams({ provider: entry.provider, modelId: entry.modelId }); + return mutate(() => + fetch(`/api/radar/local-model-state?${query.toString()}`, { method: "DELETE" }) + ); + }, + [mutate] + ); + + const setTombstone = useCallback( + (provider: string, modelId: string, tombstoned: boolean) => + mutate(() => + fetch("/api/radar/local-model-state", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, modelId, tombstoned }), + }) + ), + [mutate] + ); + + return ( + <> + +
+ + + + + + + + + + + + + + {entries.map((entry) => { + const key = `${entry.provider}:${entry.modelId}`; + const localState = stateByKey.get(key); + const hasOverride = + localState && (localState.displayName !== null || localState.enabled !== null); + return ( + + + + + + + + + + ); + })} + +
{t("colProvider")}{t("colModel")}{t("colQuota")}{t("colContext")}{t("colCapabilities")}{t("colTos")}{t("colActions")}
+
+ {entry.provider} + {entry.origin === "radar" && ( + + {t("newBadge")} + + )} + {entry.origin === "local" && ( + + {t("localBadge")} + + )} + {entry.setup?.keyUrl && ( + + ⚙ + + )} +
+ {entry.enabled === false && entry.disabledBy === "radar" && ( +

{t("disabledByFeed")}

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

{t("hiddenModelsTitle")}

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

{t("disabledByFeed")}

- )} -
- {entry.displayName} - {budgetLabel(entry)} - {entry.contextWindow - ? `${(entry.contextWindow / 1000).toFixed(0)}K` - : "—"} - -
- {entry.capabilities?.tools && ( - - {t("capTools")} - - )} - {entry.capabilities?.vision && ( - - {t("capVision")} - - )} - {entry.capabilities?.thinking && ( - - {t("capThinking")} - - )} -
-
- - {entry.tos} - -
-
-
+ )} )} diff --git a/src/app/api/radar/local-model-state/route.ts b/src/app/api/radar/local-model-state/route.ts new file mode 100644 index 0000000000..e569526ac0 --- /dev/null +++ b/src/app/api/radar/local-model-state/route.ts @@ -0,0 +1,165 @@ +/** + * Local-only Radar model overrides and tombstones. + * + * The browser never sends these settings to the private Radar server. The + * route is management-authenticated and exists only while RADAR_ENABLED is on. + */ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { + clearRadarLocalModelOverride, + listRadarLocalModelState, + setRadarLocalModelOverride, + setRadarModelTombstone, +} from "@/lib/db/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const providerSchema = z + .string() + .trim() + .regex(/^[a-z0-9][a-z0-9._-]{0,99}$/i); +const modelIdSchema = z + .string() + .trim() + .min(1) + .max(200) + .refine((value) => !/[\u0000-\u001f\u007f]/.test(value)); +const identityShape = { provider: providerSchema, modelId: modelIdSchema }; + +const overrideSchema = z + .object({ + ...identityShape, + displayName: z + .string() + .trim() + .min(1) + .max(160) + .refine((value) => !/[\u0000-\u001f\u007f]/.test(value)) + .nullable() + .optional(), + enabled: z.boolean().nullable().optional(), + }) + .strict() + .refine( + (value) => + Object.prototype.hasOwnProperty.call(value, "displayName") || + Object.prototype.hasOwnProperty.call(value, "enabled") + ); + +const tombstoneSchema = z.object({ ...identityShape, tombstoned: z.boolean() }).strict(); +const identitySchema = z.object(identityShape).strict(); + +function json(body: unknown, status = 200): NextResponse { + return NextResponse.json(body, { + status, + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); +} + +function error(status: number, message: string): NextResponse { + return json(buildErrorBody(status, message), status); +} + +async function authorize(request: Request): Promise { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) return error(404, "Not found"); + if (!(await isAuthenticated(request))) return error(401, "Unauthorized"); + return null; +} + +async function readJson(request: Request): Promise { + try { + return await request.json(); + } catch { + return null; + } +} + +function stateResponse(): NextResponse { + return json({ states: listRadarLocalModelState() }); +} + +function internalError(cause: unknown): NextResponse { + return error(500, sanitizeErrorMessage(cause) || "Failed to update Radar local state"); +} + +export async function OPTIONS(): Promise { + return handleCorsOptions(); +} + +export async function GET(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + try { + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function PATCH(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const parsed = overrideSchema.safeParse(await readJson(request)); + if (!parsed.success) return error(400, "Invalid Radar local override"); + + try { + const { provider, modelId, displayName, enabled } = parsed.data; + const patch: { displayName?: string | null; enabled?: boolean | null } = {}; + if (Object.prototype.hasOwnProperty.call(parsed.data, "displayName")) { + patch.displayName = displayName; + } + if (Object.prototype.hasOwnProperty.call(parsed.data, "enabled")) patch.enabled = enabled; + if (!setRadarLocalModelOverride(provider, modelId, patch)) { + return error(400, "Invalid Radar local override"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function PUT(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const parsed = tombstoneSchema.safeParse(await readJson(request)); + if (!parsed.success) return error(400, "Invalid Radar tombstone"); + + try { + const { provider, modelId, tombstoned } = parsed.data; + if (!setRadarModelTombstone(provider, modelId, tombstoned)) { + return error(400, "Invalid Radar tombstone"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} + +export async function DELETE(request: Request): Promise { + const authError = await authorize(request); + if (authError) return authError; + + const url = new URL(request.url); + const parsed = identitySchema.safeParse({ + provider: url.searchParams.get("provider"), + modelId: url.searchParams.get("modelId"), + }); + if (!parsed.success) return error(400, "provider and modelId are required"); + + try { + if (!clearRadarLocalModelOverride(parsed.data.provider, parsed.data.modelId)) { + return error(400, "Invalid Radar local override"); + } + return stateResponse(); + } catch (cause: unknown) { + return internalError(cause); + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index adfcc5d3a3..7c84e6c5a7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "إعداد المزود", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index e15e0de064..3709ce24b5 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Təchizatçı Quraşdırması", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 961de69249..83d2ba777d 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Настройка на доставчика", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index aa4f9f72af..6ac5db1b9b 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "প্রদানকারী সেটআপ", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 0ee4ba8c13..5a6c8ef797 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Nastavení poskytovatele", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 3b7a08d660..00d2771893 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Udbyder Opsætning", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index eb37d0474c..088ee4a22e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Anbieter-Einrichtung", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 72241f73e4..ac89199d31 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12293,7 +12293,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 6f683aa784..a4375b1aea 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configuración del proveedor", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index b448119534..fea6f33e10 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "راه‌اندازی تأمین‌کننده", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index fc4bbebb3a..4b598dd04a 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Palveluntarjoajan asennus", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 505a8e7387..36859e21c4 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -12267,7 +12267,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configuration du fournisseur", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 548d9746a3..64e7a6a1ab 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "પ્રદાતા સેટઅપ", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index e7d69d53b3..d4fc2301cc 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "הגדרת ספק", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 9240aa497c..01164266f4 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index bf8739e7b4..206543bb1e 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Szolgáltató beállítása", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index af20d831b5..73fd1574ad 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Pengaturan Penyedia", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index e5d25afae9..1db67ada06 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 8cad003fee..98f5d1eb0f 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configurazione del fornitore", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 00ca92fd53..9cd199e37b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "プロバイダーセットアップ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 6a9e06561d..9203076caf 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "제공자 설정", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index a226858d1a..82f8074832 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "प्रदाता सेटअप", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 20f936d8ae..ea1572e6e6 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Penyediaan Penyedia", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 322fd45a22..3d01da6c9b 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverancier Configuratie", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index c47701d420..57a3975e33 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverandøroppsett", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 1f23f5f170..7886a6bc26 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Provider Setup", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index f5c2536c58..56ce285697 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -12264,7 +12264,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Konfiguracja dostawcy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 0b169975fc..f3a97861ae 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -12293,7 +12293,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Ações", + "localBadge": "local", + "editModel": "Editar", + "saveModel": "Salvar", + "cancelEdit": "Cancelar", + "resetModel": "Limpar alterações locais", + "hideModel": "Ocultar", + "restoreModel": "Restaurar", + "hiddenModelsTitle": "Modelos ocultos", + "modelDisplayName": "Nome local do modelo", + "modelEnabled": "Ativado localmente", + "localStateSaveFailed": "Falha ao salvar as configurações locais do Radar" }, "radarSetupPage": { "title": "Configuração do Provedor", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c846765a9e..f8bbdefccb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configuração do Fornecedor", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 42a61a09ff..5433dc2119 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Configurare Furnizor", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 0bffddd98f..3136d2d8a1 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -12336,7 +12336,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Настройка провайдера", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index aa02dae5d3..23bd038967 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Nastavenie poskytovateľa", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 57405f6703..80676e0dad 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverantörsinstallation", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 01d7f02a80..203f4ac7ce 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Leverantörsinstallation", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index bd531e1a46..ff0dcbdd27 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "வழங்குநர் அமைப்பு", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 9b21eab5d4..1ac839a4c6 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "ప్రొవైడర్ సెటప్", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 93405972b0..f448f0afa9 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "การตั้งค่าผู้ให้บริการ", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index a119ecf0cf..d5d361bcf1 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Sağlayıcı Kurulumu", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 277ec09abb..09be04e006 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Налаштування постачальника", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 973abd5c66..1b3afb4c38 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "فراہم کنندہ سیٹ اپ", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index ab6703f848..5cc7d91c18 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -12293,7 +12293,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "Thiết lập nhà cung cấp", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 5b9cd16e6c..6035a1094d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "提供者设置", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c5a68fdb53..c45103f2fa 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -12242,7 +12242,19 @@ "campaignsTitle": "Limited-time campaigns", "campaignsEmpty": "No active campaigns right now — check back later.", "campaignsUpsellCommunity": "Limited-time campaigns are a supporter extra. Everything on this page's fixed links stays free for everyone.", - "campaignsValidUntil": "Valid until {date}" + "campaignsValidUntil": "Valid until {date}", + "colActions": "Actions", + "localBadge": "local", + "editModel": "Edit", + "saveModel": "Save", + "cancelEdit": "Cancel", + "resetModel": "Reset local changes", + "hideModel": "Hide", + "restoreModel": "Restore", + "hiddenModelsTitle": "Hidden models", + "modelDisplayName": "Local display name", + "modelEnabled": "Enabled locally", + "localStateSaveFailed": "Failed to save local Radar settings" }, "radarSetupPage": { "title": "提供者設置", diff --git a/src/lib/db/migrations/143_radar_local_model_state.sql b/src/lib/db/migrations/143_radar_local_model_state.sql new file mode 100644 index 0000000000..eec63c86f4 --- /dev/null +++ b/src/lib/db/migrations/143_radar_local_model_state.sql @@ -0,0 +1,16 @@ +-- 143_radar_local_model_state.sql +-- Operator-owned Radar catalog state. +-- +-- Overrides are intentionally limited to display_name and enabled. A +-- tombstone is stored independently so clearing an override cannot +-- accidentally resurrect a model the operator explicitly hid. + +CREATE TABLE IF NOT EXISTS radar_local_model_state ( + provider TEXT NOT NULL, + model_id TEXT NOT NULL, + display_name TEXT, + enabled INTEGER CHECK (enabled IS NULL OR enabled IN (0, 1)), + tombstoned INTEGER NOT NULL DEFAULT 0 CHECK (tombstoned IN (0, 1)), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, model_id) +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index 6b76630362..7a03854ef7 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -13,6 +13,10 @@ * (`GET /v1/referrals/latest` — a separate, always-current artifact from * the catalog feed, see `src/lib/radar/referralsSync.ts`). * + * Tables (migration 143): + * - radar_local_model_state: operator-owned display/enabled overrides and + * deletion tombstones, keyed by provider + model ID. + * * The supporter key is encrypted at rest with AES-256-GCM using the same * `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect * provider connection credentials. @@ -47,6 +51,34 @@ export interface RadarReferralsCache { fetchedAt: string; } +export interface RadarLocalModelState { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; + updatedAt: string; +} + +export interface RadarLocalModelOverridePatch { + displayName?: string | null; + enabled?: boolean | null; +} + +export interface RadarLocalMergeState { + localOverrides: Map; + tombstones: Set; +} + +interface RadarLocalModelStateRow { + provider: string; + model_id: string; + display_name: string | null; + enabled: number | null; + tombstoned: number; + updated_at: string; +} + // --------------------------------------------------------------------------- // radar_feed_cache // --------------------------------------------------------------------------- @@ -193,3 +225,213 @@ export function setRadarReferralsCache(entry: { fetched_at = excluded.fetched_at` ).run(entry.generatedAt, entry.tier, entry.payload, entry.signature, fetchedAt); } + +// --------------------------------------------------------------------------- +// radar_local_model_state +// --------------------------------------------------------------------------- + +const RADAR_PROVIDER_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/i; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +function normalizeRadarIdentity( + provider: unknown, + modelId: unknown +): { provider: string; modelId: string } | null { + const normalizedProvider = typeof provider === "string" ? provider.trim() : ""; + const normalizedModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!RADAR_PROVIDER_PATTERN.test(normalizedProvider)) return null; + if ( + normalizedModelId.length < 1 || + normalizedModelId.length > 200 || + CONTROL_CHARACTER_PATTERN.test(normalizedModelId) + ) { + return null; + } + return { provider: normalizedProvider, modelId: normalizedModelId }; +} + +function normalizeDisplayName(value: unknown): string | null | undefined { + if (value === undefined || value === null) return value; + if (typeof value !== "string") return undefined; + const normalized = value.trim(); + if ( + normalized.length < 1 || + normalized.length > 160 || + CONTROL_CHARACTER_PATTERN.test(normalized) + ) { + return undefined; + } + return normalized; +} + +function rowToRadarLocalModelState(row: RadarLocalModelStateRow): RadarLocalModelState { + return { + provider: row.provider, + modelId: row.model_id, + displayName: row.display_name, + enabled: row.enabled === null ? null : row.enabled === 1, + tombstoned: row.tombstoned === 1, + updatedAt: row.updated_at, + }; +} + +function readRadarLocalModelStateRow( + provider: string, + modelId: string +): RadarLocalModelStateRow | null { + return ( + (getDbInstance() + .prepare( + `SELECT provider, model_id, display_name, enabled, tombstoned, updated_at + FROM radar_local_model_state WHERE provider = ? AND model_id = ?` + ) + .get(provider, modelId) as RadarLocalModelStateRow | undefined) ?? null + ); +} + +function persistRadarLocalModelState(input: { + provider: string; + modelId: string; + displayName: string | null; + enabled: boolean | null; + tombstoned: boolean; +}): void { + const db = getDbInstance(); + if (input.displayName === null && input.enabled === null && !input.tombstoned) { + db.prepare("DELETE FROM radar_local_model_state WHERE provider = ? AND model_id = ?").run( + input.provider, + input.modelId + ); + return; + } + + db.prepare( + `INSERT INTO radar_local_model_state + (provider, model_id, display_name, enabled, tombstoned, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(provider, model_id) DO UPDATE SET + display_name = excluded.display_name, + enabled = excluded.enabled, + tombstoned = excluded.tombstoned, + updated_at = excluded.updated_at` + ).run( + input.provider, + input.modelId, + input.displayName, + input.enabled === null ? null : input.enabled ? 1 : 0, + input.tombstoned ? 1 : 0 + ); +} + +/** List every persisted override/tombstone for UI editing and restore controls. */ +export function listRadarLocalModelState(): RadarLocalModelState[] { + const rows = getDbInstance() + .prepare( + `SELECT provider, model_id, display_name, enabled, tombstoned, updated_at + FROM radar_local_model_state ORDER BY provider, model_id` + ) + .all() as RadarLocalModelStateRow[]; + return rows.map(rowToRadarLocalModelState); +} + +/** + * Merge a validated partial override into the existing row. `null` clears a + * field; `undefined` preserves it. Tombstone state is never changed here. + */ +export function setRadarLocalModelOverride( + provider: unknown, + modelId: unknown, + patch: RadarLocalModelOverridePatch +): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity || !patch || typeof patch !== "object") return false; + const keys = Object.keys(patch); + if ( + keys.length === 0 || + keys.some((key) => key !== "displayName" && key !== "enabled") || + (Object.hasOwn(patch, "enabled") && + patch.enabled !== null && + typeof patch.enabled !== "boolean") + ) { + return false; + } + + const normalizedDisplayName = normalizeDisplayName(patch.displayName); + if (Object.hasOwn(patch, "displayName") && normalizedDisplayName === undefined) return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: Object.hasOwn(patch, "displayName") + ? (normalizedDisplayName ?? null) + : (current?.display_name ?? null), + enabled: Object.hasOwn(patch, "enabled") + ? (patch.enabled ?? null) + : current?.enabled === null || current?.enabled === undefined + ? null + : current.enabled === 1, + tombstoned: current?.tombstoned === 1, + }); + })(); + return true; +} + +/** Clear both editable fields while preserving an independent tombstone. */ +export function clearRadarLocalModelOverride(provider: unknown, modelId: unknown): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity) return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: null, + enabled: null, + tombstoned: current?.tombstoned === 1, + }); + })(); + return true; +} + +/** Hide or restore one model without modifying its editable local fields. */ +export function setRadarModelTombstone( + provider: unknown, + modelId: unknown, + tombstoned: boolean +): boolean { + const identity = normalizeRadarIdentity(provider, modelId); + if (!identity || typeof tombstoned !== "boolean") return false; + + const db = getDbInstance(); + db.transaction(() => { + const current = readRadarLocalModelStateRow(identity.provider, identity.modelId); + persistRadarLocalModelState({ + ...identity, + displayName: current?.display_name ?? null, + enabled: + current?.enabled === null || current?.enabled === undefined ? null : current.enabled === 1, + tombstoned, + }); + })(); + return true; +} + +/** Convert persisted rows into the exact read-time merge structures. */ +export function getRadarLocalMergeState(): RadarLocalMergeState { + const localOverrides = new Map(); + const tombstones = new Set(); + + for (const state of listRadarLocalModelState()) { + const key = `${state.provider}:${state.modelId}`; + const override: { displayName?: string; enabled?: boolean } = {}; + if (state.displayName !== null) override.displayName = state.displayName; + if (state.enabled !== null) override.enabled = state.enabled; + if (Object.keys(override).length > 0) localOverrides.set(key, override); + if (state.tombstoned) tombstones.add(key); + } + + return { localOverrides, tombstones }; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index c2a6b834df..6ee7a0939d 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -820,5 +820,17 @@ export { setRadarKey, getRadarReferralsCache, setRadarReferralsCache, + listRadarLocalModelState, + setRadarLocalModelOverride, + clearRadarLocalModelOverride, + setRadarModelTombstone, + getRadarLocalMergeState, +} from "./db/radar"; +export type { + RadarCache, + RadarSettings, + RadarReferralsCache, + RadarLocalModelState, + RadarLocalModelOverridePatch, + RadarLocalMergeState, } from "./db/radar"; -export type { RadarCache, RadarSettings, RadarReferralsCache } from "./db/radar"; diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index d5a781378b..1ae06486f0 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -15,7 +15,12 @@ import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFe import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { getRadarCache, getRadarReferralsCache } from "@/lib/db/radar"; +import { + getRadarCache, + getRadarLocalMergeState, + getRadarReferralsCache, + type RadarLocalMergeState, +} from "@/lib/db/radar"; // --------------------------------------------------------------------------- // Types @@ -39,6 +44,7 @@ export interface GetRadarCatalogDeps { baseline?: MergedEntry[]; localOverrides?: Map>; tombstones?: Set; + getLocalState?: () => RadarLocalMergeState; } // --------------------------------------------------------------------------- @@ -49,9 +55,7 @@ export interface GetRadarCatalogDeps { * Convert the static `FreeModelBudget[]` into `MergedEntry[]` so the * merge function has a uniform input shape. */ -export function baselineToMergedEntries( - budgets: typeof FREE_MODEL_BUDGETS, -): MergedEntry[] { +export function baselineToMergedEntries(budgets: typeof FREE_MODEL_BUDGETS): MergedEntry[] { return budgets.map((b) => ({ provider: b.provider, modelId: b.modelId, @@ -86,8 +90,9 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes getFlag = isFeatureFlagEnabled, getCache: getCacheFn = getRadarCache, baseline: baselineInput, - localOverrides = new Map(), - tombstones = new Set(), + localOverrides, + tombstones, + getLocalState: getLocalStateFn = getRadarLocalMergeState, } = deps; // Resolve baseline @@ -114,12 +119,15 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes return { entries: baseline, meta: null }; } + const persistedState = + localOverrides === undefined || tombstones === undefined ? getLocalStateFn() : null; + // Apply overlay const entries = applyFeed({ baseline, feed: feed.models as FeedModel[], - localOverrides, - tombstones, + localOverrides: localOverrides ?? persistedState?.localOverrides ?? new Map(), + tombstones: tombstones ?? persistedState?.tombstones ?? new Set(), }); return { @@ -195,7 +203,7 @@ export function getRadarReferrals(deps: GetRadarReferralsDeps = {}): RadarReferr */ export function getDefaultReferralFor( provider: string, - deps: GetRadarReferralsDeps = {}, + deps: GetRadarReferralsDeps = {} ): RadarReferral | null { const { fixed } = getRadarReferrals(deps); return findDefaultReferral(fixed, provider); diff --git a/tests/unit/radar-apply-feed.test.ts b/tests/unit/radar-apply-feed.test.ts index a5fd8b07d4..868d0cd76a 100644 --- a/tests/unit/radar-apply-feed.test.ts +++ b/tests/unit/radar-apply-feed.test.ts @@ -589,6 +589,7 @@ test("getRadarCatalog: corrupt payload returns baseline without throwing", () => test("getRadarCatalog: valid cache returns merged entries with meta", () => { const result = getRadarCatalog({ getFlag: () => true, + getLocalState: () => ({ localOverrides: new Map(), tombstones: new Set() }), getCache: () => ({ version: "2026.08.01.1", tier: "community", diff --git a/tests/unit/radar-local-state-db.test.ts b/tests/unit/radar-local-state-db.test.ts new file mode 100644 index 0000000000..fd30dd47d4 --- /dev/null +++ b/tests/unit/radar-local-state-db.test.ts @@ -0,0 +1,175 @@ +/** + * Persistent local Radar overrides and tombstones. + * + * These tests exercise the real migration-backed DB module and the production + * getRadarCatalog() wiring. They deliberately do not inject local merge state. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-local-state-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.RADAR_ENABLED = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const { + clearRadarLocalModelOverride, + getRadarLocalMergeState, + listRadarLocalModelState, + setRadarLocalModelOverride, + setRadarModelTombstone, + setRadarCache, +} = await import("../../src/lib/db/radar.ts"); +const { getRadarCatalog } = await import("../../src/lib/radar/index.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.RADAR_ENABLED; +}); + +test("migration 143 creates the closed local model state schema", () => { + const db = core.getDbInstance(); + const columns = db.prepare("PRAGMA table_info(radar_local_model_state)").all() as Array<{ + name: string; + }>; + + assert.deepEqual( + columns.map((column) => column.name), + ["provider", "model_id", "display_name", "enabled", "tombstoned", "updated_at"] + ); +}); + +test("local overrides round-trip, merge partial updates, and clear without stale fields", () => { + assert.equal( + setRadarLocalModelOverride(" groq ", " llama-3.3-70b-versatile ", { + displayName: " My Groq model ", + enabled: false, + }), + true + ); + + const initial = listRadarLocalModelState(); + assert.equal(initial.length, 1); + assert.deepEqual( + { ...initial[0], updatedAt: undefined }, + { + provider: "groq", + modelId: "llama-3.3-70b-versatile", + displayName: "My Groq model", + enabled: false, + tombstoned: false, + updatedAt: undefined, + } + ); + assert.match(initial[0].updatedAt, /^\d{4}-\d{2}-\d{2}/); + + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { enabled: true }), + true + ); + const updated = listRadarLocalModelState()[0]; + assert.equal(updated.displayName, "My Groq model", "partial updates preserve the other field"); + assert.equal(updated.enabled, true); + + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { displayName: null }), + true + ); + assert.equal(listRadarLocalModelState()[0].displayName, null, "null explicitly clears a field"); + + assert.equal(clearRadarLocalModelOverride("groq", "llama-3.3-70b-versatile"), true); + assert.deepEqual(listRadarLocalModelState(), [], "an empty non-tombstoned row is deleted"); +}); + +test("tombstones survive override resets and restoring the last field removes the row", () => { + assert.equal( + setRadarLocalModelOverride("groq", "llama-3.3-70b-versatile", { + displayName: "Local name", + }), + true + ); + assert.equal(setRadarModelTombstone("groq", "llama-3.3-70b-versatile", true), true); + assert.equal(clearRadarLocalModelOverride("groq", "llama-3.3-70b-versatile"), true); + + const hidden = listRadarLocalModelState()[0]; + assert.equal(hidden.tombstoned, true); + assert.equal(hidden.displayName, null); + assert.equal(hidden.enabled, null); + + const mergeState = getRadarLocalMergeState(); + assert.deepEqual([...mergeState.localOverrides], []); + assert.deepEqual([...mergeState.tombstones], ["groq:llama-3.3-70b-versatile"]); + + assert.equal(setRadarModelTombstone("groq", "llama-3.3-70b-versatile", false), true); + assert.deepEqual(listRadarLocalModelState(), []); +}); + +test("invalid identities and empty override patches fail closed", () => { + assert.equal(setRadarLocalModelOverride("", "model", { displayName: "name" }), false); + assert.equal(setRadarLocalModelOverride("groq", "", { displayName: "name" }), false); + assert.equal(setRadarLocalModelOverride("groq", "model", {}), false); + assert.equal(setRadarLocalModelOverride("groq", "model", { displayName: " " }), false); + assert.equal(setRadarModelTombstone("bad provider", "model", true), false); + assert.deepEqual(listRadarLocalModelState(), []); +}); + +test("production getRadarCatalog loads persisted overrides and tombstones", () => { + const fixturePath = path.join(process.cwd(), "tests/fixtures/radar-feed-canonical.json"); + const payload = fs.readFileSync(fixturePath, "utf8"); + const fixture = JSON.parse(payload) as { + version: string; + tier: string; + models: Array<{ provider: string; modelId: string; enabled: boolean }>; + }; + const visible = fixture.models.find((model) => model.enabled); + const hidden = fixture.models.find( + (model) => + model.enabled && + `${model.provider}:${model.modelId}` !== `${visible?.provider}:${visible?.modelId}` + ); + assert.ok(visible && hidden, "fixture must contain two enabled models"); + + setRadarCache({ + version: fixture.version, + tier: fixture.tier, + payload, + signature: "test-signature", + }); + assert.equal( + setRadarLocalModelOverride(visible.provider, visible.modelId, { + displayName: "Persisted local name", + enabled: false, + }), + true + ); + assert.equal(setRadarModelTombstone(hidden.provider, hidden.modelId, true), true); + + const catalog = getRadarCatalog(); + const overridden = catalog.entries.find( + (entry) => entry.provider === visible.provider && entry.modelId === visible.modelId + ); + assert.ok(overridden); + assert.equal(overridden.displayName, "Persisted local name"); + assert.equal(overridden.enabled, false); + assert.equal(overridden.origin, "local"); + assert.equal( + catalog.entries.some( + (entry) => entry.provider === hidden.provider && entry.modelId === hidden.modelId + ), + false, + "a persisted tombstone must remove the model from the production catalog" + ); +}); diff --git a/tests/unit/radar-local-state-route.test.ts b/tests/unit/radar-local-state-route.test.ts new file mode 100644 index 0000000000..29dd7664e4 --- /dev/null +++ b/tests/unit/radar-local-state-route.test.ts @@ -0,0 +1,155 @@ +/** API contract for persisted Radar local overrides and tombstones. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-state-api-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-local-state"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-local-state"; + +const core = await import("../../src/lib/db/core.ts"); +const route = await import("../../src/app/api/radar/local-model-state/route.ts"); + +async function authHeaders(): Promise> { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(secret); + return { Cookie: `auth_token=${token}` }; +} + +function request(method: string, body?: unknown, headers: Record = {}): Request { + return new Request("http://localhost:20128/api/radar/local-model-state", { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.DATA_DIR; + delete process.env.RADAR_ENABLED; + delete process.env.JWT_SECRET; + delete process.env.INITIAL_PASSWORD; +}); + +test("flag-off gate runs before authentication", async () => { + delete process.env.RADAR_ENABLED; + const response = await route.GET(request("GET")); + const text = await response.text(); + + assert.equal(response.status, 404); + assert.ok(!text.includes("at /")); +}); + +test("all local-state mutations require authentication when Radar is enabled", async () => { + process.env.RADAR_ENABLED = "true"; + const calls = [ + route.GET(request("GET")), + route.PATCH(request("PATCH", { provider: "groq", modelId: "model", enabled: false })), + route.PUT(request("PUT", { provider: "groq", modelId: "model", tombstoned: true })), + route.DELETE(request("DELETE")), + ]; + + for (const response of await Promise.all(calls)) { + assert.equal(response.status, 401); + assert.ok(!(await response.text()).includes("at /")); + } +}); + +test("authenticated CRUD persists overrides and tombstones without conflating them", async () => { + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + + const patch = await route.PATCH( + request( + "PATCH", + { + provider: "groq", + modelId: "llama-3.3-70b-versatile", + displayName: "My local Groq", + enabled: false, + }, + headers + ) + ); + assert.equal(patch.status, 200); + const patched = await patch.json(); + assert.equal(patched.states[0].displayName, "My local Groq"); + assert.equal(patched.states[0].enabled, false); + assert.equal(patched.states[0].tombstoned, false); + + const hide = await route.PUT( + request( + "PUT", + { provider: "groq", modelId: "llama-3.3-70b-versatile", tombstoned: true }, + headers + ) + ); + assert.equal(hide.status, 200); + assert.equal((await hide.json()).states[0].tombstoned, true); + + const removeOverrideUrl = new URL("http://localhost:20128/api/radar/local-model-state"); + removeOverrideUrl.searchParams.set("provider", "groq"); + removeOverrideUrl.searchParams.set("modelId", "llama-3.3-70b-versatile"); + const remove = await route.DELETE(new Request(removeOverrideUrl, { method: "DELETE", headers })); + assert.equal(remove.status, 200); + const removed = await remove.json(); + assert.equal(removed.states[0].displayName, null); + assert.equal(removed.states[0].enabled, null); + assert.equal(removed.states[0].tombstoned, true, "clearing overrides must not restore a model"); + + const restore = await route.PUT( + request( + "PUT", + { provider: "groq", modelId: "llama-3.3-70b-versatile", tombstoned: false }, + headers + ) + ); + assert.equal(restore.status, 200); + assert.deepEqual((await restore.json()).states, []); +}); + +test("strict schemas reject arbitrary fields, empty patches, and control characters", async () => { + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const invalidBodies = [ + { provider: "groq", modelId: "model" }, + { provider: "groq", modelId: "model", enabled: true, origin: "local" }, + { provider: "groq", modelId: "bad\nmodel", enabled: true }, + { provider: "groq", modelId: "model", displayName: " " }, + ]; + + for (const body of invalidBodies) { + const response = await route.PATCH(request("PATCH", body, headers)); + const text = await response.text(); + assert.equal(response.status, 400); + assert.ok(!text.includes("at /")); + assert.ok(!text.includes(".ts:")); + } +}); + +test("GET returns no-store local state for restore controls", async () => { + process.env.RADAR_ENABLED = "true"; + const response = await route.GET(request("GET", undefined, await authHeaders())); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { states: [] }); +}); diff --git a/tests/unit/radar-local-state-ui.test.ts b/tests/unit/radar-local-state-ui.test.ts new file mode 100644 index 0000000000..9cbccfeece --- /dev/null +++ b/tests/unit/radar-local-state-ui.test.ts @@ -0,0 +1,67 @@ +/** Source contract for the Radar local edit/hide/restore controls. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const pageSource = fs.readFileSync( + path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx"), + "utf8" +); +const controlsSource = fs.readFileSync( + path.join(process.cwd(), "src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx"), + "utf8" +); + +test("Radar catalog UI reads and mutates the dedicated local-state endpoint", () => { + assert.match(pageSource, / { + for (const key of [ + "editModel", + "saveModel", + "resetModel", + "hideModel", + "restoreModel", + "hiddenModelsTitle", + "localBadge", + ]) { + assert.match( + controlsSource, + new RegExp(`t\\(\\"${key}\\"`), + `missing UI translation key ${key}` + ); + } + assert.match(controlsSource, /aria-label=\{t\("modelDisplayName"\)\}/); + assert.match(controlsSource, /type="checkbox"/); +}); + +test("English and Brazilian Portuguese catalogs include the local state copy", () => { + for (const locale of ["en", "pt-BR"]) { + const messages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8") + ) as { radarPage: Record }; + for (const key of [ + "colActions", + "editModel", + "saveModel", + "cancelEdit", + "resetModel", + "hideModel", + "restoreModel", + "hiddenModelsTitle", + "modelDisplayName", + "modelEnabled", + "localBadge", + "localStateSaveFailed", + ]) { + assert.equal(typeof messages.radarPage[key], "string", `${locale} missing radarPage.${key}`); + assert.ok(messages.radarPage[key].length > 0); + } + } +});