402-mcp 4.2.1 → 4.3.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.
- package/README.md +60 -22
- package/build/config.d.ts +12 -1
- package/build/config.js +53 -12
- package/build/config.js.map +1 -1
- package/build/fetch/resilient-fetch.d.ts +7 -2
- package/build/fetch/resilient-fetch.js +32 -10
- package/build/fetch/resilient-fetch.js.map +1 -1
- package/build/fetch/socks-proxy.d.ts +25 -0
- package/build/fetch/socks-proxy.js +41 -0
- package/build/fetch/socks-proxy.js.map +1 -0
- package/build/fetch/ssrf-guard.d.ts +10 -0
- package/build/fetch/ssrf-guard.js +17 -1
- package/build/fetch/ssrf-guard.js.map +1 -1
- package/build/fetch/transport.d.ts +1 -0
- package/build/fetch/transport.js +37 -14
- package/build/fetch/transport.js.map +1 -1
- package/build/http-security.d.ts +13 -0
- package/build/http-security.js +38 -0
- package/build/http-security.js.map +1 -0
- package/build/index.js +186 -120
- package/build/index.js.map +1 -1
- package/build/l402/bolt11.js +3 -2
- package/build/l402/bolt11.js.map +1 -1
- package/build/spend-tracker.d.ts +31 -4
- package/build/spend-tracker.js +102 -17
- package/build/spend-tracker.js.map +1 -1
- package/build/store/cashu-tokens.d.ts +19 -0
- package/build/store/cashu-tokens.js +32 -0
- package/build/store/cashu-tokens.js.map +1 -1
- package/build/store/credentials.d.ts +5 -0
- package/build/store/credentials.js +1 -0
- package/build/store/credentials.js.map +1 -1
- package/build/store/pending-payments.d.ts +36 -0
- package/build/store/pending-payments.js +88 -0
- package/build/store/pending-payments.js.map +1 -0
- package/build/tools/buy-credits.d.ts +9 -0
- package/build/tools/buy-credits.js +40 -3
- package/build/tools/buy-credits.js.map +1 -1
- package/build/tools/config.d.ts +5 -0
- package/build/tools/config.js.map +1 -1
- package/build/tools/confirm.d.ts +12 -0
- package/build/tools/confirm.js +27 -0
- package/build/tools/confirm.js.map +1 -0
- package/build/tools/fetch-preview.js +9 -3
- package/build/tools/fetch-preview.js.map +1 -1
- package/build/tools/fetch.d.ts +7 -1
- package/build/tools/fetch.js +281 -140
- package/build/tools/fetch.js.map +1 -1
- package/build/tools/nostr-subscribe.js.map +1 -1
- package/build/tools/pay.d.ts +9 -0
- package/build/tools/pay.js +65 -8
- package/build/tools/pay.js.map +1 -1
- package/build/tools/reconcile.d.ts +33 -0
- package/build/tools/reconcile.js +111 -0
- package/build/tools/reconcile.js.map +1 -0
- package/build/tools/redeem-cashu.js +1 -1
- package/build/tools/redeem-cashu.js.map +1 -1
- package/build/tools/register-widgets.js +5 -5
- package/build/tools/register-widgets.js.map +1 -1
- package/build/tools/search.js +17 -3
- package/build/tools/search.js.map +1 -1
- package/build/tools/untrusted.d.ts +3 -0
- package/build/tools/untrusted.js +18 -0
- package/build/tools/untrusted.js.map +1 -0
- package/build/wallet/cashu.js +101 -21
- package/build/wallet/cashu.js.map +1 -1
- package/build/wallet/lnurlcash.js +25 -7
- package/build/wallet/lnurlcash.js.map +1 -1
- package/build/wallet/nwc.js +51 -3
- package/build/wallet/nwc.js.map +1 -1
- package/build/wallet/types.d.ts +17 -0
- package/build/x402/parse.d.ts +15 -3
- package/build/x402/parse.js +23 -9
- package/build/x402/parse.js.map +1 -1
- package/build/x402/payment.js +11 -6
- package/build/x402/payment.js.map +1 -1
- package/llms-full.txt +73 -34
- package/llms.txt +52 -24
- package/package.json +6 -5
- package/src/widgets/dist/payment-confirmation.html +28 -28
- package/src/widgets/dist/service-directory.html +39 -30
- package/src/widgets/dist/wallet-dashboard.html +28 -28
package/build/tools/fetch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { safeErrorMessage } from './safe-error.js';
|
|
3
3
|
import { filterResponseHeaders } from './safe-headers.js';
|
|
4
|
+
import { untrusted } from './untrusted.js';
|
|
4
5
|
const HEX_RE = /^[0-9a-fA-F]+$/;
|
|
5
6
|
const MACAROON_RE = /^[A-Za-z0-9+/_\-=]+$/;
|
|
6
7
|
/** Headers that must not be set by the caller (hop-by-hop). */
|
|
@@ -8,23 +9,112 @@ const BLOCKED_HEADERS = new Set([
|
|
|
8
9
|
'host', 'transfer-encoding', 'connection', 'upgrade',
|
|
9
10
|
'proxy-authorization', 'te', 'trailer',
|
|
10
11
|
]);
|
|
12
|
+
const RECONCILE_HINT = 'Call l402-reconcile with this paymentHash before paying this service again.';
|
|
11
13
|
function parseBalance(value) {
|
|
12
14
|
if (value === null)
|
|
13
15
|
return null;
|
|
14
16
|
const parsed = parseInt(value, 10);
|
|
15
17
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
16
18
|
}
|
|
19
|
+
/** A payment that may have executed. Always an error, so the agent does not pay again. */
|
|
20
|
+
function paymentUnknown(result, costSats, paymentHash, protocol) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{
|
|
23
|
+
type: 'text',
|
|
24
|
+
text: JSON.stringify({
|
|
25
|
+
status: 402,
|
|
26
|
+
protocol,
|
|
27
|
+
paymentState: 'unknown',
|
|
28
|
+
costSats,
|
|
29
|
+
paymentHash,
|
|
30
|
+
method: result.method,
|
|
31
|
+
message: result.outcome === 'unknown'
|
|
32
|
+
? (result.reason ?? `Payment may have executed. ${RECONCILE_HINT}`)
|
|
33
|
+
: `The wallet reported payment without a settlement preimage. ${RECONCILE_HINT}`,
|
|
34
|
+
}, null, 2),
|
|
35
|
+
}],
|
|
36
|
+
isError: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A payment that definitely did not execute. */
|
|
40
|
+
function paymentFailed(result, costSats, paymentHash, protocol) {
|
|
41
|
+
return {
|
|
42
|
+
content: [{
|
|
43
|
+
type: 'text',
|
|
44
|
+
text: JSON.stringify({
|
|
45
|
+
status: 402,
|
|
46
|
+
protocol,
|
|
47
|
+
paymentState: 'failed',
|
|
48
|
+
costSats,
|
|
49
|
+
paymentHash,
|
|
50
|
+
method: result.method,
|
|
51
|
+
message: `Payment failed and no money moved${result.reason ? `: ${result.reason}` : '.'}`,
|
|
52
|
+
}, null, 2),
|
|
53
|
+
}],
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a stored credential may be sent to every one of `origins`. One
|
|
59
|
+
* stored before origins were recorded is bound only to the origin it is keyed
|
|
60
|
+
* by, if it is keyed by one.
|
|
61
|
+
*/
|
|
62
|
+
function isBoundTo(cred, key, origins) {
|
|
63
|
+
const bound = cred.origins ?? (isOrigin(key) ? [key] : []);
|
|
64
|
+
return origins.every(o => bound.includes(o));
|
|
65
|
+
}
|
|
66
|
+
function isOrigin(value) {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(value).origin === value;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
17
74
|
/** Makes an HTTP request with automatic L402/x402 payment and credential reuse. Pays the invoice if within budget, stores the credential, and retries. */
|
|
18
75
|
export async function handleFetch(args, deps) {
|
|
19
76
|
// When multiple URLs are provided (from l402-search results), use transport fallback.
|
|
20
77
|
// The first URL in `urls` is also the primary URL for identity, origin, and payment.
|
|
21
78
|
const primaryUrl = args.urls?.length ? args.urls[0] : args.url;
|
|
22
79
|
const origin = new URL(primaryUrl).origin;
|
|
80
|
+
// Every origin this request may reach. A payment made here is recorded
|
|
81
|
+
// against all of them, and an unresolved one for any of them blocks auto-pay.
|
|
82
|
+
const candidateOrigins = [...new Set((args.urls?.length ? args.urls : [args.url]).map(u => new URL(u).origin))];
|
|
83
|
+
const recordUnknown = (entry) => {
|
|
84
|
+
deps.pendingPayments.add({
|
|
85
|
+
...entry,
|
|
86
|
+
paymentHash: entry.paymentHash.toLowerCase(),
|
|
87
|
+
origins: candidateOrigins,
|
|
88
|
+
...(args.pubkey ? { pubkey: args.pubkey } : {}),
|
|
89
|
+
createdAt: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
};
|
|
23
92
|
// When a pubkey is provided (from search results) use it as the credential key so
|
|
24
93
|
// credentials are shared across all transport URLs for the same service.
|
|
25
94
|
// For direct URL calls without a pubkey, fall back to origin-based keying.
|
|
26
|
-
|
|
27
|
-
|
|
95
|
+
//
|
|
96
|
+
// The pubkey is only the agent's claim, so a credential is sent only to the
|
|
97
|
+
// origins it was bought from: `url=https://evil pubkey=<X>` must not hand
|
|
98
|
+
// X's credential to evil. A credential that is not bound to every origin
|
|
99
|
+
// this request may reach is left alone, and anything bought here is kept
|
|
100
|
+
// under the origin instead so it cannot overwrite X's.
|
|
101
|
+
let credKey = args.pubkey ?? origin;
|
|
102
|
+
let cred = deps.credentialStore.get(credKey);
|
|
103
|
+
if (cred && !isBoundTo(cred, credKey, candidateOrigins)) {
|
|
104
|
+
cred = undefined;
|
|
105
|
+
if (args.pubkey) {
|
|
106
|
+
credKey = origin;
|
|
107
|
+
cred = deps.credentialStore.get(credKey);
|
|
108
|
+
if (cred && !isBoundTo(cred, credKey, candidateOrigins))
|
|
109
|
+
cred = undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// The most this call may auto-pay. maxCostSats lets an agent hold the server
|
|
113
|
+
// to the price it showed in a preview; it can only lower the configured cap.
|
|
114
|
+
const autoPayCap = args.maxCostSats !== undefined ? Math.min(args.maxCostSats, deps.maxAutoPaySats) : deps.maxAutoPaySats;
|
|
115
|
+
const capLabel = args.maxCostSats !== undefined && args.maxCostSats < deps.maxAutoPaySats
|
|
116
|
+
? `maxCostSats (${args.maxCostSats})`
|
|
117
|
+
: `MAX_AUTO_PAY_SATS (${deps.maxAutoPaySats})`;
|
|
28
118
|
const reqHeaders = {};
|
|
29
119
|
// Copy user headers, stripping dangerous hop-by-hop/security-sensitive ones
|
|
30
120
|
if (args.headers) {
|
|
@@ -72,13 +162,32 @@ export async function handleFetch(args, deps) {
|
|
|
72
162
|
text: JSON.stringify({
|
|
73
163
|
status: response.status,
|
|
74
164
|
headers: filterResponseHeaders(response.headers),
|
|
75
|
-
body,
|
|
165
|
+
body: untrusted(body, origin),
|
|
76
166
|
creditsRemaining: balance,
|
|
77
167
|
satsPaid: 0,
|
|
78
168
|
}, null, 2),
|
|
79
169
|
}],
|
|
80
170
|
};
|
|
81
171
|
}
|
|
172
|
+
// An earlier payment to this service may have gone through. Paying again
|
|
173
|
+
// before that is settled could buy the same thing twice.
|
|
174
|
+
if (args.autoPay) {
|
|
175
|
+
const unresolved = deps.pendingPayments.unresolvedFor(candidateOrigins, args.pubkey);
|
|
176
|
+
if (unresolved.length > 0) {
|
|
177
|
+
return {
|
|
178
|
+
content: [{
|
|
179
|
+
type: 'text',
|
|
180
|
+
text: JSON.stringify({
|
|
181
|
+
status: 402,
|
|
182
|
+
paymentState: 'blocked',
|
|
183
|
+
unresolvedPayments: unresolved.map(p => p.paymentHash),
|
|
184
|
+
message: `Auto-pay to this service is paused: ${unresolved.length} earlier payment(s) to it have an unknown outcome. Call l402-reconcile with each paymentHash before paying again.`,
|
|
185
|
+
}, null, 2),
|
|
186
|
+
}],
|
|
187
|
+
isError: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
82
191
|
// 402 response - parse the challenge body (shared by L402 and x402 paths)
|
|
83
192
|
let challengeBody = {};
|
|
84
193
|
try {
|
|
@@ -93,7 +202,7 @@ export async function handleFetch(args, deps) {
|
|
|
93
202
|
const lnurlcashChallenge = deps.parseLnurlcash(lnurlcashHeader);
|
|
94
203
|
if (lnurlcashChallenge) {
|
|
95
204
|
const lnurlcashAutoPay = args.autoPay ?? false;
|
|
96
|
-
if (lnurlcashAutoPay && lnurlcashChallenge.amount <=
|
|
205
|
+
if (lnurlcashAutoPay && lnurlcashChallenge.amount <= autoPayCap) {
|
|
97
206
|
const lnurlcashWithinLimit = deps.spendTracker.tryRecord(lnurlcashChallenge.amount, deps.maxSpendPerMinuteSats);
|
|
98
207
|
if (lnurlcashWithinLimit) {
|
|
99
208
|
const lnurlcashResult = await deps.payLnurlcash(lnurlcashChallenge);
|
|
@@ -117,7 +226,7 @@ export async function handleFetch(args, deps) {
|
|
|
117
226
|
text: JSON.stringify({
|
|
118
227
|
status: retryResponse.status,
|
|
119
228
|
headers: filterResponseHeaders(retryResponse.headers),
|
|
120
|
-
body: retryBody,
|
|
229
|
+
body: untrusted(retryBody, origin),
|
|
121
230
|
creditsRemaining: retryBalance,
|
|
122
231
|
satsPaid: lnurlcashResult.amountSats,
|
|
123
232
|
paymentMethod: 'lnurlcash',
|
|
@@ -138,7 +247,7 @@ export async function handleFetch(args, deps) {
|
|
|
138
247
|
const xcashuChallenge = deps.parseXCashu(xcashuHeader);
|
|
139
248
|
if (xcashuChallenge) {
|
|
140
249
|
const xcashuAutoPay = args.autoPay ?? false;
|
|
141
|
-
if (xcashuAutoPay && xcashuChallenge.amount <=
|
|
250
|
+
if (xcashuAutoPay && xcashuChallenge.amount <= autoPayCap) {
|
|
142
251
|
const xcashuWithinLimit = deps.spendTracker.tryRecord(xcashuChallenge.amount, deps.maxSpendPerMinuteSats);
|
|
143
252
|
if (xcashuWithinLimit) {
|
|
144
253
|
const xcashuResult = await deps.payXCashu(xcashuChallenge);
|
|
@@ -158,7 +267,7 @@ export async function handleFetch(args, deps) {
|
|
|
158
267
|
text: JSON.stringify({
|
|
159
268
|
status: retryResponse.status,
|
|
160
269
|
headers: filterResponseHeaders(retryResponse.headers),
|
|
161
|
-
body: retryBody,
|
|
270
|
+
body: untrusted(retryBody, origin),
|
|
162
271
|
creditsRemaining: retryBalance,
|
|
163
272
|
satsPaid: xcashuResult.amountSats,
|
|
164
273
|
paymentMethod: 'xcashu',
|
|
@@ -191,86 +300,106 @@ export async function handleFetch(args, deps) {
|
|
|
191
300
|
if (deps.isIETFPayment(response.headers)) {
|
|
192
301
|
const ietfChallenge = deps.parseIETFPayment(wwwAuth);
|
|
193
302
|
if (ietfChallenge?.invoice && ietfChallenge.amountSats) {
|
|
303
|
+
// The request's `amount` is only the server's claim; the invoice is
|
|
304
|
+
// what gets paid. Price every check on the invoice itself, and refuse
|
|
305
|
+
// a challenge whose claim and invoice disagree rather than pick one.
|
|
306
|
+
const ietfInvoice = deps.decodeBolt11(ietfChallenge.invoice);
|
|
307
|
+
const claimedHash = ietfChallenge.paymentHash?.toLowerCase();
|
|
308
|
+
if (ietfInvoice.costSats === null ||
|
|
309
|
+
ietfInvoice.costSats !== ietfChallenge.amountSats ||
|
|
310
|
+
(claimedHash !== undefined && claimedHash !== ietfInvoice.paymentHash)) {
|
|
311
|
+
return {
|
|
312
|
+
content: [{
|
|
313
|
+
type: 'text',
|
|
314
|
+
text: JSON.stringify({
|
|
315
|
+
status: 402,
|
|
316
|
+
protocol: 'ietf-payment',
|
|
317
|
+
error: 'Challenge does not match its invoice — refusing to pay',
|
|
318
|
+
claimedSats: ietfChallenge.amountSats,
|
|
319
|
+
invoiceSats: ietfInvoice.costSats,
|
|
320
|
+
message: ietfInvoice.costSats === null
|
|
321
|
+
? 'The invoice has no amount or could not be decoded.'
|
|
322
|
+
: 'The amount or payment hash the server states differs from the invoice it asks you to pay.',
|
|
323
|
+
}, null, 2),
|
|
324
|
+
}],
|
|
325
|
+
isError: true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
ietfChallenge.paymentHash = ietfInvoice.paymentHash ?? undefined;
|
|
194
329
|
const ietfAutoPay = args.autoPay ?? false;
|
|
195
|
-
if (ietfAutoPay && ietfChallenge.amountSats <=
|
|
330
|
+
if (ietfAutoPay && ietfChallenge.amountSats <= autoPayCap) {
|
|
196
331
|
const ietfWithinLimit = deps.spendTracker.tryRecord(ietfChallenge.amountSats, deps.maxSpendPerMinuteSats);
|
|
197
332
|
if (ietfWithinLimit) {
|
|
198
333
|
const ietfPayResult = await deps.payInvoice(ietfChallenge.invoice, { serverOrigin: origin });
|
|
334
|
+
// A payment attempt ends here whatever happens: falling through to
|
|
335
|
+
// the L402 rail after this would pay a second invoice.
|
|
199
336
|
if (!ietfPayResult.paid && ietfPayResult.outcome !== 'unknown') {
|
|
200
337
|
deps.spendTracker.unrecord(ietfChallenge.amountSats);
|
|
338
|
+
return paymentFailed(ietfPayResult, ietfChallenge.amountSats, ietfChallenge.paymentHash, 'ietf-payment');
|
|
339
|
+
}
|
|
340
|
+
if (ietfPayResult.outcome === 'unknown' || !ietfPayResult.preimage) {
|
|
341
|
+
if (ietfChallenge.paymentHash) {
|
|
342
|
+
recordUnknown({ paymentHash: ietfChallenge.paymentHash, invoice: ietfChallenge.invoice, costSats: ietfChallenge.amountSats, protocol: 'ietf-payment', method: ietfPayResult.method });
|
|
343
|
+
}
|
|
344
|
+
return paymentUnknown(ietfPayResult, ietfChallenge.amountSats, ietfChallenge.paymentHash, 'ietf-payment');
|
|
201
345
|
}
|
|
202
|
-
if (ietfPayResult.
|
|
346
|
+
if (!HEX_RE.test(ietfPayResult.preimage) || ietfPayResult.preimage.length !== 64) {
|
|
347
|
+
if (ietfChallenge.paymentHash) {
|
|
348
|
+
recordUnknown({ paymentHash: ietfChallenge.paymentHash, invoice: ietfChallenge.invoice, costSats: ietfChallenge.amountSats, protocol: 'ietf-payment', method: ietfPayResult.method });
|
|
349
|
+
}
|
|
203
350
|
return {
|
|
204
|
-
content: [{
|
|
205
|
-
|
|
206
|
-
text: JSON.stringify({
|
|
207
|
-
status: 402,
|
|
208
|
-
protocol: 'ietf-payment',
|
|
351
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
352
|
+
error: 'Payment was reported but the settlement preimage contains invalid characters; refusing to send it',
|
|
209
353
|
paymentState: 'unknown',
|
|
210
|
-
costSats: ietfChallenge.amountSats,
|
|
211
354
|
paymentHash: ietfChallenge.paymentHash,
|
|
212
|
-
message:
|
|
213
|
-
}
|
|
214
|
-
}],
|
|
355
|
+
message: RECONCILE_HINT,
|
|
356
|
+
}) }],
|
|
215
357
|
isError: true,
|
|
216
358
|
};
|
|
217
359
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
content: [{
|
|
241
|
-
type: 'text',
|
|
242
|
-
text: JSON.stringify({
|
|
243
|
-
status: retryResponse.status,
|
|
244
|
-
headers: filterResponseHeaders(retryResponse.headers),
|
|
245
|
-
body: retryBody,
|
|
246
|
-
satsPaid: ietfChallenge.amountSats,
|
|
247
|
-
paymentMethod: 'ietf-payment',
|
|
248
|
-
}, null, 2),
|
|
249
|
-
}],
|
|
250
|
-
};
|
|
251
|
-
}
|
|
360
|
+
const credential = deps.buildIETFCredential(ietfChallenge, ietfPayResult.preimage);
|
|
361
|
+
const retryHeaders = { ...reqHeaders };
|
|
362
|
+
retryHeaders['Authorization'] = `Payment ${credential}`;
|
|
363
|
+
const retryResponse = await doFetch(primaryUrl, {
|
|
364
|
+
method: args.method ?? 'GET',
|
|
365
|
+
headers: retryHeaders,
|
|
366
|
+
body: args.body,
|
|
367
|
+
});
|
|
368
|
+
const retryBody = await retryResponse.text();
|
|
369
|
+
return {
|
|
370
|
+
content: [{
|
|
371
|
+
type: 'text',
|
|
372
|
+
text: JSON.stringify({
|
|
373
|
+
status: retryResponse.status,
|
|
374
|
+
headers: filterResponseHeaders(retryResponse.headers),
|
|
375
|
+
body: untrusted(retryBody, origin),
|
|
376
|
+
satsPaid: ietfChallenge.amountSats,
|
|
377
|
+
paymentMethod: 'ietf-payment',
|
|
378
|
+
}, null, 2),
|
|
379
|
+
}],
|
|
380
|
+
};
|
|
252
381
|
}
|
|
253
382
|
}
|
|
254
|
-
// IETF Payment detected but not auto-paid
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
383
|
+
// IETF Payment detected but not auto-paid: return challenge details.
|
|
384
|
+
// Never fall through to another rail for the same request.
|
|
385
|
+
return {
|
|
386
|
+
content: [{
|
|
387
|
+
type: 'text',
|
|
388
|
+
text: JSON.stringify({
|
|
389
|
+
status: 402,
|
|
390
|
+
protocol: 'ietf-payment',
|
|
391
|
+
costSats: ietfChallenge.amountSats,
|
|
392
|
+
paymentHash: ietfChallenge.paymentHash,
|
|
393
|
+
realm: ietfChallenge.realm,
|
|
394
|
+
intent: ietfChallenge.intent,
|
|
395
|
+
message: !ietfAutoPay
|
|
396
|
+
? `Payment of ${ietfChallenge.amountSats} sats required (IETF Payment). autoPay disabled.`
|
|
397
|
+
: ietfChallenge.amountSats > autoPayCap
|
|
398
|
+
? `Payment of ${ietfChallenge.amountSats} sats required. Exceeds ${capLabel}.`
|
|
399
|
+
: `Payment of ${ietfChallenge.amountSats} sats required. ${deps.spendTracker.refusal(ietfChallenge.amountSats, deps.maxSpendPerMinuteSats) ?? 'Spend limit reached.'}`,
|
|
400
|
+
}, null, 2),
|
|
401
|
+
}],
|
|
402
|
+
};
|
|
274
403
|
}
|
|
275
404
|
}
|
|
276
405
|
// L402 challenge: Lightning payment
|
|
@@ -295,7 +424,7 @@ export async function handleFetch(args, deps) {
|
|
|
295
424
|
// otherwise tryRecord inflates the spend tracker and blocks legitimate payments.
|
|
296
425
|
// For human wallets, allow re-purchase even when credits are exhausted —
|
|
297
426
|
// the human decides whether to pay by scanning the QR code.
|
|
298
|
-
const shouldAttemptPay = (!creditsExhausted || isHumanWallet) && autoPay && challenge && decoded.costSats !== null && decoded.costSats <=
|
|
427
|
+
const shouldAttemptPay = (!creditsExhausted || isHumanWallet) && autoPay && challenge && decoded.costSats !== null && decoded.costSats <= autoPayCap;
|
|
299
428
|
// Use tryRecord as the authoritative gate — atomically checks AND records
|
|
300
429
|
// the spend before payment, closing the TOCTOU gap between check and pay.
|
|
301
430
|
const withinSpendLimit = shouldAttemptPay && deps.spendTracker.tryRecord(decoded.costSats, deps.maxSpendPerMinuteSats);
|
|
@@ -364,89 +493,100 @@ export async function handleFetch(args, deps) {
|
|
|
364
493
|
return { content };
|
|
365
494
|
}
|
|
366
495
|
const payResult = await deps.payInvoice(challenge.invoice, { serverOrigin: origin });
|
|
367
|
-
// Roll back spend-limit reservation
|
|
496
|
+
// Roll back the spend-limit reservation only for a definite failure,
|
|
497
|
+
// and report it as one rather than as a fresh challenge to pay.
|
|
368
498
|
if (!payResult.paid && payResult.outcome !== 'unknown') {
|
|
369
499
|
deps.spendTracker.unrecord(decoded.costSats);
|
|
500
|
+
return paymentFailed(payResult, decoded.costSats, decoded.paymentHash, 'l402');
|
|
501
|
+
}
|
|
502
|
+
// Unknown, or reported paid with no preimage: the money may have moved.
|
|
503
|
+
if (payResult.outcome === 'unknown' || !payResult.preimage) {
|
|
504
|
+
if (decoded.paymentHash) {
|
|
505
|
+
recordUnknown({ paymentHash: decoded.paymentHash, invoice: challenge.invoice, costSats: decoded.costSats, protocol: 'l402', method: payResult.method, macaroon: challenge.macaroon });
|
|
506
|
+
}
|
|
507
|
+
return paymentUnknown(payResult, decoded.costSats, decoded.paymentHash, 'l402');
|
|
370
508
|
}
|
|
371
|
-
|
|
509
|
+
// Validate preimage (hex) and macaroon (base64-safe) before storage
|
|
510
|
+
// to prevent header injection via Authorization: L402 {macaroon}:{preimage}
|
|
511
|
+
if (!HEX_RE.test(payResult.preimage) || payResult.preimage.length !== 64 || !MACAROON_RE.test(challenge.macaroon)) {
|
|
512
|
+
if (decoded.paymentHash) {
|
|
513
|
+
recordUnknown({ paymentHash: decoded.paymentHash, invoice: challenge.invoice, costSats: decoded.costSats, protocol: 'l402', method: payResult.method });
|
|
514
|
+
}
|
|
372
515
|
return {
|
|
373
516
|
content: [{
|
|
374
517
|
type: 'text',
|
|
375
518
|
text: JSON.stringify({
|
|
376
|
-
|
|
519
|
+
error: 'Payment was reported but the credential contains invalid characters; refusing to store it',
|
|
377
520
|
paymentState: 'unknown',
|
|
378
|
-
costSats: decoded.costSats,
|
|
379
521
|
paymentHash: decoded.paymentHash,
|
|
380
|
-
message:
|
|
381
|
-
}
|
|
522
|
+
message: RECONCILE_HINT,
|
|
523
|
+
}),
|
|
382
524
|
}],
|
|
383
525
|
isError: true,
|
|
384
526
|
};
|
|
385
527
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
creditBalance: null,
|
|
409
|
-
storedAt: new Date().toISOString(),
|
|
410
|
-
lastUsed: new Date().toISOString(),
|
|
411
|
-
server: serverInfo.type === 'toll-booth' ? 'toll-booth' : null,
|
|
412
|
-
});
|
|
413
|
-
// Retry the request with new credentials (reuse filtered headers)
|
|
414
|
-
const retryHeaders = { ...reqHeaders };
|
|
415
|
-
retryHeaders['Authorization'] = `L402 ${challenge.macaroon}:${payResult.preimage}`;
|
|
416
|
-
const retryResponse = await doFetch(primaryUrl, {
|
|
417
|
-
method: args.method ?? 'GET',
|
|
418
|
-
headers: retryHeaders,
|
|
419
|
-
body: args.body,
|
|
420
|
-
});
|
|
421
|
-
const retryBalance = parseBalance(retryResponse.headers.get('x-credit-balance'));
|
|
422
|
-
if (retryBalance !== null) {
|
|
423
|
-
deps.credentialStore.updateBalance(credKey, retryBalance);
|
|
424
|
-
}
|
|
425
|
-
const retryBody = await retryResponse.text();
|
|
426
|
-
return {
|
|
427
|
-
content: [{
|
|
428
|
-
type: 'text',
|
|
429
|
-
text: JSON.stringify({
|
|
430
|
-
status: retryResponse.status,
|
|
431
|
-
headers: filterResponseHeaders(retryResponse.headers),
|
|
432
|
-
body: retryBody,
|
|
433
|
-
creditsRemaining: retryBalance,
|
|
434
|
-
satsPaid: decoded.costSats,
|
|
435
|
-
}, null, 2),
|
|
436
|
-
}],
|
|
437
|
-
};
|
|
528
|
+
// Store credential and retry
|
|
529
|
+
deps.credentialStore.set(credKey, {
|
|
530
|
+
origins: candidateOrigins,
|
|
531
|
+
macaroon: challenge.macaroon,
|
|
532
|
+
preimage: payResult.preimage,
|
|
533
|
+
paymentHash: decoded.paymentHash ?? '',
|
|
534
|
+
creditBalance: null,
|
|
535
|
+
storedAt: new Date().toISOString(),
|
|
536
|
+
lastUsed: new Date().toISOString(),
|
|
537
|
+
server: serverInfo.type === 'toll-booth' ? 'toll-booth' : null,
|
|
538
|
+
});
|
|
539
|
+
// Retry the request with new credentials (reuse filtered headers)
|
|
540
|
+
const retryHeaders = { ...reqHeaders };
|
|
541
|
+
retryHeaders['Authorization'] = `L402 ${challenge.macaroon}:${payResult.preimage}`;
|
|
542
|
+
const retryResponse = await doFetch(primaryUrl, {
|
|
543
|
+
method: args.method ?? 'GET',
|
|
544
|
+
headers: retryHeaders,
|
|
545
|
+
body: args.body,
|
|
546
|
+
});
|
|
547
|
+
const retryBalance = parseBalance(retryResponse.headers.get('x-credit-balance'));
|
|
548
|
+
if (retryBalance !== null) {
|
|
549
|
+
deps.credentialStore.updateBalance(credKey, retryBalance);
|
|
438
550
|
}
|
|
551
|
+
const retryBody = await retryResponse.text();
|
|
552
|
+
return {
|
|
553
|
+
content: [{
|
|
554
|
+
type: 'text',
|
|
555
|
+
text: JSON.stringify({
|
|
556
|
+
status: retryResponse.status,
|
|
557
|
+
headers: filterResponseHeaders(retryResponse.headers),
|
|
558
|
+
body: untrusted(retryBody, origin),
|
|
559
|
+
creditsRemaining: retryBalance,
|
|
560
|
+
satsPaid: decoded.costSats,
|
|
561
|
+
}, null, 2),
|
|
562
|
+
}],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// Step 5: Return 402 challenge for agent decision. Cache it, so that
|
|
566
|
+
// l402-pay can pay exactly this invoice by its paymentHash.
|
|
567
|
+
if (challenge && decoded.paymentHash) {
|
|
568
|
+
deps.challengeCache.set({
|
|
569
|
+
invoice: challenge.invoice,
|
|
570
|
+
macaroon: challenge.macaroon,
|
|
571
|
+
paymentHash: decoded.paymentHash,
|
|
572
|
+
costSats: decoded.costSats,
|
|
573
|
+
expiresAt: Date.now() + decoded.expiry * 1000,
|
|
574
|
+
url: primaryUrl,
|
|
575
|
+
});
|
|
439
576
|
}
|
|
440
|
-
// Step 5: Return 402 challenge for agent decision
|
|
441
577
|
const message = creditsExhausted
|
|
442
578
|
? `Insufficient credits for ${origin}${decoded.costSats !== null ? ` (this endpoint costs ${decoded.costSats} sats)` : ''}. Use l402-buy-credits to purchase more credits${tiers ? ' — tier options are included below' : ''}.`
|
|
443
|
-
:
|
|
444
|
-
?
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
579
|
+
: decoded.costSats === null
|
|
580
|
+
? challenge
|
|
581
|
+
? 'Payment required, but the invoice states no amount, so it cannot be checked against the spending limits and will not be paid automatically.'
|
|
582
|
+
: 'Payment required, but the response carries no payment challenge this client can read.'
|
|
583
|
+
: !autoPay
|
|
584
|
+
? `Payment of ${decoded.costSats} sats required. autoPay disabled.`
|
|
585
|
+
: decoded.costSats > autoPayCap
|
|
586
|
+
? `Payment of ${decoded.costSats} sats required. Exceeds ${capLabel}.`
|
|
587
|
+
: !withinSpendLimit
|
|
588
|
+
? (deps.spendTracker.refusal(decoded.costSats ?? 0, deps.maxSpendPerMinuteSats) ?? 'Spend limit reached.')
|
|
589
|
+
: `Payment of ${decoded.costSats} sats required.`;
|
|
450
590
|
return {
|
|
451
591
|
content: [{
|
|
452
592
|
type: 'text',
|
|
@@ -475,7 +615,7 @@ export async function handleFetch(args, deps) {
|
|
|
475
615
|
/** Registers the l402-fetch tool with the MCP server. */
|
|
476
616
|
export function registerFetchTool(server, deps) {
|
|
477
617
|
server.registerTool('l402-fetch', {
|
|
478
|
-
description: 'Fetch a URL
|
|
618
|
+
description: 'Fetch a URL, paying its HTTP 402 challenge (L402, IETF Payment, Cashu or LNURLcash) when autoPay is true and the price is within MAX_AUTO_PAY_SATS, maxCostSats and the spend limits. Reuses stored credentials. Without autoPay, a 402 comes back with its price and paymentHash so the user can decide; l402-pay can then pay it. For x402 services, which this server supports only in an experimental custom format, returns the payment details for the user to pay in their own wallet. Response bodies are marked as untrusted content. With widget hosts, call l402-fetch-preview first to show the price.',
|
|
479
619
|
annotations: { destructiveHint: true, openWorldHint: true },
|
|
480
620
|
inputSchema: {
|
|
481
621
|
url: z.url().describe('The primary URL to request. When using search results, pass the first URL here and all URLs in the urls field.'),
|
|
@@ -486,6 +626,7 @@ export function registerFetchTool(server, deps) {
|
|
|
486
626
|
autoPay: z.boolean().optional().default(false).describe('Automatically pay if within MAX_AUTO_PAY_SATS budget'),
|
|
487
627
|
pubkey: z.string().max(128).optional().describe('Service pubkey from l402-search results — used to share credentials across all transport URLs for the same service'),
|
|
488
628
|
txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional().describe('Transaction hash from a completed x402 on-chain payment. When provided, retries the request with X-Payment header for server verification.'),
|
|
629
|
+
maxCostSats: z.number().int().nonnegative().optional().describe('Most this call may pay, in sats. Pass the price the user saw in l402-fetch-preview so a server cannot charge more than it showed. Lowers MAX_AUTO_PAY_SATS for this call; never raises it.'),
|
|
489
630
|
},
|
|
490
631
|
}, async (args) => handleFetch(args, deps));
|
|
491
632
|
}
|