From c200d13cd928aab3c1f20da3e8b49155310d6056 Mon Sep 17 00:00:00 2001 From: AakashFrexy Date: Sat, 23 May 2026 14:06:54 -0400 Subject: [PATCH] Public evidence brief fallback page (closes #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locally hosted Senso fallback. When Senso is unavailable, the brief is served from `/evidence/:id` on our own deploy (handoff/API §07 F4). - GET /v1/evidence/:id — public, unauthenticated. Returns a denormalised EvidenceBriefResponse (changeReport + vendor + policyFired + actionSummary). /v1/evidence/ added to PUBLIC_PATHS in auth.ts. - /v1/evidence (JSON) vs /evidence/:id (SPA): the user-facing URL is /evidence/:id (served by Vite's history fallback so React can mount), the JSON endpoint lives under /v1/evidence/:id so the dev proxy can route it cleanly without colliding with the SPA route. - apps/api/src/db/change-reports.ts — in-memory ChangeReportStore loaded from seed/change-reports.json. Track A's real runner will write to ClickHouse AND this cache when its ChangeReports land. - apps/api/src/db/policy-store.ts — in-memory policy lookups so the brief can resolve policyFiredId to display names. - Seed data: seed/change-reports.json with the locked Notion demo story (retention 90→30 days + price $16→$19 +18.75%) and a Stripe sub-processor fleet entry. seed/policies.json with the 3 seeded policies from handoff §05. seed/vendors.json populated with vnd_notion and vnd_stripe so vendor lookups resolve. - apps/web/src/screens/SensoBrief.tsx — public, no app chrome. Renders vendor, policy fired, severity, recommendation, every Change with before/after diff, every Citation with verbatim quote + URL + section + fetched timestamp + country, and the routed actions list (empty state when no actions are recorded). - apps/web/src/styles/brief.css — readable print-targeted layout with @media print rules: page-break-inside avoid for changes and citations, link URL annotation on print. - apps/web/src/App.tsx — pathname check parses /evidence/:id and renders SensoBrief without the Onboard/Upgrade chrome. No router dependency. Smoke verified end-to-end: - GET /v1/evidence/chg_seed_notion (no bearer) → 200 with full brief - GET /v1/evidence/chg_does_not_exist → 404 with standard error envelope - Vite proxy serves index.html for /evidence/:id (SPA) and forwards /v1/evidence/:id to the API (data) Tests: 39/39 passing across 8 suites. 9 new specs cover the evidence endpoint (auth-less 200, 404, denormalisation, fleet entry) and the SensoBrief component (render, not-found, citation display, empty actions, no app chrome, print class). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/auth.ts | 4 +- apps/api/src/db/change-reports.ts | 29 ++++ apps/api/src/db/policy-store.ts | 34 +++++ apps/api/src/routes/evidence.ts | 51 +++++++ apps/api/src/seed/loader.ts | 10 +- apps/api/src/server.ts | 2 + apps/web/src/App.tsx | 21 +++ apps/web/src/lib/api.ts | 17 ++- apps/web/src/screens/SensoBrief.tsx | 221 ++++++++++++++++++++++++++++ apps/web/src/styles/brief.css | 181 +++++++++++++++++++++++ packages/shared/src/schemas.ts | 106 +++++++++++++ packages/shared/src/types.ts | 101 +++++++++++++ seed/change-reports.json | 90 +++++++++++ seed/policies.json | 33 +++++ seed/vendors.json | 51 ++++++- tests/api/evidence.test.ts | 83 +++++++++++ tests/web/evidence-brief.test.tsx | 140 ++++++++++++++++++ 17 files changed, 1169 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/db/change-reports.ts create mode 100644 apps/api/src/db/policy-store.ts create mode 100644 apps/api/src/routes/evidence.ts create mode 100644 apps/web/src/screens/SensoBrief.tsx create mode 100644 apps/web/src/styles/brief.css create mode 100644 seed/change-reports.json create mode 100644 seed/policies.json create mode 100644 tests/api/evidence.test.ts create mode 100644 tests/web/evidence-brief.test.tsx diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index d1023b9..4ba72ac 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -16,7 +16,9 @@ const PUBLIC_PATHS: readonly string[] = [ // /v1/stream uses its own ?token= query param flow because EventSource has // no header support; handled inside the route, bypassed here. "/v1/stream", - "/evidence/", + // Public evidence brief fallback (handoff/API §07 F4) — locally hosted + // Senso-shaped page; intentionally unauthenticated. + "/v1/evidence/", ]; function isPublic(path: string): boolean { diff --git a/apps/api/src/db/change-reports.ts b/apps/api/src/db/change-reports.ts new file mode 100644 index 0000000..ba4b1a0 --- /dev/null +++ b/apps/api/src/db/change-reports.ts @@ -0,0 +1,29 @@ +import type { ChangeReport } from "@redline/shared"; + +// ChangeReports are persisted to ClickHouse by the agent runner (Track A's +// work). For the public evidence brief fallback we additionally keep an +// in-memory cache seeded from seed/change-reports.json so /evidence/:id can +// render demo data without depending on a live agent run. + +export class ChangeReportStore { + private byId = new Map(); + + load(reports: ChangeReport[]): void { + this.byId.clear(); + for (const r of reports) this.byId.set(r.id, r); + } + + add(report: ChangeReport): void { + this.byId.set(report.id, report); + } + + get(id: string): ChangeReport | undefined { + return this.byId.get(id); + } + + list(orgId: string): ChangeReport[] { + return [...this.byId.values()].filter((r) => r.orgId === orgId); + } +} + +export const changeReportStore = new ChangeReportStore(); diff --git a/apps/api/src/db/policy-store.ts b/apps/api/src/db/policy-store.ts new file mode 100644 index 0000000..f5a8b91 --- /dev/null +++ b/apps/api/src/db/policy-store.ts @@ -0,0 +1,34 @@ +// Policies live in-memory per handoff/Data Model §05. Loaded from +// seed/policies.json at boot. The evidence brief uses this to resolve +// policy IDs to display names. + +export interface SeededPolicy { + id: string; + orgId: string; + name: string; + description?: string; + version: number; + createdBy: string; + isActive: boolean; + severity: "P1" | "P2" | "P3"; + route: string[]; +} + +export class PolicyStore { + private byId = new Map(); + + load(policies: SeededPolicy[]): void { + this.byId.clear(); + for (const p of policies) this.byId.set(p.id, p); + } + + get(id: string): SeededPolicy | undefined { + return this.byId.get(id); + } + + list(orgId: string): SeededPolicy[] { + return [...this.byId.values()].filter((p) => p.orgId === orgId); + } +} + +export const policyStore = new PolicyStore(); diff --git a/apps/api/src/routes/evidence.ts b/apps/api/src/routes/evidence.ts new file mode 100644 index 0000000..f8de6f4 --- /dev/null +++ b/apps/api/src/routes/evidence.ts @@ -0,0 +1,51 @@ +import { Hono } from "hono"; +import type { EvidenceBriefResponse } from "@redline/shared"; +import { ErrorCodes } from "@redline/shared"; +import { ApiError } from "../lib/errors.js"; +import { changeReportStore } from "../db/change-reports.js"; +import { policyStore } from "../db/policy-store.js"; +import { vendorStore } from "../db/vendor-store.js"; + +// Public evidence brief fallback. Mounted at /evidence (not /v1/evidence) +// because it's intentionally unauthenticated — handoff/API §07 F4: "Clicking +// 'View evidence' routes to our /evidence/:id fallback (the same Senso-shaped +// template hosted locally)". + +export const evidenceRoute = new Hono(); + +evidenceRoute.get("/:id", (c) => { + const id = c.req.param("id"); + const report = changeReportStore.get(id); + if (!report) { + throw new ApiError( + ErrorCodes.NotFound, + `No evidence brief for id ${id}`, + ); + } + + const vendor = vendorStore.get(report.vendorId); + const policyFired = policyStore.get(report.policyFiredId); + const policyAlsoMatched = report.policyAlsoMatched + .map((pid) => policyStore.get(pid)) + .filter((p): p is NonNullable => p !== undefined) + .map((p) => ({ id: p.id, name: p.name })); + + const response: EvidenceBriefResponse = { + changeReport: report, + vendor: { + id: vendor?.id ?? report.vendorId, + name: vendor?.name ?? "Unknown vendor", + category: vendor?.category ?? "uncategorized", + }, + policyFired: { + id: policyFired?.id ?? report.policyFiredId, + name: policyFired?.name ?? "Unknown policy", + }, + policyAlsoMatched, + // Actions are persisted to ClickHouse by the agent runner; for seeded + // reports the array is empty until Track A's runner produces them. + actionSummary: [], + }; + + return c.json(response); +}); diff --git a/apps/api/src/seed/loader.ts b/apps/api/src/seed/loader.ts index 471a1e8..aaa7487 100644 --- a/apps/api/src/seed/loader.ts +++ b/apps/api/src/seed/loader.ts @@ -1,7 +1,9 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import type { Org, User, Vendor } from "@redline/shared"; +import type { ChangeReport, Org, User, Vendor } from "@redline/shared"; import { vendorStore } from "../db/vendor-store.js"; +import { changeReportStore } from "../db/change-reports.js"; +import { policyStore, type SeededPolicy } from "../db/policy-store.js"; // Locate seed/ relative to repo root. apps/api is two levels deep. function seedDir(): string { @@ -30,6 +32,10 @@ export function loadSeeds(opts?: { seedDir?: string }): void { const users = readJson(resolve(dir, "users.json")); const vendors = readJson(resolve(dir, "vendors.json")); const tokens = readJson>(resolve(dir, "tokens.json")); + const policies = readJson(resolve(dir, "policies.json")); + const changeReports = readJson( + resolve(dir, "change-reports.json"), + ); caches.orgs.clear(); caches.users.clear(); @@ -42,6 +48,8 @@ export function loadSeeds(opts?: { seedDir?: string }): void { } vendorStore.load(vendors); + policyStore.load(policies); + changeReportStore.load(changeReports); } export function getOrg(id: string): Org | undefined { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 1ae3fce..bc6b1f3 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -8,6 +8,7 @@ import { vendorsRoute } from "./routes/vendors.js"; import { streamRoute } from "./routes/stream.js"; import { billingRoute } from "./routes/billing.js"; import { stripeWebhookRoute } from "./routes/webhooks-stripe.js"; +import { evidenceRoute } from "./routes/evidence.js"; export function buildApp(): Hono { const app = new Hono(); @@ -30,6 +31,7 @@ export function buildApp(): Hono { app.route("/v1/stream", streamRoute); app.route("/v1/billing", billingRoute); app.route("/webhooks/stripe", stripeWebhookRoute); + app.route("/v1/evidence", evidenceRoute); app.notFound((c) => c.json( diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f9cae55..55ca8c0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,29 @@ import { useState } from "react"; import { Onboard } from "./screens/Onboard.js"; import { StripeModal } from "./screens/StripeModal.js"; +import { SensoBrief } from "./screens/SensoBrief.js"; + +// Tiny pathname router. /evidence/:id is the public Senso fallback brief and +// renders with no app chrome. Everything else is the authenticated app. +function parseEvidenceId(pathname: string): string | undefined { + const match = pathname.match(/^\/evidence\/([^/?#]+)\/?$/); + return match?.[1]; +} export function App(): JSX.Element { + const evidenceId = + typeof window !== "undefined" + ? parseEvidenceId(window.location.pathname) + : undefined; + + if (evidenceId) { + return ; + } + + return ; +} + +function RedlineApp(): JSX.Element { const [showUpgrade, setShowUpgrade] = useState(false); return (
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 37e3dac..eb4b513 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,5 +1,6 @@ import type { ApiErrorEnvelope, + EvidenceBriefResponse, VendorCreateBody, VendorCreateResponse, } from "@redline/shared"; @@ -24,10 +25,12 @@ export class ApiError extends Error { async function request( path: string, - init: RequestInit = {}, + init: RequestInit & { skipAuth?: boolean } = {}, ): Promise { const headers = new Headers(init.headers); - headers.set("Authorization", `Bearer ${DEMO_BEARER_TOKEN}`); + if (!init.skipAuth) { + headers.set("Authorization", `Bearer ${DEMO_BEARER_TOKEN}`); + } if (init.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } @@ -101,3 +104,13 @@ export function createPaymentIntent(sku: string): Promise export function simulateSuccess(): Promise<{ ok: true; paymentIntentId: string }> { return request("/v1/billing/simulate-success", { method: "POST" }); } + +// Issue #4 — public evidence brief. No bearer (the brief is public per +// handoff/API §07 F4). The user-facing URL is /evidence/:id (SPA route); +// the JSON endpoint lives under /v1/evidence so the dev proxy can route it +// without conflicting with the SPA's history fallback. +export function getEvidence(id: string): Promise { + return request(`/v1/evidence/${encodeURIComponent(id)}`, { + skipAuth: true, + }); +} diff --git a/apps/web/src/screens/SensoBrief.tsx b/apps/web/src/screens/SensoBrief.tsx new file mode 100644 index 0000000..48c4f7f --- /dev/null +++ b/apps/web/src/screens/SensoBrief.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from "react"; +import type { + Change, + Citation, + EvidenceBriefResponse, +} from "@redline/shared"; +import { ApiError, getEvidence } from "../lib/api.js"; +import "../styles/brief.css"; + +// Public evidence brief — locally hosted Senso fallback (handoff/API §07 F4). +// Intentionally has no app chrome, runs without bearer auth, and is laid out +// to print cleanly to PDF. + +interface SensoBriefProps { + changeReportId: string; +} + +type Status = "loading" | "ready" | "not-found" | "error"; + +interface State { + status: Status; + data?: EvidenceBriefResponse; + errorMessage?: string; +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toUTCString(); + } catch { + return iso; + } +} + +function formatDollar(value: number): string { + const abs = Math.abs(value); + const sign = value >= 0 ? "+" : "−"; + return `${sign}$${abs.toLocaleString("en-US")}`; +} + +export function SensoBrief({ changeReportId }: SensoBriefProps): JSX.Element { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + (async () => { + try { + const data = await getEvidence(changeReportId); + if (!cancelled) setState({ status: "ready", data }); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setState({ status: "not-found" }); + return; + } + setState({ + status: "error", + errorMessage: err instanceof Error ? err.message : "Failed to load", + }); + } + })(); + return () => { + cancelled = true; + }; + }, [changeReportId]); + + if (state.status === "loading") { + return ( +
+

Loading evidence…

+
+ ); + } + + if (state.status === "not-found") { + return ( +
+

Evidence brief not found

+

+ The brief with id {changeReportId} does not exist or has + been removed. +

+
+ ); + } + + if (state.status === "error" || !state.data) { + return ( +
+

Unable to load brief

+

{state.errorMessage ?? "Unexpected error"}

+
+ ); + } + + const { changeReport, vendor, policyFired, policyAlsoMatched, actionSummary } = + state.data; + + return ( +
+
+

Redline · Evidence brief

+ + {changeReport.severity} · {changeReport.state} + +

+ {vendor.name} · {changeReport.changes[0]?.summary ?? "Change detected"} +

+

+ Detected {formatTimestamp(changeReport.detectedAt)}{" "} + · Vendor category {vendor.category} +

+
+ +
+

Policy fired

+
+ {policyFired.name} + {policyFired.id} + {policyAlsoMatched.length > 0 && ( +

+ Also matched:{" "} + {policyAlsoMatched.map((p) => p.name).join(" · ")} +

+ )} +
+
+ +
+

Recommendation

+

+ {changeReport.recommendation.action}{" "} + — {changeReport.recommendation.copy} +

+
+ +
+

Changes ({changeReport.changes.length})

+ {changeReport.changes.map((change) => ( + + ))} +
+ +
+

Routed actions

+ {actionSummary.length === 0 ? ( +

+ No routed actions recorded for this brief. +

+ ) : ( +
    + {actionSummary.map((action, idx) => ( +
  • + + {action.kind} → {action.target} + + + {action.status} · {formatTimestamp(action.firedAt)} + +
  • + ))} +
+ )} +
+ +
+ Brief id {changeReport.id} · Immutable · Public · + Generated by Redline +
+
+ ); +} + +function ChangeBlock({ change }: { change: Change }): JSX.Element { + return ( +
+

{change.category} · {change.materiality}

+

{change.summary}

+
+
+ Before + {change.before} +
+
+ After + {change.after} +
+
+ {change.dollarImpact && ( +

+ Impact: {formatDollar(change.dollarImpact.annualUsd)}/yr{" "} + ({change.dollarImpact.pctChange > 0 ? "+" : ""} + {change.dollarImpact.pctChange.toFixed(2)}%) +

+ )} +
    + {change.citations.map((c, idx) => ( + + ))} +
+
+ ); +} + +function CitationView({ citation }: { citation: Citation }): JSX.Element { + return ( +
  • +

    “{citation.quote}”

    +

    + {citation.section && {citation.section} · } + {citation.url} + {" · "} + Fetched {formatTimestamp(citation.fetchedAt)} + {citation.country && · {citation.country}} +

    +
  • + ); +} diff --git a/apps/web/src/styles/brief.css b/apps/web/src/styles/brief.css new file mode 100644 index 0000000..44f191a --- /dev/null +++ b/apps/web/src/styles/brief.css @@ -0,0 +1,181 @@ +/* Public evidence brief styling — readable in browser, printable to PDF. + Intentionally has no app chrome (no header, no nav, no auth-only CSS). */ + +.brief { + max-width: 820px; + margin: 0 auto; + padding: 48px 28px 80px; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; + color: #1a1d23; + background: #ffffff; + line-height: 1.55; +} + +.brief__header { + border-bottom: 1px solid #e3e6ea; + padding-bottom: 18px; + margin-bottom: 28px; +} +.brief__eyebrow { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: #6b7280; +} +.brief__title { + margin: 6px 0 4px; + font-size: 26px; + font-weight: 600; + line-height: 1.25; +} +.brief__meta { + font-size: 13px; + color: #6b7280; +} +.brief__meta strong { + color: #1a1d23; + font-weight: 500; +} + +.brief__severity { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; + margin-bottom: 12px; +} +.brief__severity--P1 { background: #fee2e2; color: #991b1b; } +.brief__severity--P2 { background: #ffedd5; color: #9a3412; } +.brief__severity--P3 { background: #dcfce7; color: #166534; } + +.brief__section { + margin: 32px 0; +} +.brief__section h2 { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: #6b7280; + margin: 0 0 12px; +} + +.brief__policy { + background: #f4f6fa; + border: 1px solid #e3e6ea; + border-radius: 8px; + padding: 14px 18px; +} +.brief__policy strong { + display: block; + font-size: 15px; + margin-bottom: 4px; +} +.brief__policy-also { + margin-top: 12px; + font-size: 12px; + color: #6b7280; +} + +.change { + margin-bottom: 24px; + padding: 18px 20px; + border: 1px solid #e3e6ea; + border-radius: 8px; +} +.change__category { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #6b7280; + margin-bottom: 6px; +} +.change__summary { + font-size: 16px; + font-weight: 600; + margin: 0 0 12px; +} +.change__diff { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; + font-size: 13px; +} +.change__diff > div { + padding: 10px 12px; + border-radius: 6px; + background: #f4f6fa; +} +.change__before { border-left: 3px solid #f59e0b; } +.change__after { border-left: 3px solid #16a34a; } +.change__diff em { color: #6b7280; font-style: normal; display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; } + +.change__impact { + font-size: 13px; + color: #6b7280; + margin-bottom: 12px; +} +.change__impact strong { color: #1a1d23; } + +.citation { + border-left: 3px solid #1a1d23; + padding: 8px 14px; + margin: 8px 0; + background: #fbfbfc; + font-size: 13px; +} +.citation__quote { + font-style: italic; + margin: 0 0 6px; +} +.citation__meta { + font-size: 11px; + color: #6b7280; +} +.citation__meta a { color: #2563eb; text-decoration: none; } +.citation__meta a:hover { text-decoration: underline; } + +.actions { + list-style: none; + padding: 0; + margin: 0; +} +.actions li { + padding: 8px 0; + border-bottom: 1px solid #e3e6ea; + font-size: 13px; + display: flex; + justify-content: space-between; + gap: 12px; +} +.actions li:last-child { border-bottom: none; } +.actions__empty { color: #6b7280; font-size: 13px; font-style: italic; } + +.brief__footer { + margin-top: 48px; + padding-top: 16px; + border-top: 1px solid #e3e6ea; + font-size: 11px; + color: #6b7280; + text-align: center; +} + +.brief--not-found { + text-align: center; + padding-top: 96px; +} +.brief--not-found h1 { font-size: 22px; margin-bottom: 8px; } +.brief--not-found p { color: #6b7280; } + +@media print { + body { background: #ffffff; } + .brief { padding: 24px 16px; max-width: none; } + .brief__header { page-break-after: avoid; } + .change { page-break-inside: avoid; } + .citation { page-break-inside: avoid; } + .actions li { page-break-inside: avoid; } + a[href]::after { content: " (" attr(href) ")"; font-size: 10px; color: #6b7280; } +} diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 24d187f..4a2a547 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -90,3 +90,109 @@ export const RunCompletedEventSchema = z.object({ durationMs: z.number().nonnegative(), changeReportId: z.string().optional(), }); + +// Issue #4 — ChangeReport + evidence brief. + +export const SeveritySchema = z.enum(["P1", "P2", "P3"]); + +export const ChangeCategorySchema = z.enum([ + "data", + "pricing", + "subprocessor", + "terms", + "sla", + "security", +]); + +export const MaterialitySchema = z.enum(["material", "minor", "cosmetic"]); + +export const ChangeStateSchema = z.enum([ + "new", + "acknowledged", + "in-progress", + "resolved", + "snoozed", +]); + +export const ResolutionSchema = z.enum([ + "accepted", + "renegotiated", + "rejected", + "no-action", +]); + +export const CitationSchema = z.object({ + url: z.string().url(), + quote: z.string().min(1), + section: z.string().optional(), + fetchedAt: Iso8601Schema, + country: z.string().optional(), +}); + +export const ChangeSchema = z.object({ + id: z.string(), + category: ChangeCategorySchema, + summary: z.string().max(280), + before: z.string(), + after: z.string(), + materiality: MaterialitySchema, + dollarImpact: z + .object({ + annualUsd: z.number(), + pctChange: z.number(), + }) + .optional(), + // Handoff §03: "Each Change has ≥1 Citation. No citation, no claim." + citations: z.array(CitationSchema).min(1, "every change needs ≥1 citation"), +}); + +export const RecommendationSchema = z.object({ + action: z.enum(["renegotiate", "escalate", "accept", "reject"]), + copy: z.string(), +}); + +export const ChangeReportSchema = z.object({ + id: z.string(), + orgId: z.string(), + vendorId: z.string(), + runId: z.string(), + detectedAt: Iso8601Schema, + severity: SeveritySchema, + state: ChangeStateSchema, + acknowledgedAt: Iso8601Schema.optional(), + snoozedUntil: Iso8601Schema.optional(), + resolvedAt: Iso8601Schema.optional(), + resolution: ResolutionSchema.optional(), + policyFiredId: z.string(), + policyAlsoMatched: z.array(z.string()).default([]), + changes: z.array(ChangeSchema).min(1, "ChangeReport needs ≥1 change"), + recommendation: RecommendationSchema, + sensoUrl: z.string().url().optional(), + ownerId: z.string(), +}); + +export const EvidenceBriefResponseSchema = z.object({ + changeReport: ChangeReportSchema, + vendor: z.object({ + id: z.string(), + name: z.string(), + category: z.string(), + }), + policyFired: z.object({ id: z.string(), name: z.string() }), + policyAlsoMatched: z.array(z.object({ id: z.string(), name: z.string() })), + actionSummary: z.array( + z.object({ + kind: z.enum([ + "slack", + "jira", + "email", + "calendar", + "draft", + "payment", + ]), + target: z.string(), + status: z.enum(["queued", "delivered", "failed", "acknowledged"]), + firedAt: Iso8601Schema, + }), + ), +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 689df8c..161c866 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -24,6 +24,107 @@ export type RunStatus = "running" | "unchanged" | "changed" | "failed"; export type RunTrigger = "scheduled" | "admin" | "first-scan"; export type RunStageStatus = "started" | "completed" | "failed" | "skipped"; +// Issue #4 — evidence brief types. + +export type ChangeCategory = + | "data" + | "pricing" + | "subprocessor" + | "terms" + | "sla" + | "security"; + +export type Materiality = "material" | "minor" | "cosmetic"; + +export type ChangeState = + | "new" + | "acknowledged" + | "in-progress" + | "resolved" + | "snoozed"; + +export type Resolution = + | "accepted" + | "renegotiated" + | "rejected" + | "no-action"; + +export type RecommendationAction = + | "renegotiate" + | "escalate" + | "accept" + | "reject"; + +export interface Citation { + url: string; + quote: string; + section?: string; + fetchedAt: Iso8601; + country?: string; +} + +export interface Change { + id: string; + category: ChangeCategory; + summary: string; + before: string; + after: string; + materiality: Materiality; + dollarImpact?: { + annualUsd: number; + pctChange: number; + }; + citations: Citation[]; +} + +export interface Recommendation { + action: RecommendationAction; + copy: string; +} + +export interface ChangeReport { + id: string; + orgId: string; + vendorId: string; + runId: string; + detectedAt: Iso8601; + severity: Severity; + state: ChangeState; + acknowledgedAt?: Iso8601; + snoozedUntil?: Iso8601; + resolvedAt?: Iso8601; + resolution?: Resolution; + policyFiredId: string; + policyAlsoMatched: string[]; + changes: Change[]; + recommendation: Recommendation; + sensoUrl?: string; + ownerId: string; +} + +// Denormalised view used by the public evidence brief page. The /evidence/:id +// route returns this so the page renders without needing extra lookups. + +export interface EvidenceBriefResponse { + changeReport: ChangeReport; + vendor: { + id: string; + name: string; + category: string; + }; + policyFired: { + id: string; + name: string; + }; + policyAlsoMatched: { id: string; name: string }[]; + actionSummary: { + kind: "slack" | "jira" | "email" | "calendar" | "draft" | "payment"; + target: string; + status: "queued" | "delivered" | "failed" | "acknowledged"; + firedAt: Iso8601; + }[]; +} + export interface Org { id: string; name: string; diff --git a/seed/change-reports.json b/seed/change-reports.json new file mode 100644 index 0000000..22027e6 --- /dev/null +++ b/seed/change-reports.json @@ -0,0 +1,90 @@ +[ + { + "id": "chg_seed_notion", + "orgId": "org_acme", + "vendorId": "vnd_notion", + "runId": "run_seed_notion", + "detectedAt": "2026-05-22T18:42:18Z", + "severity": "P1", + "state": "new", + "policyFiredId": "pol_data_retention_pii_shrink", + "policyAlsoMatched": ["pol_price_hike_near_renewal"], + "changes": [ + { + "id": "d1", + "category": "data", + "summary": "User-content retention reduced from 90 to 30 days", + "before": "User content is retained for ninety (90) days after account deletion.", + "after": "User content is retained for thirty (30) days after account deletion.", + "materiality": "material", + "citations": [ + { + "url": "https://notion.so/terms", + "quote": "User content is retained for thirty (30) days after account deletion.", + "section": "§7.2 — Data Retention", + "fetchedAt": "2026-05-22T18:42:11Z" + } + ] + }, + { + "id": "d2", + "category": "pricing", + "summary": "Team plan per-seat rises from $16 to $19 (+18.75%)", + "before": "$16 per user / month", + "after": "$19 per user / month", + "materiality": "material", + "dollarImpact": { "annualUsd": 28400, "pctChange": 18.75 }, + "citations": [ + { + "url": "https://notion.so/pricing", + "quote": "Team plan: $19 per user / month, billed annually.", + "section": "Pricing · Team plan", + "fetchedAt": "2026-05-22T18:42:13Z" + } + ] + } + ], + "recommendation": { + "action": "renegotiate", + "copy": "Open renewal conversation; cite §7.2 retention shortfall as material change." + }, + "sensoUrl": "https://senso.com/r/notion-2026-05-22-retention", + "ownerId": "usr_priya" + }, + { + "id": "chg_seed_stripe_subprocessor", + "orgId": "org_acme", + "vendorId": "vnd_stripe", + "runId": "run_seed_stripe", + "detectedAt": "2026-05-15T13:08:02Z", + "severity": "P1", + "state": "acknowledged", + "acknowledgedAt": "2026-05-15T16:21:00Z", + "policyFiredId": "pol_subprocessor_added_restricted", + "policyAlsoMatched": [], + "changes": [ + { + "id": "d1", + "category": "subprocessor", + "summary": "New sub-processor added: TextRecognize Ltd (India)", + "before": "Sub-processors list does not include TextRecognize Ltd.", + "after": "Sub-processors list now includes TextRecognize Ltd, processing OCR text for KYC document validation.", + "materiality": "material", + "citations": [ + { + "url": "https://stripe.com/legal/subprocessors", + "quote": "TextRecognize Ltd — India — OCR text extraction for identity document validation.", + "section": "Active sub-processors", + "fetchedAt": "2026-05-15T13:07:42Z", + "country": "IN" + } + ] + } + ], + "recommendation": { + "action": "escalate", + "copy": "Notify Privacy and DPO; assess transfer mechanism for non-adequate jurisdiction." + }, + "ownerId": "usr_lin" + } +] diff --git a/seed/policies.json b/seed/policies.json new file mode 100644 index 0000000..b1bd393 --- /dev/null +++ b/seed/policies.json @@ -0,0 +1,33 @@ +[ + { + "id": "pol_data_retention_pii_shrink", + "orgId": "org_acme", + "name": "Data retention shrinks for PII vendors", + "description": "Critical when a PII-processing vendor reduces retention period.", + "version": 3, + "createdBy": "usr_marcus", + "isActive": true, + "severity": "P1", + "route": ["slack:#sec-grc", "jira:SEC", "email:ciso@acme.dev"] + }, + { + "id": "pol_price_hike_near_renewal", + "orgId": "org_acme", + "name": "Price increase >10% within 90d of renewal", + "version": 2, + "createdBy": "usr_priya", + "isActive": true, + "severity": "P1", + "route": ["slack:@vendorOwner", "jira:PROC", "calendar:renewal-prep-90d"] + }, + { + "id": "pol_subprocessor_added_restricted", + "orgId": "org_acme", + "name": "Sub-processor added in non-adequate jurisdiction", + "version": 1, + "createdBy": "usr_marcus", + "isActive": true, + "severity": "P1", + "route": ["slack:#privacy", "draft:customer-notification-30d"] + } +] diff --git a/seed/vendors.json b/seed/vendors.json index fe51488..09220bd 100644 --- a/seed/vendors.json +++ b/seed/vendors.json @@ -1 +1,50 @@ -[] +[ + { + "id": "vnd_notion", + "orgId": "org_acme", + "name": "Notion", + "category": "productivity", + "tier": 1, + "posture": "risk", + "dataClasses": ["pii", "content"], + "ownerId": "usr_priya", + "urls": { + "homepage": "https://notion.so", + "terms": "https://notion.so/terms", + "pricing": "https://notion.so/pricing", + "dpa": "https://notion.so/dpa", + "subProcessors": "https://notion.so/subprocessors", + "security": "https://notion.so/security", + "sla": "https://notion.so/sla" + }, + "contract": { + "renewsAt": "2026-07-04", + "annualSpendUsd": 158000, + "seatCount": 1420 + } + }, + { + "id": "vnd_stripe", + "orgId": "org_acme", + "name": "Stripe", + "category": "payments", + "tier": 1, + "posture": "watch", + "dataClasses": ["financial", "pii"], + "ownerId": "usr_lin", + "urls": { + "homepage": "https://stripe.com", + "terms": "https://stripe.com/legal", + "pricing": "https://stripe.com/pricing", + "dpa": "https://stripe.com/legal/dpa", + "subProcessors": "https://stripe.com/legal/subprocessors", + "security": "https://stripe.com/security", + "sla": "https://stripe.com/sla" + }, + "contract": { + "renewsAt": "2026-07-18", + "annualSpendUsd": 84000, + "seatCount": 20 + } + } +] diff --git a/tests/api/evidence.test.ts b/tests/api/evidence.test.ts new file mode 100644 index 0000000..9912cc4 --- /dev/null +++ b/tests/api/evidence.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { resolve } from "node:path"; +import { buildApp } from "../../apps/api/src/server.js"; +import { loadSeeds } from "../../apps/api/src/seed/loader.js"; + +const SEED_DIR = resolve(__dirname, "../../seed"); + +beforeEach(() => { + loadSeeds({ seedDir: SEED_DIR }); +}); + +describe("GET /evidence/:id", () => { + it("returns the brief without requiring a bearer token", async () => { + const resp = await buildApp().request("/v1/evidence/chg_seed_notion"); + expect(resp.status).toBe(200); + const body = (await resp.json()) as Record; + expect(body.changeReport).toBeDefined(); + expect(body.vendor).toBeDefined(); + expect(body.policyFired).toBeDefined(); + }); + + it("returns 404 with the standard error envelope for an unknown id", async () => { + const resp = await buildApp().request("/v1/evidence/chg_does_not_exist"); + expect(resp.status).toBe(404); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe("not-found"); + }); + + it("denormalises vendor and policy by id and includes citations verbatim", async () => { + const resp = await buildApp().request("/v1/evidence/chg_seed_notion"); + const body = (await resp.json()) as { + changeReport: { + id: string; + severity: string; + changes: Array<{ + summary: string; + citations: Array<{ quote: string; url: string; fetchedAt: string }>; + }>; + }; + vendor: { id: string; name: string; category: string }; + policyFired: { id: string; name: string }; + policyAlsoMatched: Array<{ id: string; name: string }>; + actionSummary: unknown[]; + }; + + expect(body.vendor.id).toBe("vnd_notion"); + expect(body.vendor.name).toBe("Notion"); + expect(body.vendor.category).toBe("productivity"); + + expect(body.policyFired.id).toBe("pol_data_retention_pii_shrink"); + expect(body.policyFired.name).toBe( + "Data retention shrinks for PII vendors", + ); + + expect(body.policyAlsoMatched).toHaveLength(1); + expect(body.policyAlsoMatched[0]?.name).toBe( + "Price increase >10% within 90d of renewal", + ); + + expect(body.changeReport.severity).toBe("P1"); + expect(body.changeReport.changes.length).toBeGreaterThanOrEqual(2); + const firstCitation = body.changeReport.changes[0]?.citations[0]; + expect(firstCitation?.quote).toContain("thirty (30) days"); + expect(firstCitation?.url).toBe("https://notion.so/terms"); + expect(firstCitation?.fetchedAt).toMatch(/^2026-/); + + // No actions seeded yet (Track A will populate via the runner). + expect(body.actionSummary).toEqual([]); + }); + + it("works for the background fleet entry (Stripe sub-processor)", async () => { + const resp = await buildApp().request("/v1/evidence/chg_seed_stripe_subprocessor"); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { + vendor: { name: string }; + policyFired: { name: string }; + }; + expect(body.vendor.name).toBe("Stripe"); + expect(body.policyFired.name).toBe( + "Sub-processor added in non-adequate jurisdiction", + ); + }); +}); diff --git a/tests/web/evidence-brief.test.tsx b/tests/web/evidence-brief.test.tsx new file mode 100644 index 0000000..c29813e --- /dev/null +++ b/tests/web/evidence-brief.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { SensoBrief } from "../../apps/web/src/screens/SensoBrief.js"; +import type { EvidenceBriefResponse } from "@redline/shared"; + +function mockFetch(handler: (path: string) => Response) { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const path = new URL(url, "http://test").pathname; + return handler(path); + }) as typeof fetch; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const FIXTURE: EvidenceBriefResponse = { + changeReport: { + id: "chg_seed_notion", + orgId: "org_acme", + vendorId: "vnd_notion", + runId: "run_seed_notion", + detectedAt: "2026-05-22T18:42:18Z", + severity: "P1", + state: "new", + policyFiredId: "pol_data_retention_pii_shrink", + policyAlsoMatched: ["pol_price_hike_near_renewal"], + changes: [ + { + id: "d1", + category: "data", + summary: "User-content retention reduced from 90 to 30 days", + before: "ninety (90) days", + after: "thirty (30) days", + materiality: "material", + citations: [ + { + url: "https://notion.so/terms", + quote: "User content is retained for thirty (30) days after account deletion.", + section: "§7.2 — Data Retention", + fetchedAt: "2026-05-22T18:42:11Z", + }, + ], + }, + ], + recommendation: { + action: "renegotiate", + copy: "Open renewal conversation; cite §7.2.", + }, + ownerId: "usr_priya", + }, + vendor: { id: "vnd_notion", name: "Notion", category: "productivity" }, + policyFired: { + id: "pol_data_retention_pii_shrink", + name: "Data retention shrinks for PII vendors", + }, + policyAlsoMatched: [ + { + id: "pol_price_hike_near_renewal", + name: "Price increase >10% within 90d of renewal", + }, + ], + actionSummary: [], +}; + +beforeEach(() => { + // Default: happy path. + mockFetch((path) => { + if (path === "/v1/evidence/chg_seed_notion") { + return jsonResponse(200, FIXTURE); + } + if (path === "/v1/evidence/chg_missing") { + return jsonResponse(404, { + error: { code: "not-found", message: "No evidence brief for id chg_missing" }, + }); + } + return jsonResponse(500, { error: { code: "internal", message: "boom" } }); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("SensoBrief", () => { + it("renders the brief with vendor, policy, citation, and timestamp", async () => { + render(); + expect(await screen.findByTestId("brief")).toBeInTheDocument(); + expect(screen.getByTestId("brief-title")).toHaveTextContent(/Notion/); + expect(screen.getByTestId("brief-severity")).toHaveTextContent("P1"); + expect(screen.getByTestId("brief-policy")).toHaveTextContent( + "Data retention shrinks for PII vendors", + ); + const citation = screen.getByTestId("citation"); + expect(citation).toHaveTextContent("thirty (30) days"); + expect(citation.querySelector("a")).toHaveAttribute( + "href", + "https://notion.so/terms", + ); + expect(screen.getByTestId("brief-detected-at").textContent).toMatch( + /2026/, + ); + }); + + it("shows the not-found state for unknown ids", async () => { + render(); + await waitFor(() => + expect(screen.getByTestId("brief-not-found")).toBeInTheDocument(), + ); + expect(screen.getByText(/not found/i)).toBeInTheDocument(); + }); + + it("renders 'no actions' empty state when actionSummary is empty", async () => { + render(); + await screen.findByTestId("brief"); + expect(screen.getByTestId("actions-empty")).toBeInTheDocument(); + }); + + it("does not render app chrome (no upgrade button, no nav)", async () => { + render(); + await screen.findByTestId("brief"); + expect(screen.queryByText(/upgrade to compliance pack/i)).toBeNull(); + expect(screen.queryByText(/add a vendor/i)).toBeNull(); + }); + + it("applies the brief class for print CSS targeting", async () => { + render(); + const brief = await screen.findByTestId("brief"); + expect(brief.className).toContain("brief"); + }); +});