Compare commits

...

4 Commits

Author SHA1 Message Date
diegosouzapw
59a583ddf6 test(radar): refresh canonical feed hash 2026-08-08 18:41:40 -03:00
diegosouzapw
f379e9215a test(radar): localize canonical feed fixture 2026-08-08 18:26:50 -03:00
diegosouzapw
4f21e663f0 chore(changelog): assign Radar fix to PR 9776 2026-08-08 09:21:19 -03:00
diegosouzapw
f6708c78fb fix(radar): refresh entitlement-sensitive state 2026-08-08 09:17:32 -03:00
16 changed files with 400 additions and 165 deletions

View File

@@ -0,0 +1 @@
- **fix(radar):** refresh signed catalog/referral caches when supporter entitlement changes, preserve the one-time live-to-community downgrade, and test real provider connection IDs from the setup tour

View File

@@ -1,13 +1,13 @@
---
title: "Radar Free-Model Catalog"
version: 3.8.50
lastUpdated: 2026-08-07
lastUpdated: 2026-08-08
---
# Radar Free-Model Catalog
> **Source of truth:** `src/lib/radar/`, `src/lib/db/radar.ts`, `src/app/api/radar/`
> **Last updated:** 2026-08-07 — v3.8.50
> **Last updated:** 2026-08-08 — v3.8.50
Radar is an **optional add-on** that overlays a signed, freshly-curated free-model
catalog on top of the release baseline (`FREE_MODEL_BUDGETS` in
@@ -23,6 +23,23 @@ below.
---
## Delivery status in v3.8.50
The following status distinguishes what this OSS release implements from later Radar
workstreams. It is a code-level status, not a promise that a particular hosted deployment
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. |
| 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. |
| Payments and transactional email | Not implemented in the OSS client. Purchase, donation, receipt review, and mail delivery belong to the private service and its later operational workstream. |
| Research-agent workstream | Not part of this client release. Curated feed contents remain server-side data; no autonomous research agent runs in an OmniRoute installation. |
---
## Flag: `RADAR_ENABLED` (default off)
Radar is gated end-to-end by the `RADAR_ENABLED` feature flag
@@ -67,7 +84,8 @@ When both are on, the sync path is:
plain, unauthenticated-by-default GET. OmniRoute never posts usage data, provider
configuration, or model traffic to the feed service.
3. The response is verified, validated, and cached locally (see
[Security model](#security-model)). Nothing else touches the network for Radar.
[Security model](#security-model)). Radar has exactly two server-side network paths:
`syncRadar()` for the catalog and `syncRadarReferrals()` for the standalone referrals feed.
The **supporter key** is an optional Bearer token (`radar_settings.supporter_key`)
that lets the feed service decide which tier to serve (see
@@ -77,6 +95,9 @@ that lets the feed service decide which tier to serve (see
helpers (`src/lib/db/encryption.ts`) used for provider credentials.
- Set via `POST /api/radar/settings` (`{ supporterKey: "omr_" + 40 hex chars }`) and
**never echoed back** — the response returns a masked form (`omr_****abcd`).
- Changing or clearing it atomically invalidates both the catalog and referrals caches. The
next sync/read resolves the new entitlement server-side; saving a key does not itself make
a network request or consume a single-use activation key.
- Sent to the feed service as a Bearer token on the sync GET — nothing else about the
key ever leaves the client.
@@ -102,10 +123,10 @@ pattern as `RADAR_FEED_URL`) and relayed to the dashboard through the existing
`GET /api/radar/settings` response (`contributorClaimUrl`, `supporterPlansUrl`) — the
client component never reads `process.env` itself.
| Var | Purpose |
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
| Var | Purpose |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| `RADAR_CONTRIBUTOR_CLAIM_URL` | Overrides the contributor-claim URL (default `https://radar.omniroute.online/auth/github`). |
| `RADAR_SUPPORTER_PLANS_URL` | Overrides the supporter-plans URL (default `https://radar.omniroute.online/planos`). |
Once a visitor has a key (`omr_` + 40 hex chars), the activation screen
(`src/app/(dashboard)/dashboard/radar/page.tsx`) has a paste-key input as the primary
@@ -117,7 +138,7 @@ as a UX nicety; the server's Zod schema is the authoritative check either way. O
key is set, the activation screen shows the masked form (`supporterKeyMasked` from
`GET /api/radar/settings`) instead of an empty input, with a "change key" control to
paste a new one — the raw key is never redisplayed. The two claim/plans buttons above
remain the way to *obtain* a key in the first place; this input is where an operator
remain the way to _obtain_ a key in the first place; this input is where an operator
who already has one activates it.
---
@@ -207,11 +228,11 @@ handle.
### The served tier comes from a response header, not the signed body
The signed feed **body**'s `tier` field is always `"live"` — the feed service ships
**one signed artifact per version**, so the body cannot carry a per-request tier
without invalidating the Ed25519 signature (re-signing per request would defeat the
point of a pinned, cacheable, verifiable artifact). The tier actually served for a
given request is instead carried in the **`x-omniroute-feed-tier` response header**,
decided server-side from the request's `Authorization` key.
**two signed artifacts per version**: live includes current campaigns and community
omits them. Each artifact is signed over its own exact bytes. The body still does not
serve as the entitlement decision; the tier actually selected for a request is carried
in the **`x-omniroute-feed-tier` response header**, decided server-side from the request's
`Authorization` key.
`syncRadar()` (`src/lib/radar/sync.ts::parseServedTierHeader()`) is the single place
that resolves the tier a client should trust:
@@ -223,7 +244,7 @@ that resolves the tier a client should trust:
2. Fall back to the signed body's `tier` field (always `"live"`) only when step 1
yields nothing.
3. The resolved tier is what gets cached and returned as `{ status: "updated",
version, tier }` — this is the value the dashboard shows, never the raw body
version, tier }` — this is the value the dashboard shows, never the raw body
field.
---
@@ -265,18 +286,18 @@ Every merged entry carries an `origin` field the UI renders as a badge:
Five local routes 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. |
**Hard rule: these routes never proxy the feed service.** The browser only ever talks
to the local OmniRoute server; `syncRadar()` is the single module in the whole client
that touches the network for Radar (`src/lib/radar/sync.ts`), and it always runs
server-side, never client-side. This keeps the feed URL and any supporter key
to the local OmniRoute server. The two modules that touch the Radar service are
`src/lib/radar/sync.ts` (catalog) and `src/lib/radar/referralsSync.ts` (referrals); both
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
@@ -348,11 +369,13 @@ touches the network for referrals, mirroring `syncRadar()`'s contract exactly: f
Ed25519 signature over the exact response bytes (`verifyFeedBytes`), validates against
`RadarReferralsFeedSchema`, and caches into the `radar_referrals_cache` table
(migration `142_radar_referrals_cache.sql`) — a table entirely separate from the
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor (an
incoming feed with a `generatedAt` no newer than the cached one is treated as `stale`
and never overwrites the cache — guards against a replay of an older signed artifact)
mirror the catalog sync's own `MAX_FEED_BYTES`/version-floor guards. Never throws —
always returns a status object; errors never carry a stack trace in `reason`.
catalog's `radar_feed_cache`. A 10 MB response cap and a `generatedAt` floor reject an
incoming feed older than the cached one, guarding against replay of an older signed
artifact. An equal timestamp is accepted: the server intentionally gives the community
and live referral variants the same deterministic `generatedAt`, so the signed payload
and served tier can change after a supporter-key change without the underlying link set
changing. Never throws — always returns a status object; errors never carry a stack trace
in `reason`.
Two triggers keep the referrals cache warm, both independent of the catalog's own
24h cadence:

View File

@@ -1,10 +1,16 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Card } from "@/shared/components";
import {
firstProviderConnectionId,
providerConnectionsRequestUrl,
type RadarSetupConnection,
} from "@/lib/radar/setupConnections";
import type { RadarLocalizedText } from "@/lib/radar/feedSchema";
// ---------------------------------------------------------------------------
// Types
@@ -14,17 +20,16 @@ import { Card } from "@/shared/components";
* Localized text: either a plain string or an {en, pt?} object.
* The renderer resolves the best locale with EN fallback (D25 compat).
*/
type LocalizedText = string | { en: string; pt?: string };
interface SetupInfo {
keyUrl: string | null;
steps: LocalizedText[];
steps: RadarLocalizedText[];
}
interface ProviderSetupData {
provider: string;
setup: SetupInfo | null;
configured: boolean;
connectionId: string | null;
}
// ---------------------------------------------------------------------------
@@ -32,9 +37,9 @@ interface ProviderSetupData {
// ---------------------------------------------------------------------------
/** Resolve a LocalizedText to a display string. */
function resolveText(text: LocalizedText, locale: string): string {
function resolveText(text: RadarLocalizedText, locale: string): string {
if (typeof text === "string") return text;
if (locale === "pt" && text.pt) return text.pt;
if (locale.toLowerCase().startsWith("pt") && text.pt) return text.pt;
return text.en;
}
@@ -44,9 +49,9 @@ function resolveText(text: LocalizedText, locale: string): string {
export default function RadarSetupPage() {
const t = useTranslations("radarSetupPage");
const locale = useLocale();
const searchParams = useSearchParams();
const provider = searchParams.get("provider");
const locale = "en"; // Could be derived from next-intl locale later
const [setupData, setSetupData] = useState<ProviderSetupData | null>(null);
const [loading, setLoading] = useState(true);
@@ -63,18 +68,25 @@ export default function RadarSetupPage() {
async function load() {
try {
const res = await fetch("/api/radar/catalog");
const [res, connectionsRes] = await Promise.all([
fetch("/api/radar/catalog"),
fetch(providerConnectionsRequestUrl(provider)),
]);
if (res.status === 404) {
setError(t("flagDisabled"));
setLoading(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!connectionsRes.ok) throw new Error(`HTTP ${connectionsRes.status}`);
const data = await res.json();
const connectionsData = (await connectionsRes.json()) as {
connections?: RadarSetupConnection[];
};
// Find ALL entries for this provider and extract setup from the first one that has it
const providerEntries = data.entries.filter(
(e: { provider: string }) => e.provider === provider,
(e: { provider: string }) => e.provider === provider
);
if (providerEntries.length === 0) {
@@ -85,17 +97,19 @@ export default function RadarSetupPage() {
// Find setup info from feed entries (they carry the setup field)
const entryWithSetup = providerEntries.find(
(e: { setup?: SetupInfo | null }) => e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl),
(e: { setup?: SetupInfo | null }) =>
e.setup && (e.setup.steps.length > 0 || e.setup.keyUrl)
);
// Check if provider is configured (has connections)
// We infer this from whether the provider exists in the catalog at all
// The actual connection check would need a separate API — for now we show
// the guide regardless
const connectionId = firstProviderConnectionId(
Array.isArray(connectionsData.connections) ? connectionsData.connections : [],
provider
);
setSetupData({
provider,
setup: entryWithSetup?.setup ?? null,
configured: false, // Will be enriched when connection-status API is available
configured: connectionId !== null,
connectionId,
});
} catch (err) {
setError(err instanceof Error ? err.message : t("loadFailed"));
@@ -109,15 +123,11 @@ export default function RadarSetupPage() {
// Test connection — uses the EXISTING connection-test endpoint
const handleTestConnection = useCallback(async () => {
if (!provider) return;
if (!setupData?.connectionId) return;
setTesting(true);
setTestResult(null);
try {
// The existing test endpoint is POST /api/providers/[id]/test
// We need the connection ID — for now we use the provider ID as a proxy.
// In a full implementation, the setup page would list connections for
// this provider and test each one. Here we test the first connection.
const res = await fetch(`/api/providers/${encodeURIComponent(provider)}/test`, {
const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
@@ -136,7 +146,7 @@ export default function RadarSetupPage() {
} finally {
setTesting(false);
}
}, [provider, t]);
}, [setupData?.connectionId, t]);
if (!provider) {
return (
@@ -165,9 +175,7 @@ export default function RadarSetupPage() {
<p className="text-sm text-text-muted mt-1">{t("setupSubtitle")}</p>
</div>
{error && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>
)}
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
{loading ? (
<div className="flex items-center justify-center min-h-[200px]">
@@ -248,15 +256,13 @@ export default function RadarSetupPage() {
<div className="flex items-center gap-3">
<button
onClick={handleTestConnection}
disabled={testing}
disabled={testing || !setupData.connectionId}
className="px-4 py-2 text-sm font-medium rounded-lg border border-violet-500 text-violet-400 hover:bg-violet-500/10 transition-colors disabled:opacity-50"
>
{testing ? t("testing") : t("testButton")}
</button>
{testResult && (
<span
className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}
>
<span className={`text-sm ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.message}
</span>
)}

View File

@@ -103,9 +103,7 @@ export function setRadarCache(entry: {
export function getRadarSettings(): RadarSettings {
const db = getDbInstance();
const row = db
.prepare(
"SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1"
)
.prepare("SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1")
.get() as { opt_in: number; supporter_key_encrypted: string | null; updated_at: string };
return {
@@ -120,9 +118,9 @@ export function getRadarSettings(): RadarSettings {
*/
export function setRadarOptIn(optIn: boolean): void {
const db = getDbInstance();
db.prepare(
"UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1"
).run(optIn ? 1 : 0);
db.prepare("UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1").run(
optIn ? 1 : 0
);
}
/**
@@ -133,9 +131,20 @@ export function setRadarOptIn(optIn: boolean): void {
export function setRadarKey(key: string | null): void {
const db = getDbInstance();
const encrypted = key !== null ? encrypt(key) : null;
db.prepare(
const updateKey = db.prepare(
"UPDATE radar_settings SET supporter_key_encrypted = ?, updated_at = datetime('now') WHERE id = 1"
).run(encrypted);
);
const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1");
const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1");
db.transaction(() => {
updateKey.run(encrypted);
// Both 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();
})();
}
// ---------------------------------------------------------------------------

View File

@@ -5,12 +5,15 @@
* The baseline (`FREE_MODEL_BUDGETS`) is NEVER mutated.
*
* Merge rules:
* 1. Feed never overwrites a local override.
* 1. Feed never overwrites a local override, except the safety-critical
* `enabled:false` signal for an upstream model confirmed unavailable.
* 2. `enabled:false` disables the entry with `disabledBy: "radar"` provenance.
* 3. User-added entry NOT in the feed survives untouched.
* 4. User deletion tombstone prevents feed from resurrecting the entry.
*/
import type { RadarLocalizedText } from "./feedSchema";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -73,7 +76,7 @@ export interface MergedEntry {
/** Setup guide (key URL + steps) reported by the feed. Undefined for baseline-only entries. */
setup?: {
keyUrl: string | null;
steps: string[];
steps: RadarLocalizedText[];
} | null;
}
@@ -107,7 +110,7 @@ export interface FeedModel {
tosRisk: MergedEntry["tos"];
setup: {
keyUrl: string | null;
steps: string[];
steps: RadarLocalizedText[];
} | null;
enabled: boolean;
}
@@ -127,9 +130,7 @@ function entryKey(provider: string, modelId: string): string {
* Convert a FeedModel budget into a `monthlyTokens` number compatible
* with the baseline catalog shape.
*/
function feedBudgetToMonthlyTokens(
budget: FeedModel["budget"],
): number {
function feedBudgetToMonthlyTokens(budget: FeedModel["budget"]): number {
if (budget.kind === "per_model") return budget.tokensPerMonth;
if (budget.kind === "shared_pool") return budget.tokensPerMonth;
return 0; // rate_only
@@ -138,9 +139,7 @@ function feedBudgetToMonthlyTokens(
/**
* Convert a FeedModel budget into a `poolKey` compatible with the baseline.
*/
function feedBudgetToPoolKey(
budget: FeedModel["budget"],
): string | null {
function feedBudgetToPoolKey(budget: FeedModel["budget"]): string | null {
if (budget.kind === "shared_pool") return budget.poolId;
return null;
}
@@ -242,7 +241,7 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] {
function mergeOne(
base: MergedEntry,
feed: FeedModel,
overrides: Partial<MergedEntry> | undefined,
overrides: Partial<MergedEntry> | undefined
): MergedEntry {
// Start from baseline
const result: MergedEntry = { ...base };
@@ -307,6 +306,13 @@ function mergeOne(
if (overrides.setup !== undefined) result.setup = overrides.setup;
}
// Safety exception to rule 1: a model confirmed unavailable upstream is
// never resurrected by a stale local enabled:true override.
if (!feed.enabled) {
result.enabled = false;
result.disabledBy = "radar";
}
// Origin: "local" if user has overrides, else "radar" (feed updated it)
result.origin = overriddenKeys.size > 0 ? "local" : "radar";
@@ -318,7 +324,7 @@ function mergeOne(
*/
function feedModelToMerged(
feed: FeedModel,
overrides: Partial<MergedEntry> | undefined,
overrides: Partial<MergedEntry> | undefined
): MergedEntry {
const entry: MergedEntry = {
provider: feed.provider,
@@ -329,8 +335,8 @@ function feedModelToMerged(
freeType: overrides?.freeType ?? feed.freeType,
poolKey: overrides?.poolKey ?? feedBudgetToPoolKey(feed.budget),
tos: overrides?.tos ?? feed.tosRisk,
trainsOnPrompts: overrides?.trainsOnPrompts ?? (feed.trainsOnPrompts ?? undefined),
enabled: overrides?.enabled ?? feed.enabled,
trainsOnPrompts: overrides?.trainsOnPrompts ?? feed.trainsOnPrompts ?? undefined,
enabled: feed.enabled ? (overrides?.enabled ?? true) : false,
origin: overrides ? "local" : "radar",
contextWindow: overrides?.contextWindow ?? feed.contextWindow,
capabilities: overrides?.capabilities ?? feed.capabilities,
@@ -338,10 +344,8 @@ function feedModelToMerged(
setup: overrides?.setup ?? feed.setup,
};
// Rule 2 (feed disable) — but rule 1 (local override wins) takes precedence,
// matching mergeOne(): only force-disable when the user has NOT explicitly
// overridden `enabled` locally.
if (!feed.enabled && overrides?.enabled === undefined) {
// Rule 2 is the safety exception to local override precedence.
if (!feed.enabled) {
entry.enabled = false;
entry.disabledBy = "radar";
}

View File

@@ -43,6 +43,16 @@ export type RadarTier = z.infer<typeof RadarTierSchema>;
const IntNullable = z.number().int().nullable();
/** D25: English is canonical; Portuguese is an optional localized companion. */
export const RadarLocalizedTextSchema = z.union([
z.string(), // compatibility with schema-v1 feeds published before D25
z.object({
en: z.string().min(1),
pt: z.string().min(1).optional(),
}),
]);
export type RadarLocalizedText = z.infer<typeof RadarLocalizedTextSchema>;
/**
* Budget is a discriminated union on `kind`:
* - per_model: tokensPerMonth (positive int)
@@ -80,7 +90,7 @@ const CapabilitiesSchema = z.object({
const SetupSchema = z
.object({
keyUrl: z.string().url().nullable(),
steps: z.array(z.string()),
steps: z.array(RadarLocalizedTextSchema),
})
.nullable();
@@ -167,8 +177,8 @@ const QuirkTargetSchema = z.object({
const QuirkSchema = z.object({
slug: z.string(),
title: z.string(),
body: z.string(),
title: RadarLocalizedTextSchema,
body: RadarLocalizedTextSchema,
severity: SeverityEnum,
targets: z.array(QuirkTargetSchema),
});
@@ -182,10 +192,9 @@ export const RadarFeedSchema = z.object({
schemaVersion: z.literal(1),
version: z.string(),
generatedAt: z.string().datetime(),
// NOTE: this body field is ALWAYS "live", by design — the community tier
// is the exact same signed bytes served from an older snapshot, and there
// is only one signed artifact per version (rewriting this field
// server-side per request would break the exact-bytes Ed25519 signature).
// NOTE: this body field is not the entitlement decision. The server can
// publish separate exact-byte live/community artifacts for one version,
// while the selected request tier is still communicated by the header.
// The tier ACTUALLY served is decided by the server per-request based on
// the Authorization key, and is surfaced via the `x-omniroute-feed-tier`
// response header instead. NEVER read this field for UI/display — use the

View File

@@ -18,10 +18,7 @@
* Deps are injectable for testing.
*/
import {
RadarReferralsFeedSchema,
type RadarReferralsFeed,
} from "./referralsFeedSchema";
import { RadarReferralsFeedSchema, type RadarReferralsFeed } from "./referralsFeedSchema";
import { RadarTierSchema, type RadarTier } from "./feedSchema";
import { verifyFeedBytes } from "./verify";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
@@ -144,9 +141,10 @@ export function shouldSyncReferralsOnRead(
* 4. Verify Ed25519 signature over exact bytes (same pinned key as the
* catalog feed — one key pins both artifacts).
* 5. Parse+validate with RadarReferralsFeedSchema.
* 6. Replay guard: incoming `generatedAt` must be strictly newer than the
* cache (a same/older `generatedAt` is a no-op — nothing changed, or a
* stale replay — either way the cache is left untouched).
* 6. Replay guard: reject an incoming `generatedAt` older than the cache.
* Equal timestamps remain valid because the community and live referral
* variants deliberately share one deterministic `generatedAt`; the
* served tier can still change after the supporter key changes.
* 7. Cache the result.
*
* @param deps - Injectable dependencies for testing.
@@ -262,7 +260,8 @@ export async function syncRadarReferrals(
return { status: "invalid_schema" };
}
// Step 7: generatedAt floor — replay/no-op guard.
// Step 7: generatedAt floor — reject older signed replays. Equal
// timestamps are accepted because entitlement variants share generatedAt.
let existingCache: RadarReferralsCacheEntry | null = null;
if (getCacheFn) {
existingCache = getCacheFn();
@@ -274,7 +273,7 @@ export async function syncRadarReferrals(
if (existingCache) {
const existingMs = Date.parse(existingCache.generatedAt);
const incomingMs = Date.parse(feed.generatedAt);
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs <= existingMs) {
if (Number.isFinite(existingMs) && Number.isFinite(incomingMs) && incomingMs < existingMs) {
return { status: "stale" };
}
}
@@ -282,7 +281,8 @@ export async function syncRadarReferrals(
// Step 8: Resolve served tier — the body carries no `tier` field at all
// for this feed, so the header is the only source; absent/garbage header
// degrades to the least-privileged "community" default.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
const servedTier =
parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? "community";
// Step 9: Cache the result
const cacheEntry: RadarReferralsCacheEntry = {

View File

@@ -0,0 +1,26 @@
export interface RadarSetupConnection {
id: string;
provider: string;
isActive?: boolean;
}
/** Reuse the authenticated provider-connections API with a bounded provider filter. */
export function providerConnectionsRequestUrl(provider: string): string {
return `/api/providers?provider=${encodeURIComponent(provider)}`;
}
/**
* Pick a concrete connection id for the setup test endpoint. Prefer an active
* connection, then fall back to the first valid connection for the provider.
*/
export function firstProviderConnectionId(
connections: readonly RadarSetupConnection[],
provider: string
): string | null {
const matching = connections.filter(
(connection) => connection.provider === provider && connection.id.length > 0
);
return (
matching.find((connection) => connection.isActive !== false)?.id ?? matching[0]?.id ?? null
);
}

View File

@@ -266,7 +266,14 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
return { status: "invalid_schema" };
}
// Step 7: Version floor
// Step 7: Resolve the served tier before the version floor. A single-use
// supporter key deliberately transitions from live to community after its
// first catalog pull, and the community snapshot can be older.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
// Step 8: Version floor. Same/older versions are rejected within a tier,
// but a verified live -> community transition must replace the privileged
// cache even when the community snapshot is older.
let existingCache: RadarCacheEntry | null = null;
if (getCacheFn) {
existingCache = getCacheFn();
@@ -275,17 +282,15 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
existingCache = mod.getRadarCache();
}
if (existingCache && compareVersions(feed.version, existingCache.version) <= 0) {
const isEntitlementDowngrade = existingCache?.tier === "live" && servedTier === "community";
if (
existingCache &&
!isEntitlementDowngrade &&
compareVersions(feed.version, existingCache.version) <= 0
) {
return { status: "stale" };
}
// Step 8: Resolve the served tier.
// The `x-omniroute-feed-tier` header reflects the tier ACTUALLY served
// (see `parseServedTierHeader`); fall back to the signed body's `tier`
// field only when the header is absent or unrecognized — never trust an
// arbitrary header value into the cache/UI.
const servedTier = parseServedTierHeader(res.headers.get("x-omniroute-feed-tier")) ?? feed.tier;
// Step 9: Cache the result
const cacheEntry: RadarCacheEntry = {
version: feed.version,

View File

@@ -46,7 +46,10 @@
"setup": {
"keyUrl": "https://console.groq.com/keys",
"steps": [
"Crie uma conta gratuita no console da Groq",
{
"en": "Create a free account in the Groq console",
"pt": "Crie uma conta gratuita no console da Groq"
},
"Gere uma API key na página de keys",
"Adicione a key no OmniRoute com o provider groq"
]

View File

@@ -15,11 +15,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyFeed,
type MergedEntry,
type FeedModel,
} from "../../src/lib/radar/applyFeed.ts";
import { applyFeed, type MergedEntry, type FeedModel } from "../../src/lib/radar/applyFeed.ts";
import {
getRadarCatalog,
baselineToMergedEntries,
@@ -70,7 +66,9 @@ function makeBaseline(): MergedEntry[] {
];
}
function makeFeedModel(overrides: Partial<FeedModel> & { provider: string; modelId: string }): FeedModel {
function makeFeedModel(
overrides: Partial<FeedModel> & { provider: string; modelId: string }
): FeedModel {
return {
displayName: overrides.displayName ?? overrides.modelId,
familyId: null,
@@ -116,7 +114,7 @@ test("rule 1: feed does NOT overwrite a local override field", () => {
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
// Local override fields must survive
@@ -150,7 +148,7 @@ test("rule 2: feed enabled:false disables entry and carries disabledBy provenanc
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.enabled, false);
@@ -191,9 +189,7 @@ test("rule 3: user-added entry not in feed survives untouched", () => {
tombstones: new Set(),
});
const custom = result.find(
(e) => e.provider === "custom" && e.modelId === "my-local-model",
)!;
const custom = result.find((e) => e.provider === "custom" && e.modelId === "my-local-model")!;
assert.equal(custom.displayName, "My Local Model");
assert.equal(custom.monthlyTokens, 50_000);
@@ -242,7 +238,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
});
const groq = result.filter(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
);
// Should be deduplicated to ONE entry
@@ -262,7 +258,7 @@ test("rule 3b: user-added entry that IS in the feed merges with rule 1", () => {
test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () => {
// Baseline has an entry for gemini, but user deleted it
const baseline = makeBaseline().filter(
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash"),
(e) => !(e.provider === "gemini" && e.modelId === "gemini-2.5-flash")
);
const feed: FeedModel[] = [
@@ -283,9 +279,7 @@ test("rule 4: tombstone prevents feed from resurrecting a deleted entry", () =>
tombstones,
});
const gemini = result.find(
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
);
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash");
// Must NOT be resurrected
assert.equal(gemini, undefined);
@@ -353,9 +347,7 @@ test("applyFeed: feed-only entry is added with origin 'radar'", () => {
tombstones: new Set(),
});
const added = result.find(
(e) => e.provider === "new-provider" && e.modelId === "new-model",
);
const added = result.find((e) => e.provider === "new-provider" && e.modelId === "new-model");
assert.ok(added, "feed-only entry should be present");
assert.equal(added.displayName, "Brand New Model");
@@ -388,7 +380,7 @@ test("applyFeed: feed fields merge over baseline where no local override", () =>
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
// Feed values win when no local override
@@ -440,7 +432,7 @@ test("applyFeed: duplicate key (baseline + feed) produces single merged entry",
});
const groqEntries = result.filter(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
);
assert.equal(groqEntries.length, 1, "should be deduplicated to one entry");
@@ -465,9 +457,7 @@ test("rule 4b: tombstoned entry removed even when baseline has it", () => {
tombstones,
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
);
const groq = result.find((e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile");
assert.equal(groq, undefined, "tombstoned entry should be excluded");
});
@@ -494,9 +484,7 @@ test("origin switches to 'radar' when feed updates a baseline entry", () => {
tombstones: new Set(),
});
const gemini = result.find(
(e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash",
)!;
const gemini = result.find((e) => e.provider === "gemini" && e.modelId === "gemini-2.5-flash")!;
assert.equal(gemini.origin, "radar");
assert.equal(gemini.displayName, "Updated Gemini");
@@ -612,7 +600,7 @@ test("getRadarCatalog: valid cache returns merged entries with meta", () => {
// The feed has groq:llama-3.3-70b-versatile, which merges over baseline
const groq = result.entries.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.displayName, "Feed Groq Name");
@@ -714,7 +702,7 @@ test("FIX2 mergeOne path: contextWindow/capabilities/limits/setup survive merge
});
const groq = result.find(
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile",
(e) => e.provider === "groq" && e.modelId === "llama-3.3-70b-versatile"
)!;
assert.equal(groq.contextWindow, 131072);
@@ -758,13 +746,11 @@ test("FIX2 feedModelToMerged path: contextWindow/capabilities/limits/setup survi
});
// ===========================================================================
// FIX 4 — feedModelToMerged() must honor an `enabled` local override instead
// of unconditionally forcing `enabled:false` when the feed disables the model.
// mergeOne() already gets this right (overrides applied AFTER rule 2); this
// pins the same semantics on the feed-only path.
// Feed `enabled:false` is the safety exception to local override precedence:
// a model confirmed dead upstream must not be resurrected locally.
// ===========================================================================
test("FIX4: feed-only entry with local override enabled:true wins over feed enabled:false", () => {
test("rule 2: feed-only entry stays disabled even with local enabled:true", () => {
const baseline = makeBaseline();
const feed: FeedModel[] = [
makeFeedModel({
@@ -786,11 +772,11 @@ test("FIX4: feed-only entry with local override enabled:true wins over feed enab
});
const entry = result.find(
(e) => e.provider === "new-provider" && e.modelId === "disabled-model",
(e) => e.provider === "new-provider" && e.modelId === "disabled-model"
)!;
assert.equal(entry.enabled, true, "local override must win over feed disable");
assert.equal(entry.disabledBy, undefined, "must not carry radar disabledBy when overridden on");
assert.equal(entry.enabled, false, "a local override must not resurrect a dead upstream model");
assert.equal(entry.disabledBy, "radar");
});
test("FIX4: feed-only entry with NO override still gets disabled with disabledBy provenance", () => {
@@ -811,7 +797,7 @@ test("FIX4: feed-only entry with NO override still gets disabled with disabledBy
});
const entry = result.find(
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2",
(e) => e.provider === "new-provider" && e.modelId === "disabled-model-2"
)!;
assert.equal(entry.enabled, false);

View File

@@ -165,7 +165,9 @@ test("setRadarKey encrypts at rest and getRadarSettings decrypts", () => {
assert.equal(settings.supporterKey, clearKey, "getRadarSettings must return the clear key");
// Direct DB query to prove encryption at rest
interface SettingsRow { supporter_key_encrypted: string | null }
interface SettingsRow {
supporter_key_encrypted: string | null;
}
const row = db
.prepare("SELECT supporter_key_encrypted FROM radar_settings WHERE id = 1")
.get() as SettingsRow;
@@ -197,6 +199,36 @@ test("setRadarKey(null) clears the key", () => {
assert.equal(cleared.supporter_key_encrypted, null, "DB value must be null");
});
test("changing the supporter key invalidates the entitlement-sensitive referrals cache", () => {
radar.setRadarCache({
version: "2026.08.07.1",
tier: "live",
payload: '{"models":[]}',
signature: "catalog-live-signature",
});
radar.setRadarReferralsCache({
generatedAt: "2026-08-07T12:00:00.000Z",
tier: "live",
payload: '{"referrals":{"fixed":[],"campaigns":[{"provider":"groq"}]}}',
signature: "live-signature",
});
assert.ok(radar.getRadarReferralsCache(), "precondition: live referrals cache exists");
assert.ok(radar.getRadarCache(), "precondition: live catalog cache exists");
radar.setRadarKey("omr_" + "d".repeat(40));
assert.equal(
radar.getRadarReferralsCache(),
null,
"a new key must force the next referrals read to resolve entitlement server-side"
);
assert.equal(
radar.getRadarCache(),
null,
"a new key must force the next catalog sync to resolve entitlement server-side"
);
});
test("setRadarKey uses existing AES-256-GCM encryption from encryption.ts", () => {
const db = core.getDbInstance();
const clearKey = "omr_" + "c".repeat(40);
@@ -271,7 +303,9 @@ test("second setRadarReferralsCache REPLACES the row (still single row)", () =>
assert.equal(result.tier, "live", "must have the second tier");
assert.equal(result.payload, '{"new":true}', "must have the second payload");
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as { c: number };
const count = db.prepare("SELECT COUNT(*) AS c FROM radar_referrals_cache").get() as {
c: number;
};
assert.equal(count.c, 1, "must have exactly one row");
});

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { RadarFeedSchema } from "../../src/lib/radar/feedSchema.ts";
const fixture = JSON.parse(
readFileSync(new URL("../fixtures/radar-feed-canonical.json", import.meta.url), "utf8")
) as Record<string, unknown>;
test("canonical fixture carries D25 localized setup and accepts localized quirks", () => {
const localized = structuredClone(fixture) as {
models: Array<{ setup: { steps: unknown[] } | null }>;
quirks: Array<{ title: unknown; body: unknown }>;
};
localized.quirks = [
{
slug: "shared-pool",
title: { en: "Shared quota", pt: "Cota compartilhada" },
body: { en: "Models share one pool." },
severity: "info",
targets: [{ provider: "groq", modelGlob: null }],
},
];
const parsed = RadarFeedSchema.parse(localized);
assert.deepEqual(parsed.models[0]!.setup!.steps[0], {
en: "Create a free account in the Groq console",
pt: "Crie uma conta gratuita no console da Groq",
});
});
test("RadarFeedSchema preserves schema-v1 legacy setup and quirk strings", () => {
const legacy = structuredClone(fixture) as {
models: Array<{ setup: { steps: unknown[] } | null }>;
quirks: Array<{ title: unknown; body: unknown }>;
};
legacy.models[0]!.setup!.steps[0] = "Create an account";
legacy.quirks[0]!.title = "Shared quota";
legacy.quirks[0]!.body = "Models share one pool.";
const parsed = RadarFeedSchema.parse(legacy);
assert.equal(parsed.models[0]!.setup!.steps[0], "Create an account");
assert.equal(parsed.quirks[0]!.title, "Shared quota");
assert.equal(parsed.quirks[0]!.body, "Models share one pool.");
});

View File

@@ -118,7 +118,11 @@ test("RadarReferralsFeedSchema: rejects missing referrals section", () => {
const feed = baseReferralsFeed();
delete (feed as Record<string, unknown>).referrals;
const result = referralsFeedSchema.RadarReferralsFeedSchema.safeParse(feed);
assert.equal(result.success, false, "referrals section is required (no old-feed compat needed here)");
assert.equal(
result.success,
false,
"referrals section is required (no old-feed compat needed here)"
);
});
test("RadarReferralsFeedSchema: rejects a non-https referral url", () => {
@@ -215,7 +219,10 @@ test("syncRadarReferrals: valid signature => cache updated, payload byte-identic
},
fetch: (() =>
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig, "x-omniroute-feed-tier": "community" })
mockResponse(bytes, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "community",
})
)) as unknown as typeof globalThis.fetch,
now: () => new Date("2026-08-07T12:05:00.000Z"),
});
@@ -296,32 +303,47 @@ test("syncRadarReferrals: valid sig over garbage JSON => invalid_schema, cache u
// syncRadarReferrals — generatedAt floor (replay/no-op guard)
// ===========================================================================
test("syncRadarReferrals: same generatedAt as cache => stale, cache untouched", async () => {
test("syncRadarReferrals: same generatedAt with a new served tier replaces the cache", async () => {
const feed = baseReferralsFeed("2026-08-07T12:00:00.000Z");
(feed.referrals as { campaigns: Array<Record<string, unknown>> }).campaigns = [
{
provider: "groq",
url: "https://groq.com/?campaign=live",
kind: "campanha",
validUntil: null,
requiredAction: null,
isDefault: false,
},
];
const bytes = feedBytes(feed);
const sig = signBytes(bytes);
let cacheWritten = false;
const cacheStore: referralsSync.RadarReferralsCacheEntry[] = [];
const result = await referralsSync.syncRadarReferrals({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
getCache: () => ({
generatedAt: "2026-08-07T12:00:00.000Z",
tier: "community",
payload: "{}",
signature: "old-sig",
}),
setCache: () => {
cacheWritten = true;
setCache: (entry) => {
cacheStore.push(entry);
},
fetch: (() =>
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
mockResponse(bytes, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "live",
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "stale");
assert.equal(cacheWritten, false);
assert.equal(result.status, "updated");
assert.equal(cacheStore.length, 1);
assert.equal(cacheStore[0]!.tier, "live");
assert.equal(cacheStore[0]!.payload, bytes.toString("utf-8"));
});
test("syncRadarReferrals: older generatedAt than cache => stale (replay rejected)", async () => {
@@ -487,7 +509,9 @@ test("syncRadarReferrals: header absent => falls back to 'community' (no body ti
cacheStore.push(entry);
},
fetch: (() =>
Promise.resolve(mockResponse(bytes, { "x-omniroute-feed-signature": sig }))) as unknown as typeof globalThis.fetch,
Promise.resolve(
mockResponse(bytes, { "x-omniroute-feed-signature": sig })
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
@@ -596,7 +620,9 @@ test("syncRadarReferrals: HTTP non-200 => error mentioning the status code", asy
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
fetch: (() =>
Promise.resolve(mockResponse(Buffer.from("Internal Server Error"), {}, 500))) as unknown as typeof globalThis.fetch,
Promise.resolve(
mockResponse(Buffer.from("Internal Server Error"), {}, 500)
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "error");
@@ -646,7 +672,8 @@ test("FIX: oversized body without a trustworthy Content-Length header => too_lar
setCache: () => {
setCacheCalled = true;
},
fetch: (() => Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
fetch: (() =>
Promise.resolve(mockResponse(oversized, {}))) as unknown as typeof globalThis.fetch,
});
assert.deepEqual(result, { status: "too_large" });

View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
firstProviderConnectionId,
providerConnectionsRequestUrl,
} from "../../src/lib/radar/setupConnections.ts";
test("providerConnectionsRequestUrl filters the existing providers API", () => {
assert.equal(
providerConnectionsRequestUrl("openrouter/custom"),
"/api/providers?provider=openrouter%2Fcustom"
);
});
test("firstProviderConnectionId selects a real connection id, never the provider slug", () => {
assert.equal(
firstProviderConnectionId(
[
{ id: "connection-disabled", provider: "groq", isActive: false },
{ id: "connection-active", provider: "groq", isActive: true },
],
"groq"
),
"connection-active"
);
assert.equal(firstProviderConnectionId([], "groq"), null);
});

View File

@@ -82,7 +82,7 @@ test("contract: fixture sha256 matches the server's canonical hash", () => {
const hash = crypto.createHash("sha256").update(FIXTURE_BYTES).digest("hex");
assert.equal(
hash,
"13992d27702071bcb240306b052d0fdfacb2a2c688056c5e5118e92957e28a3d",
"80194e15a8add2a3be57eaef63b26ab75976c83e20b5589172aefa806eae72d3",
"Fixture sha256 must match the server's canonical fixture. " +
"If this fails, the fixture was modified or re-downloaded with different formatting."
);
@@ -409,6 +409,35 @@ test("syncRadar: version floor — incoming older => stale", async () => {
assert.equal(cacheWritten, false, "cache must NOT be overwritten with older version");
});
test("syncRadar: an entitlement downgrade replaces a newer live cache with community", async () => {
const sig = signBytes(FIXTURE_BYTES);
const cacheStore: syncMod.RadarCacheEntry[] = [];
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: "omr_" + "a".repeat(40) }),
getCache: () => ({
version: "2026.08.02.1",
tier: "live",
payload: "{}",
signature: "old-live-sig",
}),
setCache: (entry) => cacheStore.push(entry),
fetch: (() =>
Promise.resolve(
mockResponse(FIXTURE_BYTES, {
"x-omniroute-feed-signature": sig,
"x-omniroute-feed-tier": "community",
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
assert.equal(cacheStore.length, 1);
assert.equal(cacheStore[0]!.tier, "community");
assert.equal(cacheStore[0]!.version, "2026.08.01.1");
});
test("syncRadar: version floor — incoming newer => updated", async () => {
// Modify fixture to have a newer version
const fixtureObj = JSON.parse(FIXTURE_STRING);