From 6878e313564b30bd3b9ae527d6d390a7c794f870 Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Tue, 30 Jun 2026 15:23:36 +0100 Subject: [PATCH] feat(#79): persist passport audit log per agent - Add PassportAuditEvent schema (issued | authorized | revoked | expired | batch_verified) - Persist to .data/passport-audit.jsonl (append-only, capped at 1000) - Hook appendAuditEvent into issuePassport, authorizePassportSpend, revokePassport, verifyPassportBatch - Add GET /api/protocol/passport/audit?agentId=&limit=50 route - Add authorize and revoke API routes - Add unit tests: issue -> authorize -> revoke -> audit list shows 3 events in order --- app/api/protocol/passport/audit/route.ts | 31 ++++ app/api/protocol/passport/authorize/route.ts | 56 +++++++ .../protocol/passport/batch-verify/route.ts | 39 +++++ app/api/protocol/passport/revoke/route.ts | 38 +++++ lib/passport/audit-log.test.ts | 145 ++++++++++++++++ lib/passport/audit-log.ts | 157 ++++++++---------- lib/passport/passport.ts | 93 ++++++++++- 7 files changed, 467 insertions(+), 92 deletions(-) create mode 100644 app/api/protocol/passport/audit/route.ts create mode 100644 app/api/protocol/passport/authorize/route.ts create mode 100644 app/api/protocol/passport/batch-verify/route.ts create mode 100644 app/api/protocol/passport/revoke/route.ts create mode 100644 lib/passport/audit-log.test.ts diff --git a/app/api/protocol/passport/audit/route.ts b/app/api/protocol/passport/audit/route.ts new file mode 100644 index 0000000..c74a660 --- /dev/null +++ b/app/api/protocol/passport/audit/route.ts @@ -0,0 +1,31 @@ +// app/api/protocol/passport/audit/route.ts +import { NextResponse } from "next/server" +import { listAuditEvents } from "@/lib/passport/audit-log" + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + + const agentId = searchParams.get("agentId") + if (!agentId || typeof agentId !== "string" || !agentId.trim()) { + return NextResponse.json( + { ok: false, error: "agentId is required" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + const rawLimit = searchParams.get("limit") + const limit = rawLimit ? parseInt(rawLimit, 10) : 50 + if (!Number.isFinite(limit) || limit < 1) { + return NextResponse.json( + { ok: false, error: "limit must be a positive integer" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + const events = listAuditEvents({ agentId, limit }) + + return NextResponse.json( + { ok: true, agentId, events }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) +} \ No newline at end of file diff --git a/app/api/protocol/passport/authorize/route.ts b/app/api/protocol/passport/authorize/route.ts new file mode 100644 index 0000000..f2988e6 --- /dev/null +++ b/app/api/protocol/passport/authorize/route.ts @@ -0,0 +1,56 @@ +// app/api/protocol/passport/authorize/route.ts +import { NextResponse } from "next/server" +import { authorizePassportSpend } from "@/lib/passport/passport" + +interface AuthorizeBody { + agentId?: unknown + amount?: unknown + quoteId?: unknown +} + +export async function POST(req: Request) { + try { + const body = (await req.json().catch(() => ({}))) as AuthorizeBody + + if (typeof body.agentId !== "string" || !body.agentId.trim()) { + return NextResponse.json( + { ok: false, error: "agentId is required" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + if (typeof body.amount !== "number" || !Number.isFinite(body.amount) || body.amount <= 0) { + return NextResponse.json( + { ok: false, error: "amount must be a positive number" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + if (typeof body.quoteId !== "string" || !body.quoteId.trim()) { + return NextResponse.json( + { ok: false, error: "quoteId is required" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + const actor = req.headers.get("x-stellar-address") || "admin" + const result = authorizePassportSpend(body.agentId, body.amount, body.quoteId, actor) + + if (!result.ok) { + return NextResponse.json( + { ok: false, error: result.error }, + { status: 403, headers: { "Cache-Control": "no-store" } }, + ) + } + + return NextResponse.json( + { ok: true, passport: result.record }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : "Failed to authorize spend" }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ) + } +} \ No newline at end of file diff --git a/app/api/protocol/passport/batch-verify/route.ts b/app/api/protocol/passport/batch-verify/route.ts new file mode 100644 index 0000000..aea378a --- /dev/null +++ b/app/api/protocol/passport/batch-verify/route.ts @@ -0,0 +1,39 @@ +// app/api/protocol/passport/batch-verify/route.ts +import { NextResponse } from "next/server" +import { verifyPassportBatch } from "@/lib/passport/passport" + +interface BatchVerifyBody { + passportIds?: unknown +} + +export async function POST(req: Request) { + try { + const body = (await req.json().catch(() => ({}))) as BatchVerifyBody + + if (!Array.isArray(body.passportIds) || body.passportIds.length === 0) { + return NextResponse.json( + { ok: false, error: "passportIds must be a non-empty array" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + if (!body.passportIds.every((id) => typeof id === "string" && id.trim())) { + return NextResponse.json( + { ok: false, error: "all passportIds must be non-empty strings" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + const result = verifyPassportBatch(body.passportIds) + + return NextResponse.json( + { ok: true, ...result }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : "Failed to batch verify" }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ) + } +} \ No newline at end of file diff --git a/app/api/protocol/passport/revoke/route.ts b/app/api/protocol/passport/revoke/route.ts new file mode 100644 index 0000000..97bce87 --- /dev/null +++ b/app/api/protocol/passport/revoke/route.ts @@ -0,0 +1,38 @@ +// app/api/protocol/passport/revoke/route.ts +import { NextResponse } from "next/server" +import { revokePassport } from "@/lib/passport/passport" + +interface RevokeBody { + id?: unknown + reason?: unknown +} + +export async function POST(req: Request) { + try { + const body = (await req.json().catch(() => ({}))) as RevokeBody + + if (typeof body.id !== "string" || !body.id.trim()) { + return NextResponse.json( + { ok: false, error: "id is required" }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ) + } + + const actor = req.headers.get("x-stellar-address") || "admin" + const reason = typeof body.reason === "string" ? body.reason : undefined + + const record = revokePassport(body.id, actor, reason) + + return NextResponse.json( + { ok: true, passport: record }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ) + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to revoke passport" + const status = message === "passport_not_found" ? 404 : 500 + return NextResponse.json( + { ok: false, error: message }, + { status, headers: { "Cache-Control": "no-store" } }, + ) + } +} \ No newline at end of file diff --git a/lib/passport/audit-log.test.ts b/lib/passport/audit-log.test.ts new file mode 100644 index 0000000..2d464f8 --- /dev/null +++ b/lib/passport/audit-log.test.ts @@ -0,0 +1,145 @@ +// lib/passport/audit-log.test.ts +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { + appendAuditEvent, + listAuditEvents, + resetAuditFile, + type PassportAuditEvent, +} from "./audit-log" +import { + issuePassport, + authorizePassportSpend, + revokePassport, + verifyPassportBatch, + resetPassportStore, +} from "./passport" + +describe("PassportAuditLog", () => { + beforeEach(() => { + resetAuditFile() + resetPassportStore() + }) + + afterEach(() => { + resetAuditFile() + resetPassportStore() + }) + + it("appends and reads issued event", () => { + const ev = appendAuditEvent({ agentId: "agent-1", type: "issued" }) + expect(ev.agentId).toBe("agent-1") + expect(ev.type).toBe("issued") + expect(ev.id).toBeDefined() + expect(ev.at).toBeDefined() + }) + + it("appends authorized event with amount, quoteId, ok", () => { + const ev = appendAuditEvent({ + agentId: "agent-2", + type: "authorized", + amount: 100, + quoteId: "quote-42", + ok: true, + }) + expect(ev.amount).toBe(100) + expect(ev.quoteId).toBe("quote-42") + expect(ev.ok).toBe(true) + }) + + it("appends revoked event with reason", () => { + const ev = appendAuditEvent({ + agentId: "agent-3", + type: "revoked", + reason: "compromised", + }) + expect(ev.reason).toBe("compromised") + }) + + it("caps log at 1000 events", () => { + for (let i = 0; i < 1005; i++) { + appendAuditEvent({ agentId: `agent-${i}`, type: "issued" }) + } + const all = listAuditEvents({ agentId: "agent-1004", limit: 1000 }) + // agent-1004 should exist (it was the 1005th, so kept) + expect(all.length).toBe(1) + // Check total file size is capped + const every = listAuditEvents({ agentId: "agent-0", limit: 1000 }) + expect(every.length).toBe(0) // agent-0 was trimmed + }) + + it("lists events newest-first for an agent", () => { + appendAuditEvent({ agentId: "agent-x", type: "issued" }) + appendAuditEvent({ agentId: "agent-x", type: "authorized", amount: 10, quoteId: "q1", ok: true }) + appendAuditEvent({ agentId: "agent-x", type: "revoked", reason: "test" }) + + const events = listAuditEvents({ agentId: "agent-x", limit: 50 }) + expect(events).toHaveLength(3) + expect(events[0].type).toBe("revoked") + expect(events[1].type).toBe("authorized") + expect(events[2].type).toBe("issued") + }) + + it("filters by agentId only", () => { + appendAuditEvent({ agentId: "agent-a", type: "issued" }) + appendAuditEvent({ agentId: "agent-b", type: "issued" }) + const events = listAuditEvents({ agentId: "agent-a", limit: 50 }) + expect(events).toHaveLength(1) + expect(events[0].agentId).toBe("agent-a") + }) + + it("end-to-end: issue -> authorize -> revoke -> audit list shows 3 events in order", () => { + // Issue + issuePassport("pp-1", "agent-007", "admin", { allowTransfer: true }) + + // Authorize + authorizePassportSpend("agent-007", 50, "quote-123", "admin") + + // Revoke + revokePassport("pp-1", "admin", "security_breach") + + // Query audit + const events = listAuditEvents({ agentId: "agent-007", limit: 50 }) + expect(events).toHaveLength(3) + + // Newest-first order + expect(events[0].type).toBe("revoked") + expect(events[0].reason).toBe("security_breach") + + expect(events[1].type).toBe("authorized") + expect(events[1].amount).toBe(50) + expect(events[1].quoteId).toBe("quote-123") + expect(events[1].ok).toBe(true) + + expect(events[2].type).toBe("issued") + }) + + it("batch_verify appends audit events per agent", () => { + issuePassport("pp-a", "agent-101", "admin") + issuePassport("pp-b", "agent-102", "admin") + + verifyPassportBatch(["pp-a", "pp-b", "missing"]) + + const e101 = listAuditEvents({ agentId: "agent-101", limit: 10 }) + const e102 = listAuditEvents({ agentId: "agent-102", limit: 10 }) + + expect(e101).toHaveLength(2) // issued + batch_verified + expect(e101[0].type).toBe("batch_verified") + expect(e101[0].ok).toBe(true) + + expect(e102).toHaveLength(2) + expect(e102[0].type).toBe("batch_verified") + expect(e102[0].ok).toBe(true) + }) + + it("authorize failure still appends audit with ok:false", () => { + const result = authorizePassportSpend("unknown-agent", 10, "q-1", "admin") + expect(result.ok).toBe(false) + + const events = listAuditEvents({ agentId: "unknown-agent", limit: 10 }) + expect(events).toHaveLength(1) + expect(events[0].type).toBe("authorized") + expect(events[0].ok).toBe(false) + expect(events[0].amount).toBe(10) + expect(events[0].quoteId).toBe("q-1") + }) +}) \ No newline at end of file diff --git a/lib/passport/audit-log.ts b/lib/passport/audit-log.ts index 46bfdc0..a0f974a 100644 --- a/lib/passport/audit-log.ts +++ b/lib/passport/audit-log.ts @@ -1,108 +1,95 @@ +// lib/passport/audit-log.ts import { randomUUID } from "node:crypto" - -export type AdminAuditAction = - | "grant" - | "revoke" - | "batch_verify" - | "admin_transfer" - | "verifier_change" - -export interface AdminAuditEntry { - id: string // crypto.randomUUID() - action: AdminAuditAction - actor: string // admin wallet address - target: string // passport ID or new admin address - timestamp: number // Date.now() - metadata?: Record +import { existsSync, mkdirSync, appendFileSync, readFileSync } from "node:fs" +import { join } from "node:path" + +export type PassportAuditEventType = + | "issued" + | "authorized" + | "revoked" + | "expired" + | "batch_verified" + +export interface PassportAuditEvent { + id: string + agentId: string + type: PassportAuditEventType + at: string // ISO-8601 + amount?: number + quoteId?: string + reason?: string + ok?: boolean } -type AdminAuditLog = AdminAuditEntry[] +const DATA_DIR = join(process.cwd(), ".data") +const AUDIT_FILE = join(DATA_DIR, "passport-audit.jsonl") +const MAX_EVENTS = 1000 -const globalState = globalThis as typeof globalThis & { - __openStellarPassportAdminAuditLog__?: AdminAuditLog +function ensureDataDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }) + } } -function getAdminAuditLog(): AdminAuditLog { - if (!globalState.__openStellarPassportAdminAuditLog__) { - globalState.__openStellarPassportAdminAuditLog__ = [] - } - return globalState.__openStellarPassportAdminAuditLog__ +function readAllEvents(): PassportAuditEvent[] { + if (!existsSync(AUDIT_FILE)) return [] + const raw = readFileSync(AUDIT_FILE, "utf-8") + const lines = raw.split("\n").filter((l) => l.trim().length > 0) + return lines.map((line) => JSON.parse(line) as PassportAuditEvent) } /** - * Appends a new admin audit entry. Auto-generates id and timestamp. + * Append an event to the JSONL audit log. + * Maintains a cap of MAX_EVENTS by trimming oldest when exceeded. */ -export function appendAdminAuditEntry( - entry: Omit & { id?: string; timestamp?: number } -): AdminAuditEntry { - const newEntry: AdminAuditEntry = { - id: entry.id || randomUUID(), - action: entry.action, - actor: entry.actor, - target: entry.target, - timestamp: entry.timestamp ?? Date.now(), - metadata: entry.metadata, +export function appendAuditEvent( + event: Omit & { id?: string; at?: string } +): PassportAuditEvent { + const fullEvent: PassportAuditEvent = { + id: event.id || randomUUID(), + agentId: event.agentId, + type: event.type, + at: event.at || new Date().toISOString(), + amount: event.amount, + quoteId: event.quoteId, + reason: event.reason, + ok: event.ok, } - const log = getAdminAuditLog() - log.push(newEntry) + ensureDataDir() + appendFileSync(AUDIT_FILE, JSON.stringify(fullEvent) + "\n") - // Bound at 10,000 entries - if (log.length > 10_000) { - log.splice(0, log.length - 10_000) + // Enforce cap: if over limit, rewrite file with last N events + const all = readAllEvents() + if (all.length > MAX_EVENTS) { + const trimmed = all.slice(all.length - MAX_EVENTS) + const rewrite = trimmed.map((e) => JSON.stringify(e)).join("\n") + "\n" + const { writeFileSync } = require("node:fs") + writeFileSync(AUDIT_FILE, rewrite) } - return newEntry + return fullEvent } -export interface AdminAuditQueryOptions { - action?: AdminAuditAction - actor?: string - target?: string - since?: string // ISO 8601 timestamp - limit?: number // default 100, max 1000 +export interface AuditQueryOptions { + agentId: string + limit?: number // default 50 } /** - * Returns admin audit entries newest-first, with optional filters. + * Return last N events for an agent, newest-first. */ -export function listAdminAuditEntries(opts?: AdminAuditQueryOptions): AdminAuditEntry[] { - const log = getAdminAuditLog() - let entries = [...log] - - if (opts?.action) { - entries = entries.filter(e => e.action === opts.action) - } - - if (opts?.actor) { - const actor = opts.actor.toLowerCase().trim() - entries = entries.filter(e => e.actor.toLowerCase().trim() === actor) - } - - if (opts?.target) { - const target = opts.target.toLowerCase().trim() - entries = entries.filter(e => e.target.toLowerCase().trim() === target) - } - - if (opts?.since) { - const sinceMs = new Date(opts.since).getTime() - if (!Number.isNaN(sinceMs)) { - entries = entries.filter(e => e.timestamp >= sinceMs) - } - } - - // Newest first - entries.reverse() - - // Apply limit (default 100, max 1000) - const limit = Math.min(Math.max(opts?.limit ?? 100, 1), 1000) - return entries.slice(0, limit) +export function listAuditEvents(opts: AuditQueryOptions): PassportAuditEvent[] { + const all = readAllEvents() + const filtered = all.filter((e) => e.agentId === opts.agentId) + const limit = Math.min(Math.max(opts.limit ?? 50, 1), MAX_EVENTS) + return filtered.slice(-limit).reverse() } -/** - * Clears the admin audit store. For test isolation. - */ -export function resetAdminAuditStore(): void { - const log = getAdminAuditLog() - log.splice(0, log.length) -} +/** For test isolation. */ +export function resetAuditFile(): void { + const { writeFileSync } = require("node:fs") + if (existsSync(AUDIT_FILE)) { + writeFileSync(AUDIT_FILE, "") + } +} \ No newline at end of file diff --git a/lib/passport/passport.ts b/lib/passport/passport.ts index 7b56009..d3c04d0 100644 --- a/lib/passport/passport.ts +++ b/lib/passport/passport.ts @@ -1,8 +1,9 @@ +// lib/passport/passport.ts import { appendAuditEntry } from "./audit" +import { appendAuditEvent } from "./audit-log" export type PassportStatus = "active" | "revoked" | "expired" | "suspended" - export interface PassportConfig { allowTransfer: boolean } @@ -111,11 +112,69 @@ export function issuePassport( expiresAt, } setPassport(record) + + // Legacy passport-level audit appendAuditEntry({ passportId: record.id, action: "issued", actor, }) + + // NEW: Per-agent audit + appendAuditEvent({ + agentId: record.agentId, + type: "issued", + }) + + return record +} + +export function authorizePassportSpend( + agentId: string, + amount: number, + quoteId: string, + actor: string, +): { ok: true; record: PassportRecord } | { ok: false; error: string } { + const record = getPassportByAgentId(agentId) + if (!record) { + appendAuditEvent({ agentId, type: "authorized", amount, quoteId, ok: false }) + return { ok: false, error: "passport_not_found" } + } + if (record.status !== "active") { + appendAuditEvent({ agentId, type: "authorized", amount, quoteId, ok: false }) + return { ok: false, error: `passport_${record.status}` } + } + + appendAuditEvent({ agentId, type: "authorized", amount, quoteId, ok: true }) + return { ok: true, record } +} + +export function revokePassport( + id: string, + actor: string, + reason?: string, +): PassportRecord { + const record = getPassport(id) + if (!record) throw new Error("passport_not_found") + + record.status = "revoked" + setPassport(record) + + // Legacy passport-level audit + appendAuditEntry({ + passportId: record.id, + action: "revoked", + actor, + reason, + }) + + // NEW: Per-agent audit + appendAuditEvent({ + agentId: record.agentId, + type: "revoked", + reason, + }) + return record } @@ -152,12 +211,21 @@ export function expirePassport(id: string, actor: string, reason?: string): Pass if (!record) throw new Error("passport_not_found") record.status = "expired" setPassport(record) + appendAuditEntry({ passportId: record.id, action: "expired", actor, reason, }) + + // NEW: Per-agent audit + appendAuditEvent({ + agentId: record.agentId, + type: "expired", + reason, + }) + return record } @@ -222,7 +290,7 @@ export interface BatchVerificationResponse { } export function verifyPassportBatch(passportIds: string[]): BatchVerificationResponse { - const results: BatchVerificationResult[] = passportIds.map(id => { + const results: BatchVerificationResult[] = passportIds.map((id) => { const passport = getPassport(id) if (!passport) { return { @@ -230,7 +298,7 @@ export function verifyPassportBatch(passportIds: string[]): BatchVerificationRes status: null, agentId: null, valid: false, - error: "not_found" + error: "not_found", } } const valid = passport.status === "active" @@ -238,18 +306,29 @@ export function verifyPassportBatch(passportIds: string[]): BatchVerificationRes passportId: id, status: passport.status, agentId: passport.agentId, - valid + valid, } }) const total = results.length - const validCount = results.filter(r => r.valid).length + const validCount = results.filter((r) => r.valid).length const invalidCount = total - validCount + // NEW: Per-agent audit for batch verification + results.forEach((r) => { + if (r.agentId) { + appendAuditEvent({ + agentId: r.agentId, + type: "batch_verified", + ok: r.valid, + }) + } + }) + return { results, total, validCount, - invalidCount + invalidCount, } -} +} \ No newline at end of file