diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index 4861b3b..1348d47 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams } from "next/navigation"; import { copyText } from "@/lib/clipboard"; +import { formatStoredWebhookPayload } from "@/lib/webhook-payload"; import { filterSignupsByQuery, filterSignupsByStatus, @@ -968,12 +969,30 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;

Recent inbound events ({data.events?.length || 0})

)} diff --git a/lib/webhook-payload.ts b/lib/webhook-payload.ts new file mode 100644 index 0000000..0831b53 --- /dev/null +++ b/lib/webhook-payload.ts @@ -0,0 +1,72 @@ +function previousNonWhitespace(value: string, from: number): string | null { + for (let index = from; index >= 0; index--) { + if (!/\s/.test(value[index])) return value[index]; + } + return null; +} + +function nextNonWhitespace(value: string, from: number): string | null { + for (let index = from; index < value.length; index++) { + if (!/\s/.test(value[index])) return value[index]; + } + return null; +} + +const MAX_PRETTY_DEPTH = 32; +const MAX_FORMATTED_LENGTH = 64 * 1024; + +/** Pretty-print valid JSON without reparsing number literals into JavaScript numbers. */ +export function formatStoredWebhookPayload(value: unknown): string { + const raw = typeof value === "string" ? value : String(value ?? ""); + try { + JSON.parse(raw); + } catch { + return raw; + } + + let formatted = ""; + let indent = 0; + let inString = false; + let escaped = false; + const pad = () => " ".repeat(indent); + const outputLimit = Math.min(MAX_FORMATTED_LENGTH, Math.max(4096, raw.length * 4)); + const append = (text: string): boolean => { + formatted += text; + return formatted.length <= outputLimit; + }; + + for (let index = 0; index < raw.length; index++) { + const char = raw[index]; + if (inString) { + if (!append(char)) return raw; + if (escaped) escaped = false; + else if (char === "\\") escaped = true; + else if (char === '"') inString = false; + continue; + } + + if (char === '"') { + inString = true; + if (!append(char)) return raw; + } else if (char === "{" || char === "[") { + if (!append(char)) return raw; + indent++; + if (indent > MAX_PRETTY_DEPTH) return raw; + const closing = char === "{" ? "}" : "]"; + if (nextNonWhitespace(raw, index + 1) !== closing && !append(`\n${pad()}`)) return raw; + } else if (char === "}" || char === "]") { + indent--; + const opening = char === "}" ? "{" : "["; + if (previousNonWhitespace(raw, index - 1) !== opening && !append(`\n${pad()}`)) return raw; + if (!append(char)) return raw; + } else if (char === ",") { + if (!append(`,\n${pad()}`)) return raw; + } else if (char === ":") { + if (!append(": ")) return raw; + } else if (!/\s/.test(char)) { + if (!append(char)) return raw; + } + } + + return formatted; +} diff --git a/tests/webhook-payload.test.mjs b/tests/webhook-payload.test.mjs new file mode 100644 index 0000000..8c10197 --- /dev/null +++ b/tests/webhook-payload.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatStoredWebhookPayload } from "../lib/webhook-payload.ts"; + +test("stored JSON webhook payloads are formatted for inspection and copying", () => { + assert.equal( + formatStoredWebhookPayload('{"event":"payment.succeeded","data":{"amount":100,"paid":true}}'), + `{ + "event": "payment.succeeded", + "data": { + "amount": 100, + "paid": true + } +}`, + ); +}); + +test("non-JSON webhook payloads fall back to the stored body verbatim", () => { + const raw = "payment.succeeded\namount=100&paid=true"; + assert.equal(formatStoredWebhookPayload(raw), raw); + assert.equal(formatStoredWebhookPayload(""), ""); +}); + +test("JSON formatting preserves number literals exactly", () => { + const raw = '{"id":9007199254740993,"ratio":0.1234567890123456789,"empty":[]}'; + const formatted = formatStoredWebhookPayload(raw); + + assert.match(formatted, /9007199254740993/); + assert.match(formatted, /0\.1234567890123456789/); + assert.equal(formatted, `{ + "id": 9007199254740993, + "ratio": 0.1234567890123456789, + "empty": [] +}`); +}); + +test("JSON formatting preserves escaped strings and duplicate keys", () => { + const raw = '{"text":"comma, colon: braces {} [\\"quoted\\"]","key":1,"key":2}'; + + assert.equal(formatStoredWebhookPayload(raw), `{ + "text": "comma, colon: braces {} [\\"quoted\\"]", + "key": 1, + "key": 2 +}`); +}); + +test("deep JSON falls back to raw text instead of amplifying indentation", () => { + const raw = `${"[".repeat(2000)}0${"]".repeat(2000)}`; + const formatted = formatStoredWebhookPayload(raw); + + assert.equal(formatted, raw); + assert.equal(formatted.length, 4001); +});