Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/db/change-reports.ts
Original file line number Diff line number Diff line change
@@ -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<string, ChangeReport>();

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();
34 changes: 34 additions & 0 deletions apps/api/src/db/policy-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, SeededPolicy>();

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();
51 changes: 51 additions & 0 deletions apps/api/src/routes/evidence.ts
Original file line number Diff line number Diff line change
@@ -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<typeof p> => 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);
});
10 changes: 9 additions & 1 deletion apps/api/src/seed/loader.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -30,6 +32,10 @@ export function loadSeeds(opts?: { seedDir?: string }): void {
const users = readJson<User[]>(resolve(dir, "users.json"));
const vendors = readJson<Vendor[]>(resolve(dir, "vendors.json"));
const tokens = readJson<Record<string, string>>(resolve(dir, "tokens.json"));
const policies = readJson<SeededPolicy[]>(resolve(dir, "policies.json"));
const changeReports = readJson<ChangeReport[]>(
resolve(dir, "change-reports.json"),
);

caches.orgs.clear();
caches.users.clear();
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 <SensoBrief changeReportId={evidenceId} />;
}

return <RedlineApp />;
}

function RedlineApp(): JSX.Element {
const [showUpgrade, setShowUpgrade] = useState(false);
return (
<main className="app">
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ApiErrorEnvelope,
EvidenceBriefResponse,
VendorCreateBody,
VendorCreateResponse,
} from "@redline/shared";
Expand All @@ -24,10 +25,12 @@ export class ApiError extends Error {

async function request<T>(
path: string,
init: RequestInit = {},
init: RequestInit & { skipAuth?: boolean } = {},
): Promise<T> {
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");
}
Expand Down Expand Up @@ -101,3 +104,13 @@ export function createPaymentIntent(sku: string): Promise<PaymentIntentResponse>
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<EvidenceBriefResponse> {
return request<EvidenceBriefResponse>(`/v1/evidence/${encodeURIComponent(id)}`, {
skipAuth: true,
});
}
Loading