mppx 0.13.3 → 0.13.5

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 (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/Errors.d.ts +63 -2
  3. package/dist/Errors.js +52 -2
  4. package/dist/Method.d.ts +10 -0
  5. package/dist/cli/cli.js +133 -133
  6. package/dist/cli/plugins/index.js +12 -12
  7. package/dist/cli/validate/helpers.d.ts +1 -0
  8. package/dist/cli/validate/helpers.js +2 -1
  9. package/dist/client/Mppx.d.ts +2 -0
  10. package/dist/client/Mppx.js +8 -10
  11. package/dist/client/internal/Fetch.js +107 -8
  12. package/dist/client/internal/MethodResponse.d.ts +6 -0
  13. package/dist/client/internal/MethodResponse.js +16 -0
  14. package/dist/client/internal/protocols/Mcp.js +36 -5
  15. package/dist/evm/server/Charge.js +3 -1
  16. package/dist/internal/version.d.ts +2 -2
  17. package/dist/internal/version.js +1 -1
  18. package/dist/internal/warnOnce.d.ts +3 -0
  19. package/dist/internal/warnOnce.js +9 -0
  20. package/dist/server/Mppx.d.ts +6 -1
  21. package/dist/server/Mppx.js +20 -16
  22. package/dist/server/Transport.js +10 -0
  23. package/dist/server/internal/challengeMeta.d.ts +5 -0
  24. package/dist/server/internal/challengeMeta.js +14 -0
  25. package/dist/tempo/Attribution.d.ts +24 -9
  26. package/dist/tempo/Attribution.js +25 -5
  27. package/dist/tempo/client/Charge.d.ts +116 -10
  28. package/dist/tempo/client/Charge.js +116 -22
  29. package/dist/tempo/client/Methods.d.ts +88 -9
  30. package/dist/tempo/index.d.ts +1 -0
  31. package/dist/tempo/index.js +1 -0
  32. package/dist/tempo/internal/fee-payer.js +4 -0
  33. package/dist/tempo/server/Charge.d.ts +11 -0
  34. package/dist/tempo/server/Charge.js +160 -29
  35. package/dist/tempo/server/Methods.d.ts +5 -0
  36. package/dist/tempo/server/Methods.js +18 -4
  37. package/dist/tempo/server/Subscription.js +2 -1
  38. package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
  39. package/dist/tempo/server/internal/fallback-store.js +13 -0
  40. package/dist/tempo/server/internal/html.gen.js +1 -1
  41. package/dist/tempo/session/client/Transports.js +39 -12
  42. package/dist/tempo/session/precompile/Chain.d.ts +4 -0
  43. package/dist/tempo/session/precompile/Chain.js +4 -1
  44. package/dist/tempo/session/server/Session.js +12 -11
  45. package/dist/tempo/session/server/Settlement.d.ts +7 -0
  46. package/dist/tempo/session/server/Settlement.js +33 -8
  47. package/dist/x402/server/EvmCharge.js +10 -3
  48. package/package.json +1 -1
@@ -37,6 +37,7 @@ export type Counts = {
37
37
  skipped: number;
38
38
  };
39
39
  export declare function printResults(results: CheckResult[], counts: Counts): void;
40
+ /** Fetches the validation target without allowing it to redirect probes elsewhere. */
40
41
  export declare function fetchWithTimeout(url: RequestInfo | URL, init: RequestInit, timeoutMs?: number): Promise<Response>;
41
42
  export declare function formatBytes(bytes: number): string;
42
43
  export declare const HTTP_METHODS: Set<string>;
@@ -57,11 +57,12 @@ export function printResults(results, counts) {
57
57
  counts.skipped++;
58
58
  }
59
59
  }
