From 3af3ae0da8f072093fca7123c7c2cb3623edb312 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:05:24 +0200 Subject: [PATCH 01/10] Add webhook dashboard management design spec --- .../2026-04-09-webhook-dashboard-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-webhook-dashboard-design.md diff --git a/docs/superpowers/specs/2026-04-09-webhook-dashboard-design.md b/docs/superpowers/specs/2026-04-09-webhook-dashboard-design.md new file mode 100644 index 0000000..611bb97 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-webhook-dashboard-design.md @@ -0,0 +1,110 @@ +# Webhook Management Dashboard + +## Summary + +Add a dedicated **Webhooks tab** to the Strimulator dashboard with full endpoint management (create, edit, delete, enable/disable), delivery history (unified log + per-endpoint), one-click retry for failed deliveries, and a "send test event" action with selectable event types. Includes a prerequisite refactor of the dashboard monolith into modular files. + +## Part 1: Dashboard Refactor + +The current `src/dashboard/server.ts` (~1,530 lines) contains the full SPA inline. Before adding the Webhooks tab, extract it into modular files: + +``` +src/dashboard/ +├── server.ts # Elysia plugin: mounts API + serves HTML shell +├── api.ts # Existing dashboard API endpoints (extended) +├── html/ +│ ├── shell.ts # HTML skeleton: , nav, Pico CSS, script imports +│ ├── tabs/ +│ │ ├── activity.ts # Activity tab markup + Preact components +│ │ ├── resources.ts # Resources tab markup + Preact components +│ │ ├── actions.ts # Actions tab markup + Preact components +│ │ └── webhooks.ts # NEW — Webhooks tab +│ └── components/ +│ ├── table.ts # Reusable table component (used across tabs) +│ └── badge.ts # Status badge component +``` + +Each tab file exports a function returning an HTML string (template literal) with its inline Preact components. `shell.ts` assembles them into the full page. This is a mechanical extraction with no behavior changes to existing tabs. + +## Part 2: Webhooks Tab UI + +### Default View: Endpoint List + Delivery Log + +**Top half — Endpoint cards:** +- Each card shows: URL, status (enabled/disabled toggle), enabled events count, delivery success rate +- Edit and Delete buttons per card +- "Create Endpoint" button at the top opens an inline form (fields: URL, enabled events multi-select) + +**Bottom half — Unified delivery log:** +- Table columns: event type, endpoint URL (truncated), status (delivered/pending/failed), attempts, timestamp +- Filterable by endpoint via dropdown +- Failed deliveries get a "Retry" button inline + +### Drill-Down View: Endpoint Detail + +Activated by clicking an endpoint card. Shows: +- **Header:** Full URL, status toggle, secret (hidden by default, click to reveal + copy), enabled events list +- **Edit form:** Inline editing for URL and enabled_events +- **Delivery history:** Same table format as unified log, pre-filtered to this endpoint +- **Send Test Event:** Button with event type dropdown (common types: customer.created, invoice.paid, payment_intent.succeeded, charge.succeeded, subscription.created, etc.) +- **Back button** to return to default view + +All navigation is client-side Preact state, no page reloads. + +## Part 3: New Dashboard API Endpoints + +Added to `api.ts` under `/dashboard/api/`: + +| Method | Route | Purpose | +|--------|-------|---------| +| POST | `/webhooks` | Create endpoint (url, enabled_events) | +| PATCH | `/webhooks/:id` | Update endpoint (url, enabled_events, status) | +| DELETE | `/webhooks/:id` | Delete endpoint | +| GET | `/webhooks/:id/deliveries` | Delivery history for one endpoint (paginated) | +| GET | `/deliveries` | Unified delivery log (paginated, optional `endpoint_id` filter) | +| POST | `/webhooks/:id/test` | Send test event to endpoint (accepts `event_type`) | +| POST | `/deliveries/:id/retry` | Retry a specific failed delivery | + +Dashboard API routes call existing services directly (WebhookEndpointService, WebhookDeliveryService, EventService). Not auth-protected, consistent with existing dashboard pattern. + +## Part 4: Backend Additions + +### WebhookEndpointService.update(id, params) + +New method accepting partial updates: `url`, `enabled_events`, `status` (enable/disable). Updates DB columns and rebuilds the stored `data` JSON. Emits `webhook_endpoint.updated` event. + +### WebhookDeliveryService new methods + +- `listByEndpoint(endpointId, opts)` — paginated delivery history for one endpoint +- `listAll(opts)` — paginated delivery log with optional `endpointId` filter +- `retry(deliveryId)` — re-fetch original event and endpoint, re-attempt delivery, update delivery record + +These methods query `webhook_deliveries` joined with `events` (for type) and `webhook_endpoints` (for URL). Returns enough data for the dashboard table without separate lookups. + +### Test event flow + +`POST /dashboard/api/webhooks/:id/test` handler: +1. Builds a minimal Stripe object for the selected event type (e.g., stub `customer` for `customer.created`) +2. Calls `EventService.emit()` — stores the event and triggers normal delivery pipeline +3. Delivery pipeline handles matching, signing, posting + +No special "test" flag — flows through the same code path as real events. + +## Part 5: Testing Strategy + +### Unit tests + +- `WebhookEndpointService.update()` — URL/events/status changes persist, data JSON rebuilt correctly, returns updated Stripe object +- `WebhookDeliveryService.listByEndpoint()` / `listAll()` — pagination, endpoint filtering, correct join data +- `WebhookDeliveryService.retry()` — re-delivery attempt, status update on success/failure + +### Integration tests + +- CRUD lifecycle: create endpoint via dashboard API, update, verify, delete +- Delivery log: create endpoint, trigger event, verify delivery in unified and per-endpoint logs +- Test event: send test event, verify event created and delivery attempted +- Retry: trigger delivery to dead endpoint (fails), retry after fix, verify status change + +### No UI/E2E tests + +Dashboard is a dev tool — Preact components are thin API wrappers. Testing the API layer provides the real confidence. From 6e2575a4eb84211c09e58ff4adb50f2fdd7f9045 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:15:21 +0200 Subject: [PATCH 02/10] Add webhook dashboard implementation plan --- .../plans/2026-04-09-webhook-dashboard.md | 1957 +++++++++++++++++ 1 file changed, 1957 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-webhook-dashboard.md diff --git a/docs/superpowers/plans/2026-04-09-webhook-dashboard.md b/docs/superpowers/plans/2026-04-09-webhook-dashboard.md new file mode 100644 index 0000000..1676459 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-webhook-dashboard.md @@ -0,0 +1,1957 @@ +# Webhook Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dedicated Webhooks tab to the Strimulator dashboard with endpoint management (CRUD, enable/disable), delivery history (unified + per-endpoint), one-click retry, and send-test-event, preceded by a modular refactor of the dashboard HTML. + +**Architecture:** The inline SPA in `server.ts` gets split into modular files under `src/dashboard/html/` — each tab exports a JS string constant that the shell assembles. New backend methods (`WebhookEndpointService.update`, `WebhookDeliveryService.deliverToEndpoint`) support the dashboard API, which gets new endpoints for webhook CRUD, delivery listing, test events, and retry. The Webhooks tab is a new Preact component rendered client-side. + +**Tech Stack:** Elysia, Drizzle ORM (bun:sqlite), Preact + HTM (inline, via ESM CDN), Pico CSS, bun:test + +--- + +## File Structure + +**New files:** +- `src/dashboard/html/shell.ts` — HTML skeleton + App component assembly +- `src/dashboard/html/styles.ts` — All CSS (extracted + webhook additions) +- `src/dashboard/html/helpers.ts` — Shared JS helpers (statusClass, formatTime, formatDate) +- `src/dashboard/html/tabs/activity.ts` — StatCard + ActivityTab components +- `src/dashboard/html/tabs/resources.ts` — Config constants + ResourcesTab component +- `src/dashboard/html/tabs/actions.ts` — ActionCard + useAction + ActionsTab components +- `src/dashboard/html/tabs/webhooks.ts` — WebhooksTab + sub-components +- `tests/unit/services/webhook-endpoints.test.ts` — Tests for update method +- `tests/integration/webhook-dashboard.test.ts` — Dashboard API integration tests + +**Modified files:** +- `src/dashboard/server.ts` — Replace inline HTML with import from shell.ts +- `src/dashboard/api.ts` — Add webhook CRUD, delivery listing, test event, retry endpoints +- `src/services/webhook-endpoints.ts` — Add `update()` method + `UpdateWebhookEndpointParams` interface +- `src/services/webhook-delivery.ts` — Extract `deliverToEndpoint()` from `deliver()` + +--- + +### Task 1: Extract dashboard HTML into modular files + +**Files:** +- Create: `src/dashboard/html/styles.ts` +- Create: `src/dashboard/html/helpers.ts` +- Create: `src/dashboard/html/tabs/activity.ts` +- Create: `src/dashboard/html/tabs/resources.ts` +- Create: `src/dashboard/html/tabs/actions.ts` +- Create: `src/dashboard/html/shell.ts` +- Modify: `src/dashboard/server.ts` + +- [ ] **Step 1: Create `src/dashboard/html/styles.ts`** + +Extract the CSS from the ` + + + +
Loading...
+ + +`; +} +``` + +- [ ] **Step 7: Update `src/dashboard/server.ts` to use shell** + +Replace the inline `DASHBOARD_HTML` with the shell function: + +```typescript +import { Elysia } from "elysia"; +import type { StrimulatorDB } from "../db"; +import { dashboardApi } from "./api"; +import { buildDashboardHtml } from "./html/shell"; + +export function dashboardServer(db: StrimulatorDB) { + const dashboardHtml = buildDashboardHtml(); + + return new Elysia() + .use(dashboardApi(db)) + .get("/dashboard", () => { + return new Response(dashboardHtml, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + }) + .get("/dashboard/*", () => { + return new Response(dashboardHtml, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + }); +} +``` + +- [ ] **Step 8: Verify the refactor produces identical behavior** + +Run: `bun run dev` + +Open `http://localhost:12111/dashboard` and verify: +- Activity tab loads with stats and request log +- Resources tab shows sidebar with counts, table loads for each resource type +- Actions tab shows all 5 action cards +- SSE streaming still works (make an API request and see it appear in real-time) + +- [ ] **Step 9: Commit** + +```bash +git add src/dashboard/html/ src/dashboard/server.ts +git commit -m "Refactor dashboard HTML into modular files" +``` + +--- + +### Task 2: Add WebhookEndpointService.update() + +**Files:** +- Test: `tests/unit/services/webhook-endpoints.test.ts` +- Modify: `src/services/webhook-endpoints.ts` + +- [ ] **Step 1: Write failing test for update** + +Create `tests/unit/services/webhook-endpoints.test.ts`: + +```typescript +import { describe, it, expect } from "bun:test"; +import { createDB } from "../../../src/db"; +import { WebhookEndpointService } from "../../../src/services/webhook-endpoints"; +import { StripeError } from "../../../src/errors"; + +function makeService() { + const db = createDB(":memory:"); + return new WebhookEndpointService(db); +} + +describe("WebhookEndpointService", () => { + describe("update", () => { + it("updates the url", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://old.example.com/hook", enabled_events: ["*"] }); + + const updated = svc.update(ep.id, { url: "https://new.example.com/hook" }); + + expect(updated.url).toBe("https://new.example.com/hook"); + expect(updated.id).toBe(ep.id); + // Verify persistence + const retrieved = svc.retrieve(ep.id); + expect(retrieved.url).toBe("https://new.example.com/hook"); + }); + + it("updates enabled_events", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["*"] }); + + const updated = svc.update(ep.id, { enabled_events: ["customer.created", "invoice.paid"] }); + + expect(updated.enabled_events).toEqual(["customer.created", "invoice.paid"]); + const retrieved = svc.retrieve(ep.id); + expect(retrieved.enabled_events).toEqual(["customer.created", "invoice.paid"]); + }); + + it("updates status to disabled", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["*"] }); + + const updated = svc.update(ep.id, { status: "disabled" }); + + expect(updated.status).toBe("disabled"); + const retrieved = svc.retrieve(ep.id); + expect(retrieved.status).toBe("disabled"); + }); + + it("preserves unchanged fields", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["customer.created"] }); + + const updated = svc.update(ep.id, { url: "https://new.example.com/hook" }); + + expect(updated.enabled_events).toEqual(["customer.created"]); + expect(updated.secret).toBe(ep.secret); + expect(updated.created).toBe(ep.created); + }); + + it("throws 404 for nonexistent endpoint", () => { + const svc = makeService(); + + expect(() => svc.update("we_nonexistent", { url: "https://example.com" })).toThrow(); + try { + svc.update("we_nonexistent", { url: "https://example.com" }); + } catch (err) { + expect(err).toBeInstanceOf(StripeError); + expect((err as StripeError).statusCode).toBe(404); + } + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/unit/services/webhook-endpoints.test.ts` + +Expected: FAIL — `svc.update is not a function` + +- [ ] **Step 3: Implement update method** + +Add to `src/services/webhook-endpoints.ts`: + +After `CreateWebhookEndpointParams`, add the new interface: + +```typescript +export interface UpdateWebhookEndpointParams { + url?: string; + enabled_events?: string[]; + status?: string; +} +``` + +Add the `update` method to `WebhookEndpointService`: + +```typescript + update(id: string, params: UpdateWebhookEndpointParams): Stripe.WebhookEndpoint { + const existing = this.retrieve(id); + + const updated: Record = { ...existing }; + const dbUpdates: Record = {}; + + if (params.url !== undefined) { + updated.url = params.url; + dbUpdates.url = params.url; + } + if (params.enabled_events !== undefined) { + updated.enabled_events = params.enabled_events; + dbUpdates.enabledEvents = JSON.stringify(params.enabled_events); + } + if (params.status !== undefined) { + updated.status = params.status; + dbUpdates.status = params.status; + } + + dbUpdates.data = JSON.stringify(updated); + + this.db.update(webhookEndpoints) + .set(dbUpdates) + .where(eq(webhookEndpoints.id, id)) + .run(); + + return updated as unknown as Stripe.WebhookEndpoint; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/unit/services/webhook-endpoints.test.ts` + +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/services/webhook-endpoints.ts tests/unit/services/webhook-endpoints.test.ts +git commit -m "Add WebhookEndpointService.update() with tests" +``` + +--- + +### Task 3: Extract WebhookDeliveryService.deliverToEndpoint() + +**Files:** +- Modify: `tests/unit/services/webhook-delivery.test.ts` +- Modify: `src/services/webhook-delivery.ts` + +- [ ] **Step 1: Write failing test for deliverToEndpoint** + +Add a new `describe` block to `tests/unit/services/webhook-delivery.test.ts`: + +```typescript + describe("deliverToEndpoint", () => { + it("creates a delivery record for the specific endpoint", async () => { + const { db, endpointService, deliveryService } = makeServices(); + const { getRawSqlite } = await import("../../../src/db"); + const sqlite = getRawSqlite(db); + + const endpoint = endpointService.create({ + url: "https://example.com/webhook", + enabled_events: ["*"], + }); + + const event = { + id: "evt_test123", + object: "event" as const, + type: "customer.created", + data: { object: { id: "cus_123" } }, + api_version: "2024-12-18", + created: 1700000000, + livemode: false, + pending_webhooks: 0, + request: { id: null, idempotency_key: null }, + } as any; + + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: endpoint.id, + url: endpoint.url, + secret: endpoint.secret!, + }); + + expect(deliveryId).toMatch(/^whdel_/); + + // Verify delivery record was created + const row = sqlite.query("SELECT * FROM webhook_deliveries WHERE id = ?").get(deliveryId) as any; + expect(row).not.toBeNull(); + expect(row.event_id).toBe("evt_test123"); + expect(row.endpoint_id).toBe(endpoint.id); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/unit/services/webhook-delivery.test.ts` + +Expected: FAIL — `deliveryService.deliverToEndpoint is not a function` + +- [ ] **Step 3: Extract deliverToEndpoint from deliver** + +Modify `src/services/webhook-delivery.ts`. Replace the `deliver` method with a public `deliverToEndpoint` method and update `deliver` to use it: + +```typescript + async deliverToEndpoint( + event: Stripe.Event, + endpoint: { id: string; url: string; secret: string }, + ): Promise { + const deliveryId = generateId("webhook_delivery"); + const createdAt = now(); + + this.db.insert(webhookDeliveries).values({ + id: deliveryId, + eventId: event.id, + endpointId: endpoint.id, + status: "pending", + attempts: 0, + nextRetryAt: null, + created: createdAt, + }).run(); + + this.attemptDelivery(deliveryId, endpoint, event, 0); + return deliveryId; + } + + async deliver(event: Stripe.Event): Promise { + const matchingEndpoints = this.findMatchingEndpoints(event.type); + + for (const endpoint of matchingEndpoints) { + await this.deliverToEndpoint(event, endpoint); + } + } +``` + +Remove the old `deliver` method body entirely — it's fully replaced by the above. + +- [ ] **Step 4: Run all webhook delivery tests** + +Run: `bun test tests/unit/services/webhook-delivery.test.ts` + +Expected: All tests PASS (existing tests still work because `deliver` delegates to `deliverToEndpoint`) + +Also verify the integration tests still pass: + +Run: `bun test tests/integration/webhook-delivery.test.ts` + +Expected: All 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/services/webhook-delivery.ts tests/unit/services/webhook-delivery.test.ts +git commit -m "Extract deliverToEndpoint from deliver in WebhookDeliveryService" +``` + +--- + +### Task 4: Add dashboard webhook API endpoints + +**Files:** +- Modify: `src/dashboard/api.ts` + +- [ ] **Step 1: Add webhook CRUD endpoints to api.ts** + +Add these routes to the Elysia chain in `dashboardApi`, after the existing action endpoints. Import `WebhookEndpointService` (already imported) and add the CRUD routes: + +```typescript + // --- Webhook management endpoints --- + + .post("/webhooks", async ({ request }) => { + let body: { url?: string; enabled_events?: string[] } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!body.url || !body.enabled_events?.length) { + return new Response(JSON.stringify({ error: "url and enabled_events are required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + const endpoint = endpointService.create({ + url: body.url, + enabled_events: body.enabled_events, + }); + return endpoint; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .patch("/webhooks/:id", async ({ params, request }) => { + let body: { url?: string; enabled_events?: string[]; status?: string } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + return endpointService.update(params.id, body); + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .delete("/webhooks/:id", ({ params }) => { + try { + const endpointService = new WebhookEndpointService(db); + return endpointService.del(params.id); + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) +``` + +- [ ] **Step 2: Add delivery listing endpoints** + +Continue the Elysia chain: + +```typescript + .get("/deliveries", ({ query }) => { + const limit = Math.min(parseInt(String(query.limit ?? "20"), 10) || 20, 200); + const offset = parseInt(String(query.offset ?? "0"), 10) || 0; + const endpointId = query.endpoint_id as string | undefined; + + try { + let countSql = "SELECT COUNT(*) as count FROM webhook_deliveries"; + let dataSql = `SELECT + wd.id, wd.event_id, wd.endpoint_id, wd.status, wd.attempts, wd.next_retry_at, wd.created, + e.type as event_type, + we.url as endpoint_url + FROM webhook_deliveries wd + LEFT JOIN events e ON e.id = wd.event_id + LEFT JOIN webhook_endpoints we ON we.id = wd.endpoint_id`; + + const params: unknown[] = []; + if (endpointId) { + countSql += " WHERE endpoint_id = ?"; + dataSql += " WHERE wd.endpoint_id = ?"; + params.push(endpointId); + } + + dataSql += " ORDER BY wd.created DESC LIMIT ? OFFSET ?"; + + const totalRow = sqlite.query(countSql).get(...params) as { count: number } | null; + const rows = sqlite.query(dataSql).all(...params, limit, offset); + + return { + data: rows, + total: totalRow?.count ?? 0, + limit, + offset, + }; + } catch { + return new Response(JSON.stringify({ error: "Query failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }) + + .get("/webhooks/:id/deliveries", ({ params, query }) => { + const limit = Math.min(parseInt(String(query.limit ?? "20"), 10) || 20, 200); + const offset = parseInt(String(query.offset ?? "0"), 10) || 0; + + try { + const totalRow = sqlite.query( + "SELECT COUNT(*) as count FROM webhook_deliveries WHERE endpoint_id = ?" + ).get(params.id) as { count: number } | null; + + const rows = sqlite.query(`SELECT + wd.id, wd.event_id, wd.endpoint_id, wd.status, wd.attempts, wd.next_retry_at, wd.created, + e.type as event_type, + we.url as endpoint_url + FROM webhook_deliveries wd + LEFT JOIN events e ON e.id = wd.event_id + LEFT JOIN webhook_endpoints we ON we.id = wd.endpoint_id + WHERE wd.endpoint_id = ? + ORDER BY wd.created DESC + LIMIT ? OFFSET ?`).all(params.id, limit, offset); + + return { + data: rows, + total: totalRow?.count ?? 0, + limit, + offset, + }; + } catch { + return new Response(JSON.stringify({ error: "Query failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }) +``` + +- [ ] **Step 3: Add test event endpoint** + +Continue the Elysia chain: + +```typescript + .post("/webhooks/:id/test", async ({ params, request }) => { + let body: { event_type?: string } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const eventType = body.event_type; + if (!eventType) { + return new Response(JSON.stringify({ error: "event_type is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + const deliveryService = new WebhookDeliveryService(db, endpointService); + const eventService = new EventService(db); + + // Verify endpoint exists and get its details + const endpoint = endpointService.retrieve(params.id); + const allEndpoints = endpointService.listAll(); + const epData = allEndpoints.find((ep) => ep.id === params.id); + if (!epData) { + return new Response(JSON.stringify({ error: "Endpoint not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Build a minimal stub object for the event type + const [resource] = eventType.split("."); + const stubObject: Record = { + id: `test_${resource}_${Date.now()}`, + object: resource, + }; + + // Emit the event (persists to DB) + const event = eventService.emit(eventType, stubObject); + + // Deliver to the specific endpoint + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: epData.id, + url: epData.url, + secret: epData.secret, + }); + + return { ok: true, event_id: event.id, delivery_id: deliveryId }; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) +``` + +- [ ] **Step 4: Add retry delivery endpoint** + +Continue the Elysia chain: + +```typescript + .post("/deliveries/:id/retry", async ({ params }) => { + try { + // Look up the delivery record + const delivery = sqlite.query( + "SELECT * FROM webhook_deliveries WHERE id = ?" + ).get(params.id) as { event_id: string; endpoint_id: string } | null; + + if (!delivery) { + return new Response(JSON.stringify({ error: "Delivery not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const eventService = new EventService(db); + const endpointService = new WebhookEndpointService(db); + const deliveryService = new WebhookDeliveryService(db, endpointService); + + const event = eventService.retrieve(delivery.event_id); + const allEndpoints = endpointService.listAll(); + const epData = allEndpoints.find((ep) => ep.id === delivery.endpoint_id); + + if (!epData) { + return new Response(JSON.stringify({ error: "Endpoint no longer exists" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: epData.id, + url: epData.url, + secret: epData.secret, + }); + + return { ok: true, delivery_id: deliveryId }; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) +``` + +- [ ] **Step 5: Add missing imports to api.ts** + +At the top of `api.ts`, ensure these imports exist (some are already there): + +```typescript +import { EventService } from "../services/events"; +import { WebhookEndpointService } from "../services/webhook-endpoints"; +import { WebhookDeliveryService } from "../services/webhook-delivery"; +``` + +All three are already imported. No changes needed. + +- [ ] **Step 6: Commit** + +```bash +git add src/dashboard/api.ts +git commit -m "Add dashboard API endpoints for webhook management" +``` + +--- + +### Task 5: Build Webhooks tab UI + +**Files:** +- Create: `src/dashboard/html/tabs/webhooks.ts` +- Modify: `src/dashboard/html/styles.ts` +- Modify: `src/dashboard/html/shell.ts` + +- [ ] **Step 1: Add webhook-specific styles to `src/dashboard/html/styles.ts`** + +Append to the `dashboardStyles` string: + +```css + /* Webhooks tab */ + .wh-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } + .wh-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; margin-bottom: 2rem; } + .wh-card { border: 1px solid var(--pico-muted-border-color); border-radius: 8px; padding: 1rem; } + .wh-card .url { font-family: monospace; font-size: 0.9rem; word-break: break-all; margin-bottom: 0.5rem; } + .wh-card .meta { display: flex; gap: 1rem; align-items: center; font-size: 0.8rem; color: var(--pico-muted-color); flex-wrap: wrap; } + .wh-card .actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; } + .wh-card .actions button { padding: 0.25rem 0.75rem; font-size: 0.8rem; } + .wh-badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; } + .wh-badge-enabled { background: #d4edda; color: #155724; } + .wh-badge-disabled { background: #f8d7da; color: #721c24; } + .wh-badge-delivered { background: #d4edda; color: #155724; } + .wh-badge-pending { background: #fff3cd; color: #856404; } + .wh-badge-failed { background: #f8d7da; color: #721c24; } + .wh-detail-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } + .wh-detail-header button { padding: 0.25rem 0.5rem; font-size: 0.85rem; } + .wh-secret { display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0; } + .wh-secret code { font-size: 0.85rem; background: var(--pico-code-background, #1e1e2e); color: var(--pico-code-color, #cdd6f4); padding: 0.2rem 0.5rem; border-radius: 4px; } + .wh-events-list { font-size: 0.85rem; color: var(--pico-muted-color); } + .wh-form { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 8px; } + .wh-form label { font-size: 0.9rem; } + .wh-form input, .wh-form select { font-size: 0.9rem; } + .wh-delivery-table { width: 100%; } + .wh-delivery-table td, .wh-delivery-table th { font-size: 0.85rem; padding: 0.4rem 0.5rem; } + .wh-delivery-table td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } + .wh-retry-btn { padding: 0.15rem 0.5rem; font-size: 0.75rem; } + .wh-test-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; } + .wh-test-row select { max-width: 280px; } + .wh-test-row button { white-space: nowrap; } + .wh-filter-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; } + .wh-filter-row select { max-width: 300px; } +``` + +- [ ] **Step 2: Create `src/dashboard/html/tabs/webhooks.ts`** + +```typescript +export const webhooksTabJs = ` + const TEST_EVENT_TYPES = [ + 'customer.created', 'customer.updated', 'customer.deleted', + 'invoice.created', 'invoice.paid', 'invoice.payment_failed', + 'payment_intent.succeeded', 'payment_intent.payment_failed', + 'charge.succeeded', 'charge.failed', + 'subscription.created', 'subscription.updated', 'subscription.deleted', + ]; + + function DeliveryStatusBadge({ status }) { + const cls = 'wh-badge wh-badge-' + (status || 'pending'); + return html\`\${status}\`; + } + + function DeliveryTable({ deliveries, onRetry }) { + if (!deliveries || deliveries.length === 0) { + return html\`

