mppx 0.16.0 → 0.17.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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Challenge.d.ts +6 -0
  4. package/dist/Challenge.js +12 -0
  5. package/dist/Method.d.ts +7 -0
  6. package/dist/cli/cli.js +140 -134
  7. package/dist/cli/plugins/index.js +11 -11
  8. package/dist/cli/sessions/store.js +7 -1
  9. package/dist/cli/validate/helpers.d.ts +1 -1
  10. package/dist/cli/validate/helpers.js +14 -2
  11. package/dist/cli/validate/payment.js +39 -15
  12. package/dist/client/Mppx.js +11 -3
  13. package/dist/client/internal/Fetch.d.ts +2 -0
  14. package/dist/client/internal/Fetch.js +59 -3
  15. package/dist/client/internal/protocols/Mpp.js +4 -1
  16. package/dist/identity/Client.js +3 -3
  17. package/dist/identity/Server.d.ts +13 -0
  18. package/dist/identity/Server.js +50 -1
  19. package/dist/identity/Types.d.ts +32 -10
  20. package/dist/internal/AcceptPayment.d.ts +12 -2
  21. package/dist/internal/AcceptPayment.js +12 -3
  22. package/dist/internal/version.d.ts +2 -2
  23. package/dist/internal/version.js +1 -1
  24. package/dist/mcp/client/McpClient.js +1 -1
  25. package/dist/proxy/Proxy.d.ts +15 -0
  26. package/dist/proxy/Proxy.js +89 -1
  27. package/dist/proxy/internal/Replay.d.ts +105 -0
  28. package/dist/proxy/internal/Replay.js +257 -0
  29. package/dist/server/Mppx.d.ts +4 -2
  30. package/dist/server/Mppx.js +50 -24
  31. package/dist/server/internal/challengeMeta.d.ts +6 -0
  32. package/dist/server/internal/challengeMeta.js +17 -1
  33. package/dist/tempo/client/Charge.d.ts +20 -0
  34. package/dist/tempo/client/Charge.js +43 -10
  35. package/dist/tempo/client/Methods.d.ts +2 -0
  36. package/dist/tempo/internal/defaults.d.ts +9 -0
  37. package/dist/tempo/internal/defaults.js +9 -0
  38. package/dist/tempo/internal/fee-payer.js +2 -2
  39. package/dist/tempo/server/Charge.js +16 -2
  40. package/dist/tempo/server/internal/html.gen.js +1 -1
  41. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  42. package/dist/tempo/session/client/ChannelStore.js +5 -0
  43. package/dist/tempo/session/client/Session.d.ts +26 -1
  44. package/dist/tempo/session/client/Session.js +72 -1
  45. package/dist/tempo/session/client/SessionManager.d.ts +1 -0
  46. package/dist/tempo/session/client/SessionManager.js +54 -7
  47. package/dist/tempo/session/client/Transports.d.ts +2 -0
  48. package/dist/tempo/session/client/Transports.js +31 -2
  49. package/dist/tempo/session/precompile/Voucher.js +20 -7
  50. package/dist/tempo/session/server/Session.d.ts +2 -1
  51. package/dist/tempo/session/server/Session.js +2 -1
  52. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  53. package/dist/tempo/session/server/Settlement.js +2 -2
  54. package/dist/tempo/session/server/Ws.js +29 -12
  55. package/package.json +1 -1
@@ -33,6 +33,7 @@ export class SessionBusyError extends Error {
33
33
  }
34
34
  }
