diff --git a/README.md b/README.md
index 5edc2d69c7..535ff0519d 100644
--- a/README.md
+++ b/README.md
@@ -491,6 +491,25 @@ Pix copia-e-cola:
+## 📡 OmniRoute Radar
+
+The main free-tier headline remains **~1.53B tokens/month** from the documented,
+pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
+month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
+free-model availability between OmniRoute releases; the community catalog and every existing free
+feature remain free.
+
+Supporters can receive the live catalog and additional provider opportunities. Its separate,
+mutable ceiling is **approximately 3B tokens/month at most**, depending on provider availability.
+That ceiling is not a guarantee: providers can change quotas, eligibility, models, or regions at
+any time.
+
+Radar is opt-in and GET-only. The OmniRoute client does not upload prompts, traffic, provider
+configuration, usage telemetry, or local announcement-dismiss state. Learn about eligibility and
+the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute.online/planos)**.
+
+
+
## ✨ What's New
diff --git a/changelog.d/features/9926-radar-launch-news.md b/changelog.d/features/9926-radar-launch-news.md
new file mode 100644
index 0000000000..9ec56bebd0
--- /dev/null
+++ b/changelog.d/features/9926-radar-launch-news.md
@@ -0,0 +1 @@
+- **feat(radar):** add a localized public news feed and dismissible dashboard launch banner, with the Radar announcement staged inactive for a separately authorized launch ([#9926](https://github.com/diegosouzapw/OmniRoute/pull/9926))
diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md
index 20c31c4829..1a5e8eb8be 100644
--- a/docs/architecture/REPOSITORY_MAP.md
+++ b/docs/architecture/REPOSITORY_MAP.md
@@ -102,7 +102,7 @@ OmniRoute/
| **.gitleaks.toml** | gitleaks secret-scan ruleset |
| **.zizmor.yml** | zizmor GitHub-Actions security-lint config |
| **socket.yml** | Socket.dev supply-chain config |
-| **news.json** | In-app release-notes feed (read by `src/shared/utils/releaseNotes.ts`) |
+| **news.json** | Localized v2 announcement feed; Radar launch item ships inactive |
| **flake.nix** / **flake.lock** | Nix dev-shell definition + lock |
| **.env** | Local secrets (gitignored — generated from `.env.example`) |
@@ -256,6 +256,7 @@ src/
| `utils/circuitBreaker.ts` | Provider circuit breaker (see `docs/architecture/RESILIENCE_GUIDE.md`) |
| `utils/apiAuth.ts` | API key validation, scope checking |
| `utils/fetchTimeout.ts` | Timeout/abort wrappers for upstream fetch |
+| `utils/releaseNotes.ts` | Closed v2/legacy announcement parser, localization and ID dismissal |
---
diff --git a/docs/frameworks/RADAR.md b/docs/frameworks/RADAR.md
index 467269b04f..eeac6b6044 100644
--- a/docs/frameworks/RADAR.md
+++ b/docs/frameworks/RADAR.md
@@ -43,6 +43,29 @@ or external integration is currently available.
---
+## Public announcement reader
+
+The generic announcement reader is separate from the Radar feature flag. The dashboard Home and
+Changelog viewer fetch the repository's public `news.json` through a plain `GET` to
+`NEWS_JSON_URL` (`src/shared/utils/releaseNotes.ts`). They send no Radar setting, prompt, provider
+configuration, usage record, or local dismissal state.
+
+`news.json` uses the closed v2 schema implemented by `parseNewsPayload()`:
+
+- `schemaVersion: 2` and a bounded `items[]` collection;
+- stable, unique announcement `id` values;
+- explicit `active` and ISO `publishedAt` fields;
+- required English copy with optional localized copy;
+- optional credential-free HTTPS links and an allowlisted icon;
+- newest-active-first selection, locale fallback to English, and per-ID local dismissal.
+
+The parser temporarily accepts the former singular `{ active, title, message, ... }` shape so
+older forks can migrate without a broken Changelog view. Invalid feeds are inert. The Radar launch
+entry ships with `active: false`; changing it to `true` is a separate post-merge, post-deploy
+release action and does not change `RADAR_ENABLED` or the independent feed-sync opt-in.
+
+---
+
## Flag: `RADAR_ENABLED` (default off)
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md
index 4d298c66fd..f1bf226293 100644
--- a/docs/ops/RELEASE_CHECKLIST.md
+++ b/docs/ops/RELEASE_CHECKLIST.md
@@ -65,6 +65,7 @@ directly from anywhere — CI can only stage; only the owner's 2FA releases.
as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents
window and never as the first move. Docker: never rewrite a version tag — rollback is
repointing `latest` to the last good digest.
+
## Hotfix Fast-Lane (label `hotfix`)
A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet,
@@ -276,6 +277,22 @@ Deploy skills use the light rsync flow — no `npm pack`, no `npm i -g`:
- [ ] Open milestone for next version
- [ ] If critical: pin discussion or post in `news.json` for in-app banner
+### Radar public-launch gate
+
+The Radar announcement is intentionally committed with `active: false`. Activation is a separate
+change after every item below is evidenced:
+
+- [ ] All stacked Radar PRs are merged and the release-tip CI is green
+- [ ] Deploy and smoke the OSS Radar routes with `RADAR_ENABLED` still off by default
+- [ ] Smoke `GET /planos`, `/termos`, `/privacidade`, and `/reembolso` on the named Radar host
+- [ ] Record operator identity/contact/address and owner-approved legal review in the private service
+- [ ] Exercise Stripe Checkout and the signed webhook in test mode only
+- [ ] Exercise one encrypted transactional-email delivery with the approved sender/domain
+- [ ] Prove backup restore and one supervised, budget-capped research run
+- [ ] Approve the BRL/PIX review policy before accepting donation evidence
+- [ ] Enable public Checkout only after the preceding gates, then activate the new `news.json` ID
+- [ ] Verify the Home banner uses localized copy and a new ID reappears after an older ID is dismissed
+
## Embedded Services smoke (v3.8.4+)
Before shipping any release that includes embedded services changes, verify:
diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md
index 69a9fe79af..542183d3fb 100644
--- a/docs/reference/ENVIRONMENT.md
+++ b/docs/reference/ENVIRONMENT.md
@@ -1292,6 +1292,11 @@ self-hosted or forked feed / supporter-key flow instead of the default OmniRoute
Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full
module doc.
+The generic Home/Changelog announcement reader is not configured by an environment
+variable and does not depend on the RADAR_ENABLED feature flag. It reads the public repository
+`news.json` URL declared in `src/shared/utils/releaseNotes.ts` by
+GET only; dismissal IDs remain in browser local storage.
+
| Variable | Default | Source File | Description |
| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/{sync,referralsSync,offersSync,intelSync}.ts` | Base URL shared by the separately signed catalog, referrals, supporter-offers, and Intel feeds. Override to point at a self-hosted or forked service. |
diff --git a/news.json b/news.json
index bf4077df7d..16e2de2c47 100644
--- a/news.json
+++ b/news.json
@@ -1,8 +1,43 @@
{
- "active": false,
- "title": "Novidade no Omniverse",
- "message": "Está lançado hoje o tOmni, o terminal interativo múltiplo para Agentes de AI! Experimente a nova interface focada em produtividade para desenvolvedores.",
- "link": "https://github.com/diegosouzapw/tOmni",
- "linkLabel": "Conhecer o tOmni",
- "icon": "campaign"
+ "schemaVersion": 2,
+ "items": [
+ {
+ "id": "radar-launch-2026-08",
+ "active": false,
+ "publishedAt": "2026-08-09T00:00:00.000Z",
+ "text": {
+ "en": {
+ "title": "OmniRoute Radar",
+ "message": "An opt-in, GET-only free-model catalog overlay with no telemetry from the OmniRoute client.",
+ "linkLabel": "Learn about Radar"
+ },
+ "pt-BR": {
+ "title": "OmniRoute Radar",
+ "message": "Um catálogo opcional de modelos gratuitos, somente GET e sem telemetria enviada pelo cliente OmniRoute.",
+ "linkLabel": "Conheça o Radar"
+ }
+ },
+ "link": "https://radar.omniroute.online/planos",
+ "icon": "radar"
+ },
+ {
+ "id": "tomni-launch-2026-07",
+ "active": false,
+ "publishedAt": "2026-07-01T00:00:00.000Z",
+ "text": {
+ "en": {
+ "title": "New in the Omniverse",
+ "message": "tOmni is an interactive multi-agent terminal focused on developer productivity.",
+ "linkLabel": "Meet tOmni"
+ },
+ "pt-BR": {
+ "title": "Novidade no Omniverse",
+ "message": "O tOmni é um terminal interativo para múltiplos agentes, focado na produtividade de desenvolvedores.",
+ "linkLabel": "Conhecer o tOmni"
+ }
+ },
+ "link": "https://github.com/diegosouzapw/tOmni",
+ "icon": "campaign"
+ }
+ ]
}
diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx
index b5b5883dfd..3185ce3071 100644
--- a/src/app/(dashboard)/dashboard/HomePageClient.tsx
+++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx
@@ -22,7 +22,6 @@ import { HomeProviderTopologySection } from "./HomeProviderTopologySection";
import { shouldShowProviderTopologyOnHome } from "./homeAppearance";
const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false });
-import type { NewsAnnouncement } from "@/shared/utils/releaseNotes";
type UpdateStep = {
step: string;
@@ -37,7 +36,6 @@ type VersionInfo = {
channel: string;
autoUpdateSupported: boolean;
autoUpdateError?: string | null;
- news?: NewsAnnouncement | null;
};
type HomePageClientProps = {
@@ -1049,37 +1047,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
)}
-
- {/* News Notification Banner */}
- {versionInfo?.news && (
-
- )}
)}
diff --git a/src/app/(dashboard)/dashboard/NewsBanner.tsx b/src/app/(dashboard)/dashboard/NewsBanner.tsx
new file mode 100644
index 0000000000..10922cbb24
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/NewsBanner.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+import { useLocale, useTranslations } from "next-intl";
+
+import {
+ NEWS_DISMISS_EVENT,
+ NEWS_DISMISS_STORAGE_KEY,
+ fetchNewsPayload,
+ parseDismissedNewsIds,
+ selectActiveNews,
+ serializeDismissedNewsIds,
+} from "@/shared/utils/releaseNotes";
+
+function subscribeToDismissals(callback: () => void) {
+ window.addEventListener("storage", callback);
+ window.addEventListener(NEWS_DISMISS_EVENT, callback);
+ return () => {
+ window.removeEventListener("storage", callback);
+ window.removeEventListener(NEWS_DISMISS_EVENT, callback);
+ };
+}
+
+function readDismissedIds(): string {
+ try {
+ return localStorage.getItem(NEWS_DISMISS_STORAGE_KEY) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+function getServerDismissedIds(): string {
+ return "";
+}
+
+/**
+ * Generic, fail-silent reader for the public announcement feed. Fetching the
+ * static JSON is GET-only and does not send product state or telemetry.
+ */
+export default function NewsBanner() {
+ const locale = useLocale();
+ const t = useTranslations("common");
+ const [payload, setPayload] = useState(null);
+ const dismissedSnapshot = useSyncExternalStore(
+ subscribeToDismissals,
+ readDismissedIds,
+ getServerDismissedIds
+ );
+ const dismissedIds = parseDismissedNewsIds(dismissedSnapshot);
+ const announcement = selectActiveNews(payload, locale, dismissedIds);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ void fetchNewsPayload(fetch, controller.signal).then((value) => {
+ if (value !== null) setPayload(value);
+ });
+
+ return () => controller.abort();
+ }, []);
+
+ if (!announcement) return null;
+
+ const dismiss = () => {
+ dismissedIds.add(announcement.id);
+ try {
+ localStorage.setItem(NEWS_DISMISS_STORAGE_KEY, serializeDismissedNewsIds(dismissedIds));
+ } catch {
+ // Storage is optional; the next announcement fetch remains functional.
+ }
+ window.dispatchEvent(new Event(NEWS_DISMISS_EVENT));
+ };
+
+ return (
+
+
+
+
+ {announcement.icon}
+
+
+
+
{announcement.title}
+
{announcement.message}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx
index 44e7997874..764565eb61 100644
--- a/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx
+++ b/src/app/(dashboard)/dashboard/changelog/components/NewsViewer.tsx
@@ -1,39 +1,39 @@
"use client";
-import { useState, useEffect } from "react";
-import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+
import { Button } from "@/shared/components";
import {
- NEWS_JSON_URL,
- parseActiveNewsPayload,
+ fetchNewsPayload,
+ listActiveNews,
type NewsAnnouncement,
} from "@/shared/utils/releaseNotes";
export default function NewsViewer() {
+ const locale = useLocale();
const t = useTranslations("changelogPage");
- const [news, setNews] = useState(null);
+ const [news, setNews] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
- async function fetchNews() {
- try {
- const res = await fetch(NEWS_JSON_URL, { cache: "no-store" });
- if (res.ok) {
- const data = await res.json();
- setNews(parseActiveNewsPayload(data));
- } else {
- setError(true);
+ const controller = new AbortController();
+
+ void fetchNewsPayload(fetch, controller.signal)
+ .then((payload) => {
+ if (payload === null) {
+ if (!controller.signal.aborted) setError(true);
+ return;
}
- } catch (err) {
- console.error("Failed to fetch news:", err);
- setError(true);
- } finally {
- setLoading(false);
- }
- }
- fetchNews();
- }, []);
+ setNews(listActiveNews(payload, locale));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+
+ return () => controller.abort();
+ }, [locale]);
if (loading) {
return (
@@ -48,7 +48,7 @@ export default function NewsViewer() {
if (error) {
return (
-
+
error_outline
{t("announcementsLoadFailed")}
@@ -56,10 +56,10 @@ export default function NewsViewer() {
);
}
- if (!news || !news.active) {
+ if (news.length === 0) {
return (
-
+
notifications_off
{t("noAnnouncements")}
@@ -68,30 +68,37 @@ export default function NewsViewer() {
}
return (
-
-
-
-
- {news.icon || "campaign"}
-
-
-
-
-
{news.title}
-
{news.message}
-
-
- {news.link && (
-
);
}
diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx
index 1926f8fd32..92d336a71e 100644
--- a/src/app/(dashboard)/home/page.tsx
+++ b/src/app/(dashboard)/home/page.tsx
@@ -4,6 +4,7 @@ import { getSettings } from "@/lib/localDb";
import HomePageClient from "../dashboard/HomePageClient";
import BootstrapBanner from "../dashboard/BootstrapBanner";
import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
+import NewsBanner from "../dashboard/NewsBanner";
export const dynamic = "force-dynamic";
@@ -18,6 +19,7 @@ export default async function HomePage() {
<>
{isBootstrapped &&
}
+
>
);
diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts
index a1e25b3ad9..bdea01d498 100644
--- a/src/shared/utils/releaseNotes.ts
+++ b/src/shared/utils/releaseNotes.ts
@@ -6,34 +6,242 @@ export const CHANGELOG_RAW_URL =
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md";
export const CHANGELOG_GITHUB_URL =
"https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md";
+export const NEWS_DISMISS_STORAGE_KEY = "omniroute-news-dismissed-v2";
+export const NEWS_DISMISS_EVENT = "omniroute:news-dismissed";
-const activeNewsSchema = z.object({
- active: z.literal(true),
- title: z.string().trim().min(1).max(120),
- message: z.string().trim().min(1).max(600),
- link: z.string().url().optional(),
- linkLabel: z.string().trim().min(1).max(80).optional(),
- icon: z
- .string()
- .trim()
- .regex(/^[a-z0-9_]+$/)
- .optional(),
+const NEWS_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$/;
+const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-[A-Z]{2}|-[0-9]{3})?$/;
+const MAX_DISMISSED_IDS = 50;
+
+const newsIconSchema = z.enum(["campaign", "celebration", "info", "new_releases", "radar"]);
+const localizedTextSchema = z
+ .object({
+ title: z.string().trim().min(1).max(120),
+ message: z.string().trim().min(1).max(600),
+ linkLabel: z.string().trim().min(1).max(80).optional(),
+ })
+ .strict();
+
+const httpsUrlSchema = z
+ .string()
+ .url()
+ .max(500)
+ .refine((value) => {
+ const url = new URL(value);
+ return url.protocol === "https:" && !url.username && !url.password;
+ }, "Announcement links must use HTTPS without embedded credentials");
+
+const localizedTextMapSchema = z.record(localizedTextSchema).superRefine((value, context) => {
+ if (!value.en) {
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "English copy is required" });
+ }
+ for (const locale of Object.keys(value)) {
+ if (!LOCALE_PATTERN.test(locale)) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Invalid locale: ${locale}`,
+ });
+ }
+ }
});
-const inactiveNewsSchema = z
+const newsFeedItemSchema = z
+ .object({
+ id: z.string().regex(NEWS_ID_PATTERN),
+ active: z.boolean(),
+ publishedAt: z.string().datetime({ offset: true }),
+ text: localizedTextMapSchema,
+ link: httpsUrlSchema.optional(),
+ icon: newsIconSchema,
+ })
+ .strict();
+
+const newsFeedSchema = z
+ .object({
+ schemaVersion: z.literal(2),
+ items: z.array(newsFeedItemSchema).max(50),
+ })
+ .strict()
+ .superRefine(({ items }, context) => {
+ const ids = new Set
();
+ for (const [index, item] of items.entries()) {
+ if (ids.has(item.id)) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Duplicate announcement id: ${item.id}`,
+ path: ["items", index, "id"],
+ });
+ }
+ ids.add(item.id);
+ }
+ });
+
+const legacyActiveNewsSchema = z
+ .object({
+ active: z.literal(true),
+ title: z.string().trim().min(1).max(120),
+ message: z.string().trim().min(1).max(600),
+ link: httpsUrlSchema.optional(),
+ linkLabel: z.string().trim().min(1).max(80).optional(),
+ icon: newsIconSchema.optional(),
+ })
+ .strict();
+
+const legacyInactiveNewsSchema = z
.object({
active: z.literal(false),
})
.passthrough();
-const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]);
+const legacyNewsSchema = z.discriminatedUnion("active", [
+ legacyActiveNewsSchema,
+ legacyInactiveNewsSchema,
+]);
-export type NewsAnnouncement = z.infer;
+export type NewsFeedItem = z.infer;
+export type NewsIcon = z.infer;
+
+export type NewsAnnouncement = {
+ id: string;
+ active: true;
+ publishedAt: string;
+ title: string;
+ message: string;
+ link?: string;
+ linkLabel?: string;
+ icon: NewsIcon;
+};
+
+type NewsFetchResponse = Pick;
+type NewsFetch = (url: string, init: RequestInit) => Promise;
+
+export async function fetchNewsPayload(
+ fetchNews: NewsFetch = fetch,
+ signal?: AbortSignal
+): Promise {
+ try {
+ const response = await fetchNews(NEWS_JSON_URL, {
+ cache: "no-store",
+ credentials: "omit",
+ referrerPolicy: "no-referrer",
+ signal,
+ });
+ return response.ok ? await response.json() : null;
+ } catch {
+ return null;
+ }
+}
+
+function stableHash(value: string): string {
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return (hash >>> 0).toString(16).padStart(8, "0");
+}
+
+function normalizeLegacyNews(payload: unknown): NewsFeedItem[] {
+ const parsed = legacyNewsSchema.safeParse(payload);
+ if (!parsed.success || !parsed.data.active) return [];
+
+ const item = parsed.data;
+ return [
+ {
+ id: `legacy-${stableHash(`${item.title}\n${item.message}\n${item.link ?? ""}`)}`,
+ active: true,
+ publishedAt: "1970-01-01T00:00:00.000Z",
+ text: {
+ en: {
+ title: item.title,
+ message: item.message,
+ ...(item.linkLabel ? { linkLabel: item.linkLabel } : {}),
+ },
+ },
+ ...(item.link ? { link: item.link } : {}),
+ icon: item.icon ?? "campaign",
+ },
+ ];
+}
+
+export function parseNewsPayload(payload: unknown): NewsFeedItem[] {
+ const feed = newsFeedSchema.safeParse(payload);
+ if (feed.success) return feed.data.items;
+ return normalizeLegacyNews(payload);
+}
+
+function resolveLocalizedText(item: NewsFeedItem, locale: string) {
+ const normalizedLocale = locale.trim().replace("_", "-");
+ const exactKey = Object.keys(item.text).find(
+ (key) => key.toLowerCase() === normalizedLocale.toLowerCase()
+ );
+ if (exactKey) return item.text[exactKey];
+
+ const language = normalizedLocale.split("-")[0]?.toLowerCase();
+ const languageKey = Object.keys(item.text).find((key) => key.toLowerCase() === language);
+ return (languageKey && item.text[languageKey]) || item.text.en;
+}
+
+export function listActiveNews(
+ payload: unknown,
+ locale = "en",
+ now = new Date()
+): NewsAnnouncement[] {
+ const nowMs = now.getTime();
+ if (!Number.isFinite(nowMs)) return [];
+
+ return parseNewsPayload(payload)
+ .filter((item) => item.active && Date.parse(item.publishedAt) <= nowMs)
+ .sort(
+ (left, right) =>
+ Date.parse(right.publishedAt) - Date.parse(left.publishedAt) ||
+ left.id.localeCompare(right.id)
+ )
+ .map((item) => {
+ const text = resolveLocalizedText(item, locale);
+ return {
+ id: item.id,
+ active: true as const,
+ publishedAt: item.publishedAt,
+ title: text.title,
+ message: text.message,
+ ...(item.link ? { link: item.link } : {}),
+ ...(text.linkLabel ? { linkLabel: text.linkLabel } : {}),
+ icon: item.icon,
+ };
+ });
+}
+
+export function selectActiveNews(
+ payload: unknown,
+ locale = "en",
+ dismissedIds: ReadonlySet = new Set(),
+ now = new Date()
+): NewsAnnouncement | null {
+ return listActiveNews(payload, locale, now).find((item) => !dismissedIds.has(item.id)) ?? null;
+}
export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null {
- const parsed = newsPayloadSchema.safeParse(payload);
- if (!parsed.success || parsed.data.active !== true) return null;
- return parsed.data;
+ return selectActiveNews(payload, "en");
+}
+
+export function parseDismissedNewsIds(raw: string | null): Set {
+ if (!raw) return new Set();
+ try {
+ const value: unknown = JSON.parse(raw);
+ if (!Array.isArray(value)) return new Set();
+ const ids = value.filter(
+ (id): id is string => typeof id === "string" && NEWS_ID_PATTERN.test(id)
+ );
+ return new Set(ids.slice(-MAX_DISMISSED_IDS));
+ } catch {
+ return new Set();
+ }
+}
+
+export function serializeDismissedNewsIds(ids: Iterable): string {
+ const sanitized = [...new Set(ids)].filter((id) => NEWS_ID_PATTERN.test(id));
+ return JSON.stringify(sanitized.slice(-MAX_DISMISSED_IDS));
}
export function getLatestChangelogMarkdown(markdown: string, limit = 10): string {
diff --git a/tests/unit/news-feed-contract.test.ts b/tests/unit/news-feed-contract.test.ts
new file mode 100644
index 0000000000..4508eef200
--- /dev/null
+++ b/tests/unit/news-feed-contract.test.ts
@@ -0,0 +1,33 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+
+const root = new URL("../../", import.meta.url);
+
+test("news.json ships Radar inactive in the localized v2 feed without commercial details", async () => {
+ const source = await readFile(new URL("news.json", root), "utf8");
+ const payload = JSON.parse(source);
+ const radar = payload.items.find((item: { id?: string }) => item.id === "radar-launch-2026-08");
+
+ assert.equal(payload.schemaVersion, 2);
+ assert.equal(radar.active, false);
+ assert.equal(radar.link, "https://radar.omniroute.online/planos");
+ assert.match(radar.text.en.message, /opt-in/i);
+ assert.match(radar.text.en.message, /GET-only/i);
+ assert.match(radar.text.en.message, /no telemetry/i);
+ assert.doesNotMatch(source, /R\$|US\$|coupon|cupom|discount|desconto/i);
+});
+
+test("the generic banner is ID-dismissable and independent from the Radar feature flag", async () => {
+ const source = await readFile(
+ new URL("src/app/(dashboard)/dashboard/NewsBanner.tsx", root),
+ "utf8"
+ );
+
+ assert.match(source, /selectActiveNews/);
+ assert.match(source, /parseDismissedNewsIds/);
+ assert.match(source, /localStorage/);
+ assert.match(source, /announcement\.id/);
+ assert.doesNotMatch(source, /RADAR_ENABLED/);
+ assert.doesNotMatch(source, /method:\s*["']POST["']/);
+});
diff --git a/tests/unit/release-notes.test.ts b/tests/unit/release-notes.test.ts
index 6a90764bc1..19dfc38388 100644
--- a/tests/unit/release-notes.test.ts
+++ b/tests/unit/release-notes.test.ts
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
const releaseNotes = await import("../../src/shared/utils/releaseNotes.ts");
-test("parseActiveNewsPayload returns only valid active announcements", () => {
+test("parseActiveNewsPayload keeps the legacy singular contract", () => {
assert.deepEqual(
releaseNotes.parseActiveNewsPayload({
active: true,
@@ -14,7 +14,9 @@ test("parseActiveNewsPayload returns only valid active announcements", () => {
icon: "campaign",
}),
{
+ id: "legacy-8d301d21",
active: true,
+ publishedAt: "1970-01-01T00:00:00.000Z",
title: "Launch",
message: "A short announcement",
link: "https://github.com/diegosouzapw/tOmni",
@@ -35,6 +37,167 @@ test("parseActiveNewsPayload returns only valid active announcements", () => {
);
});
+test("parseNewsPayload validates the closed v2 feed and preserves inactive entries", () => {
+ const payload = {
+ schemaVersion: 2,
+ items: [
+ {
+ id: "radar-launch-2026-08",
+ active: false,
+ publishedAt: "2026-08-09T12:00:00.000Z",
+ text: {
+ en: { title: "Radar", message: "Opt-in catalog", linkLabel: "Learn more" },
+ "pt-BR": { title: "Radar", message: "Catálogo opt-in", linkLabel: "Saiba mais" },
+ },
+ link: "https://radar.omniroute.online/planos",
+ icon: "radar",
+ },
+ ],
+ };
+
+ assert.deepEqual(releaseNotes.parseNewsPayload(payload), payload.items);
+ assert.deepEqual(releaseNotes.parseNewsPayload({ ...payload, unexpected: true }), []);
+ assert.deepEqual(
+ releaseNotes.parseNewsPayload({
+ ...payload,
+ items: [{ ...payload.items[0], link: "http://radar.omniroute.online/planos" }],
+ }),
+ []
+ );
+ assert.deepEqual(
+ releaseNotes.parseNewsPayload({
+ ...payload,
+ items: [...payload.items, payload.items[0]],
+ }),
+ []
+ );
+});
+
+test("listActiveNews localizes, filters future entries and sorts newest first", () => {
+ const payload = {
+ schemaVersion: 2,
+ items: [
+ {
+ id: "older",
+ active: true,
+ publishedAt: "2026-08-08T12:00:00.000Z",
+ text: { en: { title: "Older", message: "English" } },
+ icon: "info",
+ },
+ {
+ id: "newer",
+ active: true,
+ publishedAt: "2026-08-09T12:00:00.000Z",
+ text: {
+ en: { title: "Newer", message: "English" },
+ "pt-BR": { title: "Mais recente", message: "Português" },
+ },
+ icon: "campaign",
+ },
+ {
+ id: "future",
+ active: true,
+ publishedAt: "2026-08-11T12:00:00.000Z",
+ text: { en: { title: "Future", message: "Not published yet" } },
+ icon: "campaign",
+ },
+ {
+ id: "inactive",
+ active: false,
+ publishedAt: "2026-08-09T13:00:00.000Z",
+ text: { en: { title: "Inactive", message: "Hidden" } },
+ icon: "campaign",
+ },
+ ],
+ };
+
+ const news = releaseNotes.listActiveNews(payload, "pt-BR", new Date("2026-08-10T00:00:00.000Z"));
+ assert.deepEqual(
+ news.map((item: { id: string; title: string; message: string }) => [
+ item.id,
+ item.title,
+ item.message,
+ ]),
+ [
+ ["newer", "Mais recente", "Português"],
+ ["older", "Older", "English"],
+ ]
+ );
+ assert.equal(
+ releaseNotes.listActiveNews(payload, "pt-PT", new Date("2026-08-10T00:00:00.000Z"))[0]?.message,
+ "English"
+ );
+});
+
+test("selectActiveNews skips dismissed ids while retaining new announcements", () => {
+ const payload = {
+ schemaVersion: 2,
+ items: [
+ {
+ id: "newer",
+ active: true,
+ publishedAt: "2026-08-09T12:00:00.000Z",
+ text: { en: { title: "Newer", message: "English" } },
+ icon: "campaign",
+ },
+ {
+ id: "older",
+ active: true,
+ publishedAt: "2026-08-08T12:00:00.000Z",
+ text: { en: { title: "Older", message: "English" } },
+ icon: "campaign",
+ },
+ ],
+ };
+
+ assert.equal(
+ releaseNotes.selectActiveNews(
+ payload,
+ "en",
+ new Set(["newer"]),
+ new Date("2026-08-10T00:00:00.000Z")
+ )?.id,
+ "older"
+ );
+});
+
+test("dismissed announcement ids are sanitized, deduplicated and bounded", () => {
+ assert.deepEqual(
+ [...releaseNotes.parseDismissedNewsIds('["valid-id","valid-id","