From 7ba4804ae060409bb885aacae872cc9f63ea28eb Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 29 Jun 2026 22:42:06 +0200 Subject: [PATCH 1/3] feat: add SCF evidence checklist panel to dashboard --- apps/api/src/routes/public.ts | 1 + apps/web/src/lib/api.ts | 6 ++ apps/web/src/pages/ControlDeckPage.tsx | 110 +++++++++++++++++++++++-- apps/web/src/styles.css | 90 ++++++++++++++++++++ apps/web/src/types.ts | 15 ++++ 5 files changed, 217 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index d0d4b5b..8efdd1e 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -27,6 +27,7 @@ publicRouter.get("/health", (_req, res) => { nodeEnv: config.NODE_ENV, network: config.STELLAR_NETWORK, sponsorshipEnabled: config.sponsorshipEnabled, + demoMode: config.demoMode, timestamp: new Date().toISOString(), uptimeSeconds: process.uptime(), diagnostics: getConfigSnapshot() diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 1c5e254..3fb2dd2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import type { HealthResponse } from "../types.js"; + export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3001"; export async function fetchJson(url: string, init?: RequestInit): Promise { @@ -22,6 +24,10 @@ export async function fetchJson(url: string, init?: RequestInit): Promise return (await response.json()) as T; } +export async function fetchHealth(apiBaseUrl: string): Promise { + return fetchJson(`${apiBaseUrl}/health`); +} + export function money(value: number) { return `$${value.toFixed(3)}`; } diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index 78144f6..c2b082f 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -12,11 +12,14 @@ import { ShieldCheck, Sparkles, TerminalSquare, - XCircle + XCircle, + Check, + AlertTriangle, + Clock } from "lucide-react"; import { Link } from "react-router-dom"; -import type { AnalyticsResponse, PaidQueryResponse } from "../types.js"; -import { API_BASE_URL, fetchJson, money } from "../lib/api.js"; +import type { AnalyticsResponse, EvidenceCheckItem, PaidQueryResponse } from "../types.js"; +import { API_BASE_URL, fetchHealth, fetchJson, money } from "../lib/api.js"; import { fetchSponsorshipEnabled, fetchSponsorshipPreview, @@ -72,6 +75,7 @@ export default function ControlDeckPage() { const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [demoMode, setDemoMode] = useState(false); const modeProviders = useMemo( () => providers.filter((provider) => provider.category === mode && provider.enabled), @@ -91,6 +95,68 @@ export default function ControlDeckPage() { ? toTokenBaseUnits(selectedProviderDetails.priceUsd) : "0"; + const evidenceItems: EvidenceCheckItem[] = useMemo(() => { + const resultOk = result !== null; + const resultHasItems = (result?.result?.items?.length ?? 0) > 0; + const paymentCaptured = result?.payment?.paymentResponseHeader != null; + const hasUsage = (analytics?.totalQueries ?? 0) > 0; + const hasSpend = (analytics?.totalSpendUsd ?? 0) > 0; + const hasReceipts = (analytics?.recentTransactions?.length ?? 0) > 0; + + return [ + { + id: "catalog", + label: "Provider catalog loaded", + status: providers.length > 0 ? "pass" : "pending", + detail: providers.length > 0 ? `${providers.length} providers` : undefined + }, + { + id: "query-exec", + label: "Paid/demo query executed", + status: resultOk ? "pass" : "pending", + detail: resultOk ? result!.result.providerName : undefined + }, + { + id: "result", + label: "Result returned", + status: resultOk ? (resultHasItems ? "pass" : "warn") : "pending", + detail: resultOk + ? `${result!.result.items.length} items, ${result!.result.latencyMs}ms` + : undefined + }, + { + id: "payment", + label: "Payment evidence captured", + status: paymentCaptured ? "pass" : "pending", + detail: paymentCaptured + ? demoMode + ? "demo tx (DEMO_MODE)" + : result!.payment.paymentResponseHeader!.slice(0, 16) + "..." + : undefined + }, + { + id: "usage", + label: "Usage event persisted", + status: hasUsage ? "pass" : "pending", + detail: hasUsage ? `${analytics!.totalQueries} total` : undefined + }, + { + id: "analytics", + label: "Analytics updated", + status: hasSpend ? "pass" : "pending", + detail: hasSpend ? money(analytics!.totalSpendUsd) + " tracked" : undefined + }, + { + id: "receipt", + label: "Receipt/export available", + status: hasReceipts ? "pass" : "pending", + detail: hasReceipts + ? `${analytics!.recentTransactions.length} transaction(s)` + : undefined + } + ]; + }, [providers, result, analytics, demoMode]); + function shortAddress(address: string) { if (address.length < 12) { return address; @@ -127,13 +193,15 @@ export default function ControlDeckPage() { useEffect(() => { async function bootstrap() { - const [providersResponse, sponsorshipActive] = await Promise.all([ + const [providersResponse, sponsorshipActive, health] = await Promise.all([ fetchJson<{ providers: ProviderDefinition[] }>(`${API_BASE_URL}/api/providers`), - fetchSponsorshipEnabled(API_BASE_URL) + fetchSponsorshipEnabled(API_BASE_URL), + fetchHealth(API_BASE_URL) ]); setProviders(providersResponse.providers); setSelectedProvider(modeDefaultProvider.search); setSponsorshipEnabled(sponsorshipActive); + setDemoMode(health.demoMode ?? false); await refreshMetrics(); } @@ -710,6 +778,19 @@ export default function ControlDeckPage() { )} + +
+

+ + SCF Evidence Checklist + {demoMode ? DEMO : null} +

+
    + {evidenceItems.map((item) => ( + + ))} +
+
@@ -965,3 +1046,22 @@ function denyActionableCopy(decision: string) { return "Policy will deny this request. See the reason above and adjust inputs."; } } + +const evidenceIconMap: Record = { + pass: , + warn: , + pending: +}; + +function EvidenceRow(props: { item: EvidenceCheckItem }) { + const { item } = props; + return ( +
  • + + {evidenceIconMap[item.status]} + + {item.label} + {item.detail ? {item.detail} : null} +
  • + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c7ecfd1..8367a86 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1260,6 +1260,96 @@ body { line-height: 1.5; } +.evidence-panel { + border: 1px solid rgba(129, 160, 201, 0.24); + border-radius: 14px; + background: rgba(11, 16, 26, 0.84); + padding: 0.72rem; +} + +.evidence-panel h3 { + margin: 0 0 0.55rem; + color: #e8f2ff; + font-size: 0.86rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.evidence-panel h3 span { + font-size: 0.65rem; + color: #fbbf24; + text-transform: uppercase; + letter-spacing: 0.08em; + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 999px; + padding: 0.08rem 0.4rem; + background: rgba(251, 191, 36, 0.1); +} + +.evidence-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.3rem; +} + +.evidence-item { + display: flex; + align-items: center; + gap: 0.45rem; + border: 1px solid rgba(130, 160, 196, 0.18); + border-radius: 9px; + padding: 0.35rem 0.5rem; + background: rgba(13, 18, 30, 0.5); + font-size: 0.75rem; + transition: border-color 170ms ease; +} + +.evidence-item .evidence-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + border-radius: 50%; + flex-shrink: 0; + font-size: 0.6rem; + font-weight: 700; +} + +.evidence-item .evidence-icon.pass { + background: rgba(52, 211, 153, 0.18); + color: #34d399; + border: 1px solid rgba(52, 211, 153, 0.35); +} + +.evidence-item .evidence-icon.warn { + background: rgba(251, 191, 36, 0.18); + color: #fbbf24; + border: 1px solid rgba(251, 191, 36, 0.35); +} + +.evidence-item .evidence-icon.pending { + background: rgba(107, 114, 128, 0.18); + color: #6b7280; + border: 1px solid rgba(107, 114, 128, 0.35); +} + +.evidence-item .evidence-label { + flex: 1; + color: #dce9ff; +} + +.evidence-item .evidence-detail { + color: #768ba8; + font-size: 0.65rem; + font-family: + "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", monospace; +} + @media (max-width: 1140px) { .landing-hero { grid-template-columns: 1fr; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b83c0a6..13d1ba3 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -80,3 +80,18 @@ export interface AnalyticsResponse { } export type ProviderMap = Record; + +export interface HealthResponse { + ok: boolean; + demoMode?: boolean; + sponsorshipEnabled?: boolean; +} + +export type EvidenceStatus = "pass" | "warn" | "pending"; + +export interface EvidenceCheckItem { + id: string; + label: string; + status: EvidenceStatus; + detail?: string; +} From 0cdb32d558b6a2596eac588bbc75fb7a4498e5a0 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 29 Jun 2026 22:54:56 +0200 Subject: [PATCH 2/3] feat: add provider capability matrix endpoint for marketplace diligence --- apps/api/src/lib/pricing.test.ts | 46 ++++++++++++++++++++++++++++++ apps/api/src/lib/pricing.ts | 40 +++++++++++++++++++++++++- apps/api/src/routes/public.test.ts | 29 +++++++++++++++++++ apps/api/src/routes/public.ts | 9 +++++- packages/shared/src/schemas.ts | 12 ++++++++ packages/shared/src/types.ts | 12 ++++++++ 6 files changed, 146 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/pricing.test.ts b/apps/api/src/lib/pricing.test.ts index c98a423..2ced044 100644 --- a/apps/api/src/lib/pricing.test.ts +++ b/apps/api/src/lib/pricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildCapabilityMatrix, getProviderById, getProvidersByCategory, protectedRouteBasePrices, @@ -104,3 +105,48 @@ describe("provider catalog baseline", () => { }); } }); + +describe("capability matrix", () => { + it("returns all providers with correct shape", () => { + const matrix = buildCapabilityMatrix(); + expect(matrix.length).toBe(providers.length); + + for (const entry of matrix) { + expect(entry).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + category: expect.stringMatching(/^(search|news|scrape)$/), + priceUsd: expect.any(Number), + sourceType: expect.stringMatching(/^(live|deterministic-fallback|unavailable)$/), + latencyEstimateMs: expect.any(Number), + enabled: expect.any(Boolean), + hasFallback: true, + caveat: expect.toBeOneOf([expect.any(String), null]) + }); + expect(entry.priceUsd).toBeGreaterThan(0); + expect(entry.latencyEstimateMs).toBeGreaterThan(0); + } + }); + + it("sorts deterministically by category then id", () => { + const matrix = buildCapabilityMatrix(); + for (let i = 1; i < matrix.length; i++) { + const prev = matrix[i - 1]; + const curr = matrix[i]; + const catCmp = prev.category.localeCompare(curr.category); + if (catCmp === 0) { + expect(prev.id.localeCompare(curr.id)).toBeLessThanOrEqual(0); + } else { + expect(catCmp).toBeLessThan(0); + } + } + }); + + it("reports caveat when GROQ_API_KEY is missing", () => { + const matrix = buildCapabilityMatrix(); + const allHaveCaveat = matrix.every( + (entry) => entry.caveat !== null && entry.caveat.includes("GROQ_API_KEY") + ); + expect(allHaveCaveat).toBe(true); + }); +}); diff --git a/apps/api/src/lib/pricing.ts b/apps/api/src/lib/pricing.ts index 021f696..cec91ea 100644 --- a/apps/api/src/lib/pricing.ts +++ b/apps/api/src/lib/pricing.ts @@ -1,4 +1,42 @@ -import type { ProviderDefinition } from "@query402/shared"; +import type { ProviderCapability, ProviderDefinition } from "@query402/shared"; +import { config } from "./config.js"; + +const envKeyMapping: Record = { + "search.live": ["GROQ_API_KEY"], + "search.basic": ["GROQ_API_KEY"], + "search.pro": ["GROQ_API_KEY"], + "news.fast": ["GROQ_API_KEY"], + "news.deep": ["GROQ_API_KEY"], + "scrape.page": ["GROQ_API_KEY"], + "scrape.extract": ["GROQ_API_KEY"] +}; + +function computeCaveat(providerId: string): string | null { + const required = envKeyMapping[providerId]; + if (!required) return null; + const missing = required.filter((key) => !(config as Record)[key]); + if (missing.length === 0) return null; + return `${missing.join(", ")} not configured — falling back to deterministic results`; +} + +export function buildCapabilityMatrix(): ProviderCapability[] { + return providers + .map((p) => ({ + id: p.id, + name: p.name, + category: p.category, + priceUsd: p.priceUsd, + sourceType: p.sourceType, + latencyEstimateMs: p.latencyEstimateMs, + enabled: p.enabled, + hasFallback: true, + caveat: computeCaveat(p.id) + })) + .sort((a, b) => { + const cat = a.category.localeCompare(b.category); + return cat !== 0 ? cat : a.id.localeCompare(b.id); + }); +} export const providers: ProviderDefinition[] = [ { diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index a9262cf..764b054 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -1,5 +1,6 @@ import express from "express"; import request from "supertest"; +import { providerCapabilitySchema } from "@query402/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildTestUsageEvent } from "../test/storage-test-helpers.js"; import { applyApiTestEnv, resetApiTestStorage } from "../test/api-test-helpers.js"; @@ -259,4 +260,32 @@ describe("public routes", () => { expect(analyticsResponse.body.totalSpendUsd).toBe(0.01); expect(analyticsResponse.body.spendByCategory.search).toBe(0.01); }); + + it("returns capability matrix with correct shape and deterministic order", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/matrix"); + + expect(response.status).toBe(200); + expect(response.body.updatedAt).toEqual(expect.any(String)); + + const { providers: matrix } = response.body; + expect(Array.isArray(matrix)).toBe(true); + expect(matrix.length).toBeGreaterThan(0); + + for (const entry of matrix) { + const parsed = providerCapabilitySchema.safeParse(entry); + expect(parsed.success).toBe(true); + } + + for (let i = 1; i < matrix.length; i++) { + const prev = matrix[i - 1]; + const curr = matrix[i]; + const catCmp = prev.category.localeCompare(curr.category); + if (catCmp === 0) { + expect(prev.id.localeCompare(curr.id)).toBeLessThanOrEqual(0); + } else { + expect(catCmp).toBeLessThan(0); + } + } + }); }); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 8efdd1e..ad1677e 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { z } from "zod"; -import { providers } from "../lib/pricing.js"; +import { buildCapabilityMatrix, providers } from "../lib/pricing.js"; import { getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js"; import { config, getConfigSnapshot } from "../lib/config.js"; import { apiVersion } from "../lib/build-metadata.js"; @@ -42,6 +42,13 @@ publicRouter.get("/api/catalog", (_req, res) => { res.json(getCatalog()); }); +publicRouter.get("/api/matrix", (_req, res) => { + res.json({ + updatedAt: new Date().toISOString(), + providers: buildCapabilityMatrix() + }); +}); + publicRouter.get("/api/usage", async (req, res, next) => { try { const parsed = usageQuerySchema.safeParse(req.query); diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 2fe1fa0..c759b39 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -32,6 +32,18 @@ export const scrapeQuerySchema = baseQuerySchema.extend({ url: z.string().url() }); +export const providerCapabilitySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + category: providerCategorySchema, + priceUsd: z.number().positive(), + sourceType: z.enum(["live", "deterministic-fallback", "unavailable"]), + latencyEstimateMs: z.number().int().positive(), + enabled: z.boolean(), + hasFallback: z.boolean(), + caveat: z.string().nullable() +}); + const stellarPublicKeySchema = z.string().regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar public key"); export { stellarPublicKeySchema }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ecc0687..21e7dc9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -126,6 +126,18 @@ export interface AnalyticsSummary { recentUsage: UsageEvent[]; } +export interface ProviderCapability { + id: string; + name: string; + category: ProviderCategory; + priceUsd: number; + sourceType: SourceType; + latencyEstimateMs: number; + enabled: boolean; + hasFallback: boolean; + caveat: string | null; +} + export interface SponsorshipGrant { grantId: string; wallet: string; From 5a565ab09e288d6cd9153d3c18ad6133c2718809 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 30 Jun 2026 08:51:00 +0200 Subject: [PATCH 3/3] fix: make caveat logic lazy to avoid config import-time failure --- apps/api/src/lib/pricing.test.ts | 4 ++-- apps/api/src/lib/pricing.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/api/src/lib/pricing.test.ts b/apps/api/src/lib/pricing.test.ts index 2ced044..46bbaba 100644 --- a/apps/api/src/lib/pricing.test.ts +++ b/apps/api/src/lib/pricing.test.ts @@ -112,6 +112,7 @@ describe("capability matrix", () => { expect(matrix.length).toBe(providers.length); for (const entry of matrix) { + expect(entry.caveat === null || typeof entry.caveat === "string").toBe(true); expect(entry).toMatchObject({ id: expect.any(String), name: expect.any(String), @@ -120,8 +121,7 @@ describe("capability matrix", () => { sourceType: expect.stringMatching(/^(live|deterministic-fallback|unavailable)$/), latencyEstimateMs: expect.any(Number), enabled: expect.any(Boolean), - hasFallback: true, - caveat: expect.toBeOneOf([expect.any(String), null]) + hasFallback: true }); expect(entry.priceUsd).toBeGreaterThan(0); expect(entry.latencyEstimateMs).toBeGreaterThan(0); diff --git a/apps/api/src/lib/pricing.ts b/apps/api/src/lib/pricing.ts index cec91ea..9aae936 100644 --- a/apps/api/src/lib/pricing.ts +++ b/apps/api/src/lib/pricing.ts @@ -1,5 +1,4 @@ import type { ProviderCapability, ProviderDefinition } from "@query402/shared"; -import { config } from "./config.js"; const envKeyMapping: Record = { "search.live": ["GROQ_API_KEY"], @@ -14,7 +13,7 @@ const envKeyMapping: Record = { function computeCaveat(providerId: string): string | null { const required = envKeyMapping[providerId]; if (!required) return null; - const missing = required.filter((key) => !(config as Record)[key]); + const missing = required.filter((key) => !process.env[key]); if (missing.length === 0) return null; return `${missing.join(", ")} not configured — falling back to deterministic results`; }