x402trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/LICENSE +201 -0
  3. package/README.md +121 -0
  4. package/dist/chain/abi.d.ts +48 -0
  5. package/dist/chain/abi.js +34 -0
  6. package/dist/chain/client.d.ts +6 -0
  7. package/dist/chain/client.js +283 -0
  8. package/dist/chain/index.d.ts +4 -0
  9. package/dist/chain/index.js +4 -0
  10. package/dist/chain/retry.d.ts +18 -0
  11. package/dist/chain/retry.js +37 -0
  12. package/dist/chain/types.d.ts +94 -0
  13. package/dist/chain/types.js +12 -0
  14. package/dist/cli/color.d.ts +30 -0
  15. package/dist/cli/color.js +26 -0
  16. package/dist/cli/exit-codes.d.ts +12 -0
  17. package/dist/cli/exit-codes.js +11 -0
  18. package/dist/cli/format-result.d.ts +20 -0
  19. package/dist/cli/format-result.js +58 -0
  20. package/dist/cli/index.d.ts +26 -0
  21. package/dist/cli/index.js +130 -0
  22. package/dist/cli/inspect-command.d.ts +28 -0
  23. package/dist/cli/inspect-command.js +63 -0
  24. package/dist/cli/proxy-command.d.ts +74 -0
  25. package/dist/cli/proxy-command.js +197 -0
  26. package/dist/cli/replay.d.ts +44 -0
  27. package/dist/cli/replay.js +193 -0
  28. package/dist/cli.d.ts +2 -0
  29. package/dist/cli.js +20 -0
  30. package/dist/decoder/decoder.d.ts +20 -0
  31. package/dist/decoder/decoder.js +118 -0
  32. package/dist/decoder/format.d.ts +9 -0
  33. package/dist/decoder/format.js +50 -0
  34. package/dist/decoder/index.d.ts +5 -0
  35. package/dist/decoder/index.js +5 -0
  36. package/dist/decoder/parse.d.ts +53 -0
  37. package/dist/decoder/parse.js +179 -0
  38. package/dist/decoder/redact.d.ts +12 -0
  39. package/dist/decoder/redact.js +22 -0
  40. package/dist/decoder/types.d.ts +86 -0
  41. package/dist/decoder/types.js +9 -0
  42. package/dist/proxy/event-bus.d.ts +18 -0
  43. package/dist/proxy/event-bus.js +75 -0
  44. package/dist/proxy/id.d.ts +6 -0
  45. package/dist/proxy/id.js +8 -0
  46. package/dist/proxy/index.d.ts +5 -0
  47. package/dist/proxy/index.js +5 -0
  48. package/dist/proxy/jsonl-sink.d.ts +18 -0
  49. package/dist/proxy/jsonl-sink.js +44 -0
  50. package/dist/proxy/proxy.d.ts +21 -0
  51. package/dist/proxy/proxy.js +238 -0
  52. package/dist/proxy/types.d.ts +95 -0
  53. package/dist/proxy/types.js +32 -0
  54. package/dist/reconciliation/engine.d.ts +40 -0
  55. package/dist/reconciliation/engine.js +178 -0
  56. package/dist/reconciliation/index.d.ts +3 -0
  57. package/dist/reconciliation/index.js +3 -0
  58. package/dist/reconciliation/match.d.ts +31 -0
  59. package/dist/reconciliation/match.js +39 -0
  60. package/dist/reconciliation/types.d.ts +72 -0
  61. package/dist/reconciliation/types.js +17 -0
  62. package/package.json +85 -0
