From 1a6908034f56fb470d6f6f01f5408455b5eb1ba7 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Tue, 30 Jun 2026 06:43:15 +0100 Subject: [PATCH] feat: add provenance field to providers and render badge in dashboard - Provenance type (mock|fallback|live|unknown) in shared types + schema - Set provenance on all 7 providers in pricing.ts - Compact color-coded badge in ControlDeckPage provider cards - Tests for catalog shape, provenance values, and baseline verification --- apps/api/src/lib/pricing.test.ts | 17 +++++++++--- apps/api/src/lib/pricing.ts | 7 +++++ apps/api/src/routes/public.test.ts | 37 ++++++++++++++++++++++++++ apps/web/src/pages/ControlDeckPage.tsx | 3 +++ apps/web/src/styles.css | 20 ++++++++++++++ packages/shared/src/schemas.ts | 3 +++ packages/shared/src/types.ts | 2 ++ 7 files changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/api/src/lib/pricing.test.ts b/apps/api/src/lib/pricing.test.ts index c98a423..638eaaa 100644 --- a/apps/api/src/lib/pricing.test.ts +++ b/apps/api/src/lib/pricing.test.ts @@ -23,6 +23,12 @@ describe("provider pricing", () => { expect(providers.length).toBeGreaterThanOrEqual(7); }); + it("all providers have a valid provenance", () => { + for (const provider of providers) { + expect(["mock", "fallback", "live", "unknown"]).toContain(provider.provenance); + } + }); + it("returns provider-specific prices for search, news, and scrape", () => { expect(getProviderById("search.basic")?.priceUsd).toBe(0.01); expect(getProviderById("search.pro")?.priceUsd).toBe(0.02); @@ -58,6 +64,7 @@ describe("provider catalog baseline", () => { priceUsd: number; enabled: boolean; sourceType: string; + provenance: string; } // These are the canonical baseline providers the demo and SCF pitch depend on. @@ -69,21 +76,24 @@ describe("provider catalog baseline", () => { category: "search", priceUsd: 0.01, enabled: true, - sourceType: "deterministic-fallback" + sourceType: "deterministic-fallback", + provenance: "mock" }, { id: "news.fast", category: "news", priceUsd: 0.015, enabled: true, - sourceType: "deterministic-fallback" + sourceType: "deterministic-fallback", + provenance: "mock" }, { id: "scrape.page", category: "scrape", priceUsd: 0.02, enabled: true, - sourceType: "deterministic-fallback" + sourceType: "deterministic-fallback", + provenance: "mock" } ]; @@ -101,6 +111,7 @@ describe("provider catalog baseline", () => { expect(actual!.priceUsd, `${rowLabel} priceUsd mismatch`).toBe(expected.priceUsd); expect(actual!.enabled, `${rowLabel} enabled mismatch`).toBe(expected.enabled); expect(actual!.sourceType, `${rowLabel} sourceType mismatch`).toBe(expected.sourceType); + expect(actual!.provenance, `${rowLabel} provenance mismatch`).toBe(expected.provenance); }); } }); diff --git a/apps/api/src/lib/pricing.ts b/apps/api/src/lib/pricing.ts index 021f696..229a452 100644 --- a/apps/api/src/lib/pricing.ts +++ b/apps/api/src/lib/pricing.ts @@ -10,6 +10,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 1500, qualityScore: 99, sourceType: "live", + provenance: "live", enabled: true }, { @@ -21,6 +22,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 700, qualityScore: 75, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true }, { @@ -32,6 +34,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 1100, qualityScore: 90, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true }, { @@ -43,6 +46,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 800, qualityScore: 72, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true }, { @@ -54,6 +58,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 1400, qualityScore: 93, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true }, { @@ -65,6 +70,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 1000, qualityScore: 70, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true }, { @@ -76,6 +82,7 @@ export const providers: ProviderDefinition[] = [ latencyEstimateMs: 1700, qualityScore: 95, sourceType: "deterministic-fallback", + provenance: "mock", enabled: true } ]; diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index a9262cf..af9f60b 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -202,6 +202,43 @@ describe("public routes", () => { expect(catalogResponse.body.byCategory.scrape.length).toBeGreaterThan(0); }); + it("every provider in catalog and providers endpoint includes provenance", async () => { + const app = await createPublicApp(); + + const providersResponse = await request(app).get("/api/providers"); + const catalogResponse = await request(app).get("/api/catalog"); + + const allProviders = [ + ...providersResponse.body.providers, + ...catalogResponse.body.providers, + ...catalogResponse.body.byCategory.search, + ...catalogResponse.body.byCategory.news, + ...catalogResponse.body.byCategory.scrape + ]; + + for (const provider of allProviders) { + expect(provider).toHaveProperty("provenance"); + expect(["mock", "fallback", "live", "unknown"]).toContain(provider.provenance); + } + }); + + it("live provider has provenance live and mock providers have provenance mock", async () => { + const app = await createPublicApp(); + + const providersResponse = await request(app).get("/api/providers"); + const providersList = providersResponse.body.providers; + + const live = providersList.find((p: { id: string }) => p.id === "search.live"); + expect(live).toBeDefined(); + expect(live.provenance).toBe("live"); + + for (const p of providersList) { + if (p.id !== "search.live") { + expect(p.provenance).toBe("mock"); + } + } + }); + it("returns safe default analytics shape for fresh storage", async () => { const app = await createPublicApp(); diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index 78144f6..be0f4e1 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -428,6 +428,9 @@ export default function ControlDeckPage() { {provider.sourceType} + + {provider.provenance} + )) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c7ecfd1..9429dd7 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -699,6 +699,26 @@ body { padding: 0.2rem 0.45rem; } +.provenance-badge.mock { + border-color: rgba(250, 204, 21, 0.5); + color: #facc15; +} + +.provenance-badge.fallback { + border-color: rgba(251, 146, 60, 0.5); + color: #fb923c; +} + +.provenance-badge.live { + border-color: rgba(74, 222, 128, 0.5); + color: #4ade80; +} + +.provenance-badge.unknown { + border-color: rgba(148, 163, 184, 0.5); + color: #94a3b8; +} + .action-row { border: 1px dashed rgba(130, 159, 199, 0.34); border-radius: 14px; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 2fe1fa0..6dcb01d 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -4,6 +4,8 @@ export const queryModeSchema = z.enum(["search", "news", "scrape"]); export const providerCategorySchema = queryModeSchema; +export const provenanceSchema = z.enum(["mock", "fallback", "live", "unknown"]); + export const providerSchema = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -13,6 +15,7 @@ export const providerSchema = z.object({ latencyEstimateMs: z.number().int().positive(), qualityScore: z.number().min(1).max(100), sourceType: z.enum(["live", "deterministic-fallback", "unavailable"]), + provenance: provenanceSchema, enabled: z.boolean() }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ecc0687..b542f6b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,6 +1,7 @@ export type QueryMode = "search" | "news" | "scrape"; export type ProviderCategory = QueryMode; export type SourceType = "live" | "deterministic-fallback" | "unavailable"; +export type Provenance = "mock" | "fallback" | "live" | "unknown"; export type ExecutionFallbackReason = | "timeout" | "circuit-open" @@ -30,6 +31,7 @@ export interface ProviderDefinition { latencyEstimateMs: number; qualityScore: number; sourceType: SourceType; + provenance: Provenance; enabled: boolean; }