diff --git a/pdb/api/correlation.ts b/pdb/api/correlation.ts index 3421f1ed..57323dc4 100644 --- a/pdb/api/correlation.ts +++ b/pdb/api/correlation.ts @@ -15,9 +15,11 @@ export interface LogEntry { ts: string; method: string; path: string; + url: string; status: number; ms: number; reqHeaders: Record; + reqBody: string | null; resHeaders: Record; resBody: string | null; clientIp: string; @@ -146,7 +148,11 @@ export class FlowCorrelation { const flow: PaymentFlow = { id, protocol, - resource: entry.path, + resource: entry.url, + requestMethod: entry.method, + requestUrl: entry.url, + requestHeaders: entry.reqHeaders, + requestBody: entry.reqBody, status: "payment-required", clientIp: entry.clientIp, startedAt: now, @@ -164,11 +170,14 @@ export class FlowCorrelation { message: `402 Payment Required`, detail: protocol === "mpp" - ? `www-authenticate: ${truncate(resHeader(entry, "www-authenticate"), 120)}` - : `x-payment-required: ${truncate(resHeader(entry, "x-payment-required"), 120)}`, + ? formatHeaderDetail("www-authenticate", resHeader(entry, "www-authenticate")) + : formatHeaderDetail("x-payment-required", resHeader(entry, "x-payment-required")), }, ], challengeHeaders: entry.resHeaders, + responseStatus: entry.status, + responseHeaders: entry.resHeaders, + responseBody: entry.resBody, }; this.addFlow(flow); @@ -183,7 +192,7 @@ export class FlowCorrelation { if (!flow || flow.status !== "payment-required") { // Path-only fallback: find most recent pending flow for this path flow = [...this.flows].reverse().find( - (f) => f.resource === entry.path && f.status === "payment-required" + (f) => pathFromUrl(f.requestUrl) === entry.path && f.status === "payment-required" ) ?? null; } @@ -209,8 +218,12 @@ export class FlowCorrelation { // Update flow flow.paymentHeaders = entry.reqHeaders; + flow.responseStatus = entry.status; flow.responseHeaders = entry.resHeaders; flow.responseBody = entry.resBody; + if (!flow.requestBody && entry.reqBody) { + flow.requestBody = entry.reqBody; + } flow.updatedAt = now; flow.durationMs = new Date(now).getTime() - new Date(flow.startedAt).getTime(); @@ -222,8 +235,8 @@ export class FlowCorrelation { message: `Payment accepted`, detail: protocol === "mpp" - ? `payment-receipt: ${truncate(resHeader(entry, "payment-receipt"), 120)}` - : `x-payment-response verified`, + ? formatHeaderDetail("payment-receipt", resHeader(entry, "payment-receipt")) + : formatHeaderDetail("x-payment-response", "verified"), }); flow.events.push({ ts: now, @@ -289,7 +302,11 @@ export class FlowCorrelation { const flow: PaymentFlow = { id, protocol, - resource: entry.path, + resource: entry.url, + requestMethod: entry.method, + requestUrl: entry.url, + requestHeaders: entry.reqHeaders, + requestBody: entry.reqBody, status: "resource-delivered", clientIp: entry.clientIp, startedAt: now, @@ -303,6 +320,7 @@ export class FlowCorrelation { detail: "Payment flow completed (challenge not captured)", }, ], + responseStatus: entry.status, responseHeaders: entry.resHeaders, responseBody: entry.resBody, }; @@ -335,9 +353,9 @@ export class FlowCorrelation { this.flows.push(flow); if (this.flows.length > MAX_FLOWS) { const removed = this.flows.shift()!; - this.flowIndex.delete(flowKey(removed.clientIp, removed.resource)); + this.flowIndex.delete(flowKey(removed.clientIp, pathFromUrl(removed.requestUrl))); } - this.flowIndex.set(flowKey(flow.clientIp, flow.resource), flow); + this.flowIndex.set(flowKey(flow.clientIp, pathFromUrl(flow.requestUrl)), flow); } private emit(msg: SSEMessage): void { @@ -415,3 +433,29 @@ function resHeader(entry: LogEntry, key: string): string { function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "…" : s; } + +function formatHeaderDetail(name: string, value: string): string { + return `${name}: ${truncate(redactHeaderValue(name, value), 120)}`; +} + +function redactHeaderValue(name: string, value: string): string { + switch (name.toLowerCase()) { + case "authorization": + case "www-authenticate": + case "payment-receipt": + case "x-payment": + case "x-payment-response": + case "x-payment-required": + return "[REDACTED]"; + default: + return value; + } +} + +function pathFromUrl(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} diff --git a/pdb/api/index.ts b/pdb/api/index.ts index 9ed11839..38d168ec 100644 --- a/pdb/api/index.ts +++ b/pdb/api/index.ts @@ -122,6 +122,7 @@ async function createApp() { if (req.path === "/" || req.path.startsWith("/__402/pdb")) return next(); const start = Date.now(); + const requestUrl = `${req.protocol}://${req.get("host") || "localhost"}${req.originalUrl}`; const chunks: Buffer[] = []; let writeHeadHeaders: Record = {}; @@ -152,6 +153,7 @@ async function createApp() { for (const [k, v] of Object.entries(req.headers)) { if (typeof v === "string") reqHeaders[k] = v; } + const reqBody = serializeRequestBody(req.method, req.body); const resHeaders: Record = { ...writeHeadHeaders }; for (const [k, v] of Object.entries(res.getHeaders())) { if (v != null) resHeaders[k] = String(v); @@ -171,9 +173,11 @@ async function createApp() { ts: new Date().toISOString(), method: req.method, path: req.path, + url: requestUrl, status: res.statusCode, ms: Date.now() - start, reqHeaders, + reqBody, resHeaders, resBody, clientIp, @@ -568,6 +572,23 @@ function toWebRequest(req: express.Request): globalThis.Request { }); } +function serializeRequestBody(method: string, body: unknown): string | null { + if (method === "GET" || method === "HEAD" || body == null) { + return null; + } + + if (typeof body === "string") { + return body.length > 4096 ? `${body.slice(0, 4096)}…` : body; + } + + try { + const serialized = JSON.stringify(body); + return serialized.length > 4096 ? `${serialized.slice(0, 4096)}…` : serialized; + } catch { + return null; + } +} + // ── Local dev server ── const isVercel = !!process.env.VERCEL; if (!isVercel) { diff --git a/pdb/api/types.ts b/pdb/api/types.ts index 8209788d..12fda855 100644 --- a/pdb/api/types.ts +++ b/pdb/api/types.ts @@ -33,6 +33,10 @@ export interface PaymentFlow { id: string; // "flow-1", "flow-2", … protocol: Protocol; resource: string; // URL path, e.g. "/mpp/quote/GOOG" + requestMethod: string; + requestUrl: string; + requestHeaders?: Record; + requestBody?: string | null; status: FlowStatus; clientIp: string; startedAt: string; // ISO @@ -45,6 +49,7 @@ export interface PaymentFlow { // Raw data for detail inspection challengeHeaders?: Record; paymentHeaders?: Record; + responseStatus?: number; responseHeaders?: Record; responseBody?: string | null; } diff --git a/pdb/src/App.css b/pdb/src/App.css index b81e7e2b..a288e519 100644 --- a/pdb/src/App.css +++ b/pdb/src/App.css @@ -369,14 +369,18 @@ body { background: var(--bg-raised); display: grid; grid-template-columns: 220px 1fr 1fr; + height: clamp(220px, 48vh, 500px); min-height: 220px; - max-height: 500px; overflow: hidden; animation: slideDown 0.15s ease; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +.flow-detail > * { + min-height: 0; +} + /* ── Sequence Diagram ── */ .sequence-diagram { @@ -454,10 +458,16 @@ body { /* ── Detail Panel (tabs + content) ── */ .detail-panel { - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: auto minmax(0, 1fr); overflow: hidden; min-height: 0; + border-right: 1px solid var(--border); +} + +.detail-content { + min-height: 0; + overflow: hidden; } .detail-tabs { @@ -492,6 +502,118 @@ body { border-bottom-color: var(--accent); } +.replay-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + min-height: 0; +} + +.replay-card { + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.replay-header h3, +.raw-http-header h3 { + font-size: 11px; + font-weight: 600; + color: var(--fg-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.replay-header p, +.raw-http-header span, +.replay-note { + color: var(--fg-muted); + font-size: 12px; + line-height: 1.5; +} + +.replay-actions { + display: flex; + flex-wrap: nowrap; + gap: 8px; + margin-top: 14px; + min-width: 0; +} + +.replay-button { + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg); + font-family: var(--font); + font-size: 12px; + font-weight: 500; + padding: 8px 12px; + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s ease; + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + +.replay-button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.replay-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.replay-button.copied { + border-color: var(--green); + color: var(--green); +} + +.replay-note { + margin-top: 10px; +} + +.replay-panel .splits { + border-right: none; + padding-top: 14px; +} + +.raw-http-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + padding: 16px 20px; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + overscroll-behavior: contain; +} + +.raw-http-header { + flex-shrink: 0; + margin-bottom: 12px; +} + +.raw-http-pre { + margin: 0; + padding: 12px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + min-height: 0; + overflow-x: auto; + overflow-y: visible; + white-space: pre-wrap; + word-break: break-word; +} + /* ── Payment Splits ── */ .splits { @@ -499,7 +621,6 @@ body { overflow-y: auto; min-height: 0; flex: 1; - border-right: 1px solid var(--border); } .splits h3 { diff --git a/pdb/src/components/FlowDetail.tsx b/pdb/src/components/FlowDetail.tsx index d5c1e103..2891fb21 100644 --- a/pdb/src/components/FlowDetail.tsx +++ b/pdb/src/components/FlowDetail.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import type { PaymentFlow } from "../types"; import { SequenceDiagram } from "./SequenceDiagram"; import { EventLog } from "./EventLog"; -import { PaymentSplits } from "./PaymentSplits"; +import { ReplayPanel } from "./ReplayPanel"; +import { RawHttpPanel } from "./RawHttpPanel"; interface Props { flow: PaymentFlow; @@ -9,10 +11,41 @@ interface Props { export function FlowDetail({ flow }: Props) { const success = flow.status === "resource-delivered"; + const [tab, setTab] = useState<"replay" | "request" | "response">("replay"); + return (
- +
+
+ + + +
+
+ {tab === "replay" && } + {tab === "request" && } + {tab === "response" && } +
+
); diff --git a/pdb/src/components/RawHttpPanel.tsx b/pdb/src/components/RawHttpPanel.tsx new file mode 100644 index 00000000..19f9376c --- /dev/null +++ b/pdb/src/components/RawHttpPanel.tsx @@ -0,0 +1,25 @@ +import { useMemo } from "react"; +import type { PaymentFlow } from "../types"; +import { buildRawRequest, buildRawResponse } from "../utils/httpReplay"; + +interface Props { + flow: PaymentFlow; + mode: "request" | "response"; +} + +export function RawHttpPanel({ flow, mode }: Props) { + const content = useMemo( + () => (mode === "request" ? buildRawRequest(flow) : buildRawResponse(flow)), + [flow, mode], + ); + + return ( +
+
+

{mode === "request" ? "Raw Request" : "Raw Response"}

+ Payment headers are redacted automatically. +
+
{content}
+
+ ); +} diff --git a/pdb/src/components/ReplayPanel.tsx b/pdb/src/components/ReplayPanel.tsx new file mode 100644 index 00000000..ac35c9dd --- /dev/null +++ b/pdb/src/components/ReplayPanel.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from "react"; +import type { PaymentFlow } from "../types"; +import { + buildReplayCommand, + replaySupportMessage, +} from "../utils/httpReplay"; +import { PaymentSplits } from "./PaymentSplits"; + +interface Props { + flow: PaymentFlow; + success: boolean; +} + +export function ReplayPanel({ flow, success }: Props) { + const [copiedKind, setCopiedKind] = useState(null); + const curlCommand = useMemo(() => buildReplayCommand(flow, "curl"), [flow]); + const httpieCommand = useMemo(() => buildReplayCommand(flow, "httpie"), [flow]); + const payFetchCommand = useMemo(() => buildReplayCommand(flow, "pay-fetch"), [flow]); + const payFetchNote = replaySupportMessage(flow); + + async function copyCommand(kind: string, command: string | null) { + if (!command) return; + await copyToClipboard(command); + setCopiedKind(kind); + window.setTimeout(() => setCopiedKind((current) => (current === kind ? null : current)), 1600); + } + + return ( +
+
+
+
+

Replay

+

Re-run this request in your terminal with redacted payment headers.

+
+
+
+ copyCommand("curl", curlCommand)} + /> + copyCommand("httpie", httpieCommand)} + /> + copyCommand("pay-fetch", payFetchCommand)} + /> +
+ {payFetchNote &&
{payFetchNote}
} +
+ +
+ ); +} + +function CopyButton({ + copied, + disabled, + label, + title, + onClick, +}: { + copied: boolean; + disabled?: boolean; + label: string; + title?: string; + onClick: () => void; +}) { + return ( + + ); +} + +async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +} diff --git a/pdb/src/utils/httpReplay.ts b/pdb/src/utils/httpReplay.ts new file mode 100644 index 00000000..3985d143 --- /dev/null +++ b/pdb/src/utils/httpReplay.ts @@ -0,0 +1,188 @@ +import type { PaymentFlow } from "../types"; + +type ReplayKind = "curl" | "httpie" | "pay-fetch"; + +const REDACTED = "[REDACTED]"; +const REDACTED_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "www-authenticate", + "payment-receipt", + "x-payment", + "x-payment-response", + "x-payment-required", + "cookie", + "set-cookie", +]); + +const OMITTED_REPLAY_HEADERS = new Set([ + "accept-encoding", + "connection", + "content-length", + "host", + "origin", + "referer", + "user-agent", +]); + +export function redactHeaders( + headers: Record | undefined, +): Record { + if (!headers) return {}; + + return Object.fromEntries( + Object.entries(headers).map(([name, value]) => [ + name, + REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTED : value, + ]), + ); +} + +export function buildRawRequest(flow: PaymentFlow): string { + const lines = [`${flow.requestMethod} ${flow.requestUrl} HTTP/1.1`]; + const headers = redactHeaders(flow.requestHeaders); + + for (const [name, value] of Object.entries(headers)) { + lines.push(`${name}: ${value}`); + } + + if (flow.requestBody) { + lines.push("", prettyBody(flow.requestBody)); + } + + return lines.join("\n"); +} + +export function buildRawResponse(flow: PaymentFlow): string { + const lines = [ + `HTTP/1.1 ${flow.responseStatus ?? 0}`, + ]; + const headers = redactHeaders(flow.responseHeaders); + + for (const [name, value] of Object.entries(headers)) { + lines.push(`${name}: ${value}`); + } + + if (flow.responseBody) { + lines.push("", prettyBody(flow.responseBody)); + } + + return lines.join("\n"); +} + +export function buildReplayCommand( + flow: PaymentFlow, + kind: ReplayKind, +): string | null { + const headers = replayHeaders(flow.requestHeaders); + const body = normalizedBody(flow.requestBody); + + switch (kind) { + case "curl": + return buildCurlCommand(flow, headers, body); + case "httpie": + return buildHttpieCommand(flow, headers, body); + case "pay-fetch": + if (flow.requestMethod !== "GET" || body) { + return null; + } + return buildPayFetchCommand(flow, headers); + default: + return null; + } +} + +export function replaySupportMessage(flow: PaymentFlow): string | null { + if (flow.requestMethod !== "GET" || normalizedBody(flow.requestBody)) { + return "`pay fetch` replay currently supports GET requests without a body."; + } + + return null; +} + +function buildCurlCommand( + flow: PaymentFlow, + headers: Array<[string, string]>, + body: string | null, +): string { + const parts = ["curl", "-X", shellQuote(flow.requestMethod), shellQuote(flow.requestUrl)]; + + for (const [name, value] of headers) { + parts.push("-H", shellQuote(`${name}: ${value}`)); + } + + if (body) { + parts.push("--data", shellQuote(compactBody(body))); + } + + return joinCommand(parts); +} + +function buildHttpieCommand( + flow: PaymentFlow, + headers: Array<[string, string]>, + body: string | null, +): string { + const parts = ["http", shellQuote(flow.requestMethod), shellQuote(flow.requestUrl)]; + + for (const [name, value] of headers) { + parts.push(shellQuote(`${name}:${value}`)); + } + + if (body) { + parts.push("--raw", shellQuote(compactBody(body))); + } + + return joinCommand(parts); +} + +function buildPayFetchCommand( + flow: PaymentFlow, + headers: Array<[string, string]>, +): string { + const parts = ["pay", "fetch", shellQuote(flow.requestUrl)]; + + for (const [name, value] of headers) { + parts.push("-H", shellQuote(`${name}: ${value}`)); + } + + return joinCommand(parts); +} + +function joinCommand(parts: string[]): string { + return parts.join(" "); +} + +function replayHeaders( + headers: Record | undefined, +): Array<[string, string]> { + return Object.entries(redactHeaders(headers)) + .filter(([name]) => !OMITTED_REPLAY_HEADERS.has(name.toLowerCase())) + .sort(([a], [b]) => a.localeCompare(b)); +} + +function normalizedBody(body: string | null | undefined): string | null { + if (!body) return null; + const trimmed = body.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function prettyBody(body: string): string { + try { + return JSON.stringify(JSON.parse(body), null, 2); + } catch { + return body; + } +} + +function compactBody(body: string): string { + try { + return JSON.stringify(JSON.parse(body)); + } catch { + return body; + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +}