mppx 0.17.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 (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Method.d.ts +5 -0
  4. package/dist/cli/cli.js +133 -133
  5. package/dist/cli/plugins/index.js +11 -11
  6. package/dist/cli/sessions/store.js +7 -1
  7. package/dist/cli/validate/helpers.d.ts +1 -1
  8. package/dist/cli/validate/helpers.js +14 -2
  9. package/dist/client/Mppx.js +11 -3
  10. package/dist/client/internal/Fetch.d.ts +2 -0
  11. package/dist/client/internal/Fetch.js +59 -3
  12. package/dist/internal/AcceptPayment.d.ts +12 -2
  13. package/dist/internal/AcceptPayment.js +12 -3
  14. package/dist/internal/version.d.ts +2 -2
  15. package/dist/internal/version.js +1 -1
  16. package/dist/mcp/client/McpClient.js +1 -1
  17. package/dist/proxy/Proxy.d.ts +15 -0
  18. package/dist/proxy/Proxy.js +89 -1
  19. package/dist/proxy/internal/Replay.d.ts +105 -0
  20. package/dist/proxy/internal/Replay.js +257 -0
  21. package/dist/server/Mppx.d.ts +2 -0
  22. package/dist/server/Mppx.js +2 -1
  23. package/dist/tempo/client/Charge.d.ts +9 -0
  24. package/dist/tempo/client/Charge.js +13 -9
  25. package/dist/tempo/client/Methods.d.ts +2 -0
  26. package/dist/tempo/server/Charge.js +4 -1
  27. package/dist/tempo/server/internal/html.gen.js +1 -1
  28. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  29. package/dist/tempo/session/client/ChannelStore.js +5 -0
  30. package/dist/tempo/session/client/Session.d.ts +26 -1
  31. package/dist/tempo/session/client/Session.js +72 -1
  32. package/dist/tempo/session/client/SessionManager.d.ts +1 -0
  33. package/dist/tempo/session/client/SessionManager.js +54 -7
  34. package/dist/tempo/session/client/Transports.d.ts +2 -0
  35. package/dist/tempo/session/client/Transports.js +31 -2
  36. package/dist/tempo/session/precompile/Voucher.js +20 -7
  37. package/dist/tempo/session/server/Session.d.ts +2 -1
  38. package/dist/tempo/session/server/Session.js +2 -1
  39. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  40. package/dist/tempo/session/server/Settlement.js +2 -2
  41. package/dist/tempo/session/server/Ws.js +29 -12
  42. 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);
