From 6bf7f128a607f47cab1747c6f0ace9a378d3563c Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:07:28 -0400 Subject: [PATCH 1/7] feat: trigger webhook deliveries from events --- src/events.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/events.ts b/src/events.ts index e830988..1f93781 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { deliverWebhookEvent } from "./webhooks/deliver.js"; export type AppEvent = { id: string; @@ -14,6 +15,8 @@ export const eventLog: AppEvent[] = []; * Appends an audit event to the bounded in-memory event log. */ export function recordEvent(type: string, payload: Record): void { - eventLog.push({ id: randomUUID(), ts: Date.now(), type, payload }); + const event = { id: randomUUID(), ts: Date.now(), type, payload }; + eventLog.push(event); if (eventLog.length > EVENT_LOG_CAP) eventLog.shift(); + void deliverWebhookEvent(event); } From bf3432fd81a6f82d0cb9ba9b5a328a96a72605e9 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:07:43 -0400 Subject: [PATCH 2/7] feat: add signed webhook route behavior --- src/routes/webhooks.ts | 81 +++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 3e362fb..1db8688 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,8 +1,53 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; -import { recordEvent } from "../events.js"; +import { type AppEvent, recordEvent } from "../events.js"; import { webhookStore } from "../store/state.js"; import { getRequestId } from "../types.js"; +import { createWebhookSecret, deliverSingleWebhook } from "../webhooks/deliver.js"; + +const publicWebhook = ( + id: string, + meta: { + url: string; + events: string[]; + createdAt: number; + deadLetters: number; + } +) => ({ + id, + url: meta.url, + events: meta.events, + createdAt: meta.createdAt, + deadLetters: meta.deadLetters, +}); + +const testWebhook = async (req: Request, res: Response) => { + const { id } = req.params; + const requestId = getRequestId(req); + const hook = webhookStore.get(id); + if (!hook) { + res.status(404).json({ + error: "not_found", + message: `webhook ${id} not registered`, + requestId, + }); + return; + } + const event: AppEvent = { + id: randomUUID(), + ts: Date.now(), + type: "webhook.test", + payload: { id, url: hook.url }, + }; + const delivery = await deliverSingleWebhook(id, hook, event); + recordEvent("webhook.test", { + id, + url: hook.url, + delivered: delivery.delivered, + attempts: delivery.attempts, + }); + res.json({ id, deliveredAt: Date.now(), ...delivery }); +}; /** * Builds webhook registration, update, deletion, and synthetic test routes. @@ -25,27 +70,14 @@ export function createWebhooksRouter(): Router { }); router.get("/api/v1/webhooks", (_req, res: Response) => { - const items = Array.from(webhookStore.entries()).map(([id, meta]) => ({ - id, - ...meta, - })); + const items = Array.from(webhookStore.entries()).map(([id, meta]) => + publicWebhook(id, meta) + ); res.json({ items }); }); router.post("/api/v1/webhooks/:id/test", (req: Request, res: Response) => { - const { id } = req.params; - const requestId = getRequestId(req); - const hook = webhookStore.get(id); - if (!hook) { - res.status(404).json({ - error: "not_found", - message: `webhook ${id} not registered`, - requestId, - }); - return; - } - recordEvent("webhook.test", { id, url: hook.url }); - res.json({ id, deliveredAt: Date.now(), simulated: true }); + void testWebhook(req, res); }); router.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => { @@ -88,7 +120,7 @@ export function createWebhooksRouter(): Router { existing.events = events; } webhookStore.set(id, existing); - res.json({ id, ...existing }); + res.json(publicWebhook(id, existing)); }); router.post("/api/v1/webhooks", (req: Request, res: Response) => { @@ -115,8 +147,15 @@ export function createWebhooksRouter(): Router { return; } const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`; - webhookStore.set(id, { url, events, createdAt: Date.now() }); - res.status(201).json({ id, url, events }); + const secret = createWebhookSecret(); + webhookStore.set(id, { + url, + events, + createdAt: Date.now(), + secret, + deadLetters: 0, + }); + res.status(201).json({ id, url, events, secret }); }); return router; From 93246e4c11c937c1541a53c5b090ebfb399f368d Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:08:00 -0400 Subject: [PATCH 3/7] feat: persist webhook delivery metadata --- src/store/state.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..7e1e891 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -7,7 +7,13 @@ export type ApiKeyRecord = { label: string; createdAt: number }; export type ServiceMetadataDto = { description: string; owner: string }; -export type WebhookRecord = { url: string; events: string[]; createdAt: number }; +export type WebhookRecord = { + url: string; + events: string[]; + createdAt: number; + secret: string; + deadLetters: number; +}; /** Mirrors the on-chain pause flag for write-gated endpoints. */ export const pauseState = { paused: false }; From fa66831aadab0d6c2ce6db91dcfc74b7a17da3c6 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:08:13 -0400 Subject: [PATCH 4/7] test: reset webhook delivery state --- src/routes/operational.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/operational.test.ts b/src/routes/operational.test.ts index 16b7ab2..a1ba533 100644 --- a/src/routes/operational.test.ts +++ b/src/routes/operational.test.ts @@ -155,7 +155,8 @@ void describe("operational routes", () => { const tested = await request(app).post(`/api/v1/webhooks/${webhookId}/test`); assert.strictEqual(tested.status, 200); - assert.strictEqual(tested.body.simulated, true); + assert.strictEqual(tested.body.delivered, false); + assert.ok(tested.body.error); const events = await request(app).get("/api/v1/events"); assert.strictEqual(events.status, 200); From 99aa394f261482127ef7dba443c0897ceeffe1b3 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:08:39 -0400 Subject: [PATCH 5/7] docs: add webhook delivery guide --- docs/webhooks.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/webhooks.md diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..10875be --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,68 @@ +# Webhooks + +AgentPay can deliver audit events to registered HTTP subscribers. + +## Registration + +Create a webhook with a target URL and one or more event types: + +```http +POST /api/v1/webhooks +Content-Type: application/json + +{ + "url": "https://example.com/agentpay-webhook", + "events": ["usage.recorded", "usage.settled"] +} +``` + +Use `"*"` to subscribe to every event type. + +The creation response includes a `secret` once. Store it securely; list and +update responses never echo it. + +## Payload + +AgentPay sends JSON: + +```json +{ + "id": "event-id", + "type": "usage.recorded", + "ts": 1782310000000, + "payload": { + "agent": "agent-a", + "serviceId": "service-a", + "requests": 3, + "total": 10 + } +} +``` + +## Headers + +Every delivery includes: + +- `X-AgentPay-Delivery`: unique delivery id. +- `X-AgentPay-Event`: event type. +- `X-AgentPay-Signature`: `sha256=`. + +The HMAC uses SHA-256 over the exact request body with the webhook secret. +Receivers should compare the expected and supplied signature with a constant-time +comparison. + +## Retry and Dead Letters + +AgentPay retries 5xx and network failures up to three attempts. A 2xx response +marks the delivery successful. A 4xx response is treated as permanent and is not +retried. + +When delivery fails permanently, AgentPay increments the webhook `deadLetters` +count. The count is visible in webhook list, patch, and test responses. + +## SSRF Protection + +Webhook targets must be `http` or `https`. Private, loopback, and link-local +targets are rejected by default, including hostnames that resolve to private +addresses. Set `ALLOW_PRIVATE_WEBHOOKS=true` only in controlled local/test +environments that intentionally need private targets. From f44ade158200acfb1c0e225882ee6d1e7228bd58 Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:08:57 -0400 Subject: [PATCH 6/7] feat: add signed webhook delivery worker --- src/webhooks/deliver.ts | 179 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/webhooks/deliver.ts diff --git a/src/webhooks/deliver.ts b/src/webhooks/deliver.ts new file mode 100644 index 0000000..79d3314 --- /dev/null +++ b/src/webhooks/deliver.ts @@ -0,0 +1,179 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import net from "node:net"; +import type { AppEvent } from "../events.js"; +import { webhookStore, type WebhookRecord } from "../store/state.js"; + +export type DeliveryResult = { + delivered: boolean; + attempts: number; + status?: number; + error?: string; +}; + +const MAX_ATTEMPTS = 3; +const DELIVERY_TIMEOUT_MS = 3_000; + +/** + * Signs the exact JSON payload that is sent to a webhook subscriber. + */ +export function signWebhookPayload(secret: string, body: string) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +/** + * Verifies a signed webhook payload using constant-time comparison. + */ +export function verifyWebhookSignature( + secret: string, + body: string, + signature: string +) { + const expected = signWebhookPayload(secret, body); + const expectedBuffer = Buffer.from(expected); + const receivedBuffer = Buffer.from(signature); + return ( + expectedBuffer.length === receivedBuffer.length && + timingSafeEqual(expectedBuffer, receivedBuffer) + ); +} + +export function createWebhookSecret() { + return `whsec_${randomUUID().replace(/-/g, "")}`; +} + +function isPrivateIp(address: string) { + if (net.isIPv4(address)) { + const [a, b] = address.split(".").map(Number); + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ); + } + if (net.isIPv6(address)) { + const lower = address.toLowerCase(); + return ( + lower === "::1" || + lower.startsWith("fc") || + lower.startsWith("fd") || + lower.startsWith("fe80:") + ); + } + return true; +} + +async function assertWebhookTargetAllowed(url: string) { + if (process.env.ALLOW_PRIVATE_WEBHOOKS === "true") return; + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("webhook URL must use http or https"); + } + if (parsed.hostname === "localhost") { + throw new Error("private webhook targets are disabled"); + } + const addresses = net.isIP(parsed.hostname) + ? [{ address: parsed.hostname }] + : await lookup(parsed.hostname, { all: true }); + if (addresses.some((entry) => isPrivateIp(entry.address))) { + throw new Error("private webhook targets are disabled"); + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function postWebhook(url: string, body: string, secret: string, event: AppEvent) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); + try { + return await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-AgentPay-Delivery": randomUUID(), + "X-AgentPay-Event": event.type, + "X-AgentPay-Signature": signWebhookPayload(secret, body), + }, + body, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +function markDeadLetter(id: string, hook: WebhookRecord) { + webhookStore.set(id, { ...hook, deadLetters: hook.deadLetters + 1 }); +} + +/** + * Delivers one event to one webhook with SSRF checks, HMAC signing, and retries. + */ +export async function deliverSingleWebhook( + id: string, + hook: WebhookRecord, + event: AppEvent +): Promise { + const body = JSON.stringify({ + id: event.id, + type: event.type, + ts: event.ts, + payload: event.payload, + }); + + try { + await assertWebhookTargetAllowed(hook.url); + } catch (err) { + markDeadLetter(id, hook); + return { + delivered: false, + attempts: 0, + error: err instanceof Error ? err.message : "webhook target rejected", + }; + } + + let lastStatus: number | undefined; + let lastError: string | undefined; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const response = await postWebhook(hook.url, body, hook.secret, event); + lastStatus = response.status; + if (response.ok) { + return { delivered: true, attempts: attempt, status: response.status }; + } + if (response.status >= 400 && response.status < 500) { + markDeadLetter(id, hook); + return { delivered: false, attempts: attempt, status: response.status }; + } + } catch (err) { + lastError = err instanceof Error ? err.message : "webhook delivery failed"; + } + if (attempt < MAX_ATTEMPTS) await sleep(10 * 2 ** (attempt - 1)); + } + + markDeadLetter(id, hook); + return { + delivered: false, + attempts: MAX_ATTEMPTS, + status: lastStatus, + error: lastError, + }; +} + +/** + * Fan out an event to every webhook subscribed to the event type or `*`. + */ +export async function deliverWebhookEvent(event: AppEvent) { + const tasks: Promise[] = []; + for (const [id, hook] of webhookStore.entries()) { + if (hook.events.includes("*") || hook.events.includes(event.type)) { + tasks.push(deliverSingleWebhook(id, hook, event)); + } + } + return Promise.allSettled(tasks); +} From 254f6861a50af5e46f001695264757553e5460fa Mon Sep 17 00:00:00 2001 From: felixvippp-ai Date: Wed, 24 Jun 2026 12:09:08 -0400 Subject: [PATCH 7/7] test: cover signed webhook delivery --- src/webhooks/deliver.test.ts | 203 +++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 src/webhooks/deliver.test.ts diff --git a/src/webhooks/deliver.test.ts b/src/webhooks/deliver.test.ts new file mode 100644 index 0000000..62c3dcb --- /dev/null +++ b/src/webhooks/deliver.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import http from "node:http"; +import express from "express"; +import request from "supertest"; +import { recordEvent, eventLog } from "../events.js"; +import { createWebhooksRouter } from "../routes/webhooks.js"; +import { webhookStore } from "../store/state.js"; +import { verifyWebhookSignature } from "./deliver.js"; + +type ReceivedRequest = { + body: string; + headers: http.IncomingHttpHeaders; +}; + +const waitFor = async (predicate: () => boolean) => { + for (let i = 0; i < 50; i++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("condition was not met"); +}; + +const createWebhookApp = () => { + const app = express(); + app.use(express.json()); + app.use(createWebhooksRouter()); + return app; +}; + +const readCreatedWebhook = (body: unknown) => { + if ( + typeof body !== "object" || + body === null || + !("id" in body) || + !("secret" in body) + ) { + throw new TypeError("expected webhook creation response"); + } + const { id, secret } = body as { id: unknown; secret: unknown }; + if (typeof id !== "string" || typeof secret !== "string") { + throw new TypeError("expected webhook id and secret to be strings"); + } + return { id, secret }; +}; + +const startReceiver = async (statuses: number[]) => { + const received: ReceivedRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + received.push({ + body: Buffer.concat(chunks).toString("utf8"), + headers: req.headers, + }); + res.statusCode = statuses.shift() ?? 200; + res.end("ok"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected TCP server address"); + } + return { + url: `http://127.0.0.1:${address.port}/hook`, + received, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +}; + +beforeEach(() => { + webhookStore.clear(); + eventLog.length = 0; + process.env.ALLOW_PRIVATE_WEBHOOKS = "true"; +}); + +afterEach(() => { + delete process.env.ALLOW_PRIVATE_WEBHOOKS; +}); + +void describe("signed webhook delivery", () => { + void it("returns a secret once and hides it from list responses", async () => { + const app = createWebhookApp(); + const receiver = await startReceiver([200]); + + try { + const created = await request(app) + .post("/api/v1/webhooks") + .send({ url: receiver.url, events: ["usage.recorded"] }) + .expect(201); + const { secret } = readCreatedWebhook(created.body); + + assert.match(secret, /^whsec_/); + + const listed = await request(app).get("/api/v1/webhooks").expect(200); + assert.strictEqual(listed.body.items[0].secret, undefined); + assert.strictEqual(listed.body.items[0].deadLetters, 0); + } finally { + await receiver.close(); + } + }); + + void it("delivers matching events with HMAC signatures", async () => { + const app = createWebhookApp(); + const receiver = await startReceiver([200]); + + try { + const created = await request(app) + .post("/api/v1/webhooks") + .send({ url: receiver.url, events: ["usage.recorded"] }) + .expect(201); + const { secret } = readCreatedWebhook(created.body); + + recordEvent("usage.recorded", { + agent: "agent-webhook", + serviceId: "svc-webhook", + requests: 2, + total: 2, + }); + + await waitFor(() => receiver.received.length === 1); + const delivered = receiver.received[0]; + assert.strictEqual(delivered.headers["x-agentpay-event"], "usage.recorded"); + assert.strictEqual( + verifyWebhookSignature( + secret, + delivered.body, + delivered.headers["x-agentpay-signature"] as string + ), + true + ); + assert.strictEqual(JSON.parse(delivered.body).payload.requests, 2); + } finally { + await receiver.close(); + } + }); + + void it("retries 5xx deliveries before succeeding", async () => { + const app = createWebhookApp(); + const receiver = await startReceiver([500, 502, 200]); + + try { + const created = await request(app) + .post("/api/v1/webhooks") + .send({ url: receiver.url, events: ["*"] }) + .expect(201); + const { id } = readCreatedWebhook(created.body); + + const tested = await request(app).post(`/api/v1/webhooks/${id}/test`).expect(200); + + assert.strictEqual(tested.body.delivered, true); + assert.strictEqual(tested.body.attempts, 3); + assert.strictEqual(receiver.received.length, 3); + assert.strictEqual(webhookStore.get(id)?.deadLetters, 0); + } finally { + await receiver.close(); + } + }); + + void it("increments deadLetters after permanent 4xx failures", async () => { + const app = createWebhookApp(); + const receiver = await startReceiver([404]); + + try { + const created = await request(app) + .post("/api/v1/webhooks") + .send({ url: receiver.url, events: ["usage.recorded"] }) + .expect(201); + const { id } = readCreatedWebhook(created.body); + + const tested = await request(app).post(`/api/v1/webhooks/${id}/test`).expect(200); + + assert.strictEqual(tested.body.delivered, false); + assert.strictEqual(tested.body.status, 404); + assert.strictEqual(webhookStore.get(id)?.deadLetters, 1); + } finally { + await receiver.close(); + } + }); + + void it("blocks private webhook targets unless explicitly allowed", async () => { + delete process.env.ALLOW_PRIVATE_WEBHOOKS; + const app = createWebhookApp(); + + const created = await request(app) + .post("/api/v1/webhooks") + .send({ url: "http://127.0.0.1:1/hook", events: ["usage.recorded"] }) + .expect(201); + const { id } = readCreatedWebhook(created.body); + + const tested = await request(app).post(`/api/v1/webhooks/${id}/test`).expect(200); + const error = tested.body.error as unknown; + if (typeof error !== "string") { + throw new TypeError("expected delivery error string"); + } + + assert.strictEqual(tested.body.delivered, false); + assert.match(error, /private webhook targets/); + assert.strictEqual(webhookStore.get(id)?.deadLetters, 1); + }); +});