From bf8b0ca6457ff139838df28a88a3aaccf7bff28f Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Sun, 28 Jun 2026 16:27:31 +0000 Subject: [PATCH 1/3] Audit hash-chain verification endpoint and ops card --- src/app/api/audit/integrity/route.test.ts | 296 ++++++++++++++++++++++ src/app/api/audit/integrity/route.ts | 173 +++++++++++++ src/components/ops-dashboard.tsx | 95 ++++++- 3 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 src/app/api/audit/integrity/route.test.ts create mode 100644 src/app/api/audit/integrity/route.ts diff --git a/src/app/api/audit/integrity/route.test.ts b/src/app/api/audit/integrity/route.test.ts new file mode 100644 index 0000000..1405a4c --- /dev/null +++ b/src/app/api/audit/integrity/route.test.ts @@ -0,0 +1,296 @@ +import { promises as fs } from "node:fs"; + +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.hoisted(() => { + const tmpDir = `/tmp/fortexa-audit-integrity-${Date.now()}-${Math.random().toString(36).slice(2)}`; + process.env.FORTEXA_STORE_DIR = tmpDir; + process.env.FORTEXA_AUTH_SECRET = "audit-integrity-test-secret"; + delete process.env.DATABASE_URL; +}); + +import { GET } from "@/app/api/audit/integrity/route"; +import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; +import { + appendAuditEntry, + listAuditEntries, + resetAuditState, +} from "@/lib/storage/audit-store"; +import type { AuditEntry } from "@/lib/types/domain"; + +const OPERATOR_USER_ID = "integrity-operator"; +const LEGACY_USER_ID = "integrity-legacy-user"; + +function makeEntry(overrides: Partial = {}): AuditEntry { + return { + id: "entry-1", + timestamp: "2024-01-01T00:00:00.000Z", + action: { + id: "action-1", + name: "pay", + kind: "api_payment", + target: "GDEST", + domain: "stellar.org", + amountXLM: 1, + }, + decision: "APPROVE", + explanation: "ok", + triggeredPolicies: [], + riskFindings: [], + ...overrides, + }; +} + +function operatorCookie(userId = OPERATOR_USER_ID) { + const token = createSessionToken({ + email: `${userId}@fortexa.local`, + role: "operator", + userId, + expiresInSeconds: 120, + }); + return `${AUTH_COOKIE_KEY}=${token}`; +} + +function viewerCookie() { + const token = createSessionToken({ + email: "integrity-viewer@fortexa.local", + role: "viewer", + userId: "integrity-viewer", + expiresInSeconds: 120, + }); + return `${AUTH_COOKIE_KEY}=${token}`; +} + +function getRequest(url: string, cookie: string) { + return new NextRequest(url, { + method: "GET", + headers: { cookie }, + }); +} + +async function seedChain(userId: string, count: number) { + await resetAuditState(userId); + for (let i = 0; i < count; i++) { + await appendAuditEntry( + userId, + makeEntry({ + id: `${userId}-e${i + 1}`, + timestamp: `2024-01-01T00:0${i}:00.000Z`, + }), + ); + } +} + +async function seedLegacy(userId: string) { + await resetAuditState(userId); + const dir = process.env.FORTEXA_STORE_DIR!; + await fs.mkdir(dir, { recursive: true }); + const filePath = `${dir}/audit.json`; + let existing: { auditByUser: Record; usageByUser: Record } = { + auditByUser: {}, + usageByUser: {}, + }; + try { + const raw = await fs.readFile(filePath, "utf8"); + existing = JSON.parse(raw); + if (!existing.auditByUser) existing.auditByUser = {}; + if (!existing.usageByUser) existing.usageByUser = {}; + } catch { + // File does not exist yet — start fresh. + } + existing.auditByUser[userId] = [ + makeEntry({ + id: `${userId}-legacy`, + timestamp: "2023-01-01T00:00:00.000Z", + action: { + id: "legacy-action", + name: "transfer", + kind: "transfer", + target: "GTARGET", + domain: "legacy.example", + amountXLM: 1, + }, + }), + ]; + await fs.writeFile(filePath, JSON.stringify(existing, null, 2), "utf8"); +} + +async function overwriteStore(userId: string, entries: AuditEntry[]) { + const dir = process.env.FORTEXA_STORE_DIR!; + await fs.mkdir(dir, { recursive: true }); + const file = { + auditByUser: { [userId]: entries }, + usageByUser: {}, + }; + await fs.writeFile(`${dir}/audit.json`, JSON.stringify(file, null, 2), "utf8"); +} + +afterAll(async () => { + const dir = process.env.FORTEXA_STORE_DIR; + if (dir && dir.startsWith("/tmp/fortexa-audit-integrity-")) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +}); + +beforeEach(async () => { + await resetAuditState(OPERATOR_USER_ID); + await resetAuditState(LEGACY_USER_ID); +}); + +describe("GET /api/audit/integrity", () => { + it("returns 401 when unauthenticated", async () => { + const request = new NextRequest("http://localhost/api/audit/integrity", { method: "GET" }); + const response = await GET(request); + expect(response.status).toBe(401); + }); + + it("returns valid with empty audit history", async () => { + const response = await GET( + getRequest("http://localhost/api/audit/integrity", operatorCookie()) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + checkedEntries: number; + legacyEntries: number; + firstBrokenEntryId: string | null; + reason: string | null; + scope: string; + userId: string; + timestamp: string; + }; + expect(payload.valid).toBe(true); + expect(payload.checkedEntries).toBe(0); + expect(payload.legacyEntries).toBe(0); + expect(payload.firstBrokenEntryId).toBeNull(); + expect(payload.reason).toBeNull(); + expect(payload.scope).toBe("mine"); + expect(payload.userId).toBe(OPERATOR_USER_ID); + expect(typeof payload.timestamp).toBe("string"); + }); + + it("verifies a valid multi-entry chain (scope=mine)", async () => { + await seedChain(OPERATOR_USER_ID, 3); + const response = await GET( + getRequest("http://localhost/api/audit/integrity", operatorCookie()) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + checkedEntries: number; + }; + expect(payload.valid).toBe(true); + expect(payload.checkedEntries).toBe(3); + }); + + it("detects a tampered field as an entryHash mismatch", async () => { + await seedChain(OPERATOR_USER_ID, 3); + const stored = await listAuditEntries(OPERATOR_USER_ID); + const asc = [...stored].sort((a, b) => + a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0 + ); + const tampered = asc.map((entry, idx) => + idx === 1 ? { ...entry, explanation: "tampered" } : entry + ); + await overwriteStore(OPERATOR_USER_ID, tampered); + + const response = await GET( + getRequest("http://localhost/api/audit/integrity", operatorCookie()) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + reason: string | null; + firstBrokenEntryId: string | null; + }; + expect(payload.valid).toBe(false); + expect(payload.reason).toContain("entryHash"); + expect(payload.firstBrokenEntryId).toBe(`${OPERATOR_USER_ID}-e2`); + }); + + it("detects a missing entry as a previousHash mismatch", async () => { + await seedChain(OPERATOR_USER_ID, 3); + const stored = await listAuditEntries(OPERATOR_USER_ID); + const asc = [...stored].sort((a, b) => + a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0 + ); + // Drop the second entry — entry 3's previousHash now points to a vanished hash. + const withGap = [asc[0], asc[2]].filter(Boolean) as AuditEntry[]; + await overwriteStore(OPERATOR_USER_ID, withGap); + + const response = await GET( + getRequest("http://localhost/api/audit/integrity", operatorCookie()) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + reason: string | null; + firstBrokenEntryId: string | null; + }; + expect(payload.valid).toBe(false); + expect(payload.reason).toContain("previousHash"); + expect(payload.firstBrokenEntryId).toBe(`${OPERATOR_USER_ID}-e3`); + }); + + it("treats legacy entries without hash fields as valid (scope=mine)", async () => { + await seedLegacy(LEGACY_USER_ID); + const response = await GET( + getRequest( + "http://localhost/api/audit/integrity", + operatorCookie(LEGACY_USER_ID) + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + checkedEntries: number; + legacyEntries: number; + }; + expect(payload.valid).toBe(true); + expect(payload.checkedEntries).toBe(0); + expect(payload.legacyEntries).toBeGreaterThanOrEqual(1); + }); + + it("returns 403 when viewer requests scope=all", async () => { + const request = getRequest( + "http://localhost/api/audit/integrity?scope=all", + viewerCookie() + ); + const response = await GET(request); + expect(response.status).toBe(403); + }); + + it("allows operator scope=all and aggregates across users", async () => { + await seedChain(OPERATOR_USER_ID, 2); + await seedLegacy(LEGACY_USER_ID); + + const response = await GET( + getRequest( + "http://localhost/api/audit/integrity?scope=all", + operatorCookie() + ) + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + valid: boolean; + checkedEntries: number; + legacyEntries: number; + scope: string; + }; + expect(payload.valid).toBe(true); + expect(payload.scope).toBe("all"); + expect(payload.checkedEntries).toBe(2); + expect(payload.legacyEntries).toBeGreaterThanOrEqual(1); + }); + + it("returns 400 for an unknown scope value", async () => { + const response = await GET( + getRequest( + "http://localhost/api/audit/integrity?scope=everyone", + operatorCookie() + ) + ); + expect(response.status).toBe(400); + }); +}); diff --git a/src/app/api/audit/integrity/route.ts b/src/app/api/audit/integrity/route.ts new file mode 100644 index 0000000..6cf5e0f --- /dev/null +++ b/src/app/api/audit/integrity/route.ts @@ -0,0 +1,173 @@ +import { NextRequest } from "next/server"; + +import { verifyHashChain } from "@/lib/audit/hash-chain"; +import { requireAuth } from "@/lib/auth/require-auth"; +import { jsonWithRequestContext } from "@/lib/observability/http"; +import { getRequestLogContext, logInfo, logWarn } from "@/lib/observability/logger"; +import { listAllAuditEntriesByUser, listAuditEntries } from "@/lib/storage/audit-store"; + +type IntegrityResponse = { + valid: boolean; + checkedEntries: number; + legacyEntries: number; + firstBrokenEntryId: string | null; + reason: string | null; + scope: "mine" | "all"; + timestamp: string; + userId?: string; +}; + +function nowIso() { + return new Date().toISOString(); +} + +export async function GET(request: NextRequest) { + const startedAtMs = Date.now(); + const context = getRequestLogContext(request, "/api/audit/integrity"); + const auth = requireAuth(request); + + if (!auth.ok) { + logWarn("Audit integrity unauthorized", context); + return auth.response; + } + + const scopeParam = request.nextUrl.searchParams.get("scope")?.toLowerCase() ?? "mine"; + if (scopeParam !== "mine" && scopeParam !== "all") { + return jsonWithRequestContext(request, { + route: "/api/audit/integrity", + startedAtMs, + status: 400, + body: { error: "scope must be 'mine' or 'all'" }, + }); + } + + const isOperator = auth.session.role === "operator"; + const wantAll = scopeParam === "all"; + + if (wantAll && !isOperator) { + logWarn("Audit integrity all-scope forbidden", { + ...context, + userId: auth.session.userId, + role: auth.session.role, + }); + return jsonWithRequestContext(request, { + route: "/api/audit/integrity", + startedAtMs, + status: 403, + body: { error: "Only operators may verify cross-user audit integrity." }, + }); + } + + const timestamp = nowIso(); + + if (!wantAll) { + const entries = await listAuditEntries(auth.session.userId); + const result = verifyHashChain(entries); + + if (result.valid) { + logInfo("Audit integrity verified (mine)", { + ...context, + userId: auth.session.userId, + checkedEntries: result.checkedCount, + legacyEntries: result.legacyCount, + }); + return jsonWithRequestContext(request, { + route: "/api/audit/integrity", + startedAtMs, + status: 200, + body: { + valid: true, + checkedEntries: result.checkedCount, + legacyEntries: result.legacyCount, + firstBrokenEntryId: null, + reason: null, + scope: "mine", + userId: auth.session.userId, + timestamp, + } satisfies IntegrityResponse, + }); + } + + logWarn("Audit integrity tampered (mine)", { + ...context, + userId: auth.session.userId, + reason: result.reason, + firstBrokenEntryId: result.entryId ?? null, + }); + return jsonWithRequestContext(request, { + route: "/api/audit/integrity", + startedAtMs, + status: 200, + body: { + valid: false, + checkedEntries: result.checkedCount, + legacyEntries: result.legacyCount, + firstBrokenEntryId: result.entryId ?? null, + reason: result.reason, + scope: "mine", + userId: auth.session.userId, + timestamp, + } satisfies IntegrityResponse, + }); + } + + const allByUser = await listAllAuditEntriesByUser(); + let checkedEntries = 0; + let legacyEntries = 0; + let firstBroken: { entryId: string | null; reason: string } | null = null; + + for (const entries of Object.values(allByUser)) { + const result = verifyHashChain(entries); + checkedEntries += result.checkedCount; + legacyEntries += result.legacyCount; + if (!result.valid && !firstBroken) { + firstBroken = { + entryId: result.entryId ?? null, + reason: result.reason, + }; + } + } + + const responseBody = (firstBroken + ? { + valid: false, + checkedEntries, + legacyEntries, + firstBrokenEntryId: firstBroken.entryId, + reason: firstBroken.reason, + scope: "all" as const, + timestamp, + } + : { + valid: true, + checkedEntries, + legacyEntries, + firstBrokenEntryId: null, + reason: null, + scope: "all" as const, + timestamp, + }) satisfies IntegrityResponse; + + if (firstBroken) { + logWarn("Audit integrity tampered (all)", { + ...context, + userId: auth.session.userId, + reason: firstBroken.reason, + firstBrokenEntryId: firstBroken.entryId, + }); + } else { + logInfo("Audit integrity verified (all)", { + ...context, + userId: auth.session.userId, + checkedEntries, + legacyEntries, + }); + } + + return jsonWithRequestContext(request, { + route: "/api/audit/integrity", + startedAtMs, + status: 200, + body: responseBody, + }); +} diff --git a/src/components/ops-dashboard.tsx b/src/components/ops-dashboard.tsx index 8d7e308..ed1de8f 100644 --- a/src/components/ops-dashboard.tsx +++ b/src/components/ops-dashboard.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { AlertTriangle, CheckCircle2, Clock3, Database, HelpCircle, Shield, ShieldOff } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Clock3, Database, HelpCircle, Shield, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react"; import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -31,6 +31,17 @@ type HealthResponse = { }; }; +type IntegrityResponse = { + valid: boolean; + checkedEntries: number; + legacyEntries: number; + firstBrokenEntryId: string | null; + reason: string | null; + scope: "mine" | "all"; + userId?: string; + timestamp: string; +}; + type MetricsResponse = { service: string; timestamp: string; @@ -103,12 +114,14 @@ function DependencyBadge({ name, status }: { name: string; status: string }) { export function OpsDashboard() { const [health, setHealth] = useState(null); const [metrics, setMetrics] = useState(null); + const [integrity, setIntegrity] = useState(null); const [txCount, setTxCount] = useState(null); const [samples, setSamples] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [txLoading, setTxLoading] = useState(true); const [lastRefreshed, setLastRefreshed] = useState(null); + const [integrityLoading, setIntegrityLoading] = useState(true); useEffect(() => { let cancelled = false; @@ -144,6 +157,32 @@ export function OpsDashboard() { } } + async function loadIntegrity() { + try { + const integrityResponse = await fetch("/api/audit/integrity?scope=all", { + cache: "no-store", + }); + + if (!integrityResponse.ok) { + throw new Error("Failed to fetch audit integrity."); + } + + const integrityPayload = (await integrityResponse.json()) as IntegrityResponse; + if (cancelled) { + return; + } + setIntegrity(integrityPayload); + } catch (loadError) { + if (!cancelled) { + setIntegrity(null); + } + } finally { + if (!cancelled) { + setIntegrityLoading(false); + } + } + } + async function loadCore() { try { const [healthResponse, metricsResponse] = await Promise.all([ @@ -193,8 +232,10 @@ export function OpsDashboard() { } void loadCore(); + void loadIntegrity(); const interval = window.setInterval(() => { void loadCore(); + void loadIntegrity(); }, 8000); return () => { @@ -221,7 +262,7 @@ export function OpsDashboard() { ) : null} -
+
Service Health @@ -284,6 +325,56 @@ export function OpsDashboard() { + + + Audit Integrity + + {integrity ? ( + integrity.valid ? ( + + ) : ( + + ) + ) : ( + + )} + {integrity + ? integrity.valid + ? "Valid" + : "Tampered" + : integrityLoading + ? "Loading" + : "Unknown"} + + + + {integrity ? ( + <> +

+ Checked entries: {integrity.checkedEntries} +

+ {integrity.legacyEntries > 0 ? ( +

Legacy entries: {integrity.legacyEntries}

+ ) : null} + {integrity.valid ? null : ( +
+

+ First broken entry:{" "} + + {integrity.firstBrokenEntryId ?? "unknown"} + +

+ {integrity.reason ?

{integrity.reason}

: null} +
+ )} +

Last verified: {new Date(integrity.timestamp).toLocaleString()}

+ + ) : ( +

Last verified: —

+ )} +
+
+ Blocklist Feed From 644f69f59e869a346445fade0395420d816a74d6 Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Wed, 1 Jul 2026 08:36:55 +0000 Subject: [PATCH 2/3] fix(ops-dashboard): import Shield icons, drop unused catch, untrack tsbuildinfo - import ShieldCheck and ShieldAlert from lucide-react - convert unused `catch (loadError)` in loadIntegrity to bare catch - untrack tsconfig.tsbuildinfo via .gitignore and git rm --cached --- src/components/ops-dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ops-dashboard.tsx b/src/components/ops-dashboard.tsx index ed1de8f..9c2fb65 100644 --- a/src/components/ops-dashboard.tsx +++ b/src/components/ops-dashboard.tsx @@ -172,7 +172,7 @@ export function OpsDashboard() { return; } setIntegrity(integrityPayload); - } catch (loadError) { + } catch { if (!cancelled) { setIntegrity(null); } From 1efe3d7f8d2f204642d028ec65d592eca4516bbf Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Mon, 6 Jul 2026 10:02:12 +0100 Subject: [PATCH 3/3] feat: add read-only audit integrity verification API and Ops dashboard status --- src/components/ops-dashboard.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ops-dashboard.test.ts b/src/components/ops-dashboard.test.ts index 0a913e1..693e813 100644 --- a/src/components/ops-dashboard.test.ts +++ b/src/components/ops-dashboard.test.ts @@ -112,11 +112,11 @@ describe("OpsDashboard lastRefreshed feature", () => { const getHealth = () => states[0] as HealthState; const getMetrics = () => states[1] as MetricsState; - const getTxCount = () => states[2] as number | null; - const getError = () => states[4] as string | null; - const getLoading = () => states[5] as boolean; - const getTxLoading = () => states[6] as boolean; - const getLastRefreshed = () => states[7] as string | null; + const getTxCount = () => states[3] as number | null; + const getError = () => states[5] as string | null; + const getLoading = () => states[6] as boolean; + const getTxLoading = () => states[7] as boolean; + const getLastRefreshed = () => states[8] as string | null; function setupSuccessfulFetch() { fetchMock.mockImplementation((url: string) => {