@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.
- package/dist/api/nvm-api.d.ts +4 -0
- package/dist/api/nvm-api.d.ts.map +1 -1
- package/dist/api/nvm-api.js +7 -0
- package/dist/api/nvm-api.js.map +1 -1
- package/dist/api/organizations-api/organizations-api.d.ts +19 -1
- package/dist/api/organizations-api/organizations-api.d.ts.map +1 -1
- package/dist/api/organizations-api/organizations-api.js +53 -0
- package/dist/api/organizations-api/organizations-api.js.map +1 -1
- package/dist/api/organizations-api/types.d.ts +33 -0
- package/dist/api/organizations-api/types.d.ts.map +1 -1
- package/dist/api/organizations-api/types.js.map +1 -1
- package/dist/common/api-version.d.ts +1 -1
- package/dist/common/api-version.js +1 -1
- package/dist/common/api-version.js.map +1 -1
- package/dist/common/types.d.ts +1 -1
- package/dist/common/types.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/core/server-manager.d.ts.map +1 -1
- package/dist/mcp/core/server-manager.js +3 -0
- package/dist/mcp/core/server-manager.js.map +1 -1
- package/dist/mcp/http/mcp-handler.d.ts +7 -0
- package/dist/mcp/http/mcp-handler.d.ts.map +1 -1
- package/dist/mcp/http/mcp-handler.js +8 -1
- package/dist/mcp/http/mcp-handler.js.map +1 -1
- package/dist/mcp/http/oauth-metadata.d.ts.map +1 -1
- package/dist/mcp/http/oauth-metadata.js +31 -6
- package/dist/mcp/http/oauth-metadata.js.map +1 -1
- package/dist/mcp/http/oauth-router.d.ts +17 -16
- package/dist/mcp/http/oauth-router.d.ts.map +1 -1
- package/dist/mcp/http/oauth-router.js +56 -28
- package/dist/mcp/http/oauth-router.js.map +1 -1
- package/dist/mpp/codec.d.ts +92 -0
- package/dist/mpp/codec.d.ts.map +1 -0
- package/dist/mpp/codec.js +405 -0
- package/dist/mpp/codec.js.map +1 -0
- package/dist/mpp/errors.d.ts +165 -0
- package/dist/mpp/errors.d.ts.map +1 -0
- package/dist/mpp/errors.js +177 -0
- package/dist/mpp/errors.js.map +1 -0
- package/dist/mpp/fetch.d.ts +196 -0
- package/dist/mpp/fetch.d.ts.map +1 -0
- package/dist/mpp/fetch.js +493 -0
- package/dist/mpp/fetch.js.map +1 -0
- package/dist/mpp/index.d.ts +11 -0
- package/dist/mpp/index.d.ts.map +1 -0
- package/dist/mpp/index.js +20 -0
- package/dist/mpp/index.js.map +1 -0
- package/dist/mpp/mpp-api.d.ts +153 -0
- package/dist/mpp/mpp-api.d.ts.map +1 -0
- package/dist/mpp/mpp-api.js +298 -0
- package/dist/mpp/mpp-api.js.map +1 -0
- package/dist/mpp/types.d.ts +43 -0
- package/dist/mpp/types.d.ts.map +1 -0
- package/dist/mpp/types.js +9 -0
- package/dist/mpp/types.js.map +1 -0
- package/dist/payments.d.ts +13 -0
- package/dist/payments.d.ts.map +1 -1
- package/dist/payments.js +3 -0
- package/dist/payments.js.map +1 -1
- package/dist/x402/delegation-api.d.ts +3 -1
- package/dist/x402/delegation-api.d.ts.map +1 -1
- package/dist/x402/delegation-api.js.map +1 -1
- package/dist/x402/express/index.d.ts +2 -0
- package/dist/x402/express/index.d.ts.map +1 -1
- package/dist/x402/express/index.js +2 -0
- package/dist/x402/express/index.js.map +1 -1
- package/dist/x402/express/middleware.d.ts +33 -1
- package/dist/x402/express/middleware.d.ts.map +1 -1
- package/dist/x402/express/middleware.js +831 -1
- package/dist/x402/express/middleware.js.map +1 -1
- package/dist/x402/express/mpp-support.d.ts +58 -0
- package/dist/x402/express/mpp-support.d.ts.map +1 -0
- package/dist/x402/express/mpp-support.js +67 -0
- package/dist/x402/express/mpp-support.js.map +1 -0
- package/dist/x402/express/raw-body.d.ts +24 -0
- package/dist/x402/express/raw-body.d.ts.map +1 -0
- package/dist/x402/express/raw-body.js +34 -0
- package/dist/x402/express/raw-body.js.map +1 -0
- package/dist/x402/token-request.d.ts +15 -0
- package/dist/x402/token-request.d.ts.map +1 -0
- package/dist/x402/token-request.js +66 -0
- package/dist/x402/token-request.js.map +1 -0
- package/dist/x402/token.d.ts.map +1 -1
- package/dist/x402/token.js +7 -51
- package/dist/x402/token.js.map +1 -1
- 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
|
-
|
|
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;
|