@@ -0,0 +1,22 @@
1
+ const REDACTED = "[REDACTED]";
2
+ /**
3
+ * Redact the EIP-3009 signature from a payment payload.
4
+ *
5
+ * Per the X402-11 ticket: "Redact private keys / signatures by default;
6
+ * expose with --log-secrets flag." The signature itself is not strictly
7
+ * a secret (it's broadcast on-chain in the eventual settlement tx), but
8
+ * v0.1 redacts it by default to avoid surprising users who pipe their
9
+ * JSONL into pastebins / share with teammates. Operators who want the
10
+ * full signature flip the flag explicitly.
11
+ */
12
+ export function redactPaymentPayload(payment, logSecrets) {
13
+ if (logSecrets)
14
+ return payment;
15
+ return {
16
+ ...payment,
17
+ payload: {
18
+ ...payment.payload,
19
+ signature: REDACTED,
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * X402-11 decoder types.
3
+ *
4
+ * These shapes are the boundary between raw HTTP capture (proxy) and
5
+ * structured downstream consumers (reconciliation, future inspect /
6
+ * doctor tools). The JSON schema mirrors `src/decoder/schema.md` —
7
+ * keep them in sync.
8
+ */
9
+ /** Protocol surface. v0.1 is locked to v1 per ADR-001; v2 is detected
10
+ * but not deeply parsed. */
11
+ export type X402Version = 1 | 2;
12
+ export interface PaymentRequirements {
13
+ readonly scheme: string;
14
+ readonly network: string;
15
+ readonly maxAmountRequired: string;
16
+ readonly resource: string;
17
+ readonly description?: string;
18
+ readonly mimeType?: string;
19
+ readonly payTo: string;
20
+ readonly maxTimeoutSeconds?: number;
21
+ readonly asset: string;
22
+ readonly outputSchema?: unknown;
23
+ readonly extra?: {
24
+ readonly name?: string;
25
+ readonly version?: string;
26
+ };
27
+ }
28
+ export interface PaymentAuthorization {
29
+ readonly from: string;
30
+ readonly to: string;
31
+ readonly value: string;
32
+ readonly validAfter: string;
33
+ readonly validBefore: string;
34
+ readonly nonce: string;
35
+ }
36
+ export interface PaymentPayload {
37
+ readonly x402Version: X402Version;
38
+ readonly scheme: string;
39
+ readonly network: string;
40
+ readonly payload: {
41
+ /** Either the literal signature or "[REDACTED]" depending on logSecrets flag. */
42
+ readonly signature: string;
43
+ readonly authorization: PaymentAuthorization;
44
+ };
45
+ }
46
+ export interface FacilitatorResponse {
47
+ readonly success?: boolean;
48
+ readonly isValid?: boolean;
49
+ readonly invalidReason?: string;
50
+ readonly errorReason?: string;
51
+ readonly transaction?: string;
52
+ readonly network?: string;
53
+ readonly payer?: string;
54
+ }
55
+ /**
56
+ * Discriminated union of events the decoder emits. Each corresponds to
57
+ * a row in ARCHITECTURE.md § JSONL record format. `t` is ISO 8601; `id`
58
+ * is the proxy's exchange id (so downstream tools can join).
59
+ */
60
+ export type DecodedEvent = {
61
+ readonly event: "exchange.challenge";
62
+ readonly t: string;
63
+ readonly id: string;
64
+ readonly x402Version: X402Version;
65
+ readonly challenge: PaymentRequirements;
66
+ readonly raw402Error?: string;
67
+ } | {
68
+ readonly event: "exchange.payment";
69
+ readonly t: string;
70
+ readonly id: string;
71
+ readonly x402Version: X402Version;
72
+ readonly payment: PaymentPayload;
73
+ } | {
74
+ readonly event: "exchange.settlement";
75
+ readonly t: string;
76
+ readonly id: string;
77
+ readonly settlement: FacilitatorResponse;
78
+ } | {
79
+ readonly event: "decoder.error";
80
+ readonly t: string;
81
+ readonly id?: string;
82
+ readonly stage: "challenge" | "payment" | "settlement";
83
+ readonly message: string;
84
+ };
85
+ /** Output format for the human/json logger. */
86
+ export type LogFormat = "human" | "json";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * X402-11 decoder types.
3
+ *
4
+ * These shapes are the boundary between raw HTTP capture (proxy) and
5
+ * structured downstream consumers (reconciliation, future inspect /
6
+ * doctor tools). The JSON schema mirrors `src/decoder/schema.md` —
7
+ * keep them in sync.
8
+ */
9
+ export {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Minimal pub-sub for in-process consumers (decoder, reconciler) to
3
+ * subscribe to the live event stream. The JSONL sink is a separate
4
+ * concern that does not go through this bus.
5
+ *
6
+ * A subscriber receives events from the moment it subscribes onward —
7
+ * no replay. If a subscriber falls behind, events are queued in memory
8
+ * up to `maxQueue` (default 1024), then the oldest is dropped and an
9
+ * internal "dropped" counter is incremented (visible via `droppedFor`).
10
+ */
11
+ export interface EventBus<T> {
12
+ emit(event: T): void;
13
+ subscribe(maxQueue?: number): AsyncIterable<T> & {
14
+ unsubscribe(): void;
15
+ };
16
+ droppedFor(iterable: AsyncIterable<T>): number;
17
+ }
18
+ export declare function createEventBus<T>(): EventBus<T>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Minimal pub-sub for in-process consumers (decoder, reconciler) to
3
+ * subscribe to the live event stream. The JSONL sink is a separate
4
+ * concern that does not go through this bus.
5
+ *
6
+ * A subscriber receives events from the moment it subscribes onward —
7
+ * no replay. If a subscriber falls behind, events are queued in memory
8
+ * up to `maxQueue` (default 1024), then the oldest is dropped and an
9
+ * internal "dropped" counter is incremented (visible via `droppedFor`).
10
+ */
11
+ export function createEventBus() {
12
+ const subscribers = new Set();
13
+ const iterableToSub = new WeakMap();
14
+ return {
15
+ emit(event) {
16
+ for (const sub of subscribers) {
17
+ const resolver = sub.resolvers.shift();
18
+ if (resolver) {
19
+ resolver({ value: event, done: false });
20
+ continue;
21
+ }
22
+ if (sub.queue.length >= sub.maxQueue) {
23
+ sub.queue.shift();
24
+ sub.dropped += 1;
25
+ }
26
+ sub.queue.push(event);
27
+ }
28
+ },
29
+ subscribe(maxQueue = 1024) {
30
+ const sub = {
31
+ queue: [],
32
+ maxQueue,
33
+ resolvers: [],
34
+ done: false,
35
+ dropped: 0,
36
+ };
37
+ subscribers.add(sub);
38
+ const iterable = {
39
+ [Symbol.asyncIterator]: () => ({
40
+ next() {
41
+ if (sub.queue.length > 0) {
42
+ return Promise.resolve({ value: sub.queue.shift(), done: false });
43
+ }
44
+ if (sub.done) {
45
+ return Promise.resolve({ value: undefined, done: true });
46
+ }
47
+ return new Promise((resolve) => sub.resolvers.push(resolve));
48
+ },
49
+ return() {
50
+ sub.done = true;
51
+ subscribers.delete(sub);
52
+ for (const resolver of sub.resolvers) {
53
+ resolver({ value: undefined, done: true });
54
+ }
55
+ sub.resolvers.length = 0;
56
+ return Promise.resolve({ value: undefined, done: true });
57
+ },
58
+ }),
59
+ unsubscribe() {
60
+ sub.done = true;
61
+ subscribers.delete(sub);
62
+ for (const resolver of sub.resolvers) {
63
+ resolver({ value: undefined, done: true });
64
+ }
65
+ sub.resolvers.length = 0;
66
+ },
67
+ };
68
+ iterableToSub.set(iterable, sub);
69
+ return iterable;
70
+ },
71
+ droppedFor(iterable) {
72
+ return iterableToSub.get(iterable)?.dropped ?? 0;
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,6 @@
1
+ import type { ExchangeId } from "./types.js";
2
+ /**
3
+ * Generate a unique exchange id. ULIDs would be sort-friendlier; UUIDv4
4
+ * is plenty for v0.1 (the JSONL `t` timestamp gives ordering).
5
+ */
6
+ export declare function newExchangeId(): ExchangeId;
@@ -0,0 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Generate a unique exchange id. ULIDs would be sort-friendlier; UUIDv4
4
+ * is plenty for v0.1 (the JSONL `t` timestamp gives ordering).
5
+ */
6
+ export function newExchangeId() {
7
+ return randomUUID();
8
+ }
@@ -0,0 +1,5 @@
1
+ export { createProxy, type ProxyHandle, type ProxyOptions } from "./proxy.js";
2
+ export { isLikelyX402Exchange, type CapturedRequest, type CapturedResponse, type ExchangeId, type ExchangeOutcome, type ProxyEvent, } from "./types.js";
3
+ export { createEventBus, type EventBus } from "./event-bus.js";
4
+ export { JsonlSink } from "./jsonl-sink.js";
5
+ export { newExchangeId } from "./id.js";
@@ -0,0 +1,5 @@
1
+ export { createProxy } from "./proxy.js";
2
+ export { isLikelyX402Exchange, } from "./types.js";
3
+ export { createEventBus } from "./event-bus.js";
4
+ export { JsonlSink } from "./jsonl-sink.js";
5
+ export { newExchangeId } from "./id.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Append-only JSONL writer. One event per line, terminated with `\n`.
3
+ *
4
+ * Per ARCHITECTURE.md § JSONL record format: "the file IS the API."
5
+ * No buffering beyond Node's default stream buffer; rely on the OS to
6
+ * flush. On `close()` we await the drain.
7
+ */
8
+ export declare class JsonlSink {
9
+ private stream;
10
+ private path;
11
+ constructor(path: string);
12
+ open(): Promise<void>;
13
+ /** Append one JSON-serialised event followed by a newline. */
14
+ append(event: unknown): void;
15
+ close(): Promise<void>;
16
+ /** Absolute path of the log file. */
17
+ get filePath(): string;
18
+ }
@@ -0,0 +1,44 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ /**
5
+ * Append-only JSONL writer. One event per line, terminated with `\n`.
6
+ *
7
+ * Per ARCHITECTURE.md § JSONL record format: "the file IS the API."
8
+ * No buffering beyond Node's default stream buffer; rely on the OS to
9
+ * flush. On `close()` we await the drain.
10
+ */
11
+ export class JsonlSink {
12
+ stream = null;
13
+ path;
14
+ constructor(path) {
15
+ this.path = resolve(path);
16
+ }
17
+ async open() {
18
+ await mkdir(dirname(this.path), { recursive: true });
19
+ this.stream = createWriteStream(this.path, { flags: "a", encoding: "utf8" });
20
+ await new Promise((resolveOpen, reject) => {
21
+ this.stream.once("open", () => resolveOpen());
22
+ this.stream.once("error", reject);
23
+ });
24
+ }
25
+ /** Append one JSON-serialised event followed by a newline. */
26
+ append(event) {
27
+ if (!this.stream)
28
+ throw new Error("JsonlSink: open() must be called before append()");
29
+ this.stream.write(JSON.stringify(event) + "\n");
30
+ }
31
+ async close() {
32
+ if (!this.stream)
33
+ return;
34
+ const s = this.stream;
35
+ this.stream = null;
36
+ await new Promise((resolveClose, reject) => {
37
+ s.end((err) => (err ? reject(err) : resolveClose()));
38
+ });
39
+ }
40
+ /** Absolute path of the log file. */
41
+ get filePath() {
42
+ return this.path;
43
+ }
44
+ }
@@ -0,0 +1,21 @@
1
+ import { type Server } from "node:http";
2
+ import { type EventBus } from "./event-bus.js";
3
+ import type { ProxyEvent } from "./types.js";
4
+ export interface ProxyOptions {
5
+ /** Base URL of the upstream service (e.g. `https://api.example.com`). */
6
+ readonly upstream: string;
7
+ /** Listen port. 0 = OS-assigned (used by tests). Default 8402. */
8
+ readonly port?: number;
9
+ /** Path to the JSONL log file. If undefined, no file is written. */
10
+ readonly logPath?: string;
11
+ /** Per-request upstream timeout. Default 30_000 ms. */
12
+ readonly upstreamTimeoutMs?: number;
13
+ }
14
+ export interface ProxyHandle {
15
+ readonly server: Server;
16
+ readonly url: string;
17
+ readonly events: EventBus<ProxyEvent>;
18
+ readonly logPath: string | null;
19
+ close(): Promise<void>;
20
+ }
21
+ export declare function createProxy(opts: ProxyOptions): Promise<ProxyHandle>;
@@ -0,0 +1,238 @@
1
+ import { createServer } from "node:http";
2
+ import { newExchangeId } from "./id.js";
3
+ import { createEventBus } from "./event-bus.js";
4
+ import { JsonlSink } from "./jsonl-sink.js";
5
+ const HOP_BY_HOP_HEADERS = new Set([
6
+ "connection",
7
+ "keep-alive",
8
+ "proxy-authenticate",
9
+ "proxy-authorization",
10
+ "te",
11
+ "trailer",
12
+ "transfer-encoding",
13
+ "upgrade",
14
+ "host", // we rewrite Host to match the upstream
15
+ ]);
16
+ function isLikelyTextContentType(contentType) {
17
+ if (!contentType)
18
+ return true; // empty body = treat as text
19
+ const lower = contentType.toLowerCase();
20
+ return (lower.startsWith("text/") ||
21
+ lower.includes("application/json") ||
22
+ lower.includes("application/x-www-form-urlencoded") ||
23
+ lower.includes("+json") ||
24
+ lower.includes("+xml") ||
25
+ lower.startsWith("application/xml"));
26
+ }
27
+ function isValidUtf8(buf) {
28
+ try {
29
+ const decoder = new TextDecoder("utf-8", { fatal: true });
30
+ decoder.decode(buf);
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ function bodyToCapture(body, contentType) {
38
+ if (body.length === 0)
39
+ return { body: "" };
40
+ if (isLikelyTextContentType(contentType) && isValidUtf8(body)) {
41
+ return { body: body.toString("utf8") };
42
+ }
43
+ return { bodyBase64: body.toString("base64") };
44
+ }
45
+ function collectHeaders(raw) {
46
+ const out = {};
47
+ for (const [name, value] of Object.entries(raw)) {
48
+ if (Array.isArray(value))
49
+ out[name] = value.join(", ");
50
+ else if (typeof value === "string")
51
+ out[name] = value;
52
+ }
53
+ return out;
54
+ }
55
+ async function readNodeBody(req) {
56
+ const chunks = [];
57
+ for await (const chunk of req) {
58
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
59
+ }
60
+ return Buffer.concat(chunks);
61
+ }
62
+ function classifyOutcome(res, receivedAt) {
63
+ if (res.status >= 200 && res.status < 300) {
64
+ const header = res.headers["x-payment-response"] ?? res.headers["payment-response"];
65
+ return {
66
+ kind: "paid",
67
+ status: res.status,
68
+ receivedAt,
69
+ ...(header ? { rawPaymentResponseHeader: header } : {}),
70
+ };
71
+ }
72
+ if (res.status === 402) {
73
+ return {
74
+ kind: "rejected",
75
+ status: res.status,
76
+ receivedAt,
77
+ ...(res.body !== undefined ? { rawBody: res.body } : {}),
78
+ };
79
+ }
80
+ return { kind: "unknown", status: res.status, receivedAt };
81
+ }
82
+ function stripHopByHop(headers) {
83
+ const out = {};
84
+ for (const [name, value] of Object.entries(headers)) {
85
+ if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()))
86
+ out[name] = value;
87
+ }
88
+ return out;
89
+ }
90
+ export async function createProxy(opts) {
91
+ const upstream = new URL(opts.upstream);
92
+ const upstreamTimeoutMs = opts.upstreamTimeoutMs ?? 30_000;
93
+ const events = createEventBus();
94
+ let sink = null;
95
+ if (opts.logPath) {
96
+ sink = new JsonlSink(opts.logPath);
97
+ await sink.open();
98
+ }
99
+ const emit = (event) => {
100
+ events.emit(event);
101
+ sink?.append(event);
102
+ };
103
+ const server = createServer(async (req, res) => {
104
+ const id = newExchangeId();
105
+ const startedAt = Date.now();
106
+ const startedAtIso = new Date(startedAt).toISOString();
107
+ let capturedReq = null;
108
+ try {
109
+ const reqHeaders = collectHeaders(req.headers);
110
+ const reqBodyBuf = await readNodeBody(req);
111
+ const reqContentType = reqHeaders["content-type"];
112
+ capturedReq = {
113
+ method: (req.method ?? "GET").toUpperCase(),
114
+ path: req.url ?? "/",
115
+ headers: reqHeaders,
116
+ ...bodyToCapture(reqBodyBuf, reqContentType),
117
+ };
118
+ emit({
119
+ event: "exchange.opened",
120
+ t: startedAtIso,
121
+ id,
122
+ upstreamUrl: opts.upstream,
123
+ request: capturedReq,
124
+ });
125
+ // Build upstream URL by appending the client's path to the upstream base.
126
+ const upstreamUrl = new URL(capturedReq.path.replace(/^\/+/, ""), upstream.origin + upstream.pathname.replace(/\/?$/, "/"));
127
+ // Forward request to upstream.
128
+ const fwdHeaders = stripHopByHop(capturedReq.headers);
129
+ const init = {
130
+ method: capturedReq.method,
131
+ headers: fwdHeaders,
132
+ signal: AbortSignal.timeout(upstreamTimeoutMs),
133
+ };
134
+ if (capturedReq.method !== "GET" && capturedReq.method !== "HEAD" && reqBodyBuf.length > 0) {
135
+ init.body = reqBodyBuf;
136
+ }
137
+ let upstreamResp;
138
+ try {
139
+ upstreamResp = await fetch(upstreamUrl.toString(), init);
140
+ }
141
+ catch (err) {
142
+ const isTimeout = err instanceof Error && err.name === "TimeoutError";
143
+ const receivedAt = new Date().toISOString();
144
+ const durationMs = Date.now() - startedAt;
145
+ const outcome = isTimeout
146
+ ? { kind: "upstream_timeout", afterMs: durationMs, observedAt: receivedAt }
147
+ : { kind: "unknown", status: 0, receivedAt };
148
+ emit({
149
+ event: "exchange.closed",
150
+ t: receivedAt,
151
+ id,
152
+ response: { status: 502, headers: { "content-type": "text/plain" }, body: "Bad Gateway" },
153
+ outcome,
154
+ durationMs,
155
+ });
156
+ emit({
157
+ event: "proxy.error",
158
+ t: receivedAt,
159
+ id,
160
+ message: err instanceof Error ? err.message : String(err),
161
+ stack: err instanceof Error ? err.stack : undefined,
162
+ });
163
+ res.statusCode = 502;
164
+ res.setHeader("content-type", "text/plain");
165
+ res.end("Bad Gateway");
166
+ return;
167
+ }
168
+ // Read upstream response.
169
+ const respHeaders = {};
170
+ upstreamResp.headers.forEach((value, key) => {
171
+ respHeaders[key.toLowerCase()] = value;
172
+ });
173
+ const respBodyBuf = Buffer.from(await upstreamResp.arrayBuffer());
174
+ const respContentType = respHeaders["content-type"];
175
+ const capturedRes = {
176
+ status: upstreamResp.status,
177
+ headers: respHeaders,
178
+ ...bodyToCapture(respBodyBuf, respContentType),
179
+ };
180
+ const receivedAt = new Date().toISOString();
181
+ const durationMs = Date.now() - startedAt;
182
+ const outcome = classifyOutcome(capturedRes, receivedAt);
183
+ emit({
184
+ event: "exchange.closed",
185
+ t: receivedAt,
186
+ id,
187
+ response: capturedRes,
188
+ outcome,
189
+ durationMs,
190
+ });
191
+ // Forward back to client.
192
+ res.statusCode = capturedRes.status;
193
+ for (const [name, value] of Object.entries(stripHopByHop(respHeaders))) {
194
+ res.setHeader(name, value);
195
+ }
196
+ res.end(respBodyBuf);
197
+ }
198
+ catch (err) {
199
+ const t = new Date().toISOString();
200
+ emit({
201
+ event: "proxy.error",
202
+ t,
203
+ id,
204
+ message: err instanceof Error ? err.message : String(err),
205
+ stack: err instanceof Error ? err.stack : undefined,
206
+ });
207
+ if (!res.headersSent) {
208
+ res.statusCode = 500;
209
+ res.setHeader("content-type", "text/plain");
210
+ res.end("Proxy Error");
211
+ }
212
+ else {
213
+ res.end();
214
+ }
215
+ }
216
+ });
217
+ await new Promise((resolveListen, reject) => {
218
+ server.once("error", reject);
219
+ server.listen(opts.port ?? 8402, () => {
220
+ server.off("error", reject);
221
+ resolveListen();
222
+ });
223
+ });
224
+ const address = server.address();
225
+ const url = `http://localhost:${address.port}`;
226
+ return {
227
+ server,
228
+ url,
229
+ events,
230
+ logPath: sink?.filePath ?? null,
231
+ async close() {
232
+ await new Promise((resolveClose, reject) => {
233
+ server.close((err) => (err ? reject(err) : resolveClose()));
234
+ });
235
+ await sink?.close();
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * X402-10 proxy core types.
3
+ *
4
+ * The proxy is deliberately dumb: it captures raw HTTP and emits typed
5
+ * events. No x402 protocol parsing happens here — that's the decoder's
6
+ * job (X402-11). These shapes are the JSONL-on-disk format per
7
+ * ARCHITECTURE.md § JSONL record format; downstream readers depend on
8
+ * them being stable.
9
+ */
10
+ /** Stable identifier for a single client request / upstream response pair. */
11
+ export type ExchangeId = string;
12
+ export interface CapturedRequest {
13
+ /** HTTP method, uppercase. */
14
+ readonly method: string;
15
+ /** Path + query the client asked for (e.g. `/api/weather?x=1`). */
16
+ readonly path: string;
17
+ /** Lowercase header name → value. Multi-value headers are joined with `, `. */
18
+ readonly headers: Readonly<Record<string, string>>;
19
+ /**
20
+ * Request body. Empty string when no body or GET/HEAD.
21
+ * If non-UTF8 or binary, `bodyBase64` is set instead.
22
+ */
23
+ readonly body?: string;
24
+ readonly bodyBase64?: string;
25
+ }
26
+ export interface CapturedResponse {
27
+ readonly status: number;
28
+ readonly headers: Readonly<Record<string, string>>;
29
+ readonly body?: string;
30
+ readonly bodyBase64?: string;
31
+ }
32
+ /**
33
+ * Classification of what happened to a proxied exchange. Kept coarse so
34
+ * the proxy never has to understand x402 protocol semantics — the
35
+ * discriminant is determined from HTTP status + presence of payment
36
+ * headers only.
37
+ */
38
+ export type ExchangeOutcome = {
39
+ readonly kind: "paid";
40
+ readonly status: number;
41
+ readonly receivedAt: string;
42
+ /** Raw value of X-PAYMENT-RESPONSE if upstream returned one. Decoder parses. */
43
+ readonly rawPaymentResponseHeader?: string;
44
+ } | {
45
+ readonly kind: "rejected";
46
+ readonly status: number;
47
+ readonly receivedAt: string;
48
+ /** Raw response body (text). Decoder extracts errorReason if x402-shaped. */
49
+ readonly rawBody?: string;
50
+ } | {
51
+ readonly kind: "upstream_timeout";
52
+ readonly afterMs: number;
53
+ readonly observedAt: string;
54
+ } | {
55
+ readonly kind: "unknown";
56
+ readonly status: number;
57
+ readonly receivedAt: string;
58
+ };
59
+ /**
60
+ * Discriminated union of every event the proxy emits.
61
+ * `t` is ISO 8601, `event` is the discriminant.
62
+ */
63
+ export type ProxyEvent = {
64
+ readonly event: "exchange.opened";
65
+ readonly t: string;
66
+ readonly id: ExchangeId;
67
+ readonly upstreamUrl: string;
68
+ readonly request: CapturedRequest;
69
+ } | {
70
+ readonly event: "exchange.closed";
71
+ readonly t: string;
72
+ readonly id: ExchangeId;
73
+ readonly response: CapturedResponse;
74
+ readonly outcome: ExchangeOutcome;
75
+ readonly durationMs: number;
76
+ } | {
77
+ readonly event: "proxy.error";
78
+ readonly t: string;
79
+ readonly id?: ExchangeId;
80
+ readonly message: string;
81
+ readonly stack?: string;
82
+ };
83
+ /**
84
+ * Heuristic: is this request/response pair x402-related at all?
85
+ *
86
+ * v0.1 detection rule (pure HTTP signals, no x402 parsing):
87
+ * - Request has an `X-PAYMENT` or `PAYMENT-SIGNATURE` header, OR
88
+ * - Response has a `402` status, OR
89
+ * - Response has an `X-PAYMENT-RESPONSE` or `PAYMENT-RESPONSE` header.
90
+ *
91
+ * The proxy logs every request/response regardless; this heuristic is
92
+ * used downstream by the decoder/reconciler to skip non-x402 traffic
93
+ * cheaply.
94
+ */
95
+ export declare function isLikelyX402Exchange(req: CapturedRequest, res: CapturedResponse | undefined): boolean;