diff --git a/src/app/api/radar/offers/route.ts b/src/app/api/radar/offers/route.ts new file mode 100644 index 0000000000..11bca3b756 --- /dev/null +++ b/src/app/api/radar/offers/route.ts @@ -0,0 +1,41 @@ +/** GET the verified local Radar offers cache. Never proxies the private service. */ + +import { NextResponse } from "next/server"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { getRadarOffers } from "@/lib/radar"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + try { + return NextResponse.json(getRadarOffers(), { + headers: { ...CORS_HEADERS, "Cache-Control": "no-store" }, + }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Failed to load Radar offers"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/app/api/radar/offers/sync/route.ts b/src/app/api/radar/offers/sync/route.ts new file mode 100644 index 0000000000..33c40e906d --- /dev/null +++ b/src/app/api/radar/offers/sync/route.ts @@ -0,0 +1,55 @@ +/** POST a server-side Radar offers sync. The browser never receives the supporter key. */ + +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { syncRadarOffers } from "@/lib/radar/offersSync"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const SyncBodySchema = z.object({}).strict().optional(); + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST(request: Request) { + if (!isFeatureFlagEnabled("RADAR_ENABLED")) { + return NextResponse.json(buildErrorBody(404, "Not found"), { + status: 404, + headers: CORS_HEADERS, + }); + } + if (!(await isAuthenticated(request))) { + return NextResponse.json(buildErrorBody(401, "Unauthorized"), { + status: 401, + headers: CORS_HEADERS, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + body = undefined; + } + if (!SyncBodySchema.safeParse(body).success) { + return NextResponse.json(buildErrorBody(400, "Invalid request body"), { + status: 400, + headers: CORS_HEADERS, + }); + } + + try { + return NextResponse.json(await syncRadarOffers(), { headers: CORS_HEADERS }); + } catch (error: unknown) { + return NextResponse.json( + buildErrorBody(500, sanitizeErrorMessage(error) || "Radar offers sync failed"), + { status: 500, headers: CORS_HEADERS } + ); + } +} diff --git a/src/lib/db/migrations/144_radar_offers_cache.sql b/src/lib/db/migrations/144_radar_offers_cache.sql new file mode 100644 index 0000000000..d01861b02e --- /dev/null +++ b/src/lib/db/migrations/144_radar_offers_cache.sql @@ -0,0 +1,11 @@ +-- 144_radar_offers_cache.sql +-- Single-row cache for the separately signed, live-only Radar offers feed. + +CREATE TABLE IF NOT EXISTS radar_offers_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + tier TEXT NOT NULL CHECK (tier = 'live'), + payload TEXT NOT NULL, + signature TEXT NOT NULL, + fetched_at TEXT NOT NULL +); diff --git a/src/lib/db/radar.ts b/src/lib/db/radar.ts index 7a03854ef7..fda649fb00 100644 --- a/src/lib/db/radar.ts +++ b/src/lib/db/radar.ts @@ -17,6 +17,9 @@ * - radar_local_model_state: operator-owned display/enabled overrides and * deletion tombstones, keyed by provider + model ID. * + * Tables (migration 144): + * - radar_offers_cache: single-row signed live offers feed cache. + * * 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. @@ -51,6 +54,14 @@ export interface RadarReferralsCache { fetchedAt: string; } +export interface RadarOffersCache { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt: string; +} + export interface RadarLocalModelState { provider: string; modelId: string; @@ -168,14 +179,16 @@ export function setRadarKey(key: string | null): void { ); const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1"); const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1"); + const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1"); db.transaction(() => { updateKey.run(encrypted); - // Both signed feeds are entitlement-sensitive. Clearing their cached + // All signed feeds are entitlement-sensitive. Clearing their cached // variants forces the next sync/read to resolve the new key server-side // instead of serving data fetched under the previous entitlement. clearCatalogCache.run(); clearReferralsCache.run(); + clearOffersCache.run(); })(); } @@ -226,6 +239,43 @@ export function setRadarReferralsCache(entry: { ).run(entry.generatedAt, entry.tier, entry.payload, entry.signature, fetchedAt); } +// --------------------------------------------------------------------------- +// radar_offers_cache +// --------------------------------------------------------------------------- + +export function getRadarOffersCache(): RadarOffersCache | null { + const row = getDbInstance() + .prepare( + "SELECT version, tier, payload, signature, fetched_at AS fetchedAt " + + "FROM radar_offers_cache WHERE id = 1" + ) + .get() as RadarOffersCache | undefined; + + return row ?? null; +} + +export function setRadarOffersCache(entry: { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt?: string; +}): void { + const fetchedAt = entry.fetchedAt ?? new Date().toISOString(); + getDbInstance() + .prepare( + `INSERT INTO radar_offers_cache (id, version, tier, payload, signature, fetched_at) + VALUES (1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + version = excluded.version, + tier = excluded.tier, + payload = excluded.payload, + signature = excluded.signature, + fetched_at = excluded.fetched_at` + ) + .run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt); +} + // --------------------------------------------------------------------------- // radar_local_model_state // --------------------------------------------------------------------------- diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 6ee7a0939d..d621129aa0 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -820,6 +820,8 @@ export { setRadarKey, getRadarReferralsCache, setRadarReferralsCache, + getRadarOffersCache, + setRadarOffersCache, listRadarLocalModelState, setRadarLocalModelOverride, clearRadarLocalModelOverride, @@ -830,6 +832,7 @@ export type { RadarCache, RadarSettings, RadarReferralsCache, + RadarOffersCache, RadarLocalModelState, RadarLocalModelOverridePatch, RadarLocalMergeState, diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index 1ae06486f0..ec06fa1340 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -12,12 +12,18 @@ import { FREE_MODEL_BUDGETS } from "@omniroute/open-sse/config/freeModelCatalog"; import { RadarFeedSchema, type RadarFeed, type RadarReferral } from "./feedSchema"; import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema"; +import { + filterActiveRadarOffers, + RadarOffersFeedSchema, + type RadarOffer, +} from "./offersFeedSchema"; import { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; import { findDefaultReferral } from "./referrals"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { getRadarCache, getRadarLocalMergeState, + getRadarOffersCache, getRadarReferralsCache, type RadarLocalMergeState, } from "@/lib/db/radar"; @@ -209,7 +215,54 @@ export function getDefaultReferralFor( return findDefaultReferral(fixed, provider); } +// --------------------------------------------------------------------------- +// getRadarOffers +// --------------------------------------------------------------------------- + +export interface RadarOffersResult { + offers: RadarOffer[]; + meta: { version: string; tier: "live"; fetchedAt: string } | null; +} + +export interface GetRadarOffersDeps { + getFlag?: (key: string) => boolean; + getCache?: () => { + version: string; + tier: string; + payload: string; + fetchedAt: string; + } | null; + now?: () => Date; +} + +const EMPTY_OFFERS: RadarOffersResult = { offers: [], meta: null }; + +/** Return only revalidated, unexpired offers from the local live cache. */ +export function getRadarOffers(deps: GetRadarOffersDeps = {}): RadarOffersResult { + const { + getFlag = isFeatureFlagEnabled, + getCache: getCacheFn = getRadarOffersCache, + now = () => new Date(), + } = deps; + if (!getFlag("RADAR_ENABLED")) return EMPTY_OFFERS; + + const cache = getCacheFn(); + if (!cache || cache.tier !== "live") return EMPTY_OFFERS; + + try { + const feed = RadarOffersFeedSchema.parse(JSON.parse(cache.payload)); + if (feed.version !== cache.version || feed.tier !== "live") return EMPTY_OFFERS; + return { + offers: filterActiveRadarOffers(feed.offers, now()), + meta: { version: cache.version, tier: "live", fetchedAt: cache.fetchedAt }, + }; + } catch { + return EMPTY_OFFERS; + } +} + // Re-export merge types for convenience export { applyFeed, type MergedEntry, type FeedModel } from "./applyFeed"; export { findDefaultReferral } from "./referrals"; export type { RadarReferral } from "./feedSchema"; +export type { RadarOffer, RadarOfferBenefit, RadarOfferLocalizedText } from "./offersFeedSchema"; diff --git a/src/lib/radar/offersFeedSchema.ts b/src/lib/radar/offersFeedSchema.ts new file mode 100644 index 0000000000..a1e6579a63 --- /dev/null +++ b/src/lib/radar/offersFeedSchema.ts @@ -0,0 +1,147 @@ +/** + * Closed client mirror of the private Radar offers feed contract. + * + * Keep this shape byte-compatible with `src/offers/schema.ts` in the private + * server. The canonical fixture in `tests/fixtures/` pins that cross-repo + * contract without embedding any real offer or partner data. + */ + +import { z } from "zod"; + +const OFFER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,119}$/; + +export const RadarOfferLocalizedTextSchema = z + .object({ + en: z.string().min(1), + pt: z.string().min(1).optional(), + }) + .strict(); + +export type RadarOfferLocalizedText = z.infer; + +const HttpsUrlSchema = z + .string() + .url() + .superRefine((value, ctx) => { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.username || parsed.password) { + ctx.addIssue({ code: "custom", message: "offer URL must be credential-free HTTPS" }); + } + }); + +const PercentBenefitSchema = z + .object({ + kind: z.literal("percent_off"), + basisPoints: z.number().int().min(1).max(10_000), + }) + .strict(); + +const CreditBenefitSchema = z + .object({ + kind: z.literal("credit"), + amountMinor: z.number().int().positive(), + currency: z.string().regex(/^[A-Z]{3}$/), + }) + .strict(); + +const TrialBenefitSchema = z + .object({ + kind: z.literal("trial_days"), + days: z.number().int().min(1).max(3_650), + }) + .strict(); + +export const RadarOfferBenefitSchema = z.discriminatedUnion("kind", [ + PercentBenefitSchema, + CreditBenefitSchema, + TrialBenefitSchema, +]); + +export type RadarOfferBenefit = z.infer; + +function isStrictlyBetter(benefit: RadarOfferBenefit, publicBenefit: RadarOfferBenefit): boolean { + if (benefit.kind !== publicBenefit.kind) return false; + if (benefit.kind === "percent_off" && publicBenefit.kind === "percent_off") { + return benefit.basisPoints > publicBenefit.basisPoints; + } + if (benefit.kind === "trial_days" && publicBenefit.kind === "trial_days") { + return benefit.days > publicBenefit.days; + } + if (benefit.kind === "credit" && publicBenefit.kind === "credit") { + return ( + benefit.currency === publicBenefit.currency && benefit.amountMinor > publicBenefit.amountMinor + ); + } + return false; +} + +export const RadarOfferSchema = z + .object({ + id: z.string().regex(OFFER_ID_PATTERN), + provider: z.string().regex(PROVIDER_ID_PATTERN), + title: RadarOfferLocalizedTextSchema, + description: RadarOfferLocalizedTextSchema, + benefit: RadarOfferBenefitSchema, + publicBenefit: RadarOfferBenefitSchema.nullable(), + conditions: RadarOfferLocalizedTextSchema, + validUntil: z.string().datetime().nullable(), + url: HttpsUrlSchema, + partner: z.boolean(), + }) + .strict() + .superRefine((offer, ctx) => { + if (!offer.partner && offer.publicBenefit !== null) { + ctx.addIssue({ + code: "custom", + path: ["publicBenefit"], + message: "official offer has no partner baseline", + }); + return; + } + if ( + offer.partner && + (offer.publicBenefit === null || !isStrictlyBetter(offer.benefit, offer.publicBenefit)) + ) { + ctx.addIssue({ + code: "custom", + path: ["publicBenefit"], + message: "partner benefit must be strictly better than a comparable public benefit", + }); + } + }); + +export type RadarOffer = z.infer; + +export const RadarOffersFeedSchema = z + .object({ + feed: z.literal("omniroute-radar-offers"), + schemaVersion: z.literal(1), + version: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d+$/), + generatedAt: z.string().datetime(), + tier: z.literal("live"), + count: z.number().int().nonnegative(), + offers: z.array(RadarOfferSchema), + }) + .strict() + .superRefine((feed, ctx) => { + if (feed.count !== feed.offers.length) { + ctx.addIssue({ code: "custom", path: ["count"], message: "offer count mismatch" }); + } + }); + +export type RadarOffersFeed = z.infer; + +export function filterActiveRadarOffers( + offers: readonly RadarOffer[], + now: Date = new Date() +): RadarOffer[] { + const nowMs = now.getTime(); + return offers.filter( + (offer) => offer.validUntil === null || Date.parse(offer.validUntil) > nowMs + ); +} + +export function localizeRadarOfferText(text: RadarOfferLocalizedText, locale: string): string { + return locale.toLowerCase().startsWith("pt") && text.pt ? text.pt : text.en; +} diff --git a/src/lib/radar/offersSync.ts b/src/lib/radar/offersSync.ts new file mode 100644 index 0000000000..05faa803ae --- /dev/null +++ b/src/lib/radar/offersSync.ts @@ -0,0 +1,152 @@ +/** + * Server-side sync for the separately signed, supporter-only Radar offers feed. + * Every failure preserves the last verified local cache. + */ + +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { RadarOffersFeedSchema, type RadarOffersFeed } from "./offersFeedSchema"; +import { compareVersions, type RadarSettingsSnapshot } from "./sync"; +import { verifyFeedBytes } from "./verify"; + +const DEFAULT_FEED_BASE_URL = "https://radar.omniroute.online"; +const SYNC_TIMEOUT_MS = 30_000; +const MAX_FEED_BYTES = 10 * 1024 * 1024; + +export type OffersSyncStatus = + | { status: "disabled" } + | { status: "opt_out" } + | { status: "no_key" } + | { status: "invalid_signature" } + | { status: "invalid_schema" } + | { status: "wrong_tier" } + | { status: "stale" } + | { status: "too_large" } + | { status: "updated"; version: string } + | { status: "error"; reason: string }; + +export interface RadarOffersCacheEntry { + version: string; + tier: "live"; + payload: string; + signature: string; + fetchedAt?: string; +} + +export interface OffersSyncDeps { + fetch?: typeof globalThis.fetch; + now?: () => Date; + getFlag?: (key: string) => boolean; + getSettings?: () => RadarSettingsSnapshot; + getCache?: () => RadarOffersCacheEntry | null; + setCache?: (entry: RadarOffersCacheEntry) => void; +} + +async function readBoundedBytes(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const declared = Number(contentLength); + if (Number.isFinite(declared) && declared > MAX_FEED_BYTES) return null; + } + + const body = response.body as ReadableStream | null | undefined; + if (!body || typeof body.getReader !== "function") { + const buffered = Buffer.from(await response.arrayBuffer()); + return buffered.byteLength > MAX_FEED_BYTES ? null : buffered; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > MAX_FEED_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); +} + +export async function syncRadarOffers(deps: OffersSyncDeps = {}): Promise { + const { + fetch: fetchFn = globalThis.fetch, + now = () => new Date(), + getFlag = isFeatureFlagEnabled, + getSettings: getSettingsFn, + getCache: getCacheFn, + setCache: setCacheFn, + } = deps; + + try { + if (!getFlag("RADAR_ENABLED")) return { status: "disabled" }; + + const settings = getSettingsFn + ? getSettingsFn() + : (await import("@/lib/db/radar")).getRadarSettings(); + if (!settings.optIn) return { status: "opt_out" }; + if (!settings.supporterKey) return { status: "no_key" }; + + const baseUrl = (process.env.RADAR_FEED_URL || DEFAULT_FEED_BASE_URL).replace(/\/+$/, ""); + const response = await fetchFn(`${baseUrl}/v1/offers/latest`, { + method: "GET", + headers: { Authorization: `Bearer ${settings.supporterKey}` }, + signal: AbortSignal.timeout(SYNC_TIMEOUT_MS), + }); + if (!response.ok) { + return { + status: "error", + reason: `Offers feed request failed with status ${response.status}`, + }; + } + + const rawBytes = await readBoundedBytes(response); + if (!rawBytes) return { status: "too_large" }; + + const signature = response.headers.get("x-omniroute-feed-signature") ?? ""; + if (!verifyFeedBytes(rawBytes, signature)) return { status: "invalid_signature" }; + + let feed: RadarOffersFeed; + try { + feed = RadarOffersFeedSchema.parse(JSON.parse(rawBytes.toString("utf8"))); + } catch { + return { status: "invalid_schema" }; + } + + if (response.headers.get("x-omniroute-feed-tier") !== "live" || feed.tier !== "live") { + return { status: "wrong_tier" }; + } + + const existing = getCacheFn + ? getCacheFn() + : (await import("@/lib/db/radar")).getRadarOffersCache(); + if (existing && compareVersions(feed.version, existing.version) <= 0) { + return { status: "stale" }; + } + + const cacheEntry: RadarOffersCacheEntry = { + version: feed.version, + tier: "live", + payload: rawBytes.toString("utf8"), + signature, + fetchedAt: now().toISOString(), + }; + if (setCacheFn) { + setCacheFn(cacheEntry); + } else { + (await import("@/lib/db/radar")).setRadarOffersCache(cacheEntry); + } + + return { status: "updated", version: feed.version }; + } catch (error: unknown) { + const reason = (sanitizeErrorMessage(error) || "Radar offers sync failed").replace( + /omr_[a-f0-9]{40}/gi, + "[REDACTED]" + ); + return { status: "error", reason }; + } +} diff --git a/tests/fixtures/radar-offers-canonical.json b/tests/fixtures/radar-offers-canonical.json new file mode 100644 index 0000000000..3764fe6680 --- /dev/null +++ b/tests/fixtures/radar-offers-canonical.json @@ -0,0 +1,63 @@ +{ + "feed": "omniroute-radar-offers", + "schemaVersion": 1, + "version": "2026.08.09.1", + "generatedAt": "2026-08-09T12:00:00.000Z", + "tier": "live", + "count": 2, + "offers": [ + { + "id": "example-official-trial", + "provider": "example", + "title": { + "en": "Official trial", + "pt": "Teste oficial" + }, + "description": { + "en": "Canonical official-offer fixture", + "pt": "Fixture canônico de oferta oficial" + }, + "benefit": { + "kind": "trial_days", + "days": 14 + }, + "publicBenefit": null, + "conditions": { + "en": "Fixture only; not a real offer", + "pt": "Somente fixture; não é uma oferta real" + }, + "validUntil": "2099-12-31T23:59:59.000Z", + "url": "https://provider.example/official-trial", + "partner": false + }, + { + "id": "example-partner-credit", + "provider": "example", + "title": { + "en": "Partner credit", + "pt": "Crédito de parceiro" + }, + "description": { + "en": "Canonical partner-offer fixture", + "pt": "Fixture canônico de oferta de parceiro" + }, + "benefit": { + "kind": "credit", + "amountMinor": 1000, + "currency": "USD" + }, + "publicBenefit": { + "kind": "credit", + "amountMinor": 500, + "currency": "USD" + }, + "conditions": { + "en": "Fixture only; not a real offer", + "pt": "Somente fixture; não é uma oferta real" + }, + "validUntil": null, + "url": "https://provider.example/partner-credit", + "partner": true + } + ] +} diff --git a/tests/unit/radar-offers-accessor.test.ts b/tests/unit/radar-offers-accessor.test.ts new file mode 100644 index 0000000000..98c92b1087 --- /dev/null +++ b/tests/unit/radar-offers-accessor.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { getRadarOffers } from "../../src/lib/radar/index.ts"; + +async function fixturePayload(): Promise { + return readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url), "utf8"); +} + +test("offers accessor short-circuits before cache when Radar is disabled", () => { + let reads = 0; + const result = getRadarOffers({ + getFlag: () => false, + getCache: () => { + reads += 1; + throw new Error("cache must not be read"); + }, + }); + + assert.deepEqual(result, { offers: [], meta: null }); + assert.equal(reads, 0); +}); + +test("offers accessor fails closed for missing, corrupt, or non-live cache", () => { + for (const cache of [ + null, + { version: "x", tier: "live", payload: "not-json", fetchedAt: "now" }, + { version: "x", tier: "community", payload: "{}", fetchedAt: "now" }, + ]) { + assert.deepEqual(getRadarOffers({ getFlag: () => true, getCache: () => cache }), { + offers: [], + meta: null, + }); + } +}); + +test("offers accessor revalidates the cache and removes expired entries", async () => { + const payload = await fixturePayload(); + const result = getRadarOffers({ + getFlag: () => true, + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload, + fetchedAt: "2026-08-09T12:05:00.000Z", + }), + now: () => new Date("2100-01-01T00:00:00.000Z"), + }); + + assert.deepEqual( + result.offers.map(({ id }) => id), + ["example-partner-credit"] + ); + assert.deepEqual(result.meta, { + version: "2026.08.09.1", + tier: "live", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); +}); diff --git a/tests/unit/radar-offers-contract.test.ts b/tests/unit/radar-offers-contract.test.ts new file mode 100644 index 0000000000..101bed3354 --- /dev/null +++ b/tests/unit/radar-offers-contract.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +import { + RadarOfferSchema, + RadarOffersFeedSchema, + filterActiveRadarOffers, + localizeRadarOfferText, +} from "../../src/lib/radar/offersFeedSchema.ts"; + +const EXPECTED_FIXTURE_HASH = "f01a4c03a72adbffa944b4bcc8610ad2fec31dc500feaed18bdd9d1af4f06216"; + +async function canonicalFixture(): Promise { + return readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url)); +} + +test("offers contract fixture is byte-identical to the private server contract", async () => { + const bytes = await canonicalFixture(); + assert.equal(createHash("sha256").update(bytes).digest("hex"), EXPECTED_FIXTURE_HASH); + + const feed = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))); + assert.equal(feed.count, 2); + assert.deepEqual( + feed.offers.map(({ id, partner }) => ({ id, partner })), + [ + { id: "example-official-trial", partner: false }, + { id: "example-partner-credit", partner: true }, + ] + ); +}); + +test("partner offer must be strictly better than a comparable public benefit", async () => { + const bytes = await canonicalFixture(); + const partner = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))).offers[1]!; + + assert.equal( + RadarOfferSchema.safeParse({ + ...partner, + benefit: { kind: "credit", amountMinor: 500, currency: "USD" }, + }).success, + false + ); + assert.equal( + RadarOfferSchema.safeParse({ + ...partner, + publicBenefit: { kind: "trial_days", days: 30 }, + }).success, + false + ); +}); + +test("active projection filters expired offers and localizes with English fallback", async () => { + const bytes = await canonicalFixture(); + const feed = RadarOffersFeedSchema.parse(JSON.parse(bytes.toString("utf8"))); + const expired = { + ...feed.offers[0]!, + id: "expired", + validUntil: "2026-08-01T00:00:00.000Z", + }; + + assert.deepEqual( + filterActiveRadarOffers([...feed.offers, expired], new Date("2026-08-09T12:00:00.000Z")).map( + ({ id }) => id + ), + ["example-official-trial", "example-partner-credit"] + ); + assert.equal(localizeRadarOfferText({ en: "English", pt: "Português" }, "pt-BR"), "Português"); + assert.equal(localizeRadarOfferText({ en: "English" }, "de"), "English"); +}); diff --git a/tests/unit/radar-offers-db.test.ts b/tests/unit/radar-offers-db.test.ts new file mode 100644 index 0000000000..fa93225ffa --- /dev/null +++ b/tests/unit/radar-offers-db.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-offers-db-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-offers-db-32b!"; + +const core = await import("../../src/lib/db/core.ts"); +const radar = await import("../../src/lib/db/radar.ts"); + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.STORAGE_ENCRYPTION_KEY; +}); + +test("Radar offers cache migration creates a single-row byte-preserving store", () => { + const db = core.getDbInstance(); + assert.equal(radar.getRadarOffersCache(), null); + + radar.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload: '{"byte":"exact"}\n', + signature: "signed", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + radar.setRadarOffersCache({ + version: "2026.08.09.2", + tier: "live", + payload: '{"replacement":true}', + signature: "signed-again", + fetchedAt: "2026-08-09T12:10:00.000Z", + }); + + assert.deepEqual(radar.getRadarOffersCache(), { + version: "2026.08.09.2", + tier: "live", + payload: '{"replacement":true}', + signature: "signed-again", + fetchedAt: "2026-08-09T12:10:00.000Z", + }); + const row = db.prepare("SELECT COUNT(*) AS count FROM radar_offers_cache").get() as { + count: number; + }; + assert.equal(row.count, 1); +}); + +test("changing the supporter key atomically invalidates every entitlement-sensitive cache", () => { + const db = core.getDbInstance(); + radar.setRadarCache({ version: "2026.08.09.1", tier: "live", payload: "{}", signature: "a" }); + radar.setRadarReferralsCache({ + generatedAt: "2026-08-09T12:00:00.000Z", + tier: "live", + payload: "{}", + signature: "b", + }); + radar.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload: "{}", + signature: "c", + }); + + radar.setRadarKey(`omr_${"a".repeat(40)}`); + + assert.equal(radar.getRadarCache(), null); + assert.equal(radar.getRadarReferralsCache(), null); + assert.equal(radar.getRadarOffersCache(), null); + const stored = db + .prepare("SELECT supporter_key_encrypted AS key FROM radar_settings WHERE id = 1") + .get() as { key: string }; + assert.ok(!stored.key.includes("omr_"), "supporter key must stay encrypted at rest"); +}); diff --git a/tests/unit/radar-offers-routes.test.ts b/tests/unit/radar-offers-routes.test.ts new file mode 100644 index 0000000000..1ff65c20ff --- /dev/null +++ b/tests/unit/radar-offers-routes.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { SignJWT } from "jose"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-offers-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-offers-routes-32b!"; +process.env.JWT_SECRET = "test-jwt-secret-for-radar-offers-routes"; +process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-offers-routes"; + +const core = await import("../../src/lib/db/core.ts"); +const radarDb = await import("../../src/lib/db/radar.ts"); + +async function authHeaders(): Promise> { + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + return { Cookie: `auth_token=${token}` }; +} + +function resetStorage(): void { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function request( + pathname: string, + method: "GET" | "POST", + headers: Record = {}, + body?: unknown +) { + return new Request(`http://localhost:20128${pathname}`, { + method, + headers: { ...headers, ...(body === undefined ? {} : { "content-type": "application/json" }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.RADAR_ENABLED; + delete process.env.STORAGE_ENCRYPTION_KEY; +}); + +test("offers routes are inert before auth when the feature flag is off", async () => { + resetStorage(); + delete process.env.RADAR_ENABLED; + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + assert.equal((await GET(request("/api/radar/offers", "GET"))).status, 404); + assert.equal((await POST(request("/api/radar/offers/sync", "POST"))).status, 404); +}); + +test("offers routes require dashboard or management authentication", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + assert.equal((await GET(request("/api/radar/offers", "GET"))).status, 401); + assert.equal((await POST(request("/api/radar/offers/sync", "POST"))).status, 401); +}); + +test("GET offers returns only the local cache and never exposes supporter key material", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + const payload = fs.readFileSync( + path.resolve(process.cwd(), "tests/fixtures/radar-offers-canonical.json"), + "utf8" + ); + radarDb.setRadarOffersCache({ + version: "2026.08.09.1", + tier: "live", + payload, + signature: "fixture-signature", + fetchedAt: "2026-08-09T12:05:00.000Z", + }); + const { GET } = await import("../../src/app/api/radar/offers/route.ts"); + const response = await GET(request("/api/radar/offers", "GET", await authHeaders())); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.offers.length, 2); + assert.equal(body.meta.tier, "live"); + assert.ok(!JSON.stringify(body).includes("omr_")); +}); + +test("POST offers sync validates an empty body and gates a missing key without network", async () => { + resetStorage(); + process.env.RADAR_ENABLED = "true"; + radarDb.setRadarOptIn(true); + const { POST } = await import("../../src/app/api/radar/offers/sync/route.ts"); + + const invalid = await POST( + request("/api/radar/offers/sync", "POST", await authHeaders(), { provider: "groq" }) + ); + assert.equal(invalid.status, 400); + + const response = await POST(request("/api/radar/offers/sync", "POST", await authHeaders())); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: "no_key" }); +}); + +test("local offer routes never call the private server directly", () => { + for (const file of [ + "src/app/api/radar/offers/route.ts", + "src/app/api/radar/offers/sync/route.ts", + ]) { + const source = fs.readFileSync(path.resolve(process.cwd(), file), "utf8"); + assert.ok(!/fetch\(/.test(source), `${file} must stay local-only`); + } +}); diff --git a/tests/unit/radar-offers-sync.test.ts b/tests/unit/radar-offers-sync.test.ts new file mode 100644 index 0000000000..825390de2c --- /dev/null +++ b/tests/unit/radar-offers-sync.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); +process.env.RADAR_FEED_PUBKEY = publicKey + .export({ type: "spki", format: "der" }) + .toString("base64"); + +const offersSync = await import("../../src/lib/radar/offersSync.ts"); + +async function fixtureFeed(): Promise> { + const bytes = await readFile(new URL("../fixtures/radar-offers-canonical.json", import.meta.url)); + return JSON.parse(bytes.toString("utf8")) as Record; +} + +function sign(bytes: Buffer): string { + return crypto.sign(null, bytes, privateKey).toString("base64"); +} + +function response(body: Buffer, headers: Record = {}, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + } as Response; +} + +function liveSettings(supporterKey: string | null = `omr_${"a".repeat(40)}`) { + return { optIn: true, supporterKey }; +} + +test("offers sync gates flag, opt-in, and missing supporter key before fetch", async () => { + for (const expected of ["disabled", "opt_out", "no_key"] as const) { + let fetched = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => expected !== "disabled", + getSettings: () => + expected === "opt_out" ? { optIn: false, supporterKey: null } : liveSettings(null), + fetch: (async () => { + fetched = true; + return response(Buffer.from("{}")); + }) as typeof fetch, + }); + assert.equal(result.status, expected); + assert.equal(fetched, false); + } +}); + +test("valid live offer feed sends Bearer server-side and caches exact signed bytes", async () => { + const feed = await fixtureFeed(); + const bytes = Buffer.from(JSON.stringify(feed)); + const signature = sign(bytes); + const writes: offersSync.RadarOffersCacheEntry[] = []; + let requestUrl = ""; + let authorization = ""; + + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => null, + setCache: (entry) => writes.push(entry), + fetch: (async (input, init) => { + requestUrl = String(input); + authorization = new Headers(init?.headers).get("authorization") ?? ""; + return response(bytes, { + "x-omniroute-feed-signature": signature, + "x-omniroute-feed-tier": "live", + }); + }) as typeof fetch, + now: () => new Date("2026-08-09T12:05:00.000Z"), + }); + + assert.deepEqual(result, { status: "updated", version: "2026.08.09.1" }); + assert.equal(requestUrl, "https://radar.omniroute.online/v1/offers/latest"); + assert.equal(authorization, `Bearer omr_${"a".repeat(40)}`); + assert.equal(writes[0]!.payload, bytes.toString("utf8")); + assert.equal(writes[0]!.signature, signature); + assert.equal(writes[0]!.tier, "live"); +}); + +test("signature, schema, and live-tier failures preserve the last good cache", async () => { + const feed = await fixtureFeed(); + const validBytes = Buffer.from(JSON.stringify(feed)); + const cases: Array<{ expected: string; bytes: Buffer; signature: string; tier: string | null }> = + [ + { expected: "invalid_signature", bytes: validBytes, signature: "invalid", tier: "live" }, + { + expected: "invalid_schema", + bytes: Buffer.from('{"feed":"wrong"}'), + signature: "valid-for-case", + tier: "live", + }, + { expected: "wrong_tier", bytes: validBytes, signature: "valid-for-case", tier: null }, + { expected: "wrong_tier", bytes: validBytes, signature: "valid-for-case", tier: "community" }, + ]; + + for (const item of cases) { + item.signature = item.expected === "invalid_signature" ? item.signature : sign(item.bytes); + let written = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => ({ + version: "2026.08.08.1", + tier: "live", + payload: "last-good", + signature: "old", + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(item.bytes, { + "x-omniroute-feed-signature": item.signature, + ...(item.tier ? { "x-omniroute-feed-tier": item.tier } : {}), + })) as typeof fetch, + }); + assert.equal(result.status, item.expected); + assert.equal(written, false); + } +}); + +test("same or older signed offer versions are rejected as stale", async () => { + const feed = await fixtureFeed(); + const bytes = Buffer.from(JSON.stringify(feed)); + let written = false; + const result = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => ({ + version: "2026.08.09.1", + tier: "live", + payload: "last-good", + signature: "old", + }), + setCache: () => { + written = true; + }, + fetch: (async () => + response(bytes, { + "x-omniroute-feed-signature": sign(bytes), + "x-omniroute-feed-tier": "live", + })) as typeof fetch, + }); + + assert.equal(result.status, "stale"); + assert.equal(written, false); +}); + +test("oversized and sanitized network failures never overwrite the cache or leak the key", async () => { + let written = false; + const tooLarge = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(), + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => + response(Buffer.from("ignored"), { + "content-length": String(10 * 1024 * 1024 + 1), + })) as typeof fetch, + }); + assert.equal(tooLarge.status, "too_large"); + + const secret = `omr_${"b".repeat(40)}`; + const failed = await offersSync.syncRadarOffers({ + getFlag: () => true, + getSettings: () => liveSettings(secret), + getCache: () => null, + setCache: () => { + written = true; + }, + fetch: (async () => { + throw new Error(`upstream failed for ${secret}\n at /private/path.ts:1:1`); + }) as typeof fetch, + }); + assert.equal(failed.status, "error"); + assert.ok(!("reason" in failed) || !failed.reason.includes(secret)); + assert.ok(!("reason" in failed) || !failed.reason.includes("/private/path")); + assert.equal(written, false); +});