@ch4p/plugin-x402 0.1.4
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.
- package/LICENSE +190 -0
- package/dist/index.d.ts +303 -0
- package/dist/index.js +330 -0
- package/package.json +24 -0
- package/src/index.ts +37 -0
- package/src/middleware.test.ts +335 -0
- package/src/middleware.ts +188 -0
- package/src/signer.test.ts +180 -0
- package/src/signer.ts +159 -0
- package/src/types.ts +168 -0
- package/src/x402-pay-tool.test.ts +324 -0
- package/src/x402-pay-tool.ts +228 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { createX402Middleware, pathMatches, decodePaymentHeader } from './middleware.js';
|
|
4
|
+
import type { X402Config, X402PaymentPayload } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function makeReq(
|
|
11
|
+
url = '/',
|
|
12
|
+
headers: Record<string, string | undefined> = {},
|
|
13
|
+
method = 'GET',
|
|
14
|
+
): IncomingMessage {
|
|
15
|
+
return { url, headers, method } as unknown as IncomingMessage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MockResponse {
|
|
19
|
+
statusCode: number;
|
|
20
|
+
headers: Record<string, string | number>;
|
|
21
|
+
body: string;
|
|
22
|
+
writeHead: (status: number, h: Record<string, string | number>) => void;
|
|
23
|
+
end: (data?: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRes(): MockResponse {
|
|
27
|
+
const res: MockResponse = {
|
|
28
|
+
statusCode: 200,
|
|
29
|
+
headers: {},
|
|
30
|
+
body: '',
|
|
31
|
+
writeHead(status, h) {
|
|
32
|
+
this.statusCode = status;
|
|
33
|
+
Object.assign(this.headers, h);
|
|
34
|
+
},
|
|
35
|
+
end(data?: string) {
|
|
36
|
+
if (data) this.body = data;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makePayload(overrides: Partial<X402PaymentPayload> = {}): X402PaymentPayload {
|
|
43
|
+
return {
|
|
44
|
+
x402Version: 1,
|
|
45
|
+
scheme: 'exact',
|
|
46
|
+
network: 'base',
|
|
47
|
+
payload: {
|
|
48
|
+
signature: '0xabc',
|
|
49
|
+
authorization: {
|
|
50
|
+
from: '0x1234567890123456789012345678901234567890',
|
|
51
|
+
to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
52
|
+
value: '1000000',
|
|
53
|
+
validAfter: '0',
|
|
54
|
+
validBefore: '9999999999',
|
|
55
|
+
nonce: '0xdeadbeef',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function encodePayment(payload: X402PaymentPayload): string {
|
|
63
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BASE_CONFIG: X402Config = {
|
|
67
|
+
enabled: true,
|
|
68
|
+
server: {
|
|
69
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
70
|
+
amount: '1000000',
|
|
71
|
+
network: 'base',
|
|
72
|
+
protectedPaths: ['/sessions', '/sessions/*', '/webhooks/*'],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// pathMatches
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('pathMatches', () => {
|
|
81
|
+
it('matches wildcard "*"', () => {
|
|
82
|
+
expect(pathMatches('/anything', '*')).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('matches "/**"', () => {
|
|
86
|
+
expect(pathMatches('/foo/bar', '/**')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('matches prefix wildcard "/sessions/*"', () => {
|
|
90
|
+
expect(pathMatches('/sessions/abc', '/sessions/*')).toBe(true);
|
|
91
|
+
expect(pathMatches('/sessions', '/sessions/*')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('does not prefix-match unrelated paths', () => {
|
|
95
|
+
expect(pathMatches('/other', '/sessions/*')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('exact match', () => {
|
|
99
|
+
expect(pathMatches('/sessions', '/sessions')).toBe(true);
|
|
100
|
+
expect(pathMatches('/sessions/abc', '/sessions')).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// decodePaymentHeader
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('decodePaymentHeader', () => {
|
|
109
|
+
it('returns null for non-base64 garbage', () => {
|
|
110
|
+
expect(decodePaymentHeader('!!!not base64!!!')).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns null for valid base64 but non-JSON', () => {
|
|
114
|
+
expect(decodePaymentHeader(Buffer.from('not json').toString('base64'))).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns null when x402Version is wrong', () => {
|
|
118
|
+
const bad = { ...makePayload(), x402Version: 2 };
|
|
119
|
+
expect(decodePaymentHeader(encodePayment(bad as unknown as X402PaymentPayload))).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns null when scheme is missing', () => {
|
|
123
|
+
const bad = { x402Version: 1, network: 'base', payload: { signature: '0x', authorization: { from: '0x1', to: '0x2', value: '1' } } };
|
|
124
|
+
expect(decodePaymentHeader(Buffer.from(JSON.stringify(bad)).toString('base64'))).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('parses a valid payload', () => {
|
|
128
|
+
const payload = makePayload();
|
|
129
|
+
const result = decodePaymentHeader(encodePayment(payload));
|
|
130
|
+
expect(result).not.toBeNull();
|
|
131
|
+
expect(result?.network).toBe('base');
|
|
132
|
+
expect(result?.payload.authorization.from).toBe(payload.payload.authorization.from);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// createX402Middleware — disabled / null cases
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
describe('createX402Middleware — disabled', () => {
|
|
141
|
+
it('returns null when enabled is false', () => {
|
|
142
|
+
expect(createX402Middleware({ enabled: false, server: BASE_CONFIG.server })).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns null when enabled is missing', () => {
|
|
146
|
+
expect(createX402Middleware({ server: BASE_CONFIG.server })).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns null when server config is missing', () => {
|
|
150
|
+
expect(createX402Middleware({ enabled: true })).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// createX402Middleware — public paths pass through
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe('createX402Middleware — public paths', () => {
|
|
159
|
+
it('passes /health through (returns false)', async () => {
|
|
160
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
161
|
+
const res = makeRes();
|
|
162
|
+
const result = await handler(makeReq('/health'), res as unknown as ServerResponse);
|
|
163
|
+
expect(result).toBe(false);
|
|
164
|
+
expect(res.statusCode).toBe(200); // no response written
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('passes /.well-known/agent.json through', async () => {
|
|
168
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
169
|
+
const res = makeRes();
|
|
170
|
+
const result = await handler(makeReq('/.well-known/agent.json'), res as unknown as ServerResponse);
|
|
171
|
+
expect(result).toBe(false);
|
|
172
|
+
expect(res.statusCode).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('passes /pair through', async () => {
|
|
176
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
177
|
+
const res = makeRes();
|
|
178
|
+
const result = await handler(makeReq('/pair'), res as unknown as ServerResponse);
|
|
179
|
+
expect(result).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// createX402Middleware — unprotected paths pass through
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe('createX402Middleware — unprotected paths', () => {
|
|
188
|
+
it('passes through paths not in protectedPaths', async () => {
|
|
189
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
190
|
+
const res = makeRes();
|
|
191
|
+
const result = await handler(makeReq('/other'), res as unknown as ServerResponse);
|
|
192
|
+
expect(result).toBe(false);
|
|
193
|
+
expect(res.statusCode).toBe(200);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// createX402Middleware — 402 responses
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
describe('createX402Middleware — 402 responses', () => {
|
|
202
|
+
it('returns 402 when X-PAYMENT header is absent on a protected path', async () => {
|
|
203
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
204
|
+
const res = makeRes();
|
|
205
|
+
const result = await handler(makeReq('/sessions'), res as unknown as ServerResponse);
|
|
206
|
+
expect(result).toBe(true); // handled
|
|
207
|
+
expect(res.statusCode).toBe(402);
|
|
208
|
+
const body = JSON.parse(res.body);
|
|
209
|
+
expect(body.error).toBe('X402');
|
|
210
|
+
expect(body.x402Version).toBe(1);
|
|
211
|
+
expect(body.accepts[0].payTo).toBe(BASE_CONFIG.server!.payTo);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('returns 402 for a sub-path of a wildcard pattern', async () => {
|
|
215
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
216
|
+
const res = makeRes();
|
|
217
|
+
const result = await handler(makeReq('/sessions/abc-123'), res as unknown as ServerResponse);
|
|
218
|
+
expect(result).toBe(true);
|
|
219
|
+
expect(res.statusCode).toBe(402);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('returns 402 on malformed X-PAYMENT header', async () => {
|
|
223
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
224
|
+
const res = makeRes();
|
|
225
|
+
const req = makeReq('/sessions', { 'x-payment': 'not-base64!!!' });
|
|
226
|
+
const result = await handler(req, res as unknown as ServerResponse);
|
|
227
|
+
expect(result).toBe(true);
|
|
228
|
+
expect(res.statusCode).toBe(402);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('returns 402 when payment network does not match', async () => {
|
|
232
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
233
|
+
const res = makeRes();
|
|
234
|
+
const payload = makePayload({ network: 'ethereum' });
|
|
235
|
+
const req = makeReq('/sessions', { 'x-payment': encodePayment(payload) });
|
|
236
|
+
const result = await handler(req, res as unknown as ServerResponse);
|
|
237
|
+
expect(result).toBe(true);
|
|
238
|
+
expect(res.statusCode).toBe(402);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('strips query string before path matching', async () => {
|
|
242
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
243
|
+
const res = makeRes();
|
|
244
|
+
const result = await handler(makeReq('/sessions?foo=bar'), res as unknown as ServerResponse);
|
|
245
|
+
expect(result).toBe(true); // protected path, no payment
|
|
246
|
+
expect(res.statusCode).toBe(402);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// createX402Middleware — valid payment
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
describe('createX402Middleware — valid payment', () => {
|
|
255
|
+
it('tags req._x402Authenticated and returns false on valid payment', async () => {
|
|
256
|
+
const handler = createX402Middleware(BASE_CONFIG)!;
|
|
257
|
+
const res = makeRes();
|
|
258
|
+
const payload = makePayload();
|
|
259
|
+
const req = makeReq('/sessions', { 'x-payment': encodePayment(payload) });
|
|
260
|
+
const result = await handler(req, res as unknown as ServerResponse);
|
|
261
|
+
expect(result).toBe(false); // not handled — continue routing
|
|
262
|
+
expect(res.statusCode).toBe(200); // no 402 written
|
|
263
|
+
expect((req as unknown as Record<string, unknown>)['_x402Authenticated']).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('calls verifyPayment and passes when it returns true', async () => {
|
|
267
|
+
const verify = vi.fn().mockResolvedValue(true);
|
|
268
|
+
const cfg: X402Config = {
|
|
269
|
+
enabled: true,
|
|
270
|
+
server: { ...BASE_CONFIG.server!, verifyPayment: verify },
|
|
271
|
+
};
|
|
272
|
+
const handler = createX402Middleware(cfg)!;
|
|
273
|
+
const res = makeRes();
|
|
274
|
+
const payload = makePayload();
|
|
275
|
+
const req = makeReq('/sessions', { 'x-payment': encodePayment(payload) });
|
|
276
|
+
const result = await handler(req, res as unknown as ServerResponse);
|
|
277
|
+
expect(result).toBe(false);
|
|
278
|
+
expect(verify).toHaveBeenCalledOnce();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns 402 when verifyPayment returns false', async () => {
|
|
282
|
+
const verify = vi.fn().mockResolvedValue(false);
|
|
283
|
+
const cfg: X402Config = {
|
|
284
|
+
enabled: true,
|
|
285
|
+
server: { ...BASE_CONFIG.server!, verifyPayment: verify },
|
|
286
|
+
};
|
|
287
|
+
const handler = createX402Middleware(cfg)!;
|
|
288
|
+
const res = makeRes();
|
|
289
|
+
const payload = makePayload();
|
|
290
|
+
const req = makeReq('/sessions', { 'x-payment': encodePayment(payload) });
|
|
291
|
+
const result = await handler(req, res as unknown as ServerResponse);
|
|
292
|
+
expect(result).toBe(true);
|
|
293
|
+
expect(res.statusCode).toBe(402);
|
|
294
|
+
expect(verify).toHaveBeenCalledOnce();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// createX402Middleware — defaults
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe('createX402Middleware — defaults', () => {
|
|
303
|
+
it('uses default /* protection when protectedPaths not specified', async () => {
|
|
304
|
+
const cfg: X402Config = {
|
|
305
|
+
enabled: true,
|
|
306
|
+
server: {
|
|
307
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
308
|
+
amount: '1000000',
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
const handler = createX402Middleware(cfg)!;
|
|
312
|
+
const res = makeRes();
|
|
313
|
+
// Any non-public path should be protected
|
|
314
|
+
const result = await handler(makeReq('/anything'), res as unknown as ServerResponse);
|
|
315
|
+
expect(result).toBe(true);
|
|
316
|
+
expect(res.statusCode).toBe(402);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('uses USDC on Base as default asset', async () => {
|
|
320
|
+
const cfg: X402Config = {
|
|
321
|
+
enabled: true,
|
|
322
|
+
server: {
|
|
323
|
+
payTo: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
324
|
+
amount: '500000',
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
const handler = createX402Middleware(cfg)!;
|
|
328
|
+
const res = makeRes();
|
|
329
|
+
await handler(makeReq('/protected'), res as unknown as ServerResponse);
|
|
330
|
+
const body = JSON.parse(res.body);
|
|
331
|
+
expect(body.accepts[0].asset).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
|
|
332
|
+
expect(body.accepts[0].network).toBe('base');
|
|
333
|
+
expect(body.accepts[0].maxTimeoutSeconds).toBe(300);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 gateway middleware — server-side payment enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Call createX402Middleware(config) to obtain a pre-handler compatible with
|
|
5
|
+
* GatewayServer's `preHandler` option. For each incoming request the handler:
|
|
6
|
+
*
|
|
7
|
+
* 1. Skips system paths (/health, /.well-known/agent.json, /pair).
|
|
8
|
+
* 2. Skips paths not matched by `protectedPaths`.
|
|
9
|
+
* 3. If no X-PAYMENT header: responds 402 with payment requirements and
|
|
10
|
+
* returns true (request handled, no further routing).
|
|
11
|
+
* 4. If X-PAYMENT header present: structurally validates the decoded
|
|
12
|
+
* payload and, if a verifyPayment callback is configured, calls it.
|
|
13
|
+
* - Invalid or rejected: sends 402 and returns true.
|
|
14
|
+
* - Valid: tags `req._x402Authenticated = true` and returns false
|
|
15
|
+
* so the request proceeds through normal routing with pairing auth
|
|
16
|
+
* bypassed (GatewayServer.checkAuth respects this flag).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
20
|
+
import type { X402Config, X402PaymentRequirements, X402PaymentPayload } from './types.js';
|
|
21
|
+
|
|
22
|
+
/** Paths always excluded from payment gating. */
|
|
23
|
+
const PUBLIC_PATHS: ReadonlySet<string> = new Set([
|
|
24
|
+
'/health',
|
|
25
|
+
'/.well-known/agent.json',
|
|
26
|
+
'/pair',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Default USDC on Base contract address. */
|
|
30
|
+
const DEFAULT_ASSET = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
31
|
+
|
|
32
|
+
/** Default network. */
|
|
33
|
+
const DEFAULT_NETWORK = 'base';
|
|
34
|
+
|
|
35
|
+
/** Default payment timeout in seconds. */
|
|
36
|
+
const DEFAULT_TIMEOUT = 300;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true if urlPath matches the protection pattern.
|
|
40
|
+
*
|
|
41
|
+
* Pattern rules:
|
|
42
|
+
* - "*" or "/**": matches every path
|
|
43
|
+
* - "/foo/*": prefix match — matches /foo and /foo/bar/baz
|
|
44
|
+
* - "/foo": exact match only
|
|
45
|
+
*/
|
|
46
|
+
export function pathMatches(urlPath: string, pattern: string): boolean {
|
|
47
|
+
if (pattern === '*' || pattern === '/**') return true;
|
|
48
|
+
if (pattern.endsWith('/*')) {
|
|
49
|
+
const prefix = pattern.slice(0, -2);
|
|
50
|
+
return urlPath === prefix || urlPath.startsWith(prefix + '/');
|
|
51
|
+
}
|
|
52
|
+
return urlPath === pattern;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip query string from a URL to get the path only. */
|
|
56
|
+
function extractPath(url: string): string {
|
|
57
|
+
const qIdx = url.indexOf('?');
|
|
58
|
+
return qIdx >= 0 ? url.slice(0, qIdx) : url;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Send a 402 Payment Required response with the x402 JSON body. */
|
|
62
|
+
function send402(res: ServerResponse, requirements: X402PaymentRequirements): void {
|
|
63
|
+
const body = JSON.stringify({
|
|
64
|
+
x402Version: 1,
|
|
65
|
+
error: 'X402',
|
|
66
|
+
accepts: [requirements],
|
|
67
|
+
});
|
|
68
|
+
res.writeHead(402, {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'Content-Length': Buffer.byteLength(body),
|
|
71
|
+
});
|
|
72
|
+
res.end(body);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decode and structurally validate the X-PAYMENT header value.
|
|
77
|
+
* Returns null on any parse or structure failure.
|
|
78
|
+
*/
|
|
79
|
+
export function decodePaymentHeader(header: string): X402PaymentPayload | null {
|
|
80
|
+
try {
|
|
81
|
+
const json = Buffer.from(header, 'base64').toString('utf-8');
|
|
82
|
+
const parsed = JSON.parse(json) as unknown;
|
|
83
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
84
|
+
|
|
85
|
+
const p = parsed as Record<string, unknown>;
|
|
86
|
+
if (p['x402Version'] !== 1) return null;
|
|
87
|
+
if (p['scheme'] !== 'exact') return null;
|
|
88
|
+
if (typeof p['network'] !== 'string') return null;
|
|
89
|
+
|
|
90
|
+
const payload = p['payload'];
|
|
91
|
+
if (typeof payload !== 'object' || payload === null) return null;
|
|
92
|
+
const pl = payload as Record<string, unknown>;
|
|
93
|
+
if (typeof pl['signature'] !== 'string') return null;
|
|
94
|
+
|
|
95
|
+
const auth = pl['authorization'];
|
|
96
|
+
if (typeof auth !== 'object' || auth === null) return null;
|
|
97
|
+
const a = auth as Record<string, unknown>;
|
|
98
|
+
if (
|
|
99
|
+
typeof a['from'] !== 'string' ||
|
|
100
|
+
typeof a['to'] !== 'string' ||
|
|
101
|
+
typeof a['value'] !== 'string'
|
|
102
|
+
) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return parsed as X402PaymentPayload;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type X402PreHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create an x402 middleware pre-handler for GatewayServer.
|
|
116
|
+
*
|
|
117
|
+
* Returns null when x402 is disabled or no server config is provided.
|
|
118
|
+
*
|
|
119
|
+
* @param config x402 plugin configuration
|
|
120
|
+
* @returns Pre-handler function, or null if x402 is not enabled.
|
|
121
|
+
*/
|
|
122
|
+
export function createX402Middleware(config: X402Config): X402PreHandler | null {
|
|
123
|
+
if (!config.enabled || !config.server) return null;
|
|
124
|
+
|
|
125
|
+
const serverCfg = config.server;
|
|
126
|
+
const asset = serverCfg.asset ?? DEFAULT_ASSET;
|
|
127
|
+
const network = serverCfg.network ?? DEFAULT_NETWORK;
|
|
128
|
+
const maxTimeoutSeconds = serverCfg.maxTimeoutSeconds ?? DEFAULT_TIMEOUT;
|
|
129
|
+
const description =
|
|
130
|
+
serverCfg.description ?? 'Payment required to access this gateway resource.';
|
|
131
|
+
const protectedPaths = serverCfg.protectedPaths ?? ['/*'];
|
|
132
|
+
|
|
133
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
|
134
|
+
const rawUrl = req.url ?? '/';
|
|
135
|
+
const urlPath = extractPath(rawUrl);
|
|
136
|
+
|
|
137
|
+
// Never gate well-known system paths.
|
|
138
|
+
if (PUBLIC_PATHS.has(urlPath)) return false;
|
|
139
|
+
|
|
140
|
+
// Only gate paths listed in protectedPaths.
|
|
141
|
+
const isProtected = protectedPaths.some((p) => pathMatches(urlPath, p));
|
|
142
|
+
if (!isProtected) return false;
|
|
143
|
+
|
|
144
|
+
const requirements: X402PaymentRequirements = {
|
|
145
|
+
scheme: 'exact',
|
|
146
|
+
network,
|
|
147
|
+
maxAmountRequired: serverCfg.amount,
|
|
148
|
+
resource: urlPath,
|
|
149
|
+
description,
|
|
150
|
+
mimeType: 'application/json',
|
|
151
|
+
payTo: serverCfg.payTo,
|
|
152
|
+
maxTimeoutSeconds,
|
|
153
|
+
asset,
|
|
154
|
+
extra: {},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const paymentHeader = req.headers['x-payment'];
|
|
158
|
+
if (!paymentHeader || typeof paymentHeader !== 'string') {
|
|
159
|
+
send402(res, requirements);
|
|
160
|
+
return true; // handled — no further routing
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const payment = decodePaymentHeader(paymentHeader);
|
|
164
|
+
if (!payment) {
|
|
165
|
+
send402(res, requirements);
|
|
166
|
+
return true; // handled — malformed header
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Network mismatch — reject.
|
|
170
|
+
if (payment.network !== network) {
|
|
171
|
+
send402(res, requirements);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Optional on-chain verifier.
|
|
176
|
+
if (serverCfg.verifyPayment) {
|
|
177
|
+
const allowed = await serverCfg.verifyPayment(payment, requirements);
|
|
178
|
+
if (!allowed) {
|
|
179
|
+
send402(res, requirements);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Payment accepted — tag the request so checkAuth bypasses pairing.
|
|
185
|
+
(req as unknown as Record<string, unknown>)['_x402Authenticated'] = true;
|
|
186
|
+
return false; // continue routing
|
|
187
|
+
};
|
|
188
|
+
}
|