@nevermined-io/payments 1.10.0 → 1.11.1

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 (89) hide show
  1. package/dist/api/nvm-api.d.ts +4 -0
  2. package/dist/api/nvm-api.d.ts.map +1 -1
  3. package/dist/api/nvm-api.js +7 -0
  4. package/dist/api/nvm-api.js.map +1 -1
  5. package/dist/api/organizations-api/organizations-api.d.ts +19 -1
  6. package/dist/api/organizations-api/organizations-api.d.ts.map +1 -1
  7. package/dist/api/organizations-api/organizations-api.js +53 -0
  8. package/dist/api/organizations-api/organizations-api.js.map +1 -1
  9. package/dist/api/organizations-api/types.d.ts +33 -0
  10. package/dist/api/organizations-api/types.d.ts.map +1 -1
  11. package/dist/api/organizations-api/types.js.map +1 -1
  12. package/dist/common/api-version.d.ts +1 -1
  13. package/dist/common/api-version.js +1 -1
  14. package/dist/common/api-version.js.map +1 -1
  15. package/dist/common/types.d.ts +1 -1
  16. package/dist/common/types.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcp/core/server-manager.d.ts.map +1 -1
  22. package/dist/mcp/core/server-manager.js +3 -0
  23. package/dist/mcp/core/server-manager.js.map +1 -1
  24. package/dist/mcp/http/mcp-handler.d.ts +7 -0
  25. package/dist/mcp/http/mcp-handler.d.ts.map +1 -1
  26. package/dist/mcp/http/mcp-handler.js +8 -1
  27. package/dist/mcp/http/mcp-handler.js.map +1 -1
  28. package/dist/mcp/http/oauth-metadata.d.ts.map +1 -1
  29. package/dist/mcp/http/oauth-metadata.js +31 -6
  30. package/dist/mcp/http/oauth-metadata.js.map +1 -1
  31. package/dist/mcp/http/oauth-router.d.ts +17 -16
  32. package/dist/mcp/http/oauth-router.d.ts.map +1 -1
  33. package/dist/mcp/http/oauth-router.js +56 -28
  34. package/dist/mcp/http/oauth-router.js.map +1 -1
  35. package/dist/mpp/codec.d.ts +92 -0
  36. package/dist/mpp/codec.d.ts.map +1 -0
  37. package/dist/mpp/codec.js +405 -0
  38. package/dist/mpp/codec.js.map +1 -0
  39. package/dist/mpp/errors.d.ts +165 -0
  40. package/dist/mpp/errors.d.ts.map +1 -0
  41. package/dist/mpp/errors.js +177 -0
  42. package/dist/mpp/errors.js.map +1 -0
  43. package/dist/mpp/fetch.d.ts +196 -0
  44. package/dist/mpp/fetch.d.ts.map +1 -0
  45. package/dist/mpp/fetch.js +493 -0
  46. package/dist/mpp/fetch.js.map +1 -0
  47. package/dist/mpp/index.d.ts +11 -0
  48. package/dist/mpp/index.d.ts.map +1 -0
  49. package/dist/mpp/index.js +20 -0
  50. package/dist/mpp/index.js.map +1 -0
  51. package/dist/mpp/mpp-api.d.ts +153 -0
  52. package/dist/mpp/mpp-api.d.ts.map +1 -0
  53. package/dist/mpp/mpp-api.js +298 -0
  54. package/dist/mpp/mpp-api.js.map +1 -0
  55. package/dist/mpp/types.d.ts +43 -0
  56. package/dist/mpp/types.d.ts.map +1 -0
  57. package/dist/mpp/types.js +9 -0
  58. package/dist/mpp/types.js.map +1 -0
  59. package/dist/payments.d.ts +13 -0
  60. package/dist/payments.d.ts.map +1 -1
  61. package/dist/payments.js +3 -0
  62. package/dist/payments.js.map +1 -1
  63. package/dist/x402/delegation-api.d.ts +3 -1
  64. package/dist/x402/delegation-api.d.ts.map +1 -1
  65. package/dist/x402/delegation-api.js.map +1 -1
  66. package/dist/x402/express/index.d.ts +2 -0
  67. package/dist/x402/express/index.d.ts.map +1 -1
  68. package/dist/x402/express/index.js +2 -0
  69. package/dist/x402/express/index.js.map +1 -1
  70. package/dist/x402/express/middleware.d.ts +33 -1
  71. package/dist/x402/express/middleware.d.ts.map +1 -1
  72. package/dist/x402/express/middleware.js +831 -1
  73. package/dist/x402/express/middleware.js.map +1 -1
  74. package/dist/x402/express/mpp-support.d.ts +58 -0
  75. package/dist/x402/express/mpp-support.d.ts.map +1 -0
  76. package/dist/x402/express/mpp-support.js +67 -0
  77. package/dist/x402/express/mpp-support.js.map +1 -0
  78. package/dist/x402/express/raw-body.d.ts +24 -0
  79. package/dist/x402/express/raw-body.d.ts.map +1 -0
  80. package/dist/x402/express/raw-body.js +34 -0
  81. package/dist/x402/express/raw-body.js.map +1 -0
  82. package/dist/x402/token-request.d.ts +15 -0
  83. package/dist/x402/token-request.d.ts.map +1 -0
  84. package/dist/x402/token-request.js +66 -0
  85. package/dist/x402/token-request.js.map +1 -0
  86. package/dist/x402/token.d.ts.map +1 -1
  87. package/dist/x402/token.js +7 -51
  88. package/dist/x402/token.js.map +1 -1
  89. package/package.json +1 -1
@@ -46,6 +46,10 @@
46
46
  * ```
47
47
  */
48
48
  import { buildPaymentRequired, resolveNetwork, resolveScheme, } from '../facilitator-api.js';
49
+ import { MPP_HEADERS, extractCredential, mppCredentialId, mppResource, mppVerb, resolveMppOption, } from './mpp-support.js';
50
+ import { computeBodyDigest, getRawBody } from './raw-body.js';
51
+ import { MppError, MppCredentialRejectedError, MppSettlementOutcomeUnknownError, isRetryableMppCode, } from '../../mpp/errors.js';
52
+ import { normalizeCredits } from '../../mpp/mpp-api.js';
49
53
  /**
50
54
  * x402 HTTP Transport header names (v2 spec)
51
55
  * @see https://github.com/coinbase/x402/blob/main/specs/transports-v2/http.md
@@ -136,6 +140,69 @@ function matchRoute(req, routes) {
136
140
  * }))
137
141
  * ```
138
142
  */