60
+ /** Fetches the validation target without allowing it to redirect probes elsewhere. */
60
61
  export async function fetchWithTimeout(url, init, timeoutMs = 15_000) {
61
62
  const controller = new AbortController();
62
63
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
63
64
  try {
64
- return await fetch(url, { ...init, signal: controller.signal });
65
+ return await fetch(url, { ...init, redirect: 'error', signal: controller.signal });
65
66
  }
66
67
  finally {
67
68
  clearTimeout(timeout);
@@ -62,6 +62,8 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
62
62
  transport: transport;
63
63
  /**
64
64
  * Selects a payment challenge without creating its credential.
65
+ * Native HTTP responses in payment events preserve status and headers only;
66
+ * the caller retains ownership of the original response body.
65
67
  *
66
68
  * @example
67
69
  * ```ts
@@ -405,17 +405,15 @@ function snapshotMethod(method) {
405
405
  }
406
406
  function snapshotResponse(response) {
407
407
  if (response instanceof Response) {
408
- try {
408
+ if (response.body === null)
409
409
  return response.clone();
410
- }
411
- catch {
412
- // A consumed body cannot be cloned, but status and headers are sufficient for event context.
413
- return new Response(null, {
414
- headers: response.headers,
415
- status: response.status,
416
- statusText: response.statusText,
417
- });
418
- }
410
+ // Event context can outlive an SSE response. Retaining a clone would queue
411
+ // every chunk consumed by the caller on an unread body branch.
412
+ return new Response(null, {
413
+ headers: response.headers,
414
+ status: response.status,
415
+ statusText: response.statusText,
416
+ });
419
417
  }
420
418
  return snapshotValue(response);
421
419
  }
@@ -1,5 +1,6 @@
1
1
  import * as Challenge from '../../Challenge.js';
2
2
  import * as Constants from '../../Constants.js';
3
+ import { PaymentOutcomeUnknownError } from '../../Errors.js';
3
4
  import * as Expires from '../../Expires.js';
4
5
  import * as AcceptPayment from '../../internal/AcceptPayment.js';
5
6
  import * as Transport from '../Transport.js';
@@ -10,8 +11,64 @@ import * as MethodResponse from './MethodResponse.js';
10
11
  // an unrelated fetch installed by user code or another library.
11
12
  const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper');
12
13
  const defaultMaxPaymentRetries = 3;
14
+ const maxErrorBodyBytes = 64 * 1024;
15
+ const errorBodyTimeoutMs = 2_000;
16
+ /** Response header set when a paid-retry error body was cut off by {@link bufferErrorBody}. */
17
+ const bodyTruncatedHeader = 'Mppx-Body-Truncated';
13
18
  let originalFetch;
14
19
  const eventObserverChecks = new WeakMap();
20
+ /**
21
+ * Buffers a paid-retry error body within a size and time bound so its connection is
22
+ * released. A complete body stays in the original response. The body is diagnostic
23
+ * only: one larger than 64 KiB, or still arriving after 2 seconds, is cancelled and
24
+ * replaced with what arrived, marked with an `Mppx-Body-Truncated: true` header.
25
+ */
26
+ async function bufferErrorBody(response) {
27
+ if (!response.body)
28
+ return response;
29
+ // Reading a clone pulls the shared source, buffering it in the original response.
30
+ const reader = response.clone().body.getReader();
31
+ const chunks = [];
32
+ let received = 0;
33
+ let truncated = false;
34
+ const timeout = setTimeout(() => {
35
+ truncated = true;
36
+ void reader.cancel().catch(() => { });
37
+ }, errorBodyTimeoutMs);
38
+ try {
39
+ for (;;) {
40
+ const { done, value } = await reader.read();
41
+ if (done)
42
+ break;
43
+ chunks.push(value);
44
+ received += value.byteLength;
45
+ if (received > maxErrorBodyBytes) {
46
+ truncated = true;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ finally {
52
+ clearTimeout(timeout);
53
+ // A tee's cancellation can wait for the other branch; do not await it.
54
+ void reader.cancel().catch(() => { });
55
+ }
56
+ if (!truncated)
57
+ return response;
58
+ // Cancelling both branches cancels the source and releases the connection.
59
+ void response.body.cancel().catch(() => { });
60
+ const headers = new Headers(response.headers);
61
+ headers.delete('content-length');
62
+ headers.set(bodyTruncatedHeader, 'true');
63
+ const truncatedResponse = new Response(new Blob(chunks).slice(0, maxErrorBodyBytes), {
64
+ headers,
65
+ status: response.status,
66
+ statusText: response.statusText,
67
+ });
68
+ for (const key of ['redirected', 'type', 'url'])
69
+ Object.defineProperty(truncatedResponse, key, { value: response[key] });
70
+ return truncatedResponse;
71
+ }
15
72
  async function settleAttempt(prepared, outcome) {
16
73
  const settle = prepared?.attempt.settle;
17
74
  if (!settle)
@@ -67,6 +124,8 @@ export function from(config) {
67
124
  let challenges;
68
125
  let mi;
69
126
  let preparedCredential;
127
+ // Set when the paid request throws after the credential was sent.
128
+ let outcomeUnknown;
70
129
  try {
71
130
  for (let retry = 0; retry < maxPaymentRetries; retry++) {
72
131
  challenges = await transport.getChallenges(response, transportRequest);
@@ -166,14 +225,26 @@ export function from(config) {
166
225
  method: selected.method,
167
226
  response,
168
227
  }));
169
- response = await baseFetch(paymentInput, {
170
- ...transport.setCredential({
171
- ...fetchInit,
172
- headers: initialRequest.headers,
173
- }, credential, { challenge: selectedChallenge }),
174
- // Runtimes do not strip custom payment headers on cross-origin redirects.
175
- redirect: 'manual',
228
+ const unknownOutcome = (cause) => new PaymentOutcomeUnknownError({
229
+ cause,
230
+ challenge: selectedChallenge,
231
+ credential,
232
+ transactionHash: MethodResponse.getTransactionHash(selected.method, credential),
176
233
  });
234
+ try {
235
+ response = await baseFetch(paymentInput, {
236
+ ...transport.setCredential({
237
+ ...fetchInit,
238
+ headers: initialRequest.headers,
239
+ }, credential, { challenge: selectedChallenge }),
240
+ // Runtimes do not strip custom payment headers on cross-origin redirects.
241
+ redirect: 'manual',
242
+ });
243
+ }
244
+ catch (cause) {
245
+ outcomeUnknown = unknownOutcome(cause);
246
+ throw cause;
247
+ }
177
248
  const paymentRequired = await transport.isPaymentRequired(response, transportRequest);
178
249
  if (!paymentRequired) {
179
250
  await settleAttempt(prepared, {
@@ -198,6 +269,33 @@ export function from(config) {
198
269
  response,
199
270
  }));
200
271
  if (!paymentRequired) {
272
+ // The server may have settled before failing: report the credential for
273
+ // reconciliation and return the response. Retrying could pay twice.
274
+ if (response.status >= 500 && hasEventObservers(events, 'payment.failed')) {
275
+ try {
276
+ // Release the connection before observers can make requests through it.
277
+ response = await bufferErrorBody(response);
278
+ }
279
+ catch (cause) {
280
+ outcomeUnknown = unknownOutcome(cause);
281
+ // The failed body cannot be cloned for observers; keep its status and headers.
282
+ response = new Response(null, {
283
+ headers: response.headers,
284
+ status: response.status,
285
+ statusText: response.statusText,
286
+ });
287
+ throw cause;
288
+ }
289
+ await events.emit('payment.failed', createPaymentFailedPayload({
290
+ challenge: selectedChallenge,
291
+ challenges,
292
+ error: unknownOutcome(),
293
+ init,
294
+ input,
295
+ method: selected.method,
296
+ response,
297
+ }));
298
+ }
201
299
  if (!response.ok)
202
300
  return response;
203
301
  return MethodResponse.handle(selected.method, {
@@ -237,7 +335,8 @@ export function from(config) {
237
335
  await events.emit('payment.failed', createPaymentFailedPayload({
238
336
  challenge,
239
337
  challenges,
240
- error,
338
+ // Observers get the credential to reconcile; callers keep the original error.
339
+ error: outcomeUnknown ?? error,
241
340
  init,
242
341
  input,
243
342
  method: mi,
@@ -23,6 +23,8 @@ export type HandlerParameters = {
23
23
  response: Response;
24
24
  signal?: AbortSignal | undefined;
25
25
  };
26
+ /** Derives the on-chain hash a credential's payment will have, when it is final before settlement. */
27
+ export type TransactionHash = (credential: string) => string | undefined;
26
28
  /** Internal client-method response adapter. */
27
29
  export type Handler = (parameters: HandlerParameters) => MaybePromise<Response>;
28
30
  /** Registers an internal response adapter without changing the public method shape. */
@@ -35,4 +37,8 @@ export declare function attachAttempt(method: Method.AnyClient, parameters: obje
35
37
  export declare function getAttempt(parameters: object): Attempt | undefined;
36
38
  /** Lets the selected client method handle a successful paid response. */
37
39
  export declare function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response>;
40
+ /** Registers credential hash derivation without changing the public method shape. */
41
+ export declare function registerTransactionHash<const method extends Method.AnyClient>(method: method, derive: TransactionHash): method;
42
+ /** Returns the credential's on-chain hash when its method can derive it. */
43
+ export declare function getTransactionHash(method: Method.AnyClient, credential: string): string | undefined;
38
44
  //# sourceMappingURL=MethodResponse.d.ts.map
@@ -1,5 +1,6 @@
1
1
  const handlers = new WeakMap();
2
2
  const attempts = new WeakMap();
3
+ const transactionHashes = new WeakMap();
3
4
  /** Registers an internal response adapter without changing the public method shape. */
4
5
  export function register(method, handler) {
5
6
  handlers.set(method, handler);
@@ -22,4 +23,19 @@ export function getAttempt(parameters) {
22
23
  export function handle(method, parameters) {
23
24
  return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response);
24
25
  }
26
+ /** Registers credential hash derivation without changing the public method shape. */
27
+ export function registerTransactionHash(method, derive) {
28
+ transactionHashes.set(method, derive);
29
+ return method;
30
+ }
31
+ /** Returns the credential's on-chain hash when its method can derive it. */
32
+ export function getTransactionHash(method, credential) {
33
+ try {
34
+ return transactionHashes.get(method)?.(credential);
35
+ }
36
+ catch {
37
+ // Reconciliation metadata must never mask the payment outcome itself.
38
+ return undefined;
39
+ }
40
+ }
25
41
  //# sourceMappingURL=MethodResponse.js.map
@@ -18,6 +18,8 @@ function jsonRpcRequestId(body) {
18
18
  return undefined;
19
19
  }
20
20
  }
21
+ // Bound automatic inspection; the caller retains the original response stream.
22
+ const maxInspectionBytes = 1024 * 1024;
21
23
  const responseCache = new WeakMap();
22
24
  function mcpHttpRequestId(request) {
23
25
  const id = jsonRpcRequestId(request?.body);
@@ -94,6 +96,7 @@ async function parseSseJsonRpcResponse(response) {
94
96
  if (!reader)
95
97
  return undefined;
96
98
  const decoder = new TextDecoder();
99
+ let received = 0;
97
100
  let dataEventSeen = false;
98
101
  let message;
99
102
  const parser = createParser({
@@ -119,6 +122,9 @@ async function parseSseJsonRpcResponse(response) {
119
122
  parser.reset({ consume: true });
120
123
  return message;
121
124
  }
125
+ received += value.byteLength;
126
+ if (received > maxInspectionBytes)
127
+ return undefined;
122
128
  parser.feed(decoder.decode(value, { stream: true }));
123
129
  }
124
130
  }
@@ -126,6 +132,33 @@ async function parseSseJsonRpcResponse(response) {
126
132
  void reader.cancel().catch(() => { });
127
133
  }
128
134
  }
135
+ /** Reads only a bounded JSON response, leaving the caller's stream untouched. */
136
+ async function parseJsonResponse(response) {
137
+ const reader = response.clone().body?.getReader();
138
+ if (!reader)
139
+ return undefined;
140
+ const decoder = new TextDecoder();
141
+ let received = 0;
142
+ let text = '';
143
+ try {
144
+ for (;;) {
145
+ const { done, value } = await reader.read();
146
+ if (done)
147
+ return parseMessage(JSON.parse(text + decoder.decode()));
148
+ received += value.byteLength;
149
+ if (received > maxInspectionBytes)
150
+ return undefined;
151
+ text += decoder.decode(value, { stream: true });
152
+ }
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ finally {
158
+ // A tee's cancellation can wait for the original stream; do not await it.
159
+ void reader.cancel().catch(() => { });
160
+ }
161
+ }
129
162
  /** Reads a cloned HTTP body into the first JSON-RPC response message, if any. */
130
163
  function parseJsonRpcResponse(response) {
131
164
  const cached = responseCache.get(response);
@@ -133,12 +166,10 @@ function parseJsonRpcResponse(response) {
133
166
  return cached;
134
167
  const promise = (async () => {
135
168
  const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
169
+ if (Number(response.headers.get('content-length')) > maxInspectionBytes)
170
+ return undefined;
136
171
  if (contentType.includes('application/json'))
137
- return response
138
- .clone()
139
- .json()
140
- .then(parseMessage)
141
- .catch(() => undefined);
172
+ return parseJsonResponse(response);
142
173
  if (contentType.includes('text/event-stream'))
143
174
  return parseSseJsonRpcResponse(response);
144
175
  return undefined;
@@ -77,12 +77,14 @@ export function charge(parameters) {
77
77
  source,
78
78
  };
79
79
  await config.validate?.(authorization);
80
+ const payer = getAddress(payload.from);
80
81
  return {
81
82
  challenge: credential.challenge,
82
83
  credential,
83
- details: { payer: getAddress(payload.from) },
84
+ details: { payer },
84
85
  intent: Methods.charge.intent,
85
86
  method: Methods.charge.name,
87
+ payer,
86
88
  request: credential.challenge.request,
87
89
  source,
88
90
  };
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.13.3";
2
+ export declare const version = "0.13.5";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.13.3";
4
+ export declare const sdkIdentifier = "mppx/0.13.5";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/sync-version.ts.
2
2
  /** Current mppx package version. */
3
- export const version = '0.13.3';
3
+ export const version = '0.13.5';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -0,0 +1,3 @@
1
+ /** Logs `message` with `console.warn` the first time `key` is seen in this process. */
2
+ export declare function warnOnce(key: string, message: string): void;
3
+ //# sourceMappingURL=warnOnce.d.ts.map
@@ -0,0 +1,9 @@
1
+ const warned = new Set();
2
+ /** Logs `message` with `console.warn` the first time `key` is seen in this process. */
3
+ export function warnOnce(key, message) {
4
+ if (warned.has(key))
5
+ return;
6
+ warned.add(key);
7
+ console.warn(`[mppx] ${message}`);
8
+ }
9
+ //# sourceMappingURL=warnOnce.js.map
@@ -104,6 +104,11 @@ export type PaymentSuccessContext<method extends Method.Method = Method.Method,
104
104
  envelope?: Method.VerifiedChallengeEnvelope<z.output<method['schema']['request']>, z.output<method['schema']['credential']['payload']>, method['intent'], method['name']> | undefined;
105
105
  input?: Transport.InputOf<transport> | undefined;
106
106
  method: ServerMethodDescriptor<method>;
107
+ /**
108
+ * Payer identity verified by the method before settlement (for example, the
109
+ * Tempo sender address). Absent when the method does not report one.
110
+ */
111
+ payer?: string | undefined;
107
112
  receipt: Receipt.Receipt;
108
113
  /** Canonical request represented by the challenge. */
109
114
  request: z.output<method['schema']['request']>;
@@ -359,7 +364,7 @@ declare namespace MethodFn {
359
364
  description?: string | undefined;
360
365
  /** Optional challenge expiration timestamp (ISO 8601) or Date. */
361
366
  expires?: z.DatetimeInput | undefined;
362
- /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
367
+ /** Optional server-defined correlation data serialized as `opaque`. `_mppx_nonce` is reserved for a fresh server-generated issuance nonce; clients MUST echo opaque unchanged. */
363
368
  meta?: Record<string, string> | undefined;
364
369
  /** Optional route/resource scope bound via reserved challenge metadata. */
365
370
  scope?: string | undefined;
@@ -7,11 +7,13 @@ import * as Errors from '../Errors.js';
7
7
  import * as Expires from '../Expires.js';
8
8
  import * as AcceptPayment from '../internal/AcceptPayment.js';
9
9
  import * as Env from '../internal/env.js';
10
+ import { warnOnce } from '../internal/warnOnce.js';
10
11
  import * as Mcp from '../Mcp.js';
11
12
  import * as PaymentRequest from '../PaymentRequest.js';
12
13
  import * as x402_Header from '../x402/Header.js';
13
14
  import * as x402_Types from '../x402/Types.js';
14
15
  import * as z from '../zod.js';
16
+ import * as ChallengeMeta from './internal/challengeMeta.js';
15
17
  import * as Html from './internal/html/config.js';
16
18
  import { serviceWorker } from './internal/html/serviceWorker.gen.js';
17
19
  import * as Scope from './internal/scope.js';
@@ -331,9 +333,10 @@ export function create(config) {
331
333
  }));
332
334
  };
333
335
  let receipt;
336
+ let validation;
334
337
  try {
335
338
  if (mi.broadcast && mi.validate)
336
- await mi.validate({
339
+ validation = await mi.validate({
337
340
  credential: parsedCredential,
338
341
  envelope,
339
342
  operation: 'broadcast',
@@ -343,7 +346,7 @@ export function create(config) {
343
346
  receipt = await broadcast({ credential: parsedCredential, envelope, request });
344
347
  }
345
348
  catch (e) {
346
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
349
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
347
350
  await emitStandalonePaymentFailed({
348
351
  challenge: prepared.credential.challenge,
349
352
  credential: parsedCredential,
@@ -359,6 +362,7 @@ export function create(config) {
359
362
  credential: parsedCredential,
360
363
  envelope,
361
364
  method: mi,
365
+ payer: validation?.payer,
362
366
  receipt,
363
367
  request: parsedRequest,
364
368
  ...(requestInput !== undefined && { requestInput }),
@@ -607,7 +611,7 @@ function createMethodFn(parameters) {
607
611
  ? credentialError
608
612
  : reason
609
613
  ? new Errors.MalformedCredentialError({ reason })
610
- : new Errors.InternalPaymentError();
614
+ : new Errors.InternalPaymentError({ cause: credentialError });
611
615
  await emitPaymentFailed({
612
616
  challenge,
613
617
  credential: null,
@@ -673,7 +677,7 @@ function createMethodFn(parameters) {
673
677
  catch (e) {
674
678
  if (!(e instanceof Errors.PaymentError))
675
679
  console.error('mppx: internal authorization error', e);
676
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
680
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
677
681
  await emitPaymentFailed({
678
682
  challenge,
679
683
  credential: null,
@@ -833,9 +837,10 @@ function createMethodFn(parameters) {
833
837
  // User-provided verification (e.g., check signature, submit tx, verify payment).
834
838
  // If verification fails, re-issue the challenge so the client can retry.
835
839
  let receiptData;
840
+ let validation;
836
841
  try {
837
842
  if (broadcast && validate)
838
- await validate({
843
+ validation = await validate({
839
844
  credential: parsedCredential,
840
845
  envelope,
841
846
  operation: 'broadcast',
@@ -851,7 +856,7 @@ function createMethodFn(parameters) {
851
856
  catch (e) {
852
857
  if (!(e instanceof Errors.PaymentError))
853
858
  console.error('mppx: internal verification error', e);
854
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
859
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
855
860
  await emitPaymentFailed({
856
861
  challenge,
857
862
  credential: parsedCredential,
@@ -889,6 +894,7 @@ function createMethodFn(parameters) {
889
894
  envelope,
890
895
  input,
891
896
  method,
897
+ payer: validation?.payer,
892
898
  receipt: receiptData,
893
899
  request: parsedRequest,
894
900
  requestInput: request,
@@ -985,6 +991,7 @@ function createServerEventDispatcher() {
985
991
  await method.onPaymentSuccess({
986
992
  challenge: success.challenge,
987
993
  input: success.input,
994
+ ...(success.payer !== undefined && { payer: success.payer }),
988
995
  receipt: success.receipt,
989
996
  request: success.request,
990
997
  ...(success.requestInput !== undefined && { requestInput: success.requestInput }),
@@ -1065,6 +1072,7 @@ function createPaymentSuccessContext(parameters) {
1065
1072
  ...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
1066
1073
  ...snapshotInputProperty(parameters.input),
1067
1074
  method: snapshotMethod(parameters.method),
1075
+ ...(parameters.payer !== undefined && { payer: parameters.payer }),
1068
1076
  receipt: snapshotValue(parameters.receipt),
1069
1077
  request: snapshotValue(parameters.request),
1070
1078
  ...(parameters.requestInput !== undefined && {
@@ -1083,6 +1091,7 @@ function snapshotError(error) {
1083
1091
  return error;
1084
1092
  const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error);
1085
1093
  Object.defineProperties(snapshot, {
1094
+ ...('cause' in error && { cause: { value: error.cause, enumerable: false } }),
1086
1095
  message: { value: error.message, enumerable: false },
1087
1096
  name: { value: error.name, enumerable: false },
1088
1097
  });
@@ -1260,13 +1269,6 @@ export function isMissingReceiptResponseError(error) {
1260
1269
  function normalizeExpires(expires) {
1261
1270
  return expires === undefined ? undefined : z.toDatetimeString(expires);
1262
1271
  }
1263
- const _warned = new Set();
1264
- function warnOnce(key, message) {
1265
- if (_warned.has(key))
1266
- return;
1267
- _warned.add(key);
1268
- console.warn(`[mppx] ${message}`);
1269
- }
1270
1272
  /** Extracts hostname from the captured request URL, falling back to a default. */
1271
1273
  function resolveRealmFromCapturedRequest(capturedRequest) {
1272
1274
  try {
@@ -1300,7 +1302,7 @@ async function resolveRouteChallenge(parameters) {
1300
1302
  description: parameters.description,
1301
1303
  expires: parameters.expires,
1302
1304
  header: parameters.header,
1303
- meta: parameters.meta,
1305
+ meta: ChallengeMeta.withNonce(parameters.meta),
1304
1306
  realm: effectiveRealm,
1305
1307
  request: request,
1306
1308
  secretKey: parameters.secretKey,
@@ -1316,7 +1318,7 @@ function createFallbackChallenge(parameters) {
1316
1318
  description: parameters.description,
1317
1319
  expires: parameters.expires,
1318
1320
  header: parameters.header,
1319
- meta: parameters.meta,
1321
+ meta: ChallengeMeta.withNonce(parameters.meta),
1320
1322
  realm: parameters.realm ??
1321
1323
  (parameters.capturedRequest
1322
1324
  ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
@@ -1478,7 +1480,9 @@ function normalizeComparable(value) {
1478
1480
  return typeof value === 'string' ? normalizeHex(value) : value;
1479
1481
  }
1480
1482
  function opaqueValuesMatch(expected, actual) {
1481
- return isDeepStrictEqual(expected, actual);
1483
+ // The echoed nonce is authenticated by the challenge HMAC, but is not a
1484
+ // stable route requirement. Every other metadata field remains pinned.
1485
+ return isDeepStrictEqual(ChallengeMeta.routeMeta(expected), ChallengeMeta.routeMeta(actual));
1482
1486
  }
1483
1487
  function hydrateCredentialMeta(credential) {
1484
1488
  const { challenge } = credential;
@@ -59,6 +59,16 @@ export function http(options = {}) {
59
59
  },
60
60
  async respondChallenge(options) {
61
61
  const { challenge, error, input } = options;
62
+ // A 403 means the payment was valid but cannot pay for this request.
63
+ // Omit the challenge so clients do not pay again.
64
+ if (error?.status === 403)
65
+ return new Response(JSON.stringify(error.toProblemDetails()), {
66
+ status: error.status,
67
+ headers: {
68
+ 'Cache-Control': 'no-store',
69
+ 'Content-Type': 'application/problem+json',
70
+ },
71
+ });
62
72
  if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
63
73
  return new Response(serviceWorker, {
64
74
  status: 200,
@@ -0,0 +1,5 @@
1
+ /** Adds fresh issuance randomness while preserving application and route metadata. */
2
+ export declare function withNonce(meta: Record<string, string> | undefined): Record<string, string>;
3
+ /** Removes issuance-only metadata when comparing stable route requirements. */
4
+ export declare function routeMeta(meta: Record<string, string> | undefined): Record<string, string> | undefined;
5
+ //# sourceMappingURL=challengeMeta.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { Secp256k1 } from 'ox';
2
+ /** Reserved metadata key for HMAC-bound, per-issuance challenge randomness. */
3
+ const nonceKey = '_mppx_nonce';
4
+ /** Adds fresh issuance randomness while preserving application and route metadata. */
5
+ export function withNonce(meta) {
6
+ // Ox uses runtime-specific secure randomness, including Node without global Web Crypto.
7
+ return { ...meta, [nonceKey]: Secp256k1.randomPrivateKey() };
8
+ }
9
+ /** Removes issuance-only metadata when comparing stable route requirements. */
10
+ export function routeMeta(meta) {
11
+ const { [nonceKey]: _, ...rest } = meta ?? {};
12
+ return Object.keys(rest).length ? rest : undefined;
13
+ }
14
+ //# sourceMappingURL=challengeMeta.js.map