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
@@ -0,0 +1,257 @@
1
+ import { Base64, Bytes, Hash } from 'ox';
2
+ import * as Credential from '../../Credential.js';
3
+ const keyPrefix = 'mppx:proxy:replay:';
4
+ /** Applies defaults to replay options. */
5
+ export function resolve(options) {
6
+ const ttl = options.ttl ?? 900_000;
7
+ // AbortSignal.timeout needs a positive integer that fits a 32-bit timer.
8
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > 2_147_483_647)
9
+ throw new RangeError('replay.ttl must be a positive integer number of milliseconds up to 2147483647.');
10
+ return {
11
+ maxAttempts: options.maxAttempts ?? 3,
12
+ maxBodySize: options.maxBodySize ?? 1_048_576,
13
+ maxRequestBodySize: options.maxRequestBodySize ?? 1_048_576,
14
+ retryAfter: options.retryAfter ?? 5,
15
+ store: options.store,
16
+ ttl,
17
+ waitUntil: options.waitUntil,
18
+ };
19
+ }
20
+ /** Returns the request's Payment credential header value, if it carries one. */
21
+ export function credentialOf(request, headerNames = ['authorization']) {
22
+ // Only the headers the route's payment handler verifies can name a replay.
23
+ for (const name of headerNames) {
24
+ const header = request.headers.get(name);
25
+ const payment = header ? Credential.extractPaymentScheme(header) : null;
26
+ if (payment)
27
+ return payment;
28
+ }
29
+ return undefined;
30
+ }
31
+ /** Reads a request body, or returns null when it is larger than maxRequestBodySize. */
32
+ export async function readBody(request, maxRequestBodySize) {
33
+ const length = Number(request.headers.get('content-length'));
34
+ if (Number.isFinite(length) && length > maxRequestBodySize)
35
+ return null;
36
+ if (!request.body)
37
+ return new Uint8Array();
38
+ return keep(request.body, maxRequestBodySize);
39
+ }
40
+ /** Derives the replay key from the credential and the exact request (origin, path, query, method, body) it paid for. */
41
+ export function key(parameters) {
42
+ const bodyHash = Hash.sha256(parameters.body, { as: 'Hex' });
43
+ const input = JSON.stringify([
44
+ parameters.credential,
45
+ parameters.method.toUpperCase(),
46
+ parameters.target,
47
+ bodyHash,
48
+ ]);
49
+ return keyPrefix + Hash.sha256(Bytes.fromString(input), { as: 'Hex' }).slice(2);
50
+ }
51
+ /** Rebuilds a request around an already-read body, optionally with another abort signal. */
52
+ export function withBody(request, body, signal, headers) {
53
+ const method = request.method.toUpperCase();
54
+ const hasBody = method !== 'GET' && method !== 'HEAD';
55
+ return new globalThis.Request(request.url, {
56
+ // An empty body stays absent, as on the original request: payment
57
+ // transports tell management requests apart by a missing body.
58
+ body: hasBody && body.byteLength > 0 ? body : null,
59
+ headers: headers ?? request.headers,
60
+ method: request.method,
61
+ redirect: request.redirect,
62
+ signal: signal ?? request.signal,
63
+ });
64
+ }
65
+ /** Looks up a delivery and, for a failed one, claims the next upstream attempt. */
66
+ export async function lookup(replay, replayKey) {
67
+ const now = Date.now();
68
+ // Read first: only a failed delivery needs an atomic claim, so unknown
69
+ // credentials never cause a store write.
70
+ const seen = (await replay.store.get(replayKey));
71
+ if (!seen)
72
+ return { type: 'none' };
73
+ if (seen.expires > now && seen.status !== 'failed')
74
+ return { response: respond(replay, seen), type: 'respond' };
75
+ const result = await replay.store.update(replayKey, (current) => {
76
+ const record = current;
77
+ if (!record)
78
+ return { op: 'noop', result: { type: 'none' } };
79
+ if (record.expires <= now)
80
+ return { op: 'delete', result: { type: 'none' } };
81
+ if (record.status !== 'failed' || record.attempts >= replay.maxAttempts)
82
+ return { op: 'noop', result: record };
83
+ const attempts = record.attempts + 1;
84
+ return {
85
+ op: 'set',
86
+ value: { ...record, attempts, status: 'pending' },
87
+ result: {
88
+ attempts,
89
+ receipt: record.receipt,
90
+ requestHeaders: record.requestHeaders,
91
+ type: 'retry',
92
+ },
93
+ };
94
+ });
95
+ if ('type' in result)
96
+ return result;
97
+ return { response: respond(replay, result), type: 'respond' };
98
+ }
99
+ /** Builds the response for a recorded delivery that is not retried. */
100
+ function respond(replay, record) {
101
+ if (record.status === 'complete')
102
+ return fromRecord(record);
103
+ if (record.status === 'delivered')
104
+ return Response.json({ error: 'The paid response was delivered and is not stored for replay.' }, { status: 410 });
105
+ if (record.status === 'pending')
106
+ return new Response(null, {
107
+ headers: { 'Retry-After': String(replay.retryAfter) },
108
+ status: 202,
109
+ });
110
+ return withReceiptHeader(Response.json({ error: 'The paid upstream request failed.' }, { status: 502 }), record.receipt);
111
+ }
112
+ /** Runs a paid upstream delivery detached from the client and records its outcome. */
113
+ export async function deliver(parameters) {
114
+ const { applyReceipt, attempts, replay, replayKey, requestHeaders } = parameters;
115
+ // Outcomes are kept for ttl from when they are recorded, not from the start.
116
+ const expires = () => Date.now() + replay.ttl;
117
+ await record(replay, replayKey, {
118
+ attempts,
119
+ expires: expires(),
120
+ receipt: null,
121
+ requestHeaders,
122
+ status: 'pending',
123
+ });
124
+ let response;
125
+ try {
126
+ // ttl bounds the wait for the upstream to respond, not how long its body
127
+ // streams: a paid stream keeps going once it has started.
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(new DOMException('Replay ttl elapsed.', 'TimeoutError')), replay.ttl);
130
+ let upstream;
131
+ try {
132
+ upstream = await parameters.run(controller.signal);
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ response = await applyReceipt(upstream);
138
+ }
139
+ catch {
140
+ const failed = await applyReceipt(Response.json({ error: 'The paid upstream request failed.' }, { status: 502 }));
141
+ await recordFailure(replay, replayKey, attempts, requestHeaders, expires(), failed.headers.get('Payment-Receipt'));
142
+ return failed;
143
+ }
144
+ const receipt = response.headers.get('Payment-Receipt');
145
+ if (response.status >= 500) {
146
+ await recordFailure(replay, replayKey, attempts, requestHeaders, expires(), receipt);
147
+ return response;
148
+ }
149
+ if (!response.body) {
150
+ await record(replay, replayKey, toRecord(response, new Uint8Array(), expires()));
151
+ return response;
152
+ }
153
+ if (/^text\/event-stream/i.test(response.headers.get('content-type') ?? '')) {
154
+ await record(replay, replayKey, { expires: expires(), status: 'delivered' });
155
+ return response;
156
+ }
157
+ // Keep a copy while the client reads the other branch, so a client that
158
+ // disconnects mid-body can still collect the response on retry.
159
+ const [client, kept] = response.body.tee();
160
+ const stored = keep(kept, replay.maxBodySize)
161
+ .then((body) => replay.store.put(replayKey, body
162
+ ? toRecord(response, body, expires())
163
+ : { expires: expires(), status: 'delivered' }),
164
+ // The upstream body broke off: let the payer retry the delivery.
165
+ () => recordFailure(replay, replayKey, attempts, requestHeaders, expires(), receipt))
166
+ // A failed background write leaves the record pending until it expires.
167
+ .catch(() => { });
168
+ replay.waitUntil?.(stored);
169
+ return new Response(client, {
170
+ headers: response.headers,
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ });
174
+ }
175
+ /** Reads a body branch up to maxBodySize, or returns null when it is larger. */
176
+ async function keep(stream, maxBodySize) {
177
+ const reader = stream.getReader();
178
+ const chunks = [];
179
+ let size = 0;
180
+ for (;;) {
181
+ const { done, value } = await reader.read();
182
+ if (done)
183
+ break;
184
+ size += value.byteLength;
185
+ if (size > maxBodySize) {
186
+ await reader.cancel();
187
+ return null;
188
+ }
189
+ chunks.push(value);
190
+ }
191
+ const body = new Uint8Array(size);
192
+ let offset = 0;
193
+ for (const chunk of chunks) {
194
+ body.set(chunk, offset);
195
+ offset += chunk.byteLength;
196
+ }
197
+ return body;
198
+ }
199
+ /**
200
+ * Writes a replay record without letting a store failure break the paid
201
+ * delivery: the payer still gets the upstream response, only replay is lost.
202
+ */
203
+ async function record(replay, replayKey, value) {
204
+ try {
205
+ await replay.store.put(replayKey, value);
206
+ }
207
+ catch { }
208
+ }
209
+ async function recordFailure(replay, replayKey, attempts, requestHeaders, expires, receipt) {
210
+ await record(replay, replayKey, {
211
+ attempts,
212
+ expires,
213
+ receipt,
214
+ requestHeaders,
215
+ status: 'failed',
216
+ });
217
+ }
218
+ /** Adds a stored receipt to a response built for a retried delivery. */
219
+ export function withReceiptHeader(response, receipt) {
220
+ if (!receipt)
221
+ return response;
222
+ const headers = new Headers(response.headers);
223
+ headers.set('Payment-Receipt', receipt);
224
+ headers.set('Cache-Control', withPrivateCacheControl(headers.get('Cache-Control')));
225
+ return new Response(response.body, {
226
+ headers,
227
+ status: response.status,
228
+ statusText: response.statusText,
229
+ });
230
+ }
231
+ function withPrivateCacheControl(value) {
232
+ if (!value)
233
+ return 'private';
234
+ const directives = value.split(',').map((directive) => directive.trim().toLowerCase());
235
+ if (directives.includes('private'))
236
+ return value;
237
+ return `${value}, private`;
238
+ }
239
+ function toRecord(response, body, expires) {
240
+ return {
241
+ body: Base64.fromBytes(body),
242
+ expires,
243
+ headers: [...response.headers.entries()],
244
+ responseStatus: response.status,
245
+ status: 'complete',
246
+ statusText: response.statusText,
247
+ };
248
+ }
249
+ function fromRecord(record) {
250
+ const nullBody = [101, 204, 205, 304].includes(record.responseStatus);
251
+ return new Response(nullBody ? null : Base64.toBytes(record.body), {
252
+ headers: record.headers,
253
+ status: record.responseStatus,
254
+ statusText: record.statusText,
255
+ });
256
+ }
257
+ //# sourceMappingURL=Replay.js.map
@@ -414,6 +414,8 @@ type ConfiguredHandler<transport extends Transport.AnyTransport = Transport.Http
414
414
  _stableBinding?: Method.StableBindingFn<Method.Method> | undefined;