143
+ /**
144
+ * Whether a request carries a body, per RFC 9110 §6.4.1 / what `type-is`'s
145
+ * `hasBody()` encodes: a `Transfer-Encoding` header (chunked, streamed —
146
+ * never carries `Content-Length`), or a non-zero `Content-Length`.
147
+ *
148
+ * `bindBody`'s guard used to check `Content-Length` alone, so a chunked
149
+ * request (no `Content-Length` at all) took neither the "captured" nor the
150
+ * "refuse" branch: the challenge was minted unbound, silently. Both
151
+ * `Content-Length` and `Transfer-Encoding` are the BUYER's choice, so a guard
152
+ * that only recognizes one of the two shapes lets the buyer — not the seller
153
+ * — decide whether the seller's opt-in body binding applies.
154
+ */
155
+ function requestHasBody(req) {
156
+ if (req.headers['transfer-encoding'] !== undefined)
157
+ return true;
158
+ const contentLength = req.headers['content-length'];
159
+ return contentLength !== undefined && contentLength !== '0';
160
+ }
161
+ /**
162
+ * Arms a probe that answers, at `res.end` time, whether this response is
163
+ * actually being DELIVERED to the buyer.
164
+ *
165
+ * Must be called BEFORE the handler runs — it captures a baseline — and the
166
+ * returned predicate read inside the `res.end` wrapper.
167
+ *
168
+ * Both payment paths in this file bill from that wrapper, and both get this
169
+ * wrong in a different direction if they simply look at the status code or at
170
+ * the socket:
171
+ *
172
+ * - **A status code is not a delivery.** An aborted request still carries
173
+ * `200`: after a client hangs up, `res.closed` and `res.destroyed` are true,
174
+ * `res.headersSent` is false, `res.statusCode` is still 200 — and `res.end()`
175
+ * still runs the wrapper. Billing on the status alone charges for a response
176
+ * that went into a dead socket.
177
+ * - **A dead socket is not proof of non-delivery.** On a STREAMED response
178
+ * delivery happens at `res.write()`, while this is read in the `res.end`
179
+ * wrapper: an SSE or token-streaming endpoint — the shape an agent endpoint
180
+ * takes — hands the buyer the whole value and only then calls `end()`. A
181
+ * buyer who reads the payload and hangs up has been served in full.
182
+ *
183
+ * So delivery is: the socket is still alive (we are about to write), OR bytes
184
+ * for THIS response already reached the wire.
185
+ *
186
+ * The DELTA is load-bearing — `socket.bytesWritten` alone is not a usable
187
+ * signal. It is cumulative for the whole connection, so on a keep-alive socket
188
+ * that already carried an earlier response it reads non-zero for a response
189
+ * that has written nothing at all (measured: 249 bytes from the previous
190
+ * request, delta 0 for this one). Keying on the raw value bills undelivered
191
+ * buffered responses on every reused connection.
192
+ *
193
+ * The socket reference is HELD rather than re-read from `res.socket`, which is
194
+ * allowed to be nulled once the response detaches.
195
+ */
196
+ function armDeliveryProbe(res) {
197
+ const socket = res.socket;
198
+ const bytesBeforeHandler = socket?.bytesWritten ?? 0;
199
+ return () => {
200
+ const bytesFlushed = (socket?.bytesWritten ?? bytesBeforeHandler) - bytesBeforeHandler;
201
+ const alreadyDelivered = res.headersSent && bytesFlushed > 0;
202
+ const socketStillAlive = !res.destroyed && !res.closed;
203
+ return socketStillAlive || alreadyDelivered;
204
+ };
205
+ }
139
206
  /**
140
207
  * Helper to send a 402 Payment Required response with proper x402 headers.
141
208
  */
@@ -147,6 +214,734 @@ function sendPaymentRequired(res, paymentRequired, message) {
147
214
  message,
148
215
  });
149
216
  }
