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
103 changes: 82 additions & 21 deletions app/api/protocol/passport/[id]/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { getPassportByAgentId } from "@/lib/passport/passport"
import { listAuditEntries, type AuditEntry } from "@/lib/passport/audit"

interface RouteContext {
params: Promise<{ id: string }>
Expand All @@ -8,7 +9,26 @@
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS

/** Current UTC day boundaries for the daily spend read-out. */
function utcDayRange(now = new Date()): { startMs: number; endMs: number } {
const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
return { startMs: start, endMs: start + DAY_MS }
}

function spendAmount(entry: AuditEntry): number | null {
const action = entry.action as unknown as string
if (action !== "spend" && action !== "verified_spend") return null
const amount = (entry.metadata as { amountXlm?: unknown } | undefined)?.amountXlm
return typeof amount === "number" && Number.isFinite(amount) ? amount : null
}
Comment on lines +18 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Daily/weekly spend always 0 — no producer emits spend entries

spendAmount only counts audit entries whose action is spend or verified_spend, but the AuditAction union (lib/passport/audit.ts:3-11) does not include those values and no code anywhere calls appendAuditEntry with them (grep finds references only in this route and its test, which force action: "spend" as never). Consequently dailySpentXlm and weeklySpentXlm will always be 0 against real data — the tests pass only because they bypass the type system. Add spend/verified_spend to AuditAction and ensure the spend flow actually writes those audit entries (with metadata.amountXlm), otherwise the endpoint reports fabricated zeros.

Was this helpful? React with 👍 / 👎


/**
* GET /api/protocol/passport/[id]/health (issue #83)
*
* Single human/agent-friendly health summary. Returns 200 even for
* revoked/expired passports; 404 only when no passport exists at all.
*/
export async function GET(_req: Request, context: RouteContext) {

Check failure on line 31 in app/api/protocol/passport/[id]/health/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_open-stellar-passport&issues=AaA6oeTXEOpv-Iua9Qbv&open=AaA6oeTXEOpv-Iua9Qbv&pullRequest=240
const { id } = await context.params
const agentId = decodeURIComponent(id)
const passport = getPassportByAgentId(agentId)
Expand All @@ -24,23 +44,64 @@
const remainingMs = expiresAt
? Math.max(0, new Date(expiresAt).getTime() - Date.now())
: null
const fullLifeExpiryHoursRemaining = remainingMs === null
? null
: Math.ceil(remainingMs / HOUR_MS)
const daysRemaining = remainingMs === null
? null
: Math.floor(remainingMs / DAY_MS)
const hoursRemaining = remainingMs === null || remainingMs >= DAY_MS
? null
: fullLifeExpiryHoursRemaining

const status = passport.status === "revoked"
? "revoked"
: passport.status === "suspended"
? "suspended"
: passport.status === "expired" || expiresAt && new Date(expiresAt).getTime() < Date.now()
? "expired"
: "active"
const daysRemaining =
remainingMs === null ? null : Math.floor(remainingMs / DAY_MS)
const hoursRemaining =
remainingMs === null || remainingMs >= DAY_MS
? null
: Math.ceil(remainingMs / HOUR_MS)
const fullLifeExpiryHoursRemaining =
remainingMs === null ? null : Math.ceil(remainingMs / HOUR_MS)

const status =
passport.status === "revoked"
? "revoked"
: passport.status === "suspended"
? "suspended"
: passport.status === "expired" ||
(expiresAt && new Date(expiresAt).getTime() < Date.now())
? "expired"
: "active"

Check warning on line 64 in app/api/protocol/passport/[id]/health/route.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_open-stellar-passport&issues=AaA6oeTXEOpv-Iua9Qbw&open=AaA6oeTXEOpv-Iua9Qbw&pullRequest=240

// Daily/weekly spend derives from audit entries carrying metadata.amountXlm.
const now = new Date()
const { startMs, endMs } = utcDayRange(now)
const weekStartMs = endMs - 7 * DAY_MS
let dailySpentXlm = 0
let weeklySpentXlm = 0
for (const entry of listAuditEntries(passport.id)) {
const ts = new Date(entry.timestamp).getTime()
if (!Number.isFinite(ts)) continue
const amount = spendAmount(entry)
if (amount === null) continue
if (ts >= startMs && ts < endMs) dailySpentXlm += amount
if (ts >= weekStartMs && ts < endMs) weeklySpentXlm += amount
}

// Optional protocol features: surfaced from config when present.
const config = passport.config as {
spendLimits?: { dailyMaxXlm?: number; weeklyMaxXlm?: number | null } | null
circuitBreaker?: {
consecutiveFailures?: number
maxConsecutiveFailures?: number
tripped?: boolean
} | null
}
const spendLimits =
config.spendLimits && typeof config.spendLimits.dailyMaxXlm === "number"
? {
dailyMaxXlm: config.spendLimits.dailyMaxXlm,
weeklyMaxXlm: config.spendLimits.weeklyMaxXlm ?? null,
}
: null
Comment on lines +82 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: spendLimits/circuitBreakerStatus can never populate in production

The route casts passport.config to a shape with optional spendLimits/circuitBreaker, but PassportConfig is defined as only { allowTransfer: boolean } (lib/passport/passport.ts:6), so these fields are never present on real records and spendingLimits/circuitBreakerStatus will always be null. The test only exercises this by casting injected config as Partial<PassportRecord>, bypassing the type. Extend PassportConfig to declare spendLimits/circuitBreaker so the values can actually be stored and surfaced.

Was this helpful? React with 👍 / 👎

const cb = config.circuitBreaker
const circuitBreakerStatus = cb
? {
consecutiveFailures: cb.consecutiveFailures ?? 0,
maxConsecutiveFailures: cb.maxConsecutiveFailures ?? null,
tripped: Boolean(cb.tripped),
}
: null

return NextResponse.json(
{
Expand All @@ -51,10 +112,10 @@
daysRemaining,
hoursRemaining,
fullLifeExpiryHoursRemaining,
spendingLimits: null,
dailySpentXlm: 0,
weeklySpentXlm: 0,
circuitBreakerStatus: null,
spendingLimits: spendLimits,
dailySpentXlm,
weeklySpentXlm,
circuitBreakerStatus,
},
{ status: 200, headers: { "Cache-Control": "no-store" } },
)
Expand Down
113 changes: 113 additions & 0 deletions tests/lib/passport/health-spend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { GET } from "@/app/api/protocol/passport/[id]/health/route"
import {
resetPassportStore,
setPassport,
type PassportRecord,
} from "@/lib/passport/passport"
import { appendAuditEntry, resetAuditStore } from "@/lib/passport/audit"

vi.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: { status?: number; headers?: Record<string, string> }) => {
const headers = new Headers(init?.headers)
return {
status: init?.status ?? 200,
headers,
json: async () => body,
} as unknown as Response
},
},
}))