35
35
  const storedChannelSchema = z.object({
36
+ origin: z.optional(z.string()),
36
37
  channelId: z.hash(),
37
38
  cumulativeAmount: z.string(),
38
39
  deposit: z.string(),
@@ -183,6 +184,11 @@ export function createSessionRegistry(options = {}) {
183
184
  const previous = previousValue === undefined ? undefined : parseStoredSession(previousValue, file);
184
185
  if (previous)
185
186
  assertSameSession(previous, input, file);
187
+ const origin = previous?.channel.origin ?? channel.origin;
188
+ if (previous?.channel.origin && channel.origin && previous.channel.origin !== channel.origin)
189
+ throw stateError(file, 'Session payment origin mismatch.');
190
+ if (origin !== undefined && new URL(origin).origin !== origin)
191
+ throw stateError(file, 'Session payment origin must be a normalized origin.');
186
192
  const challenge = parseSessionChallenge(input.challenge, file);
187
193
  assertChallengeMatchesChannel(challenge, channel, file);
188
194
  const receipt = input.receipt
@@ -193,7 +199,7 @@ export function createSessionRegistry(options = {}) {
193
199
  const spent = maxBigInt(input.spent ?? 0n, receipt ? BigInt(receipt.spent) : 0n, previous ? BigInt(previous.spent) : 0n);
194
200
  if (spent > cumulativeAmount)
195
201
  throw stateError(file, 'Session spend exceeds the locally authorized cumulative amount.');
196
- const storedChannel = sanitizeStoredChannel(serializeEntry({ ...channel, cumulativeAmount }), file);
202
+ const storedChannel = sanitizeStoredChannel(serializeEntry({ ...channel, cumulativeAmount, ...(origin !== undefined && { origin }) }), file);
197
203
  const timestamp = monotonicTimestamp(previous?.updatedAt, now(), file);
198
204
  const latestReceipt = selectLatestReceipt(previous?.receipt, receipt);
199
205
  const account = sanitizeAccount(input.account, file);
@@ -37,7 +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
+ /** Fetches a validation target without automatic payments or redirected probes. */
41
41
  export declare function fetchWithTimeout(url: RequestInfo | URL, init: RequestInit, timeoutMs?: number): Promise<Response>;
42
42
  export declare function formatBytes(bytes: number): string;
43
43
  export declare const HTTP_METHODS: Set<string>;
@@ -1,3 +1,4 @@
1
+ import { identityOf, skipPayment, unwrapFetch } from '../../client/internal/Fetch.js';
1
2
  import { pc } from '../utils.js';
2
3
  export function check(label, detail) {
3
4
  return detail ? { label, detail, severity: 'pass' } : { label, severity: 'pass' };
@@ -57,12 +58,23 @@ export function printResults(results, counts) {
57
58
  counts.skipped++;
58
59
  }
59
60
  }
60
- /** Fetches the validation target without allowing it to redirect probes elsewhere. */
61
+ /** Fetches a validation target without automatic payments or redirected probes. */
61
62
  export async function fetchWithTimeout(url, init, timeoutMs = 15_000) {
62
63
  const controller = new AbortController();
63
64
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
64
65
  try {
65
- return await fetch(url, { ...init, redirect: 'error', signal: controller.signal });
66
+ const fetch = globalThis.fetch;
67
+ const base = unwrapFetch(fetch);
68
+ const identity = identityOf(fetch);
69
+ const options = {
70
+ [skipPayment]: true,
71
+ ...init,
72
+ redirect: 'error',
73
+ signal: controller.signal,
74
+ };
75
+ return identity
76
+ ? (await identity.send((input, init) => base(input, { ...init, [skipPayment]: true }), new Request(url, options))).response
77
+ : await base(url, options);
66
78
  }
67
79
  finally {
68
80
  clearTimeout(timeout);
@@ -740,33 +740,57 @@ export function captureEventStream(fetch) {
740
740
  const response = await fetch(input, init);
741
741
  if (!response.body ||
742
742
  !isEventStream(response) ||
743
- response.headers.has(Constants.Headers.paymentReceipt))
743
+ response.headers.get(Constants.Headers.paymentReceipt))
744
744
  return response;
745
745
  // Session management requests (such as mid-stream voucher posts) share this fetch, so a
746
746
  // later stream without a receipt must not discard one already seen.
747
747
  receipt ??= null;
748
748
  const decoder = new TextDecoder();
749
- let pending = '';
749
+ // Normalized pieces of the current partial event, joined only when the event ends, so a
750
+ // long event costs linear time. A CR that may start a CRLF is held apart.
751
+ let parts = [];
752
+ let size = 0;
753
+ let heldCr = false;
750
754
  // Set after an oversized event is dropped, until its end, so its remaining lines are
751
755
  // never parsed as an event of their own.
752
756
  let skipping = false;
753
757
  const scan = (text, done) => {
754
- pending += text;
755
- // Keep a trailing CR: the next chunk may complete a CRLF.
756
- const end = done || !pending.endsWith('\r') ? pending.length : pending.length - 1;
757
- const events = pending
758
- .slice(0, end)
759
- .replace(/\r\n|\r/g, '\n')
760
- .split('\n\n');
761
- const rest = done ? '' : (events.pop() ?? '');
762
- if (skipping && (events.length > 0 || done)) {
758
+ let chunk = heldCr ? `\r${text}` : text;
759
+ heldCr = !done && chunk.endsWith('\r');
760
+ if (heldCr)
761
+ chunk = chunk.slice(0, -1);
762
+ let rest = chunk.replace(/\r\n|\r/g, '\n');
763
+ const events = [];
764
+ // A boundary split across chunks: the partial event ends in a newline and this text starts with one.
765
+ if (rest.startsWith('\n') && parts.at(-1)?.endsWith('\n')) {
766
+ events.push(parts.join('').slice(0, -1));
767
+ parts = [];
768
+ size = 0;
769
+ rest = rest.slice(1);
770
+ }
771
+ for (let index = rest.indexOf('\n\n'); index !== -1; index = rest.indexOf('\n\n')) {
772
+ events.push(parts.join('') + rest.slice(0, index));
773
+ parts = [];
774
+ size = 0;
775
+ rest = rest.slice(index + 2);
776
+ }
777
+ if (rest) {
778
+ parts.push(rest);
779
+ size += rest.length;
780
+ }
781
+ if (done) {
782
+ events.push(parts.join(''));
783
+ parts = [];
784
+ size = 0;
785
+ }
786
+ if (skipping && events.length > 0) {
763
787
  events.shift();
764
788
  skipping = false;
765
789
  }
766
- pending = rest + pending.slice(end);
767
- if (pending.length > maxPendingEventBytes) {
768
- // Keep a trailing line ending: it may be the first half of the event's boundary.
769
- pending = /[\r\n]*$/.exec(pending)?.[0] ?? '';
790
+ if (size > maxPendingEventBytes) {
791
+ // Keep a trailing newline: it may be the first half of the event's boundary.
792
+ parts = parts.at(-1)?.endsWith('\n') ? ['\n'] : [];
793
+ size = parts.length;
770
794
  skipping = true;
771
795
  }
772
796
  for (const raw of events) {
@@ -71,7 +71,10 @@ export function create(config) {
71
71
  function onPaymentResponse(handler) {
72
72
  return events.on('payment.response', handler);
73
73
  }
74
- async function preparePayment(response, options) {
74
+ async function preparePayment(response, options,
75
+ // Credential context known before selection, such as an explicit session close passed to
76
+ // `createCredential`, so methods can accept offers they would otherwise skip.
77
+ selectionContext) {
75
78
  const eventResponse = snapshotResponse(response);
76
79
  const challenges = await transport.getChallenges(response, options?.request);
77
80
  const challengeSnapshots = Object.freeze(challenges.map((candidate) => snapshotValue(candidate)));
@@ -79,7 +82,12 @@ export function create(config) {
79
82
  let challenge;
80
83
  let mi;
81
84
  try {
82
- const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences);
85
+ let candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences, selectionContext);
86
+ // Without a context yet, as in `preparePayment(response).createCredential(context)`, fall back
87
+ // to offers a method skips only by policy: an explicit close may still use them, and the
88
+ // method enforces the policy for anything else when the credential is created.
89
+ if (candidates.length === 0 && selectionContext === undefined)
90
+ candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences, AcceptPayment.deferredContext);
83
91
  const orderedCandidates = await resolveChallengeOrder(candidates, options?.orderChallenges ?? orderChallenges);
84
92
  const selected = orderedCandidates[0];
85
93
  if (!selected)
@@ -237,7 +245,7 @@ export function create(config) {
237
245
  preparePayment,
238
246
  prepareRequest: prepareRequest,
239
247
  async createCredential(response, context, options) {
240
- const prepared = await preparePayment(response, options);
248
+ const prepared = await preparePayment(response, options, context);
241
249
  return prepared.createCredential(context);
242
250
  },
243
251
  };
@@ -6,6 +6,8 @@ import type * as Method from '../../Method.js';
6
6
  import type * as z from '../../zod.js';
7
7
  import * as Transport from '../Transport.js';
8
8
  import * as ProofRealm from './ProofRealm.js';
9
+ /** Per-request payment opt-out preserved by forwarding fetch layers. @internal */
10
+ export declare const skipPayment: unique symbol;
9
11
  export type ClientEventMap<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[], response = Response> = {
10
12
  'challenge.received': ChallengeReceivedPayload<methods, response>;
11
13
  'credential.created': CredentialCreatedPayload<methods, response>;
@@ -14,6 +14,8 @@ import * as ProofRealm from './ProofRealm.js';
14
14
  const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper');
15
15
  /** Identity runner of an mppx fetch wrapper, inherited when that wrapper is wrapped again. */
16
16
  const MPPX_FETCH_IDENTITY = Symbol.for('mppx.fetch.identity');
17
+ /** Per-request payment opt-out preserved by forwarding fetch layers. @internal */
18
+ export const skipPayment = Symbol.for('mppx.fetch.skipPayment');
17
19
  const defaultMaxPaymentRetries = 3;
18
20
  const maxErrorBodyBytes = 64 * 1024;
19
21
  const errorBodyTimeoutMs = 2_000;
@@ -125,6 +127,8 @@ export function from(config) {
125
127
  const initialRequest = prepareInitialRequest(input, init, callerHeaders, paymentPreferences.header, hasExplicitAcceptPayment, acceptPaymentPolicy);
126
128
  let response = await send(cloneRequestInput(initialRequest.input), initialRequest.init);
127
129
  const transportRequest = withCapturedBody(initialRequest.init, await capturedBody);
130
+ if (init?.[skipPayment])
131
+ return response;
128
132
  if (!(await transport.isPaymentRequired(response, transportRequest)))
129
133
  return response;
130
134
  // Only extract context for payment handling after confirming 402.
@@ -139,7 +143,7 @@ export function from(config) {
139
143
  try {
140
144
  for (let retry = 0; retry < maxPaymentRetries; retry++) {
141
145
  challenges = await transport.getChallenges(response, transportRequest);
142
- const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries);
146
+ const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries, context);
143
147
  const orderedCandidates = await resolveChallengeOrder(candidates, requestOrderChallenges ??
144
148
  orderChallenges);
145
149
  const selected = orderedCandidates[0];
@@ -161,7 +165,7 @@ export function from(config) {
161
165
  if (challenge.expires)
162
166
  Expires.assert(challenge.expires, challenge.id);
163
167
  await identity?.challenge(response, { challenge: selectedChallenge }, resolveRequestUrl(initialRequest.input).href);
164
- const paymentInput = resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input);
168
+ const paymentInput = await resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input, fetchInit);
165
169
  ProofRealm.assert(selectedChallenge, () => resolveRequestUrl(paymentInput), trustedTempoProofRealms);
166
170
  const previousCredential = preparedCredential;
167
171
  const key = Challenge.serialize(selectedChallenge);
@@ -184,6 +188,8 @@ export function from(config) {
184
188
  await settleAttempt(previousCredential, { status: 'rejected' });
185
189
  preparedCredential = undefined;
186
190
  }
191
+ // A new payment must not start for a request the caller already aborted.
192
+ resolveRequestSignal(input, init)?.throwIfAborted();
187
193
  const credentialContext = overrideContext ?? context;
188
194
  if (!MethodChallenge.has(selected.method))
189
195
  return resolveCredential(selectedChallenge, selected.method, credentialContext);
@@ -807,7 +813,21 @@ function resolveRequestUrl(input) {
807
813
  return new URL(input.url);
808
814
  return new URL(input, isBrowser() ? globalThis.location.href : undefined);
809
815
  }
810
- function resolvePaymentRetryInput(response, fallback, initialInput) {
816
+ /** Rejects Request modes whose paid retry cannot carry a credential to the server. */
817
+ function assertPayableRequest(input, init) {
818
+ const request = input instanceof Request ? input : undefined;
819
+ const mode = init.mode ?? request?.mode;
820
+ // Fetch strips the Authorization header from no-cors requests.
821
+ if (mode === 'no-cors')
822
+ throw new Error('Cannot automatically pay a no-cors request: it cannot carry a credential.');
823
+ const cache = init.cache ?? request?.cache;
824
+ // A cache-only retry never reaches the server.
825
+ if (cache === 'only-if-cached')
826
+ throw new Error('Cannot automatically pay an only-if-cached request: its retry cannot reach the server.');
827
+ }
828
+ /** Retains Request state while targeting the same-origin challenge response URL. */
829
+ async function resolvePaymentRetryInput(response, fallback, initialInput, init) {
830
+ assertPayableRequest(fallback, init);
811
831
  if (!response.url)
812
832
  return fallback;
813
833
  const responseUrl = new URL(response.url);
@@ -815,6 +835,42 @@ function resolvePaymentRetryInput(response, fallback, initialInput) {
815
835
  if (responseUrl.origin !== initialUrl.origin) {
816
836
  throw new Error(`Refusing to send payment credential across redirect from ${initialUrl.origin} to ${responseUrl.origin}`);
817
837
  }
838
+ const method = (init.method ?? (fallback instanceof Request ? fallback.method : 'GET')).toUpperCase();
839
+ // Fetch does not expose the redirect chain, so a non-GET/HEAD request may
840
+ // have become a GET before reaching the challenge (301/302/303).
841
+ if (response.redirected && method !== 'GET' && method !== 'HEAD')
842
+ throw new Error(`Cannot automatically pay a redirected ${method} request. Use prepareRequest() to preserve redirect semantics.`);
843
+ if (fallback instanceof Request) {
844
+ // A replacement body makes a consumed or locked original body irrelevant.
845
+ // Otherwise retain the original body for any later payment challenges.
846
+ const source = init.body != null ? fallback : fallback.clone();
847
+ // User-created requests cannot use navigate mode; retry a navigation as same-origin.
848
+ const request = new Request(source, fallback.mode === 'navigate' && init.mode === undefined
849
+ ? { ...init, mode: 'same-origin' }
850
+ : init);
851
+ // Fetch never sends a URL fragment, and the response URL has none.
852
+ if (request.url.split('#')[0] === response.url)
853
+ return request;
854
+ if (request.body) {
855
+ // Retargeting would turn the body into a stream without a length (and keepalive rejects
856
+ // streams). Retarget a buffered copy so the paid retry keeps its Content-Length.
857
+ return new Request(response.url, {
858
+ body: await request.arrayBuffer(),
859
+ cache: request.cache,
860
+ credentials: request.credentials,
861
+ headers: request.headers,
862
+ integrity: request.integrity,
863
+ keepalive: request.keepalive,
864
+ method: request.method,
865
+ mode: request.mode,
866
+ redirect: request.redirect,
867
+ referrer: request.referrer,
868
+ referrerPolicy: request.referrerPolicy,
869
+ signal: request.signal,
870
+ });
871
+ }
872
+ return new Request(response.url, request);
873
+ }
818
874
  return response.url;
819
875
  }
820
876
  //# sourceMappingURL=Fetch.js.map
@@ -5,8 +5,11 @@ import { paymentRequiredStatus, setCredentialHeader } from './Shared.js';
5
5
  export function mpp() {
6
6
  return {
7
7
  getChallenges(response) {
8
+ const header = response.headers.get(Constants.Headers.wwwAuthenticate);
9
+ // A 402 without a Payment challenge (such as `WWW-Authenticate: X402`) is left to other protocols.
8
10
  if (response.status !== paymentRequiredStatus ||
9
- !response.headers.has(Constants.Headers.wwwAuthenticate))
11
+ !header ||
12
+ !Challenge.hasPaymentScheme(header))
10
13
  return [];
11
14
  return Challenge.fromResponseList(response);
12
15
  },
@@ -57,7 +57,7 @@ export function from(provers) {
57
57
  continue;
58
58
  const copy = response.clone();
59
59
  copies.push(copy);
60
- if (await prover.retry(copy, context))
60
+ if (await prover.retry(copy))
61
61
  retry = true;
62
62
  }
63
63
  if (!retry) {
@@ -69,11 +69,11 @@ export function from(provers) {
69
69
  },
70
70
  async challenge(response, context, url) {
71
71
  for (const prover of inScope(url)) {
72
- if (!prover.retry)
72
+ if (!prover.prepare)
73
73
  continue;
74
74
  const copy = response.clone();
75
75
  try {
76
- await prover.retry(copy, context);
76
+ await prover.prepare(copy, context);
77
77
  }
78
78
  finally {
79
79
  release([copy]);
@@ -35,6 +35,19 @@ export type Outcome = {
35
35
  * when several handlers evaluate the same request.
36
36
  */
37
37
  export declare function verify(request: Request, verifiers: readonly Types.Verifier[], context?: Types.Context): Promise<Outcome>;
38
+ /**
39
+ * Collects the challenge headers of payment-stage verifiers for one 402.
40
+ *
41
+ * Each verifier's hook runs at most once per request and nonce, so every offer
42
+ * in a composed 402 carries the same challenge. @internal
43
+ */
44
+ export declare function challenge(request: Request, verifiers: readonly Types.Verifier[], nonce: string): Promise<Headers>;
45
+ /**
46
+ * Returns the identity nonce for every 402 issued on one request.
47
+ *
48
+ * Composed routes call each offer with the same request, so all offers share it. @internal
49
+ */
50
+ export declare function nonceFor(request: Request, create: () => string): string;
38
51
  /** Builds the rejection response for a failed request-stage outcome. @internal */
39
52
  export declare function toResponse(outcome: Exclude<Outcome, {
40
53
  status: 'valid';
@@ -90,6 +90,48 @@ export async function verify(request, verifiers, context = {}) {
90
90
  reason: missing.reason ?? 'Identity verification is required.',
91
91
  };
92
92
  }
93
+ /**
94
+ * Collects the challenge headers of payment-stage verifiers for one 402.
95
+ *
96
+ * Each verifier's hook runs at most once per request and nonce, so every offer
97
+ * in a composed 402 carries the same challenge. @internal
98
+ */
99
+ export async function challenge(request, verifiers, nonce) {
100
+ const headers = new Headers();
101
+ for (const verifier of verifiers) {
102
+ for (const member of leaves(verifier)) {
103
+ if (!member.challenge)
104
+ continue;
105
+ let byVerifier = challenges.get(request);
106
+ if (!byVerifier)
107
+ challenges.set(request, (byVerifier = new Map()));
108
+ let byNonce = byVerifier.get(member);
109
+ if (!byNonce)
110
+ byVerifier.set(member, (byNonce = new Map()));
111
+ let result = byNonce.get(nonce);
112
+ if (!result) {
113
+ result = Promise.resolve(member.challenge(request, { nonce }));
114
+ byNonce.set(nonce, result);
115
+ }
116
+ const init = await result;
117
+ if (init)
118
+ for (const [name, value] of new Headers(init))
119
+ headers.append(name, value);
120
+ }
121
+ }
122
+ return headers;
123
+ }
124
+ /**
125
+ * Returns the identity nonce for every 402 issued on one request.
126
+ *
127
+ * Composed routes call each offer with the same request, so all offers share it. @internal
128
+ */
129
+ export function nonceFor(request, create) {
130
+ let nonce = nonces.get(request);
131
+ if (!nonce)
132
+ nonces.set(request, (nonce = create()));
133
+ return nonce;
134
+ }
93
135
  /** Builds the rejection response for a failed request-stage outcome. @internal */
94
136
  export function toResponse(outcome) {
95
137
  const headers = new Headers(outcome.headers);
@@ -112,6 +154,13 @@ export function stageOf(verifier) {
112
154
  throw new TypeError(`Unknown identity verifier stage "${String(stage)}".`);
113
155
  return stage;
114
156
  }
157
+ /** Returns a verifier and, through `anyOf`, every member it wraps. */
158
+ function leaves(verifier) {
159
+ const nested = verifier[members];
160
+ return nested ? nested.flatMap(leaves) : [verifier];
161
+ }
162
+ const nonces = new WeakMap();
163
+ const challenges = new WeakMap();
115
164
  const cache = new WeakMap();
116
165
  function run(verifier, request, context) {
117
166
  let byVerifier = cache.get(request);
@@ -121,7 +170,7 @@ function run(verifier, request, context) {
121
170
  if (!byContext)
122
171
  byVerifier.set(verifier, (byContext = new Map()));
123
172
  // Decisions can depend on the credential, so each credential object gets its own entry.
124
- const key = context.challenge?.id ?? '';
173
+ const key = `${context.challenge?.id ?? ''}|${context.nonce ?? ''}`;
125
174
  let byCredential = byContext.get(key);
126
175
  if (!byCredential)
127
176
  byContext.set(key, (byCredential = new Map()));
@@ -5,23 +5,34 @@ import type { MaybePromise } from '../internal/types.js';
5
5
  * When a verifier runs on the server.
6
6
  *
7
7
  * `request` verifiers run before any payment challenge is issued and reject
8
- * the request with their own status code. `payment` verifiers run when a
9
- * challenge is issued (to add their own challenge headers to the 402) and
10
- * again on the paid retry, before the credential is settled.
8
+ * the request with their own status code. `payment` verifiers add their own
9
+ * challenge headers to the 402 (through `challenge`) and run on the paid
10
+ * retry, before the credential is settled.
11
11
  */
12
12
  export type Stage = 'request' | 'payment';
13
13
  /**
14
14
  * Payment state passed to verifiers and provers.
15
15
  *
16
16
  * Request-stage verifiers receive an empty context. Payment-stage verifiers
17
- * receive the challenge being issued, or the challenge and credential being
18
- * answered on the paid retry.
17
+ * receive the answered challenge, its credential and the 402's identity nonce.
19
18
  */
20
19
  export type Context = {
21
- /** The MPP challenge being issued or answered. */
20
+ /** The MPP challenge being answered (or, for provers, selected). */
22
21
  challenge?: Challenge.Challenge | undefined;
23
22
  /** The payment credential submitted with the request. */
24
23
  credential?: Credential.Credential | undefined;
24
+ /**
25
+ * The identity nonce of the 402 being answered.
26
+ *
27
+ * Every payment offer in one 402 carries the same HMAC-bound nonce, so a proof
28
+ * bound to it is bound to that 402 whichever offer the client pays.
29
+ */
30
+ nonce?: string | undefined;
31
+ };
32
+ /** Context passed to a payment-stage verifier's `challenge` hook. */
33
+ export type ChallengeContext = {
34
+ /** The identity nonce shared by every payment offer in the 402. Bind the proof request to it. */
35
+ nonce: string;
25
36
  };
26
37
  /**
27
38
  * The outcome of one identity verifier.
@@ -54,6 +65,12 @@ export type Verification<value = unknown> = {
54
65
  export type Verifier<value = unknown> = {
55
66
  /** When the verifier runs. @default 'request' */
56
67
  stage?: Stage | undefined;
68
+ /**
69
+ * Returns the protocol's challenge headers to add to a 402, such as a proof request.
70
+ *
71
+ * Payment stage only. Runs once per 402, however many payment offers it carries.
72
+ */
73
+ challenge?: ((request: Request, context: ChallengeContext) => MaybePromise<HeadersInit | undefined>) | undefined;
57
74
  /** Returns the protocol's verification outcome for the request. */
58
75
  verify: (request: Request, context: Context) => MaybePromise<Verification<value>>;
59
76
  };
@@ -80,11 +97,16 @@ export type Prover = {
80
97
  /** Returns a new request carrying the protocol's identity material. */
81
98
  prove: (request: Request, context: ProveContext) => MaybePromise<Request>;
82
99
  /**
83
- * Inspects a rejected or 402 response and prepares a proof for the next attempt.
100
+ * Prepares a proof from a 402 before the payment retry, such as answering its proof request.
84
101
  *
85
- * Return `true` to resend a rejected (non-402) request once. On a 402 the
86
- * context carries the selected challenge and the payment retry always follows.
102
+ * The context carries the selected challenge; `prove` receives the same challenge
103
+ * on the paid retry.
104
+ */
105
+ prepare?: ((response: Response, context: Context) => MaybePromise<void>) | undefined;
106
+ /**
107
+ * Inspects a rejected (non-402) response. Return `true` to prove and resend the
108
+ * request once. Paid attempts are never resent.
87
109
  */
88
- retry?: ((response: Response, context: Context) => MaybePromise<boolean | undefined>) | undefined;
110
+ retry?: ((response: Response) => MaybePromise<boolean | undefined>) | undefined;
89
111
  };
90
112
  //# sourceMappingURL=Types.d.ts.map
@@ -3,10 +3,17 @@ import type { MaybePromise } from './types.js';
3
3
  type MethodLike = {
4
4
  canHandleChallenge?: ((parameters: {
5
5
  challenge: Challenge.Challenge;
6
+ context?: unknown;
6
7
  }) => boolean) | undefined;
7
8
  intent: string;
8
9
  name: string;
9
10
  };
11
+ /**
12
+ * Selection context for flows that receive the credential context only after an offer is chosen,
13
+ * such as `preparePayment()`. Methods may accept offers they would otherwise skip by policy and
14
+ * enforce that policy when the credential is created.
15
+ */
16
+ export declare const deferredContext: unique symbol;
10
17
  /** Supported challenge paired with the configured client method that can sign it. */
11
18
  export type ChallengeCandidate<method extends MethodLike = MethodLike> = method extends MethodLike ? {
12
19
  challenge: Challenge.Challenge<Record<string, unknown>, method['intent'], method['name']>;
@@ -79,8 +86,11 @@ export declare function selectChallenge<const methods extends readonly MethodLik
79
86
  challenge: Challenge.Challenge;
80
87
  method: methods[number];
81
88
  } | undefined;
82
- /** Returns supported challenge candidates ordered by client payment preferences. */
83
- export declare function selectChallengeCandidates<const methods extends readonly MethodLike[]>(challenges: readonly Challenge.Challenge[], methods: methods, preferences: readonly Entry[]): ChallengeCandidate<methods[number]>[];
89
+ /**
90
+ * Returns supported challenge candidates ordered by client payment preferences.
91
+ * `context` is the caller's credential context, when known before selection.
92
+ */
93
+ export declare function selectChallengeCandidates<const methods extends readonly MethodLike[]>(challenges: readonly Challenge.Challenge[], methods: methods, preferences: readonly Entry[], context?: unknown): ChallengeCandidate<methods[number]>[];
84
94
  /** Returns the canonical `method/intent` key for a method or challenge-like value. */
85
95
  export declare function keyOf(value: {
86
96
  intent: string;
@@ -1,4 +1,10 @@
1
1
  import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js';
2
+ /**
3
+ * Selection context for flows that receive the credential context only after an offer is chosen,
4
+ * such as `preparePayment()`. Methods may accept offers they would otherwise skip by policy and
5
+ * enforce that policy when the credential is created.
6
+ */
7
+ export const deferredContext = Symbol('mppx.deferredContext');
2
8
  /** Builds the typed key tree used by callback-style preference config. */
3
9
  export function buildKeys(methods) {
4
10
  const keys = {};
@@ -70,8 +76,11 @@ export function selectChallenge(challenges, methods, preferences) {
70
76
  method: candidate.method,
71
77
  };
72
78
  }
73
- /** Returns supported challenge candidates ordered by client payment preferences. */
74
- export function selectChallengeCandidates(challenges, methods, preferences) {
79
+ /**
80
+ * Returns supported challenge candidates ordered by client payment preferences.
81
+ * `context` is the caller's credential context, when known before selection.
82
+ */
83
+ export function selectChallengeCandidates(challenges, methods, preferences, context) {
75
84
  const methodsByKey = new Map();
76
85
  for (const method of methods) {
77
86
  const key = keyOf(method);
@@ -90,7 +99,7 @@ export function selectChallengeCandidates(challenges, methods, preferences) {
90
99
  if (!match || match.q <= 0)
91
100
  return [];
92
101
  return matchingMethods
93
- .filter((method) => method.canHandleChallenge?.({ challenge }) ?? true)
102
+ .filter((method) => method.canHandleChallenge?.(context === undefined ? { challenge } : { challenge, context }) ?? true)
94
103
  .map((method) => ({
95
104
  challenge,
96
105
  index,
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.16.0";
2
+ export declare const version = "0.17.1";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.16.0";
4
+ export declare const sdkIdentifier = "mppx/0.17.1";
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.16.0';
3
+ export const version = '0.17.1';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -100,7 +100,7 @@ function createPaymentAwareCallTool(callTool, config) {
100
100
  const paymentPreferences = AcceptPayment.resolve(methods, config.paymentPreferences);
101
101
  const retryWithPayment = async (params, call, paymentRequired, cause, attemptsRemaining = maxPaymentAttempts) => {
102
102
  const challenges = paymentRequired.challenges;
103
- const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries);
103
+ const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries, call.context);
104
104
  const orderedCandidates = config.orderChallenges
105
105
  ? await config.orderChallenges(candidates)
106
106
  : candidates;
@@ -1,5 +1,8 @@
1
1
  import type * as http from 'node:http';
2
+ import * as Replay from './internal/Replay.js';
2
3
  import * as Service from './Service.js';
4
+ /** Options for {@link create.Config.replay}. */
5
+ export type ReplayOptions = Replay.Options;
3
6
  /** A paid API proxy that gates upstream services behind the mppx 402 protocol. */
4
7
  export type Proxy = {
5
8
  /** Fetch API handler. Works with Bun, Deno, Next.js, Hono, Elysia, SvelteKit, etc. */
@@ -48,6 +51,18 @@ export declare namespace create {
48
51
  fetch?: typeof globalThis.fetch | undefined;
49
52
  /** Default handler for failed upstream attempts. Services may override it. */
50
53
  onUpstreamError?: Service.UpstreamErrorHandler | undefined;
54
+ /**
55
+ * Lets a payer collect a paid response after losing the connection.
56
+ *
57
+ * The paid upstream request runs detached from the client (its response must
58
+ * start within `ttl`), and its outcome is recorded under a key derived from the
59
+ * credential and the request's method, target and body. A retry with the
60
+ * same credential and request never pays again: it gets the stored
61
+ * response, `202` while the delivery runs, or, after a failed delivery, a
62
+ * new upstream attempt up to `maxAttempts`. Streamed (`text/event-stream`)
63
+ * and oversized responses are delivered but not stored. Off by default.
64
+ */
65
+ replay?: ReplayOptions | undefined;
51
66
  /** Services to proxy. Each service is mounted at `/{serviceId}/`. */
52
67
  services: Service.Service[];
53
68
  /** Human-readable title for the proxy shown in `llms.txt`. */