217
+ /**
218
+ * Challenge ids of the credentials currently between "verified" and
219
+ * "settled", shared across every `handleMppRequest` call in this process.
220
+ *
221
+ * Keyed on the id, never on the header bytes: the bytes are buyer-malleable
222
+ * (scheme case, inner whitespace, JSON key order) while the backend collapses
223
+ * every variant onto one burn, so a byte-keyed set is a guard a buyer walks
224
+ * around by flipping one character. See {@link mppCredentialId}.
225
+ *
226
+ * `verifyCredential` burns nothing (its own docstring says so explicitly)
227
+ * and `settleCredential` settling the SAME credential twice burns once —
228
+ * that idempotency is what makes settlement safe to retry, and it is
229
+ * exactly what makes concurrent delivery cheap: N concurrent requests
230
+ * presenting the same credential would each pass verify, each get served,
231
+ * and the N settles would collapse to a single burn. This set closes that
232
+ * window WITHIN one Node process: a second request presenting a credential
233
+ * already in this set is refused rather than served.
234
+ *
235
+ * What this does NOT close: multiple processes or horizontally-scaled
236
+ * instances of this middleware, which do not share this in-memory set and
237
+ * would need a shared store (e.g. Redis) this package does not provide.
238
+ * Deployments that scale this middleware horizontally need an external
239
+ * mitigation for the same race across instances.
240
+ */
241
+ const inFlightMppCredentials = new Set();
242
+ /** Digest of zero bytes — what a `bindBody` route binds when the request
243
+ * carries no body, so "no body" is a bound state rather than an unbound
244
+ * challenge the buyer can attach anything to later. */
245
+ const EMPTY_BODY_DIGEST = computeBodyDigest(Buffer.alloc(0));
246
+ /**
247
+ * How long a settled credential stays refused. Matches the backend's
248
+ * `MPP_CHALLENGE_TTL_SECONDS` (300): past it the challenge itself is
249
+ * expired, so the credential is refused by the backend anyway and there is
250
+ * nothing left for this set to protect.
251
+ */
252
+ const SPENT_CREDENTIAL_TTL_MS = 300000;
253
+ /**
254
+ * Challenge ids of the credentials whose settlement has already been started,
255
+ * and the instant each stops being worth remembering. Keyed on the id for the
256
+ * same reason as {@link inFlightMppCredentials}.
257
+ *
258
+ * {@link inFlightMppCredentials} closes only the OVERLAP window — it is
259
+ * released when the response closes. Without this second set, a buyer who
260
+ * simply REPEATS the same credential after the first response finished is
261
+ * served again: `verifyCredential` burns nothing so it verifies clean, and
262
+ * the backend's settle idempotency (the challenge id doubles as the burn
263
+ * key) collapses the second settle onto the first burn instead of
264
+ * rejecting it. The result is pay once, be served for the whole challenge
265
+ * TTL — the backend cannot refuse it, because from its side a replayed
266
+ * settle succeeding IS the idempotency contract working as designed.
267
+ *
268
+ * So single-use is the seller edge's job, and this is where it lives.
269
+ *
270
+ * Same process-local caveat as the in-flight set: this does not span
271
+ * processes or horizontally-scaled instances, which need a shared store
272
+ * this package does not provide.
273
+ */
274
+ const spentMppCredentials = new Map();
275
+ /**
276
+ * Marks a credential spent, and drops the entries that have aged out.
277
+ *
278
+ * Every entry gets the same TTL, so `Map`'s insertion order IS expiry
279
+ * order: the sweep can stop at the first entry still alive instead of
280
+ * walking the whole map on every settlement.
281
+ */
282
+ function markCredentialSpent(credentialId) {
283
+ const now = Date.now();
284
+ for (const [spent, expiresAt] of spentMppCredentials) {
285
+ if (expiresAt > now)
286
+ break;
287
+ spentMppCredentials.delete(spent);
288
+ }
289
+ spentMppCredentials.set(credentialId, now + SPENT_CREDENTIAL_TTL_MS);
290
+ }
291
+ /** Whether this credential has already bought a response. */
292
+ function isCredentialSpent(credentialId) {
293
+ const expiresAt = spentMppCredentials.get(credentialId);
294
+ if (expiresAt === undefined)
295
+ return false;
296
+ if (expiresAt <= Date.now()) {
297
+ spentMppCredentials.delete(credentialId);
298
+ return false;
299
+ }
300
+ return true;
301
+ }
302
+ /**
303
+ * The MPP request path.
304
+ *
305
+ * Kept whole and separate from the x402 path so that with `mpp` unset nothing
306
+ * here runs and the x402 behaviour is unchanged.
307
+ */
308
+ async function handleMppRequest(args) {
309
+ const { req, res, payments, routeConfig, paymentRequired } = args;
310
+ const { planId, credits = 1, agentId, description } = routeConfig;
311
+ const resource = mppResource(req);
312
+ const httpVerb = mppVerb(req);
313
+ const { onPaymentError, onBeforeVerify, onAfterVerify, onAfterSettle } = args.hooks;
314
+ /**
315
+ * One policy for `onPaymentError` on MPP routes: it NOTIFIES, and this
316
+ * middleware keeps ownership of the response.
317
+ *
318
+ * The x402 branch lets the hook take over the response and returns. MPP
319
+ * cannot copy that: the protocol only lets a buyer make progress if the
320
+ * 402 carries a fresh `WWW-Authenticate` challenge, so a seller who wired
321
+ * the hook for observability would silently strip the one thing the
322
+ * documented retry loop needs — and, in the other direction, an unpaid
323
+ * request sent a challenge but never notified the hook at all, so adding
324
+ * `mpp: true` to a working route quietly stopped those events.
325
+ *
326
+ * The hook may still respond itself; every write on this path is guarded
327
+ * on `res.headersSent`, so it wins if it does. A throwing hook is logged
328
+ * rather than propagated — it must not turn into a buyer-visible 500 on
329
+ * the path whose whole job is to hand back a challenge.
330
+ *
331
+ * The hook is INVOKED SYNCHRONOUSLY and only its result is deferred. The
332
+ * sibling `runAfterSettleHook` wraps its call in `Promise.resolve().then()`,
333
+ * which is right there (nothing downstream depends on when the hook runs)
334
+ * and wrong here: the callers below write the response on the very next
335
+ * synchronous line, so deferring the call would make every `res.headersSent`
336
+ * guard read `false` and the middleware would win the race it is supposed
337
+ * to lose. Calling directly inside `try/catch` keeps that ordering and
338
+ * catches a synchronous throw; the `then` on whatever the hook returns
339
+ * covers the async half — the declared return type is `void`, which happily
340
+ * accepts an `async` function whose rejection would otherwise be unhandled
341
+ * and process-fatal under Node's default.
342
+ */
343
+ const notifyPaymentError = (error) => {
344
+ if (!onPaymentError)
345
+ return;
346
+ const logHookFailure = (hookError) => {
347
+ console.error('MPP onPaymentError hook failed:', hookError);
348
+ };
349
+ try {
350
+ const result = onPaymentError(error, req, res);
351
+ if (typeof result?.then === 'function') {
352
+ void Promise.resolve(result).then(() => undefined, logHookFailure);
353
+ }
354
+ }
355
+ catch (hookError) {
356
+ logHookFailure(hookError);
357
+ }
358
+ };
359
+ // Credits are sealed into the challenge, so a credits function is evaluated
360
+ // exactly once — here. MPP has no equivalent of the x402 re-evaluation at
361
+ // settle time; the backend settles the amount the challenge carries.
362
+ //
363
+ // Wrapped for the same reason as the three sites below: `credits` is
364
+ // caller-supplied and a throw in it (a DB lookup, a rate-table fetch) would
365
+ // otherwise reject out of handleMppRequest, which is awaited outside any
366
+ // try/catch, land in Express's default error handler, and hand the buyer a
367
+ // 500 with a stack trace and no challenge — while a configured
368
+ // onPaymentError never fired.
369
+ let creditsToCharge;
370
+ try {
371
+ creditsToCharge = typeof credits === 'function' ? await credits(req, res) : credits;
372
+ }
373
+ catch (creditsError) {
374
+ notifyPaymentError(creditsError);
375
+ console.error('MPP credits evaluation failed:', creditsError);
376
+ if (!res.headersSent) {
377
+ res.status(500).json({
378
+ error: 'Internal Server Error',
379
+ message: 'Unable to determine the price of this resource.',
380
+ });
381
+ }
382
+ return;
383
+ }
384
+ // A bound challenge must be minted, verified and settled against the SAME
385
+ // digest. A request with no body binds nothing, which is correct: there
386
+ // are no bytes to bind.
387
+ //
388
+ // The guard is intentionally FAIL CLOSED: `requestHasBody(req)` recognizes
389
+ // both a non-zero Content-Length AND a bare Transfer-Encoding (chunked —
390
+ // the shape a Content-Length-only check let straight through, unbound and
391
+ // silent). Whenever the request has a body and the raw bytes were never
392
+ // captured, we refuse rather than mint an unbound challenge — the buyer
393
+ // must never get to decide whether the seller's bindBody applies.
394
+ let bodyDigest;
395
+ let bindBodyRefusal;
396
+ if (args.bindBody) {
397
+ const raw = getRawBody(req);
398
+ if (raw === undefined && requestHasBody(req)) {
399
+ const contentType = req.headers['content-type'] ?? '';
400
+ if (contentType.toLowerCase().startsWith('application/json')) {
401
+ // The documented setup is express.json({ verify: captureRawBody }).
402
+ // A JSON body with no captured bytes means that hook was never
403
+ // wired — a seller configuration mistake, not the buyer's fault.
404
+ bindBodyRefusal = {
405
+ status: 500,
406
+ loud: true,
407
+ message: 'paymentMiddleware: mpp.bindBody requires the raw request body. ' +
408
+ "Mount the parser as express.json({ verify: captureRawBody }) — import { captureRawBody } from '@nevermined-io/payments/express'.",
409
+ };
410
+ }
411
+ else {
412
+ // captureRawBody only runs for content-types the mounted parser
413
+ // matches. A buyer sending a content-type this route's parser was
414
+ // never wired for is a client-side mismatch, not a server bug.
415
+ bindBodyRefusal = {
416
+ status: 400,
417
+ loud: false,
418
+ message: `paymentMiddleware: this route requires a captured request body, and Content-Type '${contentType || '(none)'}' is not supported here.`,
419
+ };
420
+ }
421
+ }
422
+ else if (raw) {
423
+ bodyDigest = computeBodyDigest(raw);
424
+ }
425
+ else {
426
+ // No captured bytes AND no body on the wire. Leaving `bodyDigest`
427
+ // undefined here would mint an UNBOUND challenge on a route the seller
428
+ // marked `bindBody: true`, and the backend's `assertBodyDigestMatches`
429
+ // returns early when the challenge carries no digest — so the buyer
430
+ // could mint with an empty request and then attach any body they liked
431
+ // to the paid retry. That is the same "the buyer decides whether
432
+ // bindBody applies" hole the guard above closes for chunked bodies,
433
+ // one branch over.
434
+ //
435
+ // The digest of zero bytes is a well-defined value, so bind it: a
436
+ // bodyless retry reproduces it and passes, and a retry that grew a
437
+ // body computes a different digest and is refused by the backend
438
+ // (BCK.MPP.0005). `captureRawBody` skipping zero-length buffers is
439
+ // what makes "no body" and "empty body" indistinguishable here, and
440
+ // both bind to the same digest, so the two stay equivalent.
441
+ bodyDigest = EMPTY_BODY_DIGEST;
442
+ }
443
+ }
444
+ if (bindBodyRefusal) {
445
+ notifyPaymentError(new Error(bindBodyRefusal.message));
446
+ // Loud in the server-side log either way — a seller can act on both
447
+ // causes — but never as an uncaught throw: that would skip a configured
448
+ // onPaymentError and fall through to Express's default error handler,
449
+ // leaking a stack trace to the client on every request the guard
450
+ // rejects, exactly the failure sendChallenge below guards against too.
451
+ if (bindBodyRefusal.loud) {
452
+ console.error('MPP bindBody misconfiguration:', bindBodyRefusal.message);
453
+ }
454
+ else {
455
+ console.warn('MPP bindBody could not capture the request body:', bindBodyRefusal.message);
456
+ }
457
+ if (!res.headersSent) {
458
+ res.status(bindBodyRefusal.status).json({
459
+ error: bindBodyRefusal.status >= 500 ? 'Internal Server Error' : 'Bad Request',
460
+ message: bindBodyRefusal.message,
461
+ });
462
+ }
463
+ return;
464
+ }
465
+ const sendChallenge = async (message, code) => {
466
+ // issueChallenge itself can fail (e.g. MPP is turned off on this
467
+ // environment: BCK.MPP.0002 -> MppNotConfiguredError). This call site is
468
+ // reached from three places — no credential, rejected credential, and the
469
+ // verifyCredential catch block — so a failure here must never propagate
470
+ // out of handleMppRequest: unhandled it would skip a configured
471
+ // onPaymentError and fall through to Express's default error handler,
472
+ // which leaks a stack trace to the client on every unauthenticated
473
+ // request to the route.
474
+ let challenge;
475
+ try {
476
+ const issued = await payments.mpp.issueChallenge({
477
+ planId,
478
+ // Normalized here (not left to MppAPI.issueChallenge) so the exact
479
+ // wire shape is visible to a mocked payments.mpp in tests, matching
480
+ // the decimal-string contract of IssueMppChallengeParams.credits —
481
+ // and so a NaN/Infinity/non-integer credits function result is
482
+ // rejected before a mocked payments.mpp in a test could hide the
483
+ // defect by never validating it itself.
484
+ credits: normalizeCredits(creditsToCharge),
485
+ ...(agentId && { agentId }),
486
+ resource,
487
+ httpVerb,
488
+ ...(bodyDigest && { digest: bodyDigest }),
489
+ ...(description && { description }),
490
+ });
491
+ challenge = issued.challenge;
492
+ }
493
+ catch (challengeError) {
494
+ notifyPaymentError(challengeError);
495
+ console.error('MPP challenge issuance failed:', challengeError);
496
+ if (!res.headersSent) {
497
+ res.status(500).json({
498
+ error: 'Internal Server Error',
499
+ message: 'Unable to issue an MPP payment challenge. Please try again later.',
500
+ });
501
+ }
502
+ return;
503
+ }
504
+ // An onPaymentError that answered the request itself owns it — see
505
+ // notifyPaymentError. Writing the 402 on top would throw
506
+ // ERR_HTTP_HEADERS_SENT out of an async handler.
507
+ if (res.headersSent)
508
+ return;
509
+ const paymentRequiredBase64 = Buffer.from(JSON.stringify(paymentRequired)).toString('base64');
510
+ res
511
+ .status(402)
512
+ .setHeader(MPP_HEADERS.CHALLENGE, challenge)
513
+ // Advertise x402 on the same 402 so an x402 buyer is unaffected.
514
+ .setHeader(X402_HEADERS.PAYMENT_REQUIRED, paymentRequiredBase64)
515
+ // The backend's own BCK.MPP.* code rides along when we have one, so a
516
+ // buyer can tell "this credential was refused" (terminal — paying
517
+ // again with a fresh one is pointless) from "here is a fresh challenge,
518
+ // pay it" (retryable). This echoes a distinction the backend itself
519
+ // already publishes on the wire; it adds no new detail and does not
520
+ // reopen the "one rejection code" forgery-oracle discipline.
521
+ //
522
+ // `retryable` is carried alongside the code as an explicit wire signal
523
+ // rather than leaving every buyer to hardcode which BCK.MPP.* codes are
524
+ // exceptions to "code present means terminal": BCK.MPP.0004 (expired)
525
+ // and BCK.MPP.0005 (body digest mismatch) are both retryable against
526
+ // the fresh challenge on this same 402, while BCK.MPP.0003 (refused)
527
+ // is not.
528
+ .json({
529
+ error: 'Payment Required',
530
+ message,
531
+ ...(code && { code, retryable: isRetryableMppCode(code) }),
532
+ });
533
+ };
534
+ const credential = extractCredential(req);
535
+ if (!credential) {
536
+ // An ABSENT Authorization header is deliberately NOT routed through
537
+ // onPaymentError, unlike the x402 branch which does notify for a missing
538
+ // token.
539
+ //
540
+ // The two protocols make "unpaid" mean different things. An x402 access
541
+ // token is reusable, so a request arriving without one is an anomaly —
542
+ // a misconfigured client — and worth an error event. In MPP the mint /
543
+ // redeem handshake makes the FIRST request of every payment cycle
544
+ // credential-less by design: notifying there would fire onPaymentError on
545
+ // every successful payment, drowning the rejections, expiries and
546
+ // configuration failures the hook exists to surface.
547
+ //
548
+ // A PRESENT Authorization header that yielded no Payment scheme is the
549
+ // opposite case, and extractCredential returns null for both. It means
550
+ // something between the buyer and here rewrote the header — a gateway
551
+ // injecting its own Bearer, a reverse proxy with its own auth — which
552
+ // puts the buyer in a silent infinite loop: mint, pay, present, header
553
+ // rewritten, fresh challenge, repeat. Every iteration costs the seller a
554
+ // real issueChallenge round-trip, the buyer is never served, and the
555
+ // failure is invisible on the only side that can fix it. So notify on
556
+ // exactly that shape and never on the healthy opening request.
557
+ //
558
+ // Every genuine failure below does notify, and the challenge is sent
559
+ // either way — see notifyPaymentError.
560
+ if (req.headers[MPP_HEADERS.CREDENTIAL]) {
561
+ notifyPaymentError(new Error('An Authorization header was present but carried no MPP Payment scheme. ' +
562
+ 'An intermediary may be rewriting it; the buyer cannot make progress.'));
563
+ }
564
+ await sendChallenge('Payment required. Present the challenge credential in Authorization.');
565
+ return;
566
+ }
567
+ // The identity every guard below keys on. NOT the header bytes: those are
568
+ // buyer-malleable — the scheme match is case-insensitive and returns its
569
+ // slice verbatim, and the body is base64url of JSON the buyer assembles —
570
+ // while the backend collapses every variant onto ONE burn, because its own
571
+ // idempotency key is this same decoded id. Keyed on the bytes, single-use
572
+ // and the in-flight guard both cost what a guard costs and guarantee
573
+ // nothing: flip one byte of case and buy the response again.
574
+ //
575
+ // A credential that carries no usable id is refused here rather than
576
+ // forwarded. The backend rejects it anyway, so this only saves the
577
+ // round-trip — and the alternative, falling back to the raw string as a
578
+ // key, is precisely the guard-shaped hole this replaces.
579
+ const credentialId = mppCredentialId(credential);
580
+ if (!credentialId) {
581
+ const undecodable = new MppCredentialRejectedError('Credential rejected');
582
+ notifyPaymentError(undecodable);
583
+ console.error('MPP credential carries no decodable challenge id; refused at the edge');
584
+ await sendChallenge(undecodable.message, undecodable.code);
585
+ return;
586
+ }
587
+ // Already bought a response. Checked before verifyCredential so a replay
588
+ // costs no backend round-trip, and answered with a FRESH challenge rather
589
+ // than a bare error so the buyer can make progress by paying again — the
590
+ // same shape every other MPP rejection uses. The code is BCK.MPP.0003
591
+ // (credential rejected, non-retryable): a replay is a client-side bug,
592
+ // and a buyer that blindly retried it would loop.
593
+ //
594
+ // This is an optimisation, not the guard: three awaits follow before the
595
+ // claim is taken, so the authoritative check is the one immediately before
596
+ // the claim below.
597
+ if (isCredentialSpent(credentialId)) {
598
+ await sendChallenge('Credential already used. Each credential buys exactly one response; pay the new challenge.', 'BCK.MPP.0003');
599
+ return;
600
+ }
601
+ let verification;
602
+ try {
603
+ // Hook: before verification, mirroring the x402 path (middleware.ts's
604
+ // x402 branch calls it in the same position). Dropped entirely here
605
+ // before this fix: adding mpp: true to a working route silently
606
+ // disabled a documented PaymentMiddlewareOptions hook.
607
+ if (onBeforeVerify) {
608
+ await onBeforeVerify(req, paymentRequired);
609
+ }
610
+ verification = await payments.mpp.verifyCredential({
611
+ credential,
612
+ resource,
613
+ httpVerb,
614
+ ...(bodyDigest && { bodyDigest }),
615
+ });
616
+ }
617
+ catch (error) {
618
+ notifyPaymentError(error);
619
+ // Every MPP rejection — expired, replayed, refused — is answered with a
620
+ // fresh challenge, so a buyer can always make progress by paying again.
621
+ // The backend's BCK.MPP.* code (when the failure carries one) rides
622
+ // along so the buyer can tell which kind of rejection this was; it is
623
+ // confined to BCK.MPP.* so an unrelated code (network_error, http_500,
624
+ // a backend code from a different namespace) is never forwarded as if
625
+ // it were one of ours.
626
+ const code = error instanceof MppError && error.code?.startsWith('BCK.MPP.') ? error.code : undefined;
627
+ // Log the full detail — MppAPI.post folds a backend `hint` onto the
628
+ // error's message — for the seller's own diagnostics. The buyer only
629
+ // ever sees a fixed generic message plus the coarse code: forwarding
630
+ // error.message verbatim would re-widen the anti-oracle discipline
631
+ // src/mpp/errors.ts documents, handing a hint the backend deliberately
632
+ // withheld straight back to the caller most likely to probe for it.
633
+ console.error('MPP credential verification failed:', error);
634
+ await sendChallenge('Credential rejected', code);
635
+ return;
636
+ }
637
+ if (!verification.isValid) {
638
+ // This IS a credential rejection, even though VerifyPermissionsResult
639
+ // carries no code of its own. The wire contract is positional, not
640
+ // incidental: any 402 answering a request that presented a credential
641
+ // must carry a code, or a buyer cannot tell this fresh-but-otherwise-
642
+ // unmarked challenge apart from "you had not paid yet" and mints a
643
+ // second credential for a rejection that already proved terminal.
644
+ // 'BCK.MPP.0003' is the backend's own generic rejection code — nothing
645
+ // new is invented or published beyond it.
646
+ const rejectionError = new MppCredentialRejectedError(verification.invalidReason || 'Credential rejected');
647
+ // A seller who wires onPaymentError is notified of every rejected
648
+ // payment, not just the ones where verifyCredential itself threw — and
649
+ // the hook, being server-side, gets the full invalidReason.
650
+ notifyPaymentError(rejectionError);
651
+ // The BUYER gets the same fixed string the sibling rejection path sends,
652
+ // for the same anti-oracle reason spelled out nineteen lines above:
653
+ // forwarding invalidReason verbatim would hand a hint straight back to
654
+ // the caller most likely to probe for it. Latent today — the backend
655
+ // throws BCK.MPP.0003 with suppressReason rather than returning a soft
656
+ // isValid:false — which is exactly why it must not sit here waiting to
657
+ // un-sanitize itself the moment any backend softens that response.
658
+ console.error('MPP credential rejected:', verification.invalidReason ?? 'no invalidReason provided');
659
+ await sendChallenge('Credential rejected', rejectionError.code);
660
+ return;
661
+ }
662
+ // onAfterVerify runs OUTSIDE the verify try/catch above: a bug in the
663
+ // seller's OWN hook, after the credential has already been proven valid,
664
+ // must never be misreported as a payment rejection (no re-challenge) and
665
+ // must never leak the hook's own exception text into the buyer-visible
666
+ // 402 body the catch above would otherwise build from it.
667
+ if (onAfterVerify) {
668
+ try {
669
+ await onAfterVerify(req, verification);
670
+ }
671
+ catch (hookError) {
672
+ notifyPaymentError(hookError);
673
+ console.error('MPP onAfterVerify hook failed:', hookError);
674
+ if (!res.headersSent) {
675
+ res.status(500).json({
676
+ error: 'Internal Server Error',
677
+ message: 'A server-side hook failed after payment verification.',
678
+ });
679
+ }
680
+ return;
681
+ }
682
+ }
683
+ // The credential is now verified but not yet settled — exactly the window
684
+ // where an idempotent settle makes concurrent delivery cheap: a second
685
+ // request presenting this SAME credential right now would also pass
686
+ // verify (verifyCredential burns nothing) and also get served, while the
687
+ // two settles collapse into a single burn. Refuse it instead.
688
+ //
689
+ // CHECK AND CLAIM ARE ONE STEP, with no `await` between them — that is the
690
+ // whole point of re-checking `isCredentialSpent` here rather than trusting
691
+ // the early check. Three awaits separate the two (onBeforeVerify,
692
+ // verifyCredential, onAfterVerify), and in that gap a concurrent request A
693
+ // holding the same credential can complete, mark itself spent AND release
694
+ // its in-flight claim on 'close'. B, which passed the early check while A
695
+ // was still unspent, would then find both sets empty and be served: two
696
+ // responses, one burn, from nothing more exotic than ordinary latency skew.
697
+ // The window is widest on the streaming branch, where 'close' fires while
698
+ // the settle is still detached.
699
+ if (isCredentialSpent(credentialId)) {
700
+ // A 402 with a fresh challenge, not the 409 below: this credential is
701
+ // finished, so a conflict code would tell the buyer to retry the one
702
+ // thing that can never work again. BCK.MPP.0003 marks it terminal for
703
+ // THIS credential while the challenge on the same response gives them a
704
+ // way forward.
705
+ await sendChallenge('Credential already used. Each credential buys exactly one response; pay the new challenge.', 'BCK.MPP.0003');
706
+ return;
707
+ }
708
+ if (inFlightMppCredentials.has(credentialId)) {
709
+ const error = new MppCredentialRejectedError('This credential is already being processed by a concurrent request.');
710
+ notifyPaymentError(error);
711
+ if (!res.headersSent) {
712
+ res.status(409).json({ error: 'Conflict', message: error.message });
713
+ }
714
+ return;
715
+ }
716
+ inFlightMppCredentials.add(credentialId);
717
+ // Released on 'close', not from inside the settlement promise: 'close'
718
+ // fires whether this request ends in a successful settle, a failed one, a
719
+ // non-2xx handler response that never reaches settlement at all, or the
720
+ // connection simply dropping — the one event that reliably covers every
721
+ // exit from here, so the credential can never be left claimed forever.
722
+ res.on('close', () => {
723
+ inFlightMppCredentials.delete(credentialId);
724
+ });
725
+ // This listener is registered AFTER three awaits (onBeforeVerify,
726
+ // verifyCredential, onAfterVerify). A buyer who disconnected while those
727
+ // were in flight has already made `res` emit 'close' — Node emits it once —
728
+ // so the listener above would never fire and the credential would stay
729
+ // claimed for the lifetime of the process. That is worse than a leak: the
730
+ // credential was never settled, so the buyer still owns an unspent claim,
731
+ // and every legitimate retry with it would 409 forever.
732
+ if (res.closed) {
733
+ inFlightMppCredentials.delete(credentialId);
734
+ }
735
+ const paymentContext = {
736
+ token: credential,
737
+ paymentRequired,
738
+ creditsToSettle: creditsToCharge,
739
+ verified: true,
740
+ agentRequest: verification.agentRequest,
741
+ agentRequestId: verification.agentRequest?.agentRequestId || verification.agentRequestId,
742
+ mpp: { credential, resource, httpVerb },
743
+ };
744
+ req.paymentContext = paymentContext;
745
+ const originalEnd = res.end.bind(res);
746
+ let settlementStarted = false;
747
+ // Armed HERE, before next() hands control to the handler — it captures a
748
+ // byte baseline, so it has to run first. See armDeliveryProbe.
749
+ const hasBeenDelivered = armDeliveryProbe(res);
750
+ // A seller's own onAfterSettle can throw, and where that lands differed by
751
+ // branch: on the success path it rejected into the settlement .catch() and
752
+ // was logged as "MPP settlement failed" (blaming the backend for a hook
753
+ // bug), and on the timeout branch — which runs INSIDE that .catch(), with
754
+ // nothing downstream — it escaped as an unhandled rejection, process-fatal
755
+ // under Node's default. Both now log and continue: the settle has already
756
+ // happened either way, and the worst moment to take the process down is
757
+ // the branch where credits may have been burned and the seller most needs
758
+ // the record.
759
+ // `Promise.resolve().then(() => hook(...))` rather than
760
+ // `Promise.resolve(hook(...))`: the latter CALLS the hook before any
761
+ // promise wraps it, so a SYNCHRONOUS throw — the ordinary shape of a hook
762
+ // bug — escapes past the rejection handler below entirely.
763
+ const runAfterSettleHook = (credits, settlement) => Promise.resolve()
764
+ .then(() => onAfterSettle(req, credits, settlement))
765
+ .then(() => undefined, (hookError) => {
766
+ console.error('MPP onAfterSettle hook failed:', hookError);
767
+ });
768
+ const runSettlement = () => payments.mpp
769
+ .settleCredential({
770
+ credential,
771
+ resource,
772
+ httpVerb,
773
+ ...(bodyDigest && { bodyDigest }),
774
+ })
775
+ .then((settlement) => {
776
+ // Never call res.setHeader with an undefined value: MppSettleResult's
777
+ // type says success: true implies a string paymentReceipt, but a
778
+ // real backend response isn't statically checked, so this is
779
+ // defensive too. Distinguishing the three cases matters: a genuine
780
+ // settlement failure, a successful settle that (contrary to the
781
+ // type) carried no receipt, and the normal success-with-receipt
782
+ // path each get their own accurate log — collapsing them all into
783
+ // "MPP settlement failed" pointed an on-call engineer at the wrong
784
+ // system when the burn had actually succeeded.
785
+ if (settlement.success && settlement.paymentReceipt) {
786
+ if (!res.headersSent) {
787
+ // Guarded because this is inside the SUCCESS branch of a call
788
+ // that burns: res.setHeader throws ERR_INVALID_CHAR on a value
789
+ // carrying CR/LF or a non-latin1 character, and an unguarded
790
+ // throw here would fall into the terminal .catch() and log a
791
+ // definitively-burned settlement as "MPP settlement failed",
792
+ // skipping onAfterSettle for credits that are gone. A receipt we
793
+ // cannot attach is a lost header; it is not a lost burn.
794
+ try {
795
+ res.setHeader(MPP_HEADERS.RECEIPT, settlement.paymentReceipt);
796
+ }
797
+ catch (headerError) {
798
+ console.error('[paymentMiddleware] MPP settlement succeeded but its receipt could not be attached as a header:', headerError);
799
+ }
800
+ }
801
+ else {
802
+ console.warn('[paymentMiddleware] headers already flushed; Payment-Receipt not attached');
803
+ }
804
+ }
805
+ else if (!settlement.success) {
806
+ console.error('MPP settlement failed:', settlement.errorReason ?? 'no errorReason provided');
807
+ }
808
+ else {
809
+ console.warn('[paymentMiddleware] MPP settlement succeeded but carried no paymentReceipt');
810
+ }
811
+ // The amount actually burned is whatever the challenge sealed on an
812
+ // EARLIER request, reported back on the settlement — not
813
+ // creditsToCharge, which is recomputed on THIS request and can
814
+ // diverge from the minting request when `credits` is a function
815
+ // (evaluated once per request, so at least twice per payment cycle).
816
+ // A FAILED settle burned nothing, so it reports 0 rather than
817
+ // falling back to creditsToCharge — a seller writing usage or
818
+ // revenue records off this argument would otherwise over-report
819
+ // every failed settlement at the full charge.
820
+ //
821
+ // `creditsRedeemed` is a decimal string precisely because the amount
822
+ // can exceed what a JS number represents (see normalizeCredits).
823
+ // `Number()` narrows it for PaymentContext.creditsToSettle and
824
+ // onAfterSettle, which are typed `number` on the shared x402 path;
825
+ // an unparseable value would otherwise hand `NaN` straight to the
826
+ // seller's accounting hook with no signal, so it is treated as
827
+ // "the backend did not report an amount" and logged.
828
+ let creditsSettled;
829
+ if (!settlement.success) {
830
+ creditsSettled = 0;
831
+ }
832
+ else if (settlement.creditsRedeemed !== undefined) {
833
+ const redeemed = Number(settlement.creditsRedeemed);
834
+ if (Number.isFinite(redeemed)) {
835
+ creditsSettled = redeemed;
836
+ }
837
+ else {
838
+ console.warn(`[paymentMiddleware] MPP settlement reported an unusable creditsRedeemed (${String(settlement.creditsRedeemed)}); reporting the charged amount instead`);
839
+ creditsSettled = creditsToCharge;
840
+ }
841
+ }
842
+ else {
843
+ // A successful settle that reported no amount at all. Falling back
844
+ // to creditsToCharge is the best available answer — but it is a
845
+ // GUESS, recomputed on this request and free to diverge from what
846
+ // the challenge sealed whenever `credits` is a function, so the
847
+ // seller's revenue record must not be told it is a measurement.
848
+ // The sibling branch above warns for the same uncertainty.
849
+ console.warn('[paymentMiddleware] MPP settlement reported no creditsRedeemed; reporting the charged amount instead');
850
+ creditsSettled = creditsToCharge;
851
+ }
852
+ paymentContext.creditsToSettle = creditsSettled;
853
+ if (onAfterSettle) {
854
+ return runAfterSettleHook(creditsSettled, settlement);
855
+ }
856
+ return undefined;
857
+ })
858
+ .catch((settleError) => {
859
+ if (settleError instanceof MppSettlementOutcomeUnknownError) {
860
+ // Settlement is the one MPP call that burns: this rejection means
861
+ // OUR OWN deadline fired, not that the backend rejected anything —
862
+ // the credits may already be burned even though this request
863
+ // never heard back. Logging it through the same line as a genuine
864
+ // failure would tell an on-call engineer nothing was charged when
865
+ // it may well have been, and skipping onAfterSettle here would
866
+ // make a real burn vanish from the seller's own accounting.
867
+ console.warn('MPP settlement outcome unknown (request timed out before the backend responded; credits may have been burned):', settleError.message);
868
+ if (onAfterSettle) {
869
+ const outcome = {
870
+ outcome: 'unknown',
871
+ reason: settleError.message,
872
+ };
873
+ return runAfterSettleHook(creditsToCharge, outcome);
874
+ }
875
+ return undefined;
876
+ }
877
+ // A DEFINITE failure: the resource has been served and the seller has
878
+ // not been paid — the most expensive thing that can happen to them.
879
+ // stdout was previously the only record of it. onPaymentError is
880
+ // documented as the handler for payment failures, so notify through
881
+ // it; this is the one MPP failure site that runs after the response,
882
+ // so the hook cannot answer the request and is purely a signal.
883
+ //
884
+ // creditsToSettle is reset with it. It still held the optimistic
885
+ // creditsToCharge from before the settle, so anything reading
886
+ // paymentContext from res.on('finish') was told the full amount had
887
+ // settled when nothing had — while the resolved-but-failed branch
888
+ // above is scrupulous about reporting 0. The two now agree.
889
+ paymentContext.creditsToSettle = 0;
890
+ notifyPaymentError(settleError);
891
+ console.error('MPP settlement failed:', settleError);
892
+ });
893
+ res.end = function (...endArgs) {
894
+ const isSuccess = res.statusCode >= 200 && res.statusCode < 300;
895
+ // MPP's stake in this: MppAPI exposes no void, refund or reversal, so a
896
+ // burn for a response nobody received is permanent — and in the other
897
+ // direction a streamed payload the buyer already read must still be paid
898
+ // for, or the credential stays spendable for the rest of the 300s
899
+ // challenge TTL. markdown/mpp-integration.md states the invariant as "a
900
+ // credential is only marked spent when a 2xx is actually delivered for
901
+ // it"; this is where that becomes true, in both directions.
902
+ const isDelivered = hasBeenDelivered();
903
+ if (settlementStarted || !isSuccess || !isDelivered) {
904
+ if (isSuccess && !isDelivered && !settlementStarted) {
905
+ console.warn('[paymentMiddleware] MPP: the buyer disconnected before any of the response reached them; not settling, credential left unspent');
906
+ }
907
+ return originalEnd(...endArgs);
908
+ }
909
+ settlementStarted = true;
910
+ // Spent at the moment a response is actually being delivered for this
911
+ // credential, not when its settle resolves. Marking here rather than in
912
+ // the settlement callback is what covers the streaming branch below,
913
+ // where settlement is fired detached and the response closes (releasing
914
+ // the in-flight claim) while the settle is still outstanding — a second
915
+ // request arriving in that window would otherwise be served too, and
916
+ // both settles would collapse onto one burn.
917
+ //
918
+ // Only reached on a 2xx: a non-2xx never settles, so its credential is
919
+ // still unspent and the buyer can legitimately retry with it.
920
+ markCredentialSpent(credentialId);
921
+ if (res.headersSent) {
922
+ void runSettlement();
923
+ return originalEnd(...endArgs);
924
+ }
925
+ runSettlement().finally(() => {
926
+ // The buffered branch holds the response until the settle resolves, and
927
+ // REQUEST_TIMEOUT_MS is 30s — so a buyer whose own deadline is shorter
928
+ // can hang up while the burn is in flight. The gate above cannot catch
929
+ // that one: at the moment we decided to settle, the socket was alive.
930
+ //
931
+ // There is no reversal to call, so the honest handling is to make the
932
+ // loss VISIBLE to the side that can compensate for it rather than let a
933
+ // burn-without-delivery look like an ordinary completed request.
934
+ if (res.destroyed || res.closed) {
935
+ const undelivered = new Error('MPP: the buyer disconnected while settlement was in flight. The credits may have been burned for a response they never received, and MPP has no reversal.');
936
+ console.error(undelivered.message);
937
+ notifyPaymentError(undelivered);
938
+ }
939
+ originalEnd(...endArgs);
940
+ });
941
+ return res;
942
+ };
943
+ args.next();
944
+ }
150
945
  export function paymentMiddleware(payments, routes, options = {}) {
151
946
  const { tokenHeader = DEFAULT_TOKEN_HEADERS, onPaymentError, onBeforeVerify, onAfterVerify, onAfterSettle, } = options;
152
947
  return (req, res, next) => {
@@ -176,6 +971,27 @@ export function paymentMiddleware(payments, routes, options = {}) {
176
971
  });
177
972
  // Extract token from headers (x402 v2: payment-signature)
178
973
  const token = extractToken(req, tokenHeader);
974
+ const mppOption = resolveMppOption(routeConfig.mpp);
975
+ // The 402 an MPP-enabled route sends advertises both a WWW-Authenticate
976
+ // challenge and the x402 payment-required header, so both protocols
977
+ // must be payable, not just the one that minted the 402. An MPP
978
+ // credential always takes the MPP path; with no MPP credential but a
979
+ // valid x402 token present, fall through to the existing x402 flow
980
+ // below unchanged. With neither present, handleMppRequest still runs so
981
+ // the 402 keeps advertising both.
982
+ if (mppOption.enabled && (extractCredential(req) || !token)) {
983
+ await handleMppRequest({
984
+ req,
985
+ res,
986
+ next,
987
+ payments,
988
+ routeConfig,
989
+ paymentRequired,
990
+ bindBody: mppOption.bindBody,
991
+ hooks: { onPaymentError, onBeforeVerify, onAfterVerify, onAfterSettle },
992
+ });
993
+ return;
994
+ }
179
995
  if (!token) {
180
996
  const error = new Error('Payment required: missing x402 access token');
181
997
  if (onPaymentError) {
@@ -228,6 +1044,13 @@ export function paymentMiddleware(payments, routes, options = {}) {
228
1044
  // without emitting the payment-response receipt header (#1728).
229
1045
  const originalEnd = res.end.bind(res);
230
1046
  let settlementStarted = false;
1047
+ // Armed before next() for the same reason as the MPP path: it captures
1048
+ // a byte baseline. A route declared `{ planId, credits, mpp: true }`
1049
+ // advertises BOTH protocols on the same 402, so without this an MPP
1050
+ // buyer who disconnects before delivery is correctly not charged while
1051
+ // an x402 buyer doing exactly the same thing is — same route, same
1052
+ // disconnect, two different answers.
1053
+ const hasBeenDelivered = armDeliveryProbe(res);
231
1054
  const runSettlement = () => {
232
1055
  return (typeof credits === 'function'
233
1056
  ? Promise.resolve(credits(req, res))
@@ -269,7 +1092,14 @@ export function paymentMiddleware(payments, routes, options = {}) {
269
1092
  // etc. Skipping 4xx/5xx avoids charging when the handler signals
270
1093
  // failure — including `sendPaymentRequired`'s 402 which lands here.
271
1094
  const isSuccess = res.statusCode >= 200 && res.statusCode < 300;
272
- if (settlementStarted || !isSuccess) {
1095
+ // And only when the response is actually reaching the buyer — an
1096
+ // aborted request still carries 200 through this wrapper, so the
1097
+ // status alone would charge the card for bytes nobody received.
1098
+ const isDelivered = hasBeenDelivered();
1099
+ if (settlementStarted || !isSuccess || !isDelivered) {
1100
+ if (isSuccess && !isDelivered && !settlementStarted) {
1101
+ console.warn('[paymentMiddleware] x402: the buyer disconnected before any of the response reached them; not settling');
1102
+ }
273
1103
  return originalEnd(...args);
274
1104
  }
275
1105
  settlementStarted = true;