const NOW = new Date("2026-06-27T12:00:00.000Z")
const AGENT_ID = "GBBB"

function passport(overrides: Partial<PassportRecord> = {}): PassportRecord {
return {
id: "passport-h83",
agentId: AGENT_ID,
status: "active",
config: { allowTransfer: true },
createdAt: "2026-06-01T00:00:00.000Z",
...overrides,
}
}

async function getHealth(agentId = AGENT_ID) {
return GET(new Request(`http://localhost/api/protocol/passport/${agentId}/health`), {
params: Promise.resolve({ id: agentId }),
})
}

describe("issue #83: health derives live spend data from the audit log", () => {
beforeEach(() => {
resetPassportStore()
resetAuditStore()
vi.useFakeTimers()
vi.setSystemTime(NOW)
})

afterEach(() => {
vi.useRealTimers()
})

function seedSpend(amountXlm: number, timestamp: string) {
appendAuditEntry({
passportId: "passport-h83",
action: "spend" as never,
actor: AGENT_ID,
metadata: { amountXlm },
...(timestamp ? { timestamp } : {}),
})
}

it("dailySpentXlm reads only the current UTC day; weekly spans seven", async () => {
setPassport(passport())
seedSpend(10, "2026-06-27T09:00:00.000Z") // today
seedSpend(5.5, "2026-06-27T11:59:00.000Z") // today
seedSpend(100, "2026-06-24T10:00:00.000Z") // this week, not today
seedSpend(999, "2026-06-01T10:00:00.000Z") // outside the week

const response = await getHealth()
const body = await response.json()

expect(body.dailySpentXlm).toBeCloseTo(15.5, 6)
expect(body.weeklySpentXlm).toBeCloseTo(115.5, 6)
})

it("non-spend audit entries do not count toward daily spend", async () => {
setPassport(passport())
appendAuditEntry({
passportId: "passport-h83",
action: "issued",
actor: AGENT_ID,
})

const response = await getHealth()
const body = await response.json()
expect(body.dailySpentXlm).toBe(0)
})

it("surfaces configured spend limits and circuit breaker state", async () => {
setPassport(
passport({
config: {
allowTransfer: true,
spendLimits: { dailyMaxXlm: 50, weeklyMaxXlm: 300 },
circuitBreaker: { consecutiveFailures: 2, maxConsecutiveFailures: 3, tripped: false },
},
} as Partial<PassportRecord>),
)

const response = await getHealth()
const body = await response.json()

expect(body.spendingLimits).toEqual({ dailyMaxXlm: 50, weeklyMaxXlm: 300 })
expect(body.circuitBreakerStatus).toEqual({
consecutiveFailures: 2,
maxConsecutiveFailures: 3,
tripped: false,
})
})
})
Loading