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
31 changes: 31 additions & 0 deletions app/api/protocol/passport/audit/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
56 changes: 56 additions & 0 deletions app/api/protocol/passport/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
}
39 changes: 39 additions & 0 deletions app/api/protocol/passport/batch-verify/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
}
38 changes: 38 additions & 0 deletions app/api/protocol/passport/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
)
}
}
145 changes: 145 additions & 0 deletions lib/passport/audit-log.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading
Loading