@@ -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
@@ -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.17.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.17.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.17.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`. */
@@ -4,6 +4,7 @@ import * as Scope from '../server/internal/scope.js';
4
4
  import * as Mppx from '../server/Mppx.js';
5
5
  import * as Request from '../server/Request.js';
6
6
  import * as Headers from './internal/Headers.js';
7
+ import * as Replay from './internal/Replay.js';
7
8
  import * as Route from './internal/Route.js';
8
9
  import * as Service from './Service.js';
9
10
  /**
@@ -34,6 +35,7 @@ import * as Service from './Service.js';
34
35
  */
35
36
  export function create(config) {
36
37
  const fetchImpl = config.fetch ?? globalThis.fetch;
38
+ const replay = config.replay ? Replay.resolve(config.replay) : undefined;
37
39
  const services = new Map(config.services.map((s) => {
38
40
  const proxy = createFetchProxy(s.baseUrl, { fetch: fetchImpl });
39
41
  return [s.id, { service: s, proxy }];
@@ -108,13 +110,59 @@ export function create(config) {
108
110
  proxy,
109
111
  onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
110
112
  });
113
+ // With replay, a paid request whose credential already paid for this exact
114
+ // request collects its recorded delivery instead of paying again.
115
+ let paidRequest = request;
116
+ let replayBody;
117
+ let replayKey;
118
+ // Routes with identity verifiers keep paying normally: a replay would skip them.
119
+ const credential = replay && !fallbackMatch && !requiresIdentity(handler)
120
+ ? Replay.credentialOf(request, credentialHeaders(handler))
121
+ : undefined;
122
+ if (replay && credential) {
123
+ const body = await Replay.readBody(request, replay.maxRequestBodySize);
124
+ if (!body)
125
+ return new Response('Payload Too Large', { status: 413 });
126
+ replayBody = body;
127
+ paidRequest = Replay.withBody(request, body);
128
+ replayKey = Replay.key({
129
+ body,
130
+ credential,
131
+ method: request.method,
132
+ target: url.origin + url.pathname + url.search,
133
+ });
134
+ const found = await Replay.lookup(replay, replayKey);
135
+ if (found.type === 'respond')
136
+ return found.response;
137
+ if (found.type === 'retry') {
138
+ const options = Service.getOptions(endpoint);
139
+ return Replay.deliver({
140
+ applyReceipt: (response) => Replay.withReceiptHeader(response, found.receipt),
141
+ attempts: found.attempts,
142
+ replay,
143
+ replayKey,
144
+ requestHeaders: found.requestHeaders,
145
+ run: (signal) => {
146
+ // Send the headers of the request that was paid for, not the retry's.
147
+ const upstream = Replay.withBody(paidRequest, body, signal, found.requestHeaders);
148
+ return proxyUpstream({
149
+ request: upstream,
150
+ service,
151
+ ctx: { ...ctx, request: upstream, ...options },
152
+ proxy,
153
+ onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
154
+ });
155
+ },
156
+ });
157
+ }
158
+ }
111
159
  const scope = getConfiguredScope(handler) ??
112
160
  deriveRouteScope({
113
161
  basePath: config.basePath,
114
162
  routeKey: matched.key,
115
163
  serviceId,
116
164
  });
117
- const result = await handler(getConfiguredScope(handler) ? request : Scope.attach(request, scope));
165
+ const result = await handler(getConfiguredScope(handler) ? paidRequest : Scope.attach(paidRequest, scope));
118
166
  if (result.status === 402)
119
167
  return result.challenge;
120
168
  const managementResponse = (() => {
@@ -132,6 +180,26 @@ export function create(config) {
132
180
  if (fallbackMatch)
133
181
  return new Response('Method Not Allowed', { status: 405 });
134
182
  const options = Service.getOptions(endpoint);
183
+ if (replay && replayKey && replayBody) {
184
+ const body = replayBody;
185
+ return Replay.deliver({
186
+ applyReceipt: (response) => result.withReceipt(response),
187
+ attempts: 1,
188
+ replay,
189
+ replayKey,
190
+ requestHeaders: [...Headers.scrub(paidRequest.headers).entries()],
191
+ run: (signal) => {
192
+ const upstream = Replay.withBody(paidRequest, body, signal);
193
+ return proxyUpstream({
194
+ request: upstream,
195
+ service,
196
+ ctx: { ...ctx, request: upstream, ...options },
197
+ proxy,
198
+ onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
199
+ });
200
+ },
201
+ });
202
+ }
135
203
  const upstreamRes = await proxyUpstream({
136
204
  request,
137
205
  service,
@@ -284,6 +352,26 @@ function buildDiscoveryRoutes(services) {
284
352
  };
285
353
  }));
286
354
  }
355
+ /** Headers the handler reads Payment credentials from, lowercased (Authorization by default). */
356
+ function credentialHeaders(handler) {
357
+ const internal = ('_internal' in handler ? handler._internal : undefined);
358
+ const headers = internal?.offers
359
+ ? internal.offers.flatMap((offer) => offer.credentialHeaders ?? [])
360
+ : (internal?.credentialHeaders ?? []);
361
+ const names = [...new Set(headers.map((header) => header.toLowerCase()))];
362
+ return names.length > 0 ? names : ['authorization'];
363
+ }
364
+ /** Whether a paid handler, or any offer of a composed one, requires identity verifiers. */
365
+ function requiresIdentity(handler) {
366
+ if (!('_internal' in handler))
367
+ return false;
368
+ const internal = handler._internal;
369
+ if (!internal)
370
+ return false;
371
+ if (internal.offers)
372
+ return internal.offers.some((offer) => offer._identity);
373
+ return internal._identity === true;
374
+ }
287
375
  function getConfiguredScope(handler) {
288
376
  if (!('_internal' in handler))
289
377
  return undefined;
@@ -0,0 +1,105 @@
1
+ import type * as Store from '../../Store.js';
2
+ /** Options for replaying paid proxy deliveries. */
3
+ export type Options = {
4
+ /**
5
+ * Upstream attempts per credential, including the first, before a retry gets `502`.
6
+ * A retry after a failed delivery (an upstream error, a 5xx or a body that broke
7
+ * off) sends the paid request upstream again without charging, so a
8
+ * non-idempotent upstream may run the operation twice; set `1` to only ever
9
+ * replay a recorded response. @default 3
10
+ */
11
+ maxAttempts?: number | undefined;
12
+ /** Largest response body, in bytes, kept for replay. Larger or streamed bodies are delivered but not kept. @default 1_048_576 */
13
+ maxBodySize?: number | undefined;
14
+ /** Largest request body, in bytes, a paid request may carry; larger ones get `413`. @default 1_048_576 */
15
+ maxRequestBodySize?: number | undefined;
16
+ /** `Retry-After` seconds returned while a delivery is still in flight. @default 5 */
17
+ retryAfter?: number | undefined;
18
+ /** Atomic store shared by every proxy instance that may receive a retry. */
19
+ store: Store.AtomicStore;
20
+ /**
21
+ * How long to wait for the upstream to start responding, and how long its outcome is kept after it is
22
+ * recorded, in milliseconds. Expired records are removed when they are next
23
+ * looked up, as with mppx's replay markers; give the backing store its own
24
+ * key expiry to reclaim records nobody retries. @default 900_000
25
+ */
26
+ ttl?: number | undefined;
27
+ /** Keeps background work alive after the response returns, such as Cloudflare's `ctx.waitUntil`. */
28
+ waitUntil?: ((promise: Promise<unknown>) => void) | undefined;
29
+ };
30
+ /** Replay options with defaults applied. */
31
+ export type Resolved = {
32
+ maxAttempts: number;
33
+ maxBodySize: number;
34
+ maxRequestBodySize: number;
35
+ retryAfter: number;
36
+ store: Store.AtomicStore;
37
+ ttl: number;
38
+ waitUntil: ((promise: Promise<unknown>) => void) | undefined;
39
+ };
40
+ /** A delivery record, keyed by credential and request. */
41
+ export type Record = {
42
+ attempts: number;
43
+ expires: number;
44
+ receipt: string | null;
45
+ requestHeaders: [string, string][];
46
+ status: 'pending';
47
+ } | {
48
+ attempts: number;
49
+ expires: number;
50
+ receipt: string | null;
51
+ requestHeaders: [string, string][];
52
+ status: 'failed';
53
+ } | {
54
+ expires: number;
55
+ status: 'delivered';
56
+ } | {
57
+ body: string;
58
+ expires: number;
59
+ headers: [string, string][];
60
+ responseStatus: number;
61
+ status: 'complete';
62
+ statusText: string;
63
+ };
64
+ /** Applies defaults to replay options. */
65
+ export declare function resolve(options: Options): Resolved;
66
+ /** Returns the request's Payment credential header value, if it carries one. */
67
+ export declare function credentialOf(request: Request, headerNames?: readonly string[]): string | undefined;
68
+ /** Reads a request body, or returns null when it is larger than maxRequestBodySize. */
69
+ export declare function readBody(request: Request, maxRequestBodySize: number): Promise<Uint8Array<ArrayBuffer> | null>;
70
+ /** Derives the replay key from the credential and the exact request (origin, path, query, method, body) it paid for. */
71
+ export declare function key(parameters: {
72
+ body: Uint8Array;
73
+ credential: string;
74
+ method: string;
75
+ target: string;
76
+ }): string;
77
+ /** Rebuilds a request around an already-read body, optionally with another abort signal. */
78
+ export declare function withBody(request: Request, body: Uint8Array, signal?: AbortSignal, headers?: HeadersInit): Request;
79
+ /** What a request with a known credential should do. */
80
+ export type Lookup = {
81
+ type: 'none';
82
+ } | {
83
+ response: Response;
84
+ type: 'respond';
85
+ } | {
86
+ attempts: number;
87
+ receipt: string | null;
88
+ requestHeaders: [string, string][];
89
+ type: 'retry';
90
+ };
91
+ /** Looks up a delivery and, for a failed one, claims the next upstream attempt. */
92
+ export declare function lookup(replay: Resolved, replayKey: string): Promise<Lookup>;
93
+ /** Runs a paid upstream delivery detached from the client and records its outcome. */
94
+ export declare function deliver(parameters: {
95
+ applyReceipt: (response: Response) => Response | Promise<Response>;
96
+ attempts: number;
97
+ replay: Resolved;
98
+ replayKey: string;
99
+ /** The upstream headers of the paid request; a retried delivery reuses them. */
100
+ requestHeaders: [string, string][];
101
+ run: (signal: AbortSignal) => Promise<Response>;
102
+ }): Promise<Response>;
103
+ /** Adds a stored receipt to a response built for a retried delivery. */
104
+ export declare function withReceiptHeader(response: Response, receipt: string | null): Response;
105
+ //# sourceMappingURL=Replay.d.ts.map