-
Notifications
You must be signed in to change notification settings - Fork 30
feat(health): live daily spend from audit log + limits/breaker surfacing (#83) #240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }> | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| /** | ||
| * 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
|
||
| const { id } = await context.params | ||
| const agentId = decodeURIComponent(id) | ||
| const passport = getPassportByAgentId(agentId) | ||
|
|
@@ -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
|
||
|
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const cb = config.circuitBreaker | ||
| const circuitBreakerStatus = cb | ||
| ? { | ||
| consecutiveFailures: cb.consecutiveFailures ?? 0, | ||
| maxConsecutiveFailures: cb.maxConsecutiveFailures ?? null, | ||
| tripped: Boolean(cb.tripped), | ||
| } | ||
| : null | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
|
|
@@ -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" } }, | ||
| ) | ||
|
|
||
| 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, | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
spendAmountonly counts audit entries whose action isspendorverified_spend, but theAuditActionunion (lib/passport/audit.ts:3-11) does not include those values and no code anywhere callsappendAuditEntrywith them (grep finds references only in this route and its test, which forceaction: "spend" as never). ConsequentlydailySpentXlmandweeklySpentXlmwill always be 0 against real data — the tests pass only because they bypass the type system. Addspend/verified_spendtoAuditActionand ensure the spend flow actually writes those audit entries (withmetadata.amountXlm), otherwise the endpoint reports fabricated zeros.Was this helpful? React with 👍 / 👎