415
415
  /** The offer's payment-stage identity verifiers, if any. */
416
416
  _paymentIdentity?: readonly Identity.Verifier[] | undefined;
417
+ /** Whether the route requires identity verifiers (request or payment stage). */
418
+ _identity?: boolean | undefined;
417
419
  _transport: Transport.AnyTransport;
418
420
  };
419
421
  };
@@ -468,6 +468,7 @@ function createMethodFn(parameters) {
468
468
  ],
469
469
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
470
470
  _paymentIdentity: paymentVerifiers.length > 0 ? paymentVerifiers : undefined,
471
+ _identity: identity.length > 0,
471
472
  _stableBinding: stableBinding,
472
473
  _transport: transport,
473
474
  };
@@ -1916,11 +1917,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1916
1917
  composed._internal = { offers, handlers: handlers, selectOffers };
1917
1918
  return composed;
1918
1919
  }
1919
- /** Returns the flattened configured offers accepted by a handler. */
1920
1920
  /** Whether two verifier lists hold the same verifiers in the same order. */
1921
1921
  function sameVerifiers(a, b) {
1922
1922
  return a.length === b.length && a.every((verifier, i) => verifier === b[i]);
1923
1923
  }
1924
+ /** Returns the flattened configured offers accepted by a handler. */
1924
1925
  function getConfiguredOffers(handler) {
1925
1926
  const internal = handler._internal;
1926
1927
  if (!internal)
@@ -21,6 +21,7 @@ export declare const validityWindowSeconds = 25;
21
21
  export type ChargeContext = {
22
22
  account?: Account.getResolver.Parameters['account'] | undefined;
23
23
  autoSwap?: AutoSwap.resolve.Value | undefined;
24
+ feeToken?: Address | undefined;
24
25
  mode?: Methods.ChargeMode | undefined;
25
26
  };
26
27
  /**
@@ -41,6 +42,7 @@ export declare function charge(parameters?: charge.Parameters): {
41
42
  readonly context?: z.ZodMiniObject<{
42
43
  account: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | Account.Account | undefined, `0x${string}` | Account.Account | undefined>>;
43
44
  autoSwap: z.ZodMiniOptional<z.ZodMiniCustom<AutoSwap.resolve.Value | undefined, AutoSwap.resolve.Value | undefined>>;
45
+ feeToken: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
44
46
  mode: z.ZodMiniOptional<z.ZodMiniEnum<{
45
47
  pull: "pull";
46
48
  push: "push";
@@ -192,6 +194,7 @@ export declare function charge(parameters?: charge.Parameters): {
192
194
  }, {
193
195
  account?: `0x${string}` | Account.Account | undefined;
194
196
  autoSwap?: AutoSwap.resolve.Value | undefined;
197
+ feeToken?: `0x${string}` | undefined;
195
198
  mode?: "pull" | "push" | undefined;
196
199
  }>;
197
200
  };
@@ -210,6 +213,12 @@ export declare namespace charge {
210
213
  autoSwap?: AutoSwap | undefined;
211
214
  /** Client identifier used to derive the client fingerprint in attribution memos. */
212
215
  clientId?: string | undefined;
216
+ /**
217
+ * Token the payer pays its transaction fee in, when the challenge is not fee-sponsored.
218
+ * Set it for an access key whose spending limits do not cover the default fee token,
219
+ * such as a key limited to OUSD paying a USDC.e charge.
220
+ */
221
+ feeToken?: Address | undefined;
213
222
  /**
214
223
  * Chain ID this client is willing to pay on. When set, the client rejects
215
224
  * any challenge whose `methodDetails.chainId` differs, and signs on this
@@ -31,6 +31,7 @@ export const validityWindowSeconds = 25;
31
31
  const chargeContextSchema = z.object({
32
32
  account: z.optional(z.custom()),
33
33
  autoSwap: z.optional(z.custom()),
34
+ feeToken: z.optional(z.custom()),
34
35
  mode: z.optional(z.enum(Methods.chargeModes)),
35
36
  });
36
37
  /**
@@ -188,15 +189,18 @@ export function charge(parameters = {}) {
188
189
  }
189
190
  const calls = machineTokenRoute?.calls ?? [...(swapCalls ?? []), ...transferCalls];
190
191
  const allowedFeeTokens = defaultFeeTokens(chainId);
191
- const feeToken = machineTokenRoute || isMachineTokenCurrency
192
- ? await resolveFeeToken({
193
- account: account.address,
194
- allowedTokens: allowedFeeTokens,
195
- candidateTokens: allowedFeeTokens,
196
- client,
197
- prioritizeCandidates: true,
198
- })
199
- : undefined;
192
+ const explicitFeeToken = context?.feeToken ?? parameters.feeToken;
193
+ const feeToken = explicitFeeToken
194
+ ? explicitFeeToken
195
+ : machineTokenRoute || isMachineTokenCurrency
196
+ ? await resolveFeeToken({
197
+ account: account.address,
198
+ allowedTokens: allowedFeeTokens,
199
+ candidateTokens: allowedFeeTokens,
200
+ client,
201
+ prioritizeCandidates: true,
202
+ })
203
+ : undefined;
200
204
  const mode = (() => {
201
205
  const explicitMode = context?.mode ?? parameters.mode;
202
206
  if (explicitMode) {
@@ -24,6 +24,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
24
24
  readonly context?: import("zod/mini").ZodMiniObject<{
25
25
  account: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | import("../../viem/Account.js").Account | undefined, `0x${string}` | import("../../viem/Account.js").Account | undefined>>;
26
26
  autoSwap: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<import("../internal/auto-swap.js").resolve.Value | undefined, import("../internal/auto-swap.js").resolve.Value | undefined>>;
27
+ feeToken: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
27
28
  mode: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniEnum<{
28
29
  pull: "pull";
29
30
  push: "push";
@@ -175,6 +176,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
175
176
  }, {
176
177
  account?: `0x${string}` | import("../../viem/Account.js").Account | undefined;
177
178
  autoSwap?: import("../internal/auto-swap.js").resolve.Value | undefined;
179
+ feeToken?: `0x${string}` | undefined;
178
180
  mode?: "pull" | "push" | undefined;
179
181
  }>;
180
182
  }, import("../../Method.js").Client<{
@@ -1042,6 +1042,8 @@ const ambiguousErrorNames = new Set([
1042
1042
  * - JSON-RPC code `4`: the EIP-7966 timeout for a pooled, unconfirmed transaction.
1043
1043
  * - Messages containing the words "already known" or "known transaction": already
1044
1044
  * pooled ("unknown transaction type" does not match).
1045
+ * - Tempo's `ExpiringNonceReplay`: an expiring-nonce transaction with this hash was
1046
+ * already accepted, such as when a transport retries a send the node took.
1045
1047
  */
1046
1048
  function isAmbiguousBroadcastError(error) {
1047
1049
  for (let cause = error; cause; cause = cause.cause) {
@@ -1054,7 +1056,8 @@ function isAmbiguousBroadcastError(error) {
1054
1056
  return true;
1055
1057
  if (code === 4)
1056
1058
  return true;
1057
- if (typeof message === 'string' && /\balready ?known\b|\bknown transaction\b/i.test(message))
1059
+ if (typeof message === 'string' &&
1060
+ /\balready ?known\b|\bknown transaction\b|\bExpiringNonceReplay\b/i.test(message))
1058
1061
  return true;
1059
1062
  }
1060
1063
  return false;