No deliveries yet.

\`; + } + return html\` +
+ + + + + + + + + + + + + \${deliveries.map((d) => html\` + + + + + + + + + \`)} + +
Event TypeEndpointStatusAttemptsTime
\${d.event_type ?? '—'}\${d.endpoint_url ? d.endpoint_url.replace(/^https?:\\/\\//, '').slice(0, 30) : '—'}<\${DeliveryStatusBadge} status=\${d.status} />\${d.attempts}\${formatDate(d.created)}\${d.status === 'failed' ? html\`\` : null}
+
+ \`; + } + + function EndpointDetail({ endpoint, onBack, onUpdate }) { + const [showSecret, setShowSecret] = useState(false); + const [editUrl, setEditUrl] = useState(endpoint.url); + const [editEvents, setEditEvents] = useState((endpoint.enabled_events || []).join(', ')); + const [deliveries, setDeliveries] = useState([]); + const [delTotal, setDelTotal] = useState(0); + const [delOffset, setDelOffset] = useState(0); + const [testType, setTestType] = useState(TEST_EVENT_TYPES[0]); + const [saving, setSaving] = useState(false); + const [testLoading, setTestLoading] = useState(false); + const [msg, setMsg] = useState(null); + const delLimit = 20; + + async function loadDeliveries(off) { + try { + const res = await fetch(\\\`/dashboard/api/webhooks/\\\${endpoint.id}/deliveries?limit=\\\${delLimit}&offset=\\\${off}\\\`); + const data = await res.json(); + setDeliveries(data.data ?? []); + setDelTotal(data.total ?? 0); + } catch (e) { + console.error('Failed to load deliveries', e); + } + } + + useEffect(() => { loadDeliveries(0); }, []); + + async function handleSave() { + setSaving(true); + setMsg(null); + try { + const res = await fetch(\\\`/dashboard/api/webhooks/\\\${endpoint.id}\\\`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: editUrl, + enabled_events: editEvents.split(',').map(s => s.trim()).filter(Boolean), + }), + }); + if (res.ok) { + const updated = await res.json(); + setMsg({ type: 'ok', text: 'Updated successfully' }); + onUpdate(updated); + } else { + const err = await res.json(); + setMsg({ type: 'error', text: err.error?.message ?? err.error ?? 'Update failed' }); + } + } catch (e) { + setMsg({ type: 'error', text: e.message }); + } finally { + setSaving(false); + } + } + + async function handleToggleStatus() { + const newStatus = endpoint.status === 'enabled' ? 'disabled' : 'enabled'; + try { + const res = await fetch(\\\`/dashboard/api/webhooks/\\\${endpoint.id}\\\`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + if (res.ok) { + const updated = await res.json(); + onUpdate(updated); + } + } catch (e) { + console.error('Toggle failed', e); + } + } + + async function handleTestEvent() { + setTestLoading(true); + setMsg(null); + try { + const res = await fetch(\\\`/dashboard/api/webhooks/\\\${endpoint.id}/test\\\`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event_type: testType }), + }); + const data = await res.json(); + if (res.ok) { + setMsg({ type: 'ok', text: 'Test event sent (event: ' + data.event_id + ')' }); + // Reload deliveries after a short delay to show the new one + setTimeout(() => loadDeliveries(0), 500); + } else { + setMsg({ type: 'error', text: data.error?.message ?? data.error ?? 'Failed' }); + } + } catch (e) { + setMsg({ type: 'error', text: e.message }); + } finally { + setTestLoading(false); + } + } + + async function handleRetry(deliveryId) { + try { + await fetch(\\\`/dashboard/api/deliveries/\\\${deliveryId}/retry\\\`, { method: 'POST' }); + setTimeout(() => loadDeliveries(delOffset), 500); + } catch (e) { + console.error('Retry failed', e); + } + } + + function delGoPage(newOffset) { + setDelOffset(newOffset); + loadDeliveries(newOffset); + } + + return html\` +
+
+ +

\${endpoint.url}

+ \${endpoint.status} + +
+ +
+ Secret: + \${showSecret + ? html\`\${endpoint.secret}\` + : html\`whsec_••••••••\` + } + +
+ +
+ Events: \${(endpoint.enabled_events || []).join(', ')} +
+ +
+ +

Edit Endpoint

+
+ + + +
+ + \${msg ? html\`

\${msg.text}

\` : null} + +

Send Test Event

+
+ + +
+ +

Delivery History

+ <\${DeliveryTable} deliveries=\${deliveries} onRetry=\${handleRetry} /> + \${delTotal > delLimit ? html\` + + \` : null} +
+ \`; + } + + function WebhooksTab() { + const [view, setView] = useState('list'); + const [endpoints, setEndpoints] = useState([]); + const [selectedEndpoint, setSelectedEndpoint] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [createUrl, setCreateUrl] = useState(''); + const [createEvents, setCreateEvents] = useState('*'); + const [creating, setCreating] = useState(false); + const [deliveries, setDeliveries] = useState([]); + const [delTotal, setDelTotal] = useState(0); + const [delOffset, setDelOffset] = useState(0); + const [filterEndpoint, setFilterEndpoint] = useState(''); + const [msg, setMsg] = useState(null); + const delLimit = 20; + + async function loadEndpoints() { + try { + const res = await fetch('/dashboard/api/resources/webhook_endpoints?limit=200&offset=0'); + const data = await res.json(); + setEndpoints(data.data ?? []); + } catch (e) { + console.error('Failed to load endpoints', e); + } + } + + async function loadDeliveries(off, epFilter) { + try { + let url = \\\`/dashboard/api/deliveries?limit=\\\${delLimit}&offset=\\\${off}\\\`; + if (epFilter) url += \\\`&endpoint_id=\\\${epFilter}\\\`; + const res = await fetch(url); + const data = await res.json(); + setDeliveries(data.data ?? []); + setDelTotal(data.total ?? 0); + } catch (e) { + console.error('Failed to load deliveries', e); + } + } + + useEffect(() => { + loadEndpoints(); + loadDeliveries(0, ''); + }, []); + + async function handleCreate() { + setCreating(true); + setMsg(null); + try { + const events = createEvents.split(',').map(s => s.trim()).filter(Boolean); + const res = await fetch('/dashboard/api/webhooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: createUrl, enabled_events: events }), + }); + if (res.ok) { + setCreateUrl(''); + setCreateEvents('*'); + setShowCreate(false); + loadEndpoints(); + } else { + const err = await res.json(); + setMsg({ type: 'error', text: err.error?.message ?? err.error ?? 'Create failed' }); + } + } catch (e) { + setMsg({ type: 'error', text: e.message }); + } finally { + setCreating(false); + } + } + + async function handleDelete(id) { + try { + await fetch(\\\`/dashboard/api/webhooks/\\\${id}\\\`, { method: 'DELETE' }); + loadEndpoints(); + } catch (e) { + console.error('Delete failed', e); + } + } + + function openDetail(ep) { + setSelectedEndpoint(ep); + setView('detail'); + } + + function handleDetailUpdate(updated) { + setSelectedEndpoint(updated); + loadEndpoints(); + } + + async function handleRetry(deliveryId) { + try { + await fetch(\\\`/dashboard/api/deliveries/\\\${deliveryId}/retry\\\`, { method: 'POST' }); + setTimeout(() => loadDeliveries(delOffset, filterEndpoint), 500); + } catch (e) { + console.error('Retry failed', e); + } + } + + function handleFilterChange(epId) { + setFilterEndpoint(epId); + setDelOffset(0); + loadDeliveries(0, epId); + } + + function delGoPage(newOffset) { + setDelOffset(newOffset); + loadDeliveries(newOffset, filterEndpoint); + } + + if (view === 'detail' && selectedEndpoint) { + return html\`<\${EndpointDetail} + endpoint=\${selectedEndpoint} + onBack=\${() => { setView('list'); loadDeliveries(0, filterEndpoint); }} + onUpdate=\${handleDetailUpdate} + />\`; + } + + return html\` +
+
+

Webhook Endpoints

+ +
+ + \${showCreate ? html\` +
+ + + +
+ \` : null} + + \${msg ? html\`

\${msg.text}

\` : null} + + \${endpoints.length === 0 + ? html\`

No webhook endpoints. Create one to get started.

\` + : html\` +
+ \${endpoints.map((ep) => html\` +
+
\${ep.url}
+
+ \${ep.status} + \${(ep.enabled_events || []).length === 1 && ep.enabled_events[0] === '*' ? 'All events' : (ep.enabled_events || []).length + ' events'} +
+
+ + +
+
+ \`)} +
+ \` + } + +
+ +

Delivery Log

+
+ +
+ + <\${DeliveryTable} deliveries=\${deliveries} onRetry=\${handleRetry} /> + \${delTotal > delLimit ? html\` + + \` : null} +
+ \`; + } +`; +``` + +- [ ] **Step 3: Update `src/dashboard/html/shell.ts` to include Webhooks tab** + +Add import at the top: + +```typescript +import { webhooksTabJs } from "./tabs/webhooks"; +``` + +Add the webhooks tab JS after the actions tab section in the template: + +``` + // ── Webhooks Tab ──────────────────────────────────────────────────────── +${webhooksTabJs} +``` + +Update the `TABS` array in the App component: + +```javascript + const TABS = [ + { key: 'activity', label: 'Activity' }, + { key: 'resources', label: 'Resources' }, + { key: 'webhooks', label: 'Webhooks' }, + { key: 'actions', label: 'Actions' }, + ]; +``` + +Add the webhooks tab rendering in the App component, after the resources tab line: + +```javascript + \${tab === 'webhooks' ? html\`<\${WebhooksTab} />\` : null} +``` + +- [ ] **Step 4: Verify in browser** + +Run: `bun run dev` + +Open `http://localhost:12111/dashboard` and verify: +- Webhooks tab appears between Resources and Actions +- Clicking Webhooks shows "No webhook endpoints" message +- Create Endpoint form opens and closes +- Creating an endpoint shows a card +- Clicking Manage opens the detail view with edit form, secret, test event dropdown +- Back button returns to list view +- Delivery log section shows at the bottom with filter dropdown +- Existing tabs still work normally + +- [ ] **Step 5: Commit** + +```bash +git add src/dashboard/html/ +git commit -m "Add Webhooks tab to dashboard with endpoint management and delivery history" +``` + +--- + +### Task 6: Integration tests for webhook dashboard API + +**Files:** +- Create: `tests/integration/webhook-dashboard.test.ts` + +- [ ] **Step 1: Write CRUD integration tests** + +```typescript +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { createApp } from "../../src/app"; + +let app: ReturnType; +let baseUrl: string; + +beforeEach(() => { + app = createApp(); + app.listen(0); + baseUrl = `http://localhost:${app.server!.port}`; +}); + +afterEach(() => { + app.server?.stop(); +}); + +async function dashPost(path: string, body: Record = {}) { + return fetch(`${baseUrl}/dashboard/api${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function dashPatch(path: string, body: Record = {}) { + return fetch(`${baseUrl}/dashboard/api${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function dashDelete(path: string) { + return fetch(`${baseUrl}/dashboard/api${path}`, { method: "DELETE" }); +} + +async function dashGet(path: string) { + return fetch(`${baseUrl}/dashboard/api${path}`); +} + +describe("Dashboard Webhook API", () => { + describe("CRUD", () => { + test("create, update, and delete a webhook endpoint", async () => { + // Create + const createRes = await dashPost("/webhooks", { + url: "https://example.com/hook", + enabled_events: ["customer.created"], + }); + expect(createRes.status).toBe(200); + const endpoint = await createRes.json(); + expect(endpoint.id).toMatch(/^we_/); + expect(endpoint.url).toBe("https://example.com/hook"); + expect(endpoint.secret).toMatch(/^whsec_/); + + // Update URL + const updateRes = await dashPatch(`/webhooks/${endpoint.id}`, { + url: "https://new.example.com/hook", + }); + expect(updateRes.status).toBe(200); + const updated = await updateRes.json(); + expect(updated.url).toBe("https://new.example.com/hook"); + + // Update status + const disableRes = await dashPatch(`/webhooks/${endpoint.id}`, { + status: "disabled", + }); + expect(disableRes.status).toBe(200); + const disabled = await disableRes.json(); + expect(disabled.status).toBe("disabled"); + + // Delete + const deleteRes = await dashDelete(`/webhooks/${endpoint.id}`); + expect(deleteRes.status).toBe(200); + const deleted = await deleteRes.json(); + expect(deleted.deleted).toBe(true); + }); + + test("returns 400 for missing required fields on create", async () => { + const res = await dashPost("/webhooks", { url: "https://example.com" }); + expect(res.status).toBe(400); + }); + }); + + describe("Delivery listing", () => { + test("lists deliveries after event is triggered", async () => { + // Set up a webhook endpoint via the Stripe API + const Stripe = (await import("stripe")).default; + const stripe = new Stripe("sk_test_strimulator", { + host: "localhost", + port: app.server!.port, + protocol: "http", + } as any); + + // Create endpoint (uses Stripe SDK to go through normal route) + const endpoint = await stripe.webhookEndpoints.create({ + url: "http://localhost:1/nonexistent", // will fail delivery + enabled_events: ["customer.created"], + }); + + // Create a customer to trigger an event + await stripe.customers.create({ email: "delivery-test@example.com" }); + + // Wait for delivery attempt + await new Promise((r) => setTimeout(r, 500)); + + // Check unified delivery log + const res = await dashGet("/deliveries"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.length).toBeGreaterThanOrEqual(1); + expect(data.data[0].event_type).toBe("customer.created"); + + // Check per-endpoint delivery log + const epRes = await dashGet(`/webhooks/${endpoint.id}/deliveries`); + expect(epRes.status).toBe(200); + const epData = await epRes.json(); + expect(epData.data.length).toBeGreaterThanOrEqual(1); + expect(epData.data[0].endpoint_id).toBe(endpoint.id); + }); + }); + + describe("Test event", () => { + test("sends a test event to a specific endpoint", async () => { + // Create endpoint + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + // Send test event + const testRes = await dashPost(`/webhooks/${endpoint.id}/test`, { + event_type: "customer.created", + }); + expect(testRes.status).toBe(200); + const testData = await testRes.json(); + expect(testData.ok).toBe(true); + expect(testData.event_id).toMatch(/^evt_/); + expect(testData.delivery_id).toMatch(/^whdel_/); + }); + + test("returns 400 for missing event_type", async () => { + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + const res = await dashPost(`/webhooks/${endpoint.id}/test`, {}); + expect(res.status).toBe(400); + }); + }); + + describe("Retry delivery", () => { + test("retries a failed delivery", async () => { + // Create endpoint + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + // Send test event to create a delivery + const testRes = await dashPost(`/webhooks/${endpoint.id}/test`, { + event_type: "charge.succeeded", + }); + const testData = await testRes.json(); + + // Wait for delivery to be attempted + await new Promise((r) => setTimeout(r, 500)); + + // Retry the delivery + const retryRes = await dashPost(`/deliveries/${testData.delivery_id}/retry`); + expect(retryRes.status).toBe(200); + const retryData = await retryRes.json(); + expect(retryData.ok).toBe(true); + expect(retryData.delivery_id).toMatch(/^whdel_/); + // New delivery ID should be different from original + expect(retryData.delivery_id).not.toBe(testData.delivery_id); + }); + + test("returns 404 for nonexistent delivery", async () => { + const res = await dashPost("/deliveries/whdel_nonexistent/retry"); + expect(res.status).toBe(404); + }); + }); +}); +``` + +- [ ] **Step 2: Run the integration tests** + +Run: `bun test tests/integration/webhook-dashboard.test.ts` + +Expected: All tests PASS + +- [ ] **Step 3: Run the full test suite** + +Run: `bun test` + +Expected: All tests PASS (existing tests unbroken by refactor) + +- [ ] **Step 4: Run type check** + +Run: `bun x tsc --noEmit` + +Expected: No type errors + +- [ ] **Step 5: Commit** + +```bash +git add tests/integration/webhook-dashboard.test.ts +git commit -m "Add integration tests for webhook dashboard API" +``` From ecabe671b5087649e4a398b55b66a0739f61b612 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:26:49 +0200 Subject: [PATCH 03/10] Split dashboard SPA into modular files under src/dashboard/html/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted the monolithic DASHBOARD_HTML template literal from server.ts into separate files: styles.ts, helpers.ts, tabs/activity.ts, tabs/resources.ts, tabs/actions.ts, and shell.ts which assembles them. No behavior changes — output HTML is identical. --- src/dashboard/html/helpers.ts | 17 + src/dashboard/html/shell.ts | 105 ++++++ src/dashboard/html/styles.ts | 37 ++ src/dashboard/html/tabs/actions.ts | 142 ++++++++ src/dashboard/html/tabs/activity.ts | 50 +++ src/dashboard/html/tabs/resources.ts | 172 +++++++++ src/dashboard/server.ts | 512 +-------------------------- 7 files changed, 524 insertions(+), 511 deletions(-) create mode 100644 src/dashboard/html/helpers.ts create mode 100644 src/dashboard/html/shell.ts create mode 100644 src/dashboard/html/styles.ts create mode 100644 src/dashboard/html/tabs/actions.ts create mode 100644 src/dashboard/html/tabs/activity.ts create mode 100644 src/dashboard/html/tabs/resources.ts diff --git a/src/dashboard/html/helpers.ts b/src/dashboard/html/helpers.ts new file mode 100644 index 0000000..8a465f0 --- /dev/null +++ b/src/dashboard/html/helpers.ts @@ -0,0 +1,17 @@ +export const HELPERS = ` + function statusClass(status) { + if (!status) return ''; + if (status >= 500) return 'status-5xx'; + if (status >= 400) return 'status-4xx'; + return 'status-2xx'; + } + + function formatTime(ts) { + return new Date(ts).toLocaleTimeString(); + } + + function formatDate(ts) { + if (!ts) return '—'; + return new Date(ts * 1000).toLocaleString(); + } +`; diff --git a/src/dashboard/html/shell.ts b/src/dashboard/html/shell.ts new file mode 100644 index 0000000..7cb6654 --- /dev/null +++ b/src/dashboard/html/shell.ts @@ -0,0 +1,105 @@ +import { STYLES } from "./styles"; +import { HELPERS } from "./helpers"; +import { ACTIVITY_TAB } from "./tabs/activity"; +import { RESOURCES_TAB } from "./tabs/resources"; +import { ACTIONS_TAB } from "./tabs/actions"; + +export const DASHBOARD_HTML = ` + + + + + Strimulator Dashboard + + + + + +
Loading...
+ + +`; diff --git a/src/dashboard/html/styles.ts b/src/dashboard/html/styles.ts new file mode 100644 index 0000000..e35793d --- /dev/null +++ b/src/dashboard/html/styles.ts @@ -0,0 +1,37 @@ +export const STYLES = ` + :root { --pico-font-size: 14px; } + .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 1rem 0; } + .stat-card { text-align: center; padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 8px; } + .stat-card h3 { margin: 0; font-size: 2rem; } + .stat-card small { color: var(--pico-muted-color); } + .request-log { max-height: 60vh; overflow-y: auto; } + .request-item { display: flex; gap: 1rem; padding: 0.5rem; border-bottom: 1px solid var(--pico-muted-border-color); font-family: monospace; font-size: 0.85rem; } + .method { font-weight: bold; min-width: 60px; } + .status-2xx { color: green; } .status-4xx { color: orange; } .status-5xx { color: red; } + + /* Tab navigation */ + .tab-nav { display: flex; gap: 0; border-bottom: 2px solid var(--pico-muted-border-color); margin-bottom: 1.5rem; } + .tab-btn { background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; padding: 0.5rem 1.25rem; cursor: pointer; font-size: 0.95rem; color: var(--pico-muted-color); } + .tab-btn.active { color: var(--pico-primary); border-bottom-color: var(--pico-primary); font-weight: bold; } + .tab-btn:hover:not(.active) { color: var(--pico-color); } + + /* Resource Explorer */ + .resource-layout { display: flex; gap: 1.5rem; align-items: flex-start; } + .resource-sidebar { min-width: 200px; max-width: 220px; flex-shrink: 0; } + .resource-sidebar ul { list-style: none; padding: 0; margin: 0; } + .resource-sidebar li { padding: 0.4rem 0.75rem; cursor: pointer; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; } + .resource-sidebar li:hover { background: var(--pico-muted-background); } + .resource-sidebar li.active { background: var(--pico-primary-background); color: var(--pico-primary); font-weight: bold; } + .resource-sidebar .badge { font-size: 0.75rem; color: var(--pico-muted-color); } + .resource-sidebar li.active .badge { color: var(--pico-primary-hover); } + .resource-main { flex: 1; min-width: 0; } + .resource-table-wrap { overflow-x: auto; } + .resource-table-wrap table { width: 100%; } + .resource-table-wrap td, .resource-table-wrap th { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; } + .row-clickable { cursor: pointer; } + .row-clickable:hover td { background: var(--pico-muted-background); } + .detail-panel { margin-top: 1rem; } + .detail-panel pre { background: var(--pico-code-background, #1e1e2e); color: var(--pico-code-color, #cdd6f4); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.8rem; max-height: 60vh; } + .pagination { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; } + .no-data { color: var(--pico-muted-color); font-style: italic; padding: 1rem 0; } +`; diff --git a/src/dashboard/html/tabs/actions.ts b/src/dashboard/html/tabs/actions.ts new file mode 100644 index 0000000..17d1f38 --- /dev/null +++ b/src/dashboard/html/tabs/actions.ts @@ -0,0 +1,142 @@ +export const ACTIONS_TAB = ` + // ── Actions Tab ─────────────────────────────────────────────────────────── + + function ActionCard({ title, children }) { + return html\` +
+
\${title}
+ \${children} +
+ \`; + } + + function useAction(url) { + const [status, setStatus] = useState(null); // null | 'ok' | 'error' + const [message, setMessage] = useState(''); + const [loading, setLoading] = useState(false); + + async function run(body) { + setLoading(true); + setStatus(null); + setMessage(''); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (res.ok) { + setStatus('ok'); + setMessage('Success: ' + JSON.stringify(data)); + } else { + setStatus('error'); + setMessage('Error: ' + (data?.error?.message ?? data?.error ?? JSON.stringify(data))); + } + } catch (e) { + setStatus('error'); + setMessage('Request failed: ' + e.message); + } finally { + setLoading(false); + } + } + + return { run, status, message, loading }; + } + + function ActionsTab() { + // Fail Next Payment state + const [errorCode, setErrorCode] = useState('card_declined'); + const failAction = useAction('/dashboard/api/actions/fail-next-payment'); + + // Advance Clock state + const [clockId, setClockId] = useState(''); + const [frozenTime, setFrozenTime] = useState(''); + const clockAction = useAction('/dashboard/api/actions/advance-clock'); + + // Retry Webhook state + const [eventId, setEventId] = useState(''); + const [endpointId, setEndpointId] = useState(''); + const retryAction = useAction('/dashboard/api/actions/retry-webhook'); + + // Expire PI state + const [piId, setPiId] = useState(''); + const expireAction = useAction('/dashboard/api/actions/expire-payment-intent'); + + // Cycle Subscription state + const [subId, setSubId] = useState(''); + const cycleAction = useAction('/dashboard/api/actions/cycle-subscription'); + + function StatusMsg({ action }) { + if (!action.status) return null; + const color = action.status === 'ok' ? 'green' : 'red'; + return html\`

\${action.message}

\`; + } + + return html\` +
+

Actions

+

Trigger simulated scenarios to test your integration.

+ + <\${ActionCard} title="Fail Next Payment"> +

Sets a flag so the next PaymentIntent confirm will fail with the chosen error code.

+ + + <\${StatusMsg} action=\${failAction} /> + + + <\${ActionCard} title="Advance Test Clock"> +

Move a test clock forward to a new frozen_time (Unix timestamp).

+ + + + <\${StatusMsg} action=\${clockAction} /> + + + <\${ActionCard} title="Retry Webhook"> +

Re-deliver an event to a webhook endpoint.

+ + + + <\${StatusMsg} action=\${retryAction} /> + + + <\${ActionCard} title="Expire Payment Intent"> +

Force a PaymentIntent into canceled status.

+ + + <\${StatusMsg} action=\${expireAction} /> + + + <\${ActionCard} title="Cycle Subscription"> +

Advance a subscription to its next billing period and create a new invoice.

+ + + <\${StatusMsg} action=\${cycleAction} /> + +
+ \`; + } +`; diff --git a/src/dashboard/html/tabs/activity.ts b/src/dashboard/html/tabs/activity.ts new file mode 100644 index 0000000..dbe9bcb --- /dev/null +++ b/src/dashboard/html/tabs/activity.ts @@ -0,0 +1,50 @@ +export const ACTIVITY_TAB = ` + function StatCard({ label, value }) { + return html\` +
+

\${value}

+ \${label} +
+ \`; + } + + // ── Activity Tab ────────────────────────────────────────────────────────── + + function ActivityTab({ stats, requests }) { + const statLabels = [ + ['customers', 'Customers'], + ['payment_intents', 'Payment Intents'], + ['subscriptions', 'Subscriptions'], + ['invoices', 'Invoices'], + ['events', 'Events'], + ['webhook_endpoints', 'Webhook Endpoints'], + ]; + + return html\` +
+

Stats

+
+ \${stats + ? statLabels.map(([key, label]) => html\`<\${StatCard} key=\${key} label=\${label} value=\${stats[key]} />\`) + : html\`

Loading stats...

\` + } +
+ +

Recent Requests

+
+ \${requests.length === 0 + ? html\`

No requests yet.

\` + : requests.map((req, i) => html\` +
+ \${req.method} + \${req.path} + \${req.status ? html\`\${req.status}\` : null} + \${formatTime(req.timestamp)} +
+ \`) + } +
+
+ \`; + } +`; diff --git a/src/dashboard/html/tabs/resources.ts b/src/dashboard/html/tabs/resources.ts new file mode 100644 index 0000000..1678c7e --- /dev/null +++ b/src/dashboard/html/tabs/resources.ts @@ -0,0 +1,172 @@ +export const RESOURCES_TAB = ` + // ── static config ───────────────────────────────────────────────────────── + + const RESOURCE_TYPES = [ + { key: 'customers', label: 'Customers' }, + { key: 'products', label: 'Products' }, + { key: 'prices', label: 'Prices' }, + { key: 'payment_intents', label: 'Payment Intents' }, + { key: 'payment_methods', label: 'Payment Methods' }, + { key: 'charges', label: 'Charges' }, + { key: 'refunds', label: 'Refunds' }, + { key: 'setup_intents', label: 'Setup Intents' }, + { key: 'subscriptions', label: 'Subscriptions' }, + { key: 'invoices', label: 'Invoices' }, + { key: 'events', label: 'Events' }, + { key: 'webhook_endpoints',label: 'Webhook Endpoints' }, + { key: 'test_clocks', label: 'Test Clocks' }, + ]; + + // Key field shown as last column per resource type + const KEY_FIELD = { + customers: (r) => r.email ?? '—', + products: (r) => r.name ?? '—', + prices: (r) => r.unit_amount != null ? (r.unit_amount / 100).toFixed(2) + ' ' + (r.currency ?? '').toUpperCase() : '—', + payment_intents: (r) => r.amount != null ? (r.amount / 100).toFixed(2) + ' ' + (r.currency ?? '').toUpperCase() : '—', + payment_methods: (r) => r.type ?? '—', + charges: (r) => r.amount != null ? (r.amount / 100).toFixed(2) + ' ' + (r.currency ?? '').toUpperCase() : '—', + refunds: (r) => r.amount != null ? (r.amount / 100).toFixed(2) + ' ' + (r.currency ?? '').toUpperCase() : '—', + setup_intents: (r) => r.payment_method ?? '—', + subscriptions: (r) => r.customer ?? '—', + invoices: (r) => r.amount_due != null ? (r.amount_due / 100).toFixed(2) + ' ' + (r.currency ?? '').toUpperCase() : '—', + events: (r) => r.type ?? '—', + webhook_endpoints:(r) => r.url ?? '—', + test_clocks: (r) => r.name ?? '—', + }; + + const KEY_FIELD_LABEL = { + customers: 'Email', + products: 'Name', + prices: 'Amount', + payment_intents: 'Amount', + payment_methods: 'Type', + charges: 'Amount', + refunds: 'Amount', + setup_intents: 'Payment Method', + subscriptions: 'Customer', + invoices: 'Amount Due', + events: 'Event Type', + webhook_endpoints:'URL', + test_clocks: 'Name', + }; + + // ── Resources Tab ───────────────────────────────────────────────────────── + + function ResourcesTab({ stats }) { + const [selectedType, setSelectedType] = useState('customers'); + const [resources, setResources] = useState(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [selectedRow, setSelectedRow] = useState(null); + const limit = 20; + + async function loadResources(type, off) { + setResources(null); + setSelectedRow(null); + try { + const res = await fetch(\`/dashboard/api/resources/\${type}?limit=\${limit}&offset=\${off}\`); + const data = await res.json(); + setResources(data.data ?? []); + setTotal(data.total ?? 0); + } catch (e) { + console.error('Failed to fetch resources', e); + setResources([]); + } + } + + useEffect(() => { + setOffset(0); + loadResources(selectedType, 0); + }, [selectedType]); + + function selectType(key) { + setSelectedType(key); + } + + function goPage(newOffset) { + setOffset(newOffset); + loadResources(selectedType, newOffset); + } + + const keyFn = KEY_FIELD[selectedType] ?? (() => '—'); + const keyLabel = KEY_FIELD_LABEL[selectedType] ?? 'Key'; + + const hasStatus = resources && resources.length > 0 && resources.some(r => r.status != null); + + return html\` +
+ + +
+

\${RESOURCE_TYPES.find(t => t.key === selectedType)?.label ?? ''}

+ + \${resources === null + ? html\`

Loading...

\` + : resources.length === 0 + ? html\`

No records found.

\` + : html\` +
+ + + + + \${hasStatus ? html\`\` : null} + + + + + + \${resources.map((r, i) => html\` + setSelectedRow(selectedRow?.id === r.id ? null : r)} + > + + \${hasStatus ? html\`\` : null} + + + + \`)} + +
IDStatusCreated\${keyLabel}
\${r.id ?? '—'}\${r.status ?? '—'}\${formatDate(r.created)}\${keyFn(r)}
+
+ + + \` + } + + \${selectedRow ? html\` +
+ Detail: \${selectedRow.id} +
\${JSON.stringify(selectedRow, null, 2)}
+
+ \` : null} +
+
+ \`; + } +`; diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 24e478d..1d1d5f7 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -1,517 +1,7 @@ import { Elysia } from "elysia"; import type { StrimulatorDB } from "../db"; import { dashboardApi } from "./api"; - -const DASHBOARD_HTML = ` - - - - - Strimulator Dashboard - - - - - -
Loading...
- - -`; +import { DASHBOARD_HTML } from "./html/shell"; export function dashboardServer(db: StrimulatorDB) { return new Elysia() From b10894889e2d307bccb0bce9980869bba4cad674 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:31:57 +0200 Subject: [PATCH 04/10] Add WebhookEndpointService.update() with tests Supports partial updates to url, enabled_events, and status. Uses retrieve() to validate existence and get current state, then writes patched data back to the DB. --- src/services/webhook-endpoints.ts | 35 +++++++++++ tests/unit/services/webhook-endpoints.test.ts | 61 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/unit/services/webhook-endpoints.test.ts diff --git a/src/services/webhook-endpoints.ts b/src/services/webhook-endpoints.ts index cfbd3ed..6d2d71f 100644 --- a/src/services/webhook-endpoints.ts +++ b/src/services/webhook-endpoints.ts @@ -14,6 +14,12 @@ export interface CreateWebhookEndpointParams { metadata?: Record; } +export interface UpdateWebhookEndpointParams { + url?: string; + enabled_events?: string[]; + status?: string; +} + function buildEndpointShape( id: string, createdAt: number, @@ -75,6 +81,35 @@ export class WebhookEndpointService { return JSON.parse(row.data as string) as Stripe.WebhookEndpoint; } + update(id: string, params: UpdateWebhookEndpointParams): Stripe.WebhookEndpoint { + const existing = this.retrieve(id); + + const updated: Record = { ...existing }; + const dbUpdates: Record = {}; + + if (params.url !== undefined) { + updated.url = params.url; + dbUpdates.url = params.url; + } + if (params.enabled_events !== undefined) { + updated.enabled_events = params.enabled_events; + dbUpdates.enabledEvents = JSON.stringify(params.enabled_events); + } + if (params.status !== undefined) { + updated.status = params.status; + dbUpdates.status = params.status; + } + + dbUpdates.data = JSON.stringify(updated); + + this.db.update(webhookEndpoints) + .set(dbUpdates) + .where(eq(webhookEndpoints.id, id)) + .run(); + + return updated as unknown as Stripe.WebhookEndpoint; + } + del(id: string): Stripe.DeletedWebhookEndpoint { // Ensure it exists first this.retrieve(id); diff --git a/tests/unit/services/webhook-endpoints.test.ts b/tests/unit/services/webhook-endpoints.test.ts new file mode 100644 index 0000000..c69599e --- /dev/null +++ b/tests/unit/services/webhook-endpoints.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "bun:test"; +import { createDB } from "../../../src/db"; +import { WebhookEndpointService } from "../../../src/services/webhook-endpoints"; +import { StripeError } from "../../../src/errors"; + +function makeService() { + const db = createDB(":memory:"); + return new WebhookEndpointService(db); +} + +describe("WebhookEndpointService", () => { + describe("update", () => { + it("updates the url", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://old.example.com/hook", enabled_events: ["*"] }); + const updated = svc.update(ep.id, { url: "https://new.example.com/hook" }); + expect(updated.url).toBe("https://new.example.com/hook"); + expect(updated.id).toBe(ep.id); + const retrieved = svc.retrieve(ep.id); + expect(retrieved.url).toBe("https://new.example.com/hook"); + }); + + it("updates enabled_events", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["*"] }); + const updated = svc.update(ep.id, { enabled_events: ["customer.created", "invoice.paid"] }); + expect(updated.enabled_events).toEqual(["customer.created", "invoice.paid"]); + const retrieved = svc.retrieve(ep.id); + expect(retrieved.enabled_events).toEqual(["customer.created", "invoice.paid"]); + }); + + it("updates status to disabled", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["*"] }); + const updated = svc.update(ep.id, { status: "disabled" }); + expect(updated.status).toBe("disabled"); + const retrieved = svc.retrieve(ep.id); + expect(retrieved.status).toBe("disabled"); + }); + + it("preserves unchanged fields", () => { + const svc = makeService(); + const ep = svc.create({ url: "https://example.com/hook", enabled_events: ["customer.created"] }); + const updated = svc.update(ep.id, { url: "https://new.example.com/hook" }); + expect(updated.enabled_events).toEqual(["customer.created"]); + expect(updated.secret).toBe(ep.secret); + expect(updated.created).toBe(ep.created); + }); + + it("throws 404 for nonexistent endpoint", () => { + const svc = makeService(); + expect(() => svc.update("we_nonexistent", { url: "https://example.com" })).toThrow(); + try { + svc.update("we_nonexistent", { url: "https://example.com" }); + } catch (err) { + expect(err).toBeInstanceOf(StripeError); + expect((err as StripeError).statusCode).toBe(404); + } + }); + }); +}); From 4392024720bc70d849919dd5001963ddd3185ab2 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:33:32 +0200 Subject: [PATCH 05/10] Extract deliverToEndpoint from deliver in WebhookDeliveryService --- src/services/webhook-delivery.ts | 37 +++++++++++-------- tests/unit/services/webhook-delivery.test.ts | 39 ++++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/services/webhook-delivery.ts b/src/services/webhook-delivery.ts index 546bd18..efa8cf6 100644 --- a/src/services/webhook-delivery.ts +++ b/src/services/webhook-delivery.ts @@ -37,25 +37,32 @@ export class WebhookDeliveryService { return `t=${timestamp},v1=${hmac}`; } + async deliverToEndpoint( + event: Stripe.Event, + endpoint: { id: string; url: string; secret: string }, + ): Promise { + const deliveryId = generateId("webhook_delivery"); + const createdAt = now(); + + this.db.insert(webhookDeliveries).values({ + id: deliveryId, + eventId: event.id, + endpointId: endpoint.id, + status: "pending", + attempts: 0, + nextRetryAt: null, + created: createdAt, + }).run(); + + this.attemptDelivery(deliveryId, endpoint, event, 0); + return deliveryId; + } + async deliver(event: Stripe.Event): Promise { const matchingEndpoints = this.findMatchingEndpoints(event.type); for (const endpoint of matchingEndpoints) { - const deliveryId = generateId("webhook_delivery"); - const createdAt = now(); - - this.db.insert(webhookDeliveries).values({ - id: deliveryId, - eventId: event.id, - endpointId: endpoint.id, - status: "pending", - attempts: 0, - nextRetryAt: null, - created: createdAt, - }).run(); - - // Attempt delivery asynchronously - this.attemptDelivery(deliveryId, endpoint, event, 0); + await this.deliverToEndpoint(event, endpoint); } } diff --git a/tests/unit/services/webhook-delivery.test.ts b/tests/unit/services/webhook-delivery.test.ts index 9674688..db21fcf 100644 --- a/tests/unit/services/webhook-delivery.test.ts +++ b/tests/unit/services/webhook-delivery.test.ts @@ -147,4 +147,43 @@ describe("WebhookDeliveryService", () => { expect(matches[0].secret).toMatch(/^whsec_/); }); }); + + describe("deliverToEndpoint", () => { + it("creates a delivery record for the specific endpoint", async () => { + const { db, endpointService, deliveryService } = makeServices(); + const { getRawSqlite } = await import("../../../src/db"); + const sqlite = getRawSqlite(db); + + const endpoint = endpointService.create({ + url: "https://example.com/webhook", + enabled_events: ["*"], + }); + + const event = { + id: "evt_test123", + object: "event" as const, + type: "customer.created", + data: { object: { id: "cus_123" } }, + api_version: "2024-12-18", + created: 1700000000, + livemode: false, + pending_webhooks: 0, + request: { id: null, idempotency_key: null }, + } as any; + + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: endpoint.id, + url: endpoint.url, + secret: endpoint.secret!, + }); + + expect(deliveryId).toMatch(/^whdel_/); + + // Verify delivery record was created + const row = sqlite.query("SELECT * FROM webhook_deliveries WHERE id = ?").get(deliveryId) as any; + expect(row).not.toBeNull(); + expect(row.event_id).toBe("evt_test123"); + expect(row.endpoint_id).toBe(endpoint.id); + }); + }); }); From 91022a9273e731c097451f5d17e90b25ca2b6833 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:36:06 +0200 Subject: [PATCH 06/10] Add dashboard API endpoints for webhook management --- src/dashboard/api.ts | 266 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts index c013c94..a19503b 100644 --- a/src/dashboard/api.ts +++ b/src/dashboard/api.ts @@ -373,5 +373,271 @@ export function dashboardApi(db: StrimulatorDB) { } throw err; } + }) + + // --- Webhook management endpoints --- + + .post("/webhooks", async ({ request }) => { + let body: { url?: string; enabled_events?: string[] } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!body.url || !body.enabled_events?.length) { + return new Response(JSON.stringify({ error: "url and enabled_events are required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + const endpoint = endpointService.create({ + url: body.url, + enabled_events: body.enabled_events, + }); + return endpoint; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .get("/deliveries", ({ query }) => { + const limit = Math.min(parseInt(String(query.limit ?? "20"), 10) || 20, 200); + const offset = parseInt(String(query.offset ?? "0"), 10) || 0; + const endpointId = query.endpoint_id as string | undefined; + + try { + let countSql = "SELECT COUNT(*) as count FROM webhook_deliveries"; + let dataSql = `SELECT + wd.id, wd.event_id, wd.endpoint_id, wd.status, wd.attempts, wd.next_retry_at, wd.created, + e.type as event_type, + we.url as endpoint_url + FROM webhook_deliveries wd + LEFT JOIN events e ON e.id = wd.event_id + LEFT JOIN webhook_endpoints we ON we.id = wd.endpoint_id`; + + const queryParams: string[] = []; + if (endpointId) { + countSql += " WHERE endpoint_id = ?"; + dataSql += " WHERE wd.endpoint_id = ?"; + queryParams.push(endpointId); + } + + dataSql += " ORDER BY wd.created DESC LIMIT ? OFFSET ?"; + + const totalRow = sqlite.query(countSql).get(...queryParams) as { count: number } | null; + const rows = sqlite.query(dataSql).all(...queryParams, limit, offset); + + return { + data: rows, + total: totalRow?.count ?? 0, + limit, + offset, + }; + } catch { + return new Response(JSON.stringify({ error: "Query failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }) + + .get("/webhooks/:id/deliveries", ({ params, query }) => { + const limit = Math.min(parseInt(String(query.limit ?? "20"), 10) || 20, 200); + const offset = parseInt(String(query.offset ?? "0"), 10) || 0; + + try { + const totalRow = sqlite.query( + "SELECT COUNT(*) as count FROM webhook_deliveries WHERE endpoint_id = ?" + ).get(params.id) as { count: number } | null; + + const rows = sqlite.query(`SELECT + wd.id, wd.event_id, wd.endpoint_id, wd.status, wd.attempts, wd.next_retry_at, wd.created, + e.type as event_type, + we.url as endpoint_url + FROM webhook_deliveries wd + LEFT JOIN events e ON e.id = wd.event_id + LEFT JOIN webhook_endpoints we ON we.id = wd.endpoint_id + WHERE wd.endpoint_id = ? + ORDER BY wd.created DESC + LIMIT ? OFFSET ?`).all(params.id, limit, offset); + + return { + data: rows, + total: totalRow?.count ?? 0, + limit, + offset, + }; + } catch { + return new Response(JSON.stringify({ error: "Query failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }) + + .patch("/webhooks/:id", async ({ params, request }) => { + let body: { url?: string; enabled_events?: string[]; status?: string } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + return endpointService.update(params.id, body); + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .delete("/webhooks/:id", ({ params }) => { + try { + const endpointService = new WebhookEndpointService(db); + return endpointService.del(params.id); + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .post("/webhooks/:id/test", async ({ params, request }) => { + let body: { event_type?: string } = {}; + try { + const text = await request.text(); + if (text) body = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const eventType = body.event_type; + if (!eventType) { + return new Response(JSON.stringify({ error: "event_type is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + try { + const endpointService = new WebhookEndpointService(db); + const deliveryService = new WebhookDeliveryService(db, endpointService); + const eventService = new EventService(db); + + // Verify endpoint exists and get its details + endpointService.retrieve(params.id); + const allEndpoints = endpointService.listAll(); + const epData = allEndpoints.find((ep) => ep.id === params.id); + if (!epData) { + return new Response(JSON.stringify({ error: "Endpoint not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Build a minimal stub object for the event type + const [resource] = eventType.split("."); + const stubObject: Record = { + id: `test_${resource}_${Date.now()}`, + object: resource, + }; + + // Emit the event (persists to DB) + const event = eventService.emit(eventType, stubObject); + + // Deliver to the specific endpoint + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: epData.id, + url: epData.url, + secret: epData.secret, + }); + + return { ok: true, event_id: event.id, delivery_id: deliveryId }; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } + }) + + .post("/deliveries/:id/retry", async ({ params }) => { + try { + const delivery = sqlite.query( + "SELECT * FROM webhook_deliveries WHERE id = ?" + ).get(params.id) as { event_id: string; endpoint_id: string } | null; + + if (!delivery) { + return new Response(JSON.stringify({ error: "Delivery not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const eventService = new EventService(db); + const endpointService = new WebhookEndpointService(db); + const deliveryService = new WebhookDeliveryService(db, endpointService); + + const event = eventService.retrieve(delivery.event_id); + const allEndpoints = endpointService.listAll(); + const epData = allEndpoints.find((ep) => ep.id === delivery.endpoint_id); + + if (!epData) { + return new Response(JSON.stringify({ error: "Endpoint no longer exists" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: epData.id, + url: epData.url, + secret: epData.secret, + }); + + return { ok: true, delivery_id: deliveryId }; + } catch (err) { + if (err instanceof StripeError) { + return new Response(JSON.stringify(err.body), { + status: err.statusCode, + headers: { "Content-Type": "application/json" }, + }); + } + throw err; + } }); } From 6d813fbc7afa1810eb5d8a9c51371da81e2ed8b9 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:40:59 +0200 Subject: [PATCH 07/10] Add Webhooks tab to dashboard with endpoint management and delivery history --- src/dashboard/html/shell.ts | 4 + src/dashboard/html/styles.ts | 32 +++ src/dashboard/html/tabs/webhooks.ts | 404 ++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 src/dashboard/html/tabs/webhooks.ts diff --git a/src/dashboard/html/shell.ts b/src/dashboard/html/shell.ts index 7cb6654..bffd8dd 100644 --- a/src/dashboard/html/shell.ts +++ b/src/dashboard/html/shell.ts @@ -3,6 +3,7 @@ import { HELPERS } from "./helpers"; import { ACTIVITY_TAB } from "./tabs/activity"; import { RESOURCES_TAB } from "./tabs/resources"; import { ACTIONS_TAB } from "./tabs/actions"; +import { WEBHOOKS_TAB } from "./tabs/webhooks"; export const DASHBOARD_HTML = ` @@ -29,6 +30,7 @@ ${HELPERS} ${ACTIVITY_TAB} ${RESOURCES_TAB} ${ACTIONS_TAB} +${WEBHOOKS_TAB} // ── App ─────────────────────────────────────────────────────────────────── function App() { @@ -77,6 +79,7 @@ ${ACTIONS_TAB} const TABS = [ { key: 'activity', label: 'Activity' }, { key: 'resources', label: 'Resources' }, + { key: 'webhooks', label: 'Webhooks' }, { key: 'actions', label: 'Actions' }, ]; @@ -94,6 +97,7 @@ ${ACTIONS_TAB} \${tab === 'activity' ? html\`<\${ActivityTab} stats=\${stats} requests=\${requests} />\` : null} \${tab === 'resources' ? html\`<\${ResourcesTab} stats=\${stats} />\` : null} + \${tab === 'webhooks' ? html\`<\${WebhooksTab} />\` : null} \${tab === 'actions' ? html\`<\${ActionsTab} />\` : null} \`; diff --git a/src/dashboard/html/styles.ts b/src/dashboard/html/styles.ts index e35793d..7be0a11 100644 --- a/src/dashboard/html/styles.ts +++ b/src/dashboard/html/styles.ts @@ -34,4 +34,36 @@ export const STYLES = ` .detail-panel pre { background: var(--pico-code-background, #1e1e2e); color: var(--pico-code-color, #cdd6f4); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.8rem; max-height: 60vh; } .pagination { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; } .no-data { color: var(--pico-muted-color); font-style: italic; padding: 1rem 0; } + + /* Webhooks tab */ + .wh-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } + .wh-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; margin-bottom: 2rem; } + .wh-card { border: 1px solid var(--pico-muted-border-color); border-radius: 8px; padding: 1rem; } + .wh-card .url { font-family: monospace; font-size: 0.9rem; word-break: break-all; margin-bottom: 0.5rem; } + .wh-card .meta { display: flex; gap: 1rem; align-items: center; font-size: 0.8rem; color: var(--pico-muted-color); flex-wrap: wrap; } + .wh-card .actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; } + .wh-card .actions button { padding: 0.25rem 0.75rem; font-size: 0.8rem; } + .wh-badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; } + .wh-badge-enabled { background: #d4edda; color: #155724; } + .wh-badge-disabled { background: #f8d7da; color: #721c24; } + .wh-badge-delivered { background: #d4edda; color: #155724; } + .wh-badge-pending { background: #fff3cd; color: #856404; } + .wh-badge-failed { background: #f8d7da; color: #721c24; } + .wh-detail-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } + .wh-detail-header button { padding: 0.25rem 0.5rem; font-size: 0.85rem; } + .wh-secret { display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0; } + .wh-secret code { font-size: 0.85rem; background: var(--pico-code-background, #1e1e2e); color: var(--pico-code-color, #cdd6f4); padding: 0.2rem 0.5rem; border-radius: 4px; } + .wh-events-list { font-size: 0.85rem; color: var(--pico-muted-color); } + .wh-form { margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--pico-muted-border-color); border-radius: 8px; } + .wh-form label { font-size: 0.9rem; } + .wh-form input, .wh-form select { font-size: 0.9rem; } + .wh-delivery-table { width: 100%; } + .wh-delivery-table td, .wh-delivery-table th { font-size: 0.85rem; padding: 0.4rem 0.5rem; } + .wh-delivery-table td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } + .wh-retry-btn { padding: 0.15rem 0.5rem; font-size: 0.75rem; } + .wh-test-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; } + .wh-test-row select { max-width: 280px; } + .wh-test-row button { white-space: nowrap; } + .wh-filter-row { display: flex; gap: 0.5rem; align-items: end; margin-bottom: 1rem; } + .wh-filter-row select { max-width: 300px; } `; diff --git a/src/dashboard/html/tabs/webhooks.ts b/src/dashboard/html/tabs/webhooks.ts new file mode 100644 index 0000000..678e896 --- /dev/null +++ b/src/dashboard/html/tabs/webhooks.ts @@ -0,0 +1,404 @@ +export const WEBHOOKS_TAB = ` + // ── Webhooks Tab ────────────────────────────────────────────────────────── + + const TEST_EVENT_TYPES = [ + 'customer.created', 'customer.updated', 'customer.deleted', + 'invoice.created', 'invoice.paid', 'invoice.payment_failed', + 'payment_intent.succeeded', 'payment_intent.payment_failed', + 'charge.succeeded', 'charge.failed', + 'subscription.created', 'subscription.updated', 'subscription.deleted', + ]; + + function DeliveryStatusBadge({ status }) { + return html\`\${status}\`; + } + + function DeliveryTable({ deliveries, onRetry }) { + if (!deliveries || deliveries.length === 0) { + return html\`

No deliveries yet.

\`; + } + return html\` + + + + + + + + + + + + + \${deliveries.map(d => html\` + + + + + + + + + \`)} + +
Event TypeEndpointStatusAttemptsTime
\${d.event_type ?? '—'}\${d.endpoint_url ?? d.endpoint_id ?? '—'}<\${DeliveryStatusBadge} status=\${d.status ?? 'pending'} />\${d.attempts ?? 0}\${formatDate(d.created)} + \${d.status === 'failed' ? html\` + + \` : null} +
+ \`; + } + + function EndpointDetail({ endpoint, onBack, onUpdate }) { + const [showSecret, setShowSecret] = useState(false); + const [editUrl, setEditUrl] = useState(endpoint.url ?? ''); + const [editEvents, setEditEvents] = useState((endpoint.enabled_events ?? []).join(', ')); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [deliveries, setDeliveries] = useState([]); + const [delTotal, setDelTotal] = useState(0); + const [delOffset, setDelOffset] = useState(0); + const [testType, setTestType] = useState(TEST_EVENT_TYPES[0]); + const [testLoading, setTestLoading] = useState(false); + const [testMsg, setTestMsg] = useState(''); + const delLimit = 20; + + async function loadDeliveries(off) { + try { + const res = await fetch(\`/dashboard/api/webhooks/\${endpoint.id}/deliveries?limit=\${delLimit}&offset=\${off}\`); + const data = await res.json(); + setDeliveries(data.data ?? []); + setDelTotal(data.total ?? 0); + } catch (e) { + console.error('Failed to load deliveries', e); + } + } + + useEffect(() => { + loadDeliveries(0); + }, [endpoint.id]); + + async function toggleStatus() { + const newStatus = endpoint.status === 'enabled' ? 'disabled' : 'enabled'; + try { + const res = await fetch(\`/dashboard/api/webhooks/\${endpoint.id}\`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }), + }); + if (res.ok) { + const updated = await res.json(); + onUpdate && onUpdate(updated); + } + } catch (e) { + console.error('Failed to toggle status', e); + } + } + + async function saveEdit() { + setSaving(true); + try { + const events = editEvents.split(',').map(s => s.trim()).filter(Boolean); + const res = await fetch(\`/dashboard/api/webhooks/\${endpoint.id}\`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: editUrl, enabled_events: events }), + }); + if (res.ok) { + const updated = await res.json(); + onUpdate && onUpdate(updated); + setEditing(false); + } + } catch (e) { + console.error('Failed to save', e); + } finally { + setSaving(false); + } + } + + async function sendTest() { + setTestLoading(true); + setTestMsg(''); + try { + const res = await fetch(\`/dashboard/api/webhooks/\${endpoint.id}/test\`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event_type: testType }), + }); + const data = await res.json(); + if (res.ok) { + setTestMsg('Test event sent: ' + (data.event_id ?? 'ok')); + loadDeliveries(delOffset); + } else { + setTestMsg('Error: ' + (data.error ?? JSON.stringify(data))); + } + } catch (e) { + setTestMsg('Request failed: ' + e.message); + } finally { + setTestLoading(false); + } + } + + async function retryDelivery(deliveryId) { + try { + const res = await fetch(\`/dashboard/api/deliveries/\${deliveryId}/retry\`, { method: 'POST' }); + if (res.ok) { + loadDeliveries(delOffset); + } + } catch (e) { + console.error('Retry failed', e); + } + } + + function copySecret() { + if (endpoint.secret) { + navigator.clipboard.writeText(endpoint.secret); + } + } + + function goDelPage(off) { + setDelOffset(off); + loadDeliveries(off); + } + + return html\` +
+
+ +

\${endpoint.url}

+ <\${DeliveryStatusBadge} status=\${endpoint.status ?? 'enabled'} /> + +
+ +
+ Secret: + \${showSecret ? (endpoint.secret ?? '—') : '••••••••••••'} + + +
+ +
+ Enabled events: \${(endpoint.enabled_events ?? []).join(', ') || '*'} +
+ +
+ \${!editing ? html\` + + \` : html\` +
+ + +
+ + +
+
+ \`} +
+ +

Send Test Event

+
+ + +
+ \${testMsg ? html\`

\${testMsg}

\` : null} + +

Delivery History

+ <\${DeliveryTable} deliveries=\${deliveries} onRetry=\${retryDelivery} /> + + \${delTotal > delLimit ? html\` + + \` : null} +
+ \`; + } + + function WebhooksTab() { + const [endpoints, setEndpoints] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newUrl, setNewUrl] = useState(''); + const [newEvents, setNewEvents] = useState('*'); + const [creating, setCreating] = useState(false); + const [selectedEp, setSelectedEp] = useState(null); + + // Unified delivery log state + const [deliveries, setDeliveries] = useState([]); + const [delTotal, setDelTotal] = useState(0); + const [delOffset, setDelOffset] = useState(0); + const [filterEndpoint, setFilterEndpoint] = useState(''); + const delLimit = 20; + + async function loadEndpoints() { + try { + const res = await fetch(\`/dashboard/api/resources/webhook_endpoints?limit=200&offset=0\`); + const data = await res.json(); + setEndpoints(data.data ?? []); + } catch (e) { + console.error('Failed to load endpoints', e); + } finally { + setLoading(false); + } + } + + async function loadDeliveries(off, epId) { + try { + let url = \`/dashboard/api/deliveries?limit=\${delLimit}&offset=\${off}\`; + if (epId) url += \`&endpoint_id=\${epId}\`; + const res = await fetch(url); + const data = await res.json(); + setDeliveries(data.data ?? []); + setDelTotal(data.total ?? 0); + } catch (e) { + console.error('Failed to load deliveries', e); + } + } + + useEffect(() => { + loadEndpoints(); + loadDeliveries(0, ''); + }, []); + + async function createEndpoint() { + setCreating(true); + try { + const events = newEvents.split(',').map(s => s.trim()).filter(Boolean); + const res = await fetch('/dashboard/api/webhooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: newUrl, enabled_events: events }), + }); + if (res.ok) { + setNewUrl(''); + setNewEvents('*'); + setShowCreate(false); + loadEndpoints(); + } + } catch (e) { + console.error('Failed to create endpoint', e); + } finally { + setCreating(false); + } + } + + async function deleteEndpoint(id) { + try { + const res = await fetch(\`/dashboard/api/webhooks/\${id}\`, { method: 'DELETE' }); + if (res.ok) { + loadEndpoints(); + } + } catch (e) { + console.error('Failed to delete endpoint', e); + } + } + + async function retryDelivery(deliveryId) { + try { + const res = await fetch(\`/dashboard/api/deliveries/\${deliveryId}/retry\`, { method: 'POST' }); + if (res.ok) { + loadDeliveries(delOffset, filterEndpoint); + } + } catch (e) { + console.error('Retry failed', e); + } + } + + function onFilterChange(epId) { + setFilterEndpoint(epId); + setDelOffset(0); + loadDeliveries(0, epId); + } + + function goDelPage(off) { + setDelOffset(off); + loadDeliveries(off, filterEndpoint); + } + + function handleManage(ep) { + setSelectedEp(ep); + } + + function handleBack() { + setSelectedEp(null); + loadEndpoints(); + loadDeliveries(delOffset, filterEndpoint); + } + + function handleEndpointUpdate(updated) { + setSelectedEp(updated); + loadEndpoints(); + } + + // Detail view + if (selectedEp) { + return html\`<\${EndpointDetail} endpoint=\${selectedEp} onBack=\${handleBack} onUpdate=\${handleEndpointUpdate} />\`; + } + + return html\` +
+
+

Webhooks

+ +
+ + \${showCreate ? html\` +
+ + + +
+ \` : null} + + \${loading ? html\`

Loading...

\` : endpoints.length === 0 ? html\` +

No webhook endpoints configured.

+ \` : html\` +
+ \${endpoints.map(ep => html\` +
+
\${ep.url}
+
+ <\${DeliveryStatusBadge} status=\${ep.status ?? 'enabled'} /> + \${(ep.enabled_events ?? []).length} event(s) +
+
+ + +
+
+ \`)} +
+ \`} + +

Delivery Log

+
+ +
+ + <\${DeliveryTable} deliveries=\${deliveries} onRetry=\${retryDelivery} /> + + \${delTotal > delLimit ? html\` + + \` : null} +
+ \`; + } +`; From ffe7075758c0e867079f964af2d5d01b0324369a Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:45:34 +0200 Subject: [PATCH 08/10] Fix webhook UI: add delete confirmation, reset delivery offset, improve error feedback --- src/dashboard/html/tabs/webhooks.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/dashboard/html/tabs/webhooks.ts b/src/dashboard/html/tabs/webhooks.ts index 678e896..1db4d66 100644 --- a/src/dashboard/html/tabs/webhooks.ts +++ b/src/dashboard/html/tabs/webhooks.ts @@ -75,6 +75,7 @@ export const WEBHOOKS_TAB = ` } useEffect(() => { + setDelOffset(0); loadDeliveries(0); }, [endpoint.id]); @@ -230,6 +231,7 @@ export const WEBHOOKS_TAB = ` const [newUrl, setNewUrl] = useState(''); const [newEvents, setNewEvents] = useState('*'); const [creating, setCreating] = useState(false); + const [createMsg, setCreateMsg] = useState(''); const [selectedEp, setSelectedEp] = useState(null); // Unified delivery log state @@ -271,6 +273,7 @@ export const WEBHOOKS_TAB = ` async function createEndpoint() { setCreating(true); + setCreateMsg(''); try { const events = newEvents.split(',').map(s => s.trim()).filter(Boolean); const res = await fetch('/dashboard/api/webhooks', { @@ -283,22 +286,30 @@ export const WEBHOOKS_TAB = ` setNewEvents('*'); setShowCreate(false); loadEndpoints(); + } else { + const data = await res.json().catch(() => ({})); + setCreateMsg('Error: ' + (data.error ?? data.message ?? 'Failed to create endpoint.')); } } catch (e) { console.error('Failed to create endpoint', e); + setCreateMsg('Request failed: ' + e.message); } finally { setCreating(false); } } async function deleteEndpoint(id) { + if (!confirm('Delete this webhook endpoint? This cannot be undone.')) return; try { const res = await fetch(\`/dashboard/api/webhooks/\${id}\`, { method: 'DELETE' }); if (res.ok) { loadEndpoints(); + } else { + alert('Failed to delete endpoint.'); } } catch (e) { console.error('Failed to delete endpoint', e); + alert('Failed to delete endpoint: ' + e.message); } } @@ -358,6 +369,7 @@ export const WEBHOOKS_TAB = ` + \${createMsg ? html\`

\${createMsg}

\` : null} \` : null} From 1647a9b569ca26501756e35abe2ca54e94d9af77 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:47:07 +0200 Subject: [PATCH 09/10] Add integration tests for webhook dashboard API --- tests/integration/webhook-dashboard.test.ts | 178 ++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/integration/webhook-dashboard.test.ts diff --git a/tests/integration/webhook-dashboard.test.ts b/tests/integration/webhook-dashboard.test.ts new file mode 100644 index 0000000..f279622 --- /dev/null +++ b/tests/integration/webhook-dashboard.test.ts @@ -0,0 +1,178 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { createApp } from "../../src/app"; + +let app: ReturnType; +let baseUrl: string; + +beforeEach(() => { + app = createApp(); + app.listen(0); + baseUrl = `http://localhost:${app.server!.port}`; +}); + +afterEach(() => { + app.server?.stop(); +}); + +async function dashPost(path: string, body: Record = {}) { + return fetch(`${baseUrl}/dashboard/api${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function dashPatch(path: string, body: Record = {}) { + return fetch(`${baseUrl}/dashboard/api${path}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function dashDelete(path: string) { + return fetch(`${baseUrl}/dashboard/api${path}`, { method: "DELETE" }); +} + +async function dashGet(path: string) { + return fetch(`${baseUrl}/dashboard/api${path}`); +} + +describe("Dashboard Webhook API", () => { + describe("CRUD", () => { + test("create, update, and delete a webhook endpoint", async () => { + // Create + const createRes = await dashPost("/webhooks", { + url: "https://example.com/hook", + enabled_events: ["customer.created"], + }); + expect(createRes.status).toBe(200); + const endpoint = await createRes.json(); + expect(endpoint.id).toMatch(/^we_/); + expect(endpoint.url).toBe("https://example.com/hook"); + expect(endpoint.secret).toMatch(/^whsec_/); + + // Update URL + const updateRes = await dashPatch(`/webhooks/${endpoint.id}`, { + url: "https://new.example.com/hook", + }); + expect(updateRes.status).toBe(200); + const updated = await updateRes.json(); + expect(updated.url).toBe("https://new.example.com/hook"); + + // Update status + const disableRes = await dashPatch(`/webhooks/${endpoint.id}`, { + status: "disabled", + }); + expect(disableRes.status).toBe(200); + const disabled = await disableRes.json(); + expect(disabled.status).toBe("disabled"); + + // Delete + const deleteRes = await dashDelete(`/webhooks/${endpoint.id}`); + expect(deleteRes.status).toBe(200); + const deleted = await deleteRes.json(); + expect(deleted.deleted).toBe(true); + }); + + test("returns 400 for missing required fields on create", async () => { + const res = await dashPost("/webhooks", { url: "https://example.com" }); + expect(res.status).toBe(400); + }); + }); + + describe("Delivery listing", () => { + test("lists deliveries after event is triggered", async () => { + const Stripe = (await import("stripe")).default; + const stripe = new Stripe("sk_test_strimulator", { + host: "localhost", + port: app.server!.port, + protocol: "http", + } as any); + + const endpoint = await stripe.webhookEndpoints.create({ + url: "http://localhost:1/nonexistent", + enabled_events: ["customer.created"], + }); + + await stripe.customers.create({ email: "delivery-test@example.com" }); + + // Wait for delivery attempt + await new Promise((r) => setTimeout(r, 500)); + + // Check unified delivery log + const res = await dashGet("/deliveries"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.data.length).toBeGreaterThanOrEqual(1); + expect(data.data[0].event_type).toBe("customer.created"); + + // Check per-endpoint delivery log + const epRes = await dashGet(`/webhooks/${endpoint.id}/deliveries`); + expect(epRes.status).toBe(200); + const epData = await epRes.json(); + expect(epData.data.length).toBeGreaterThanOrEqual(1); + expect(epData.data[0].endpoint_id).toBe(endpoint.id); + }); + }); + + describe("Test event", () => { + test("sends a test event to a specific endpoint", async () => { + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + const testRes = await dashPost(`/webhooks/${endpoint.id}/test`, { + event_type: "customer.created", + }); + expect(testRes.status).toBe(200); + const testData = await testRes.json(); + expect(testData.ok).toBe(true); + expect(testData.event_id).toMatch(/^evt_/); + expect(testData.delivery_id).toMatch(/^whdel_/); + }); + + test("returns 400 for missing event_type", async () => { + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + const res = await dashPost(`/webhooks/${endpoint.id}/test`, {}); + expect(res.status).toBe(400); + }); + }); + + describe("Retry delivery", () => { + test("retries a failed delivery", async () => { + const createRes = await dashPost("/webhooks", { + url: "http://localhost:1/nonexistent", + enabled_events: ["*"], + }); + const endpoint = await createRes.json(); + + const testRes = await dashPost(`/webhooks/${endpoint.id}/test`, { + event_type: "charge.succeeded", + }); + const testData = await testRes.json(); + + // Wait for delivery to be attempted + await new Promise((r) => setTimeout(r, 500)); + + const retryRes = await dashPost(`/deliveries/${testData.delivery_id}/retry`); + expect(retryRes.status).toBe(200); + const retryData = await retryRes.json(); + expect(retryData.ok).toBe(true); + expect(retryData.delivery_id).toMatch(/^whdel_/); + expect(retryData.delivery_id).not.toBe(testData.delivery_id); + }); + + test("returns 404 for nonexistent delivery", async () => { + const res = await dashPost("/deliveries/whdel_nonexistent/retry"); + expect(res.status).toBe(404); + }); + }); +}); From bc51315b76474b772d8bcdfcad23d6a051c7cfc1 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:43:52 +0200 Subject: [PATCH 10/10] Fix retry-webhook action to target specific endpoint instead of broadcasting The /actions/retry-webhook endpoint accepted an endpoint_id but ignored it, calling deliver() which fans out to all matching endpoints. Now uses deliverToEndpoint() to target only the specified endpoint. Also removes redundant retrieve() calls and adds integration tests for the action. --- src/dashboard/api.ts | 15 ++-- tests/integration/webhook-dashboard.test.ts | 79 +++++++++++++++++++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/dashboard/api.ts b/src/dashboard/api.ts index a19503b..f855b54 100644 --- a/src/dashboard/api.ts +++ b/src/dashboard/api.ts @@ -243,8 +243,6 @@ export function dashboardApi(db: StrimulatorDB) { const deliveryService = new WebhookDeliveryService(db, endpointService); const event = eventService.retrieve(body.event_id); - // Verify endpoint exists - endpointService.retrieve(body.endpoint_id); const endpoint = endpointService.listAll().find((ep) => ep.id === body.endpoint_id); if (!endpoint) { @@ -254,9 +252,13 @@ export function dashboardApi(db: StrimulatorDB) { }); } - // Re-deliver - await deliveryService.deliver(event); - return { ok: true }; + // Re-deliver to the specific endpoint + const deliveryId = await deliveryService.deliverToEndpoint(event, { + id: endpoint.id, + url: endpoint.url, + secret: endpoint.secret, + }); + return { ok: true, delivery_id: deliveryId }; } catch (err) { if (err instanceof StripeError) { return new Response(JSON.stringify(err.body), { @@ -555,8 +557,7 @@ export function dashboardApi(db: StrimulatorDB) { const deliveryService = new WebhookDeliveryService(db, endpointService); const eventService = new EventService(db); - // Verify endpoint exists and get its details - endpointService.retrieve(params.id); + // Get endpoint details const allEndpoints = endpointService.listAll(); const epData = allEndpoints.find((ep) => ep.id === params.id); if (!epData) { diff --git a/tests/integration/webhook-dashboard.test.ts b/tests/integration/webhook-dashboard.test.ts index f279622..ecb9a14 100644 --- a/tests/integration/webhook-dashboard.test.ts +++ b/tests/integration/webhook-dashboard.test.ts @@ -175,4 +175,83 @@ describe("Dashboard Webhook API", () => { expect(res.status).toBe(404); }); }); + + describe("Action: retry-webhook", () => { + test("retries delivery to the specified endpoint only", async () => { + const Stripe = (await import("stripe")).default; + const stripe = new Stripe("sk_test_strimulator", { + host: "localhost", + port: app.server!.port, + protocol: "http", + } as any); + + // Create two endpoints + const ep1 = await stripe.webhookEndpoints.create({ + url: "http://localhost:1/hook1", + enabled_events: ["customer.created"], + }); + const ep2 = await stripe.webhookEndpoints.create({ + url: "http://localhost:1/hook2", + enabled_events: ["customer.created"], + }); + + // Create a customer to generate an event + await stripe.customers.create({ email: "retry-action@example.com" }); + await new Promise((r) => setTimeout(r, 500)); + + // Get the event + const events = await stripe.events.list({ type: "customer.created", limit: 1 }); + const eventId = events.data[0].id; + + // Count deliveries before retry + const beforeRes = await dashGet("/deliveries"); + const beforeData = await beforeRes.json(); + const beforeCount = beforeData.total; + + // Retry to ep1 only + const retryRes = await dashPost("/actions/retry-webhook", { + event_id: eventId, + endpoint_id: ep1.id, + }); + expect(retryRes.status).toBe(200); + const retryData = await retryRes.json(); + expect(retryData.ok).toBe(true); + expect(retryData.delivery_id).toMatch(/^whdel_/); + + // Should have created exactly 1 new delivery, not 2 + await new Promise((r) => setTimeout(r, 200)); + const afterRes = await dashGet("/deliveries"); + const afterData = await afterRes.json(); + expect(afterData.total).toBe(beforeCount + 1); + }); + + test("returns 400 for missing fields", async () => { + const res = await dashPost("/actions/retry-webhook", { event_id: "evt_123" }); + expect(res.status).toBe(400); + }); + + test("returns 404 for nonexistent endpoint", async () => { + const Stripe = (await import("stripe")).default; + const stripe = new Stripe("sk_test_strimulator", { + host: "localhost", + port: app.server!.port, + protocol: "http", + } as any); + + await stripe.webhookEndpoints.create({ + url: "http://localhost:1/hook", + enabled_events: ["customer.created"], + }); + await stripe.customers.create({ email: "retry-404@example.com" }); + await new Promise((r) => setTimeout(r, 500)); + + const events = await stripe.events.list({ type: "customer.created", limit: 1 }); + + const res = await dashPost("/actions/retry-webhook", { + event_id: events.data[0].id, + endpoint_id: "we_nonexistent", + }); + expect(res.status).toBe(404); + }); + }); });