mppx 0.17.1 → 0.17.4

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 (89) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/Errors.d.ts +94 -0
  3. package/dist/Errors.js +83 -0
  4. package/dist/Store.d.ts +413 -0
  5. package/dist/Store.js +837 -0
  6. package/dist/cli/cli.js +141 -136
  7. package/dist/cli/internal.js +28 -6
  8. package/dist/cli/plugins/index.js +16 -11
  9. package/dist/cli/sessions/Manager.js +12 -1
  10. package/dist/cli/utils.d.ts +2 -0
  11. package/dist/cli/utils.js +6 -0
  12. package/dist/cli/validate/challenge.d.ts +4 -0
  13. package/dist/cli/validate/challenge.js +9 -6
  14. package/dist/client/Mppx.d.ts +1 -1
  15. package/dist/client/Mppx.js +7 -3
  16. package/dist/client/internal/Fetch.d.ts +1 -1
  17. package/dist/client/internal/Fetch.js +9 -0
  18. package/dist/client/internal/MethodResponse.d.ts +8 -0
  19. package/dist/client/internal/MethodResponse.js +9 -0
  20. package/dist/client/internal/ProofRealm.d.ts +3 -3
  21. package/dist/client/internal/ProofRealm.js +4 -4
  22. package/dist/client/internal/protocols/Mcp.d.ts +4 -0
  23. package/dist/client/internal/protocols/Mcp.js +27 -14
  24. package/dist/cloudflare/ChannelStore.d.ts +97 -0
  25. package/dist/cloudflare/ChannelStore.js +304 -0
  26. package/dist/cloudflare/Channels.d.ts +92 -0
  27. package/dist/cloudflare/Channels.js +325 -0
  28. package/dist/cloudflare/index.d.ts +5 -0
  29. package/dist/cloudflare/index.js +5 -0
  30. package/dist/identity/Client.js +7 -1
  31. package/dist/internal/sse.d.ts +21 -0
  32. package/dist/internal/sse.js +82 -0
  33. package/dist/internal/version.d.ts +2 -2
  34. package/dist/internal/version.js +1 -1
  35. package/dist/mcp/client/McpClient.d.ts +2 -2
  36. package/dist/mcp/client/McpClient.js +5 -2
  37. package/dist/proxy/Proxy.d.ts +2 -0
  38. package/dist/proxy/Proxy.js +31 -6
  39. package/dist/proxy/internal/Replay.js +1 -1
  40. package/dist/server/Mppx.js +5 -4
  41. package/dist/server/Transport.js +1 -1
  42. package/dist/server/internal/html/config.d.ts +7 -1
  43. package/dist/server/internal/html/config.js +4 -0
  44. package/dist/stripe/internal/types.d.ts +82 -6
  45. package/dist/stripe/server/Charge.js +1 -2
  46. package/dist/stripe/server/Methods.d.ts +2 -0
  47. package/dist/stripe/server/Methods.js +2 -1
  48. package/dist/stripe/server/internal/html.gen.js +1 -1
  49. package/dist/stripe/server/internal/record-payment.d.ts +49 -0
  50. package/dist/stripe/server/internal/record-payment.js +75 -18
  51. package/dist/tempo/Methods.d.ts +8 -0
  52. package/dist/tempo/Methods.js +18 -2
  53. package/dist/tempo/client/Charge.d.ts +32 -1
  54. package/dist/tempo/client/Charge.js +201 -3
  55. package/dist/tempo/client/Methods.d.ts +16 -0
  56. package/dist/tempo/internal/fee-payer.d.ts +64 -14
  57. package/dist/tempo/internal/fee-payer.js +231 -20
  58. package/dist/tempo/internal/zone.d.ts +79 -0
  59. package/dist/tempo/internal/zone.js +133 -0
  60. package/dist/tempo/legacy/session/Chain.js +2 -0
  61. package/dist/tempo/server/Charge.d.ts +7 -3
  62. package/dist/tempo/server/Charge.js +100 -6
  63. package/dist/tempo/server/Methods.d.ts +40 -0
  64. package/dist/tempo/server/Subscription.js +1 -0
  65. package/dist/tempo/server/internal/html.gen.js +1 -1
  66. package/dist/tempo/server/internal/transport.d.ts +6 -0
  67. package/dist/tempo/server/internal/transport.js +21 -21
  68. package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
  69. package/dist/tempo/session/client/CredentialState.js +2 -0
  70. package/dist/tempo/session/client/Session.d.ts +11 -1
  71. package/dist/tempo/session/client/Session.js +39 -6
  72. package/dist/tempo/session/client/SessionManager.d.ts +1 -1
  73. package/dist/tempo/session/client/SessionManager.js +8 -4
  74. package/dist/tempo/session/client/Transports.d.ts +2 -0
  75. package/dist/tempo/session/client/Transports.js +4 -1
  76. package/dist/tempo/session/precompile/Chain.js +1 -0
  77. package/dist/tempo/session/server/ChannelStore.d.ts +14 -0
  78. package/dist/tempo/session/server/ChannelStore.js +10 -3
  79. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  80. package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
  81. package/dist/tempo/session/server/MeteredStream.js +6 -1
  82. package/dist/tempo/session/server/Sse.d.ts +59 -0
  83. package/dist/tempo/session/server/Sse.js +175 -10
  84. package/dist/validation/core.js +2 -0
  85. package/dist/zod.d.ts +2 -1
  86. package/dist/zod.js +1 -1
  87. package/package.json +5 -3
  88. package/dist/stripe/server/internal/html/types.d.ts +0 -2
  89. package/dist/stripe/server/internal/html/types.js +0 -2
@@ -0,0 +1,325 @@
1
+ import { zeroAddress } from 'viem';
2
+ import { ChannelNotFoundError, VerificationFailedError } from '../Errors.js';
3
+ import * as Store from '../Store.js';
4
+ import { parsePkhSource } from '../tempo/internal/proof.js';
5
+ import { tip20ChannelEscrow } from '../tempo/session/precompile/Protocol.js';
6
+ import { isPrecompileState } from '../tempo/session/server/ChannelStore.js';
7
+ import { settle as settleChannel, SettlementCheckpointError, } from '../tempo/session/server/Settlement.js';
8
+ import { isChannelState, pendingClose, unsettledAmount } from './ChannelStore.js';
9
+ /**
10
+ * Tempo session channel storage on a {@link ChannelStore} Durable Object namespace, to spread
11
+ * into `tempo.session()`: `store` keeps each channel in its own object, which schedules its
12
+ * settlement, and `resolveChannelId` finds the payer's open channel to the same payee, token,
13
+ * escrow, operator and chain, so a client that lost its state resumes it instead of opening another.
14
+ * The lookup needs the payer's identity, which only a `bootstrap` HEAD proof supplies: enable
15
+ * `bootstrap` on the session and in the client's session manager, or it never resolves.
16
+ *
17
+ * The store keeps that lookup current: after a write opens, closes or finalizes a channel,
18
+ * it adds or removes the channel under its payer and authorized signer. Each lookup entry is
19
+ * its own key holding the payer's open channels there, most recent first, so closing one leaves
20
+ * the others findable. The lookup returns the first whose channel still resumes, so a delayed
21
+ * write for a channel that has since closed cannot hide an open one, and mppx validates any
22
+ * channel it resolves before reusing it.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { channels } from 'mppx/cloudflare'
27
+ * import { tempo } from 'mppx/server'
28
+ *
29
+ * tempo.session({ account, bootstrap: true, currency, getClient, ...channels(env.MPPX_CHANNELS) })
30
+ * ```
31
+ */
32
+ export function channels(namespace, options) {
33
+ const base = Store.durableObject(namespace, options);
34
+ const escrowContract = options?.escrowContract ?? tip20ChannelEscrow;
35
+ /** Removes `channelId` from the entry under `key`. */
36
+ async function remove(key, channelId) {
37
+ await base.update(key, (current) => {
38
+ const ids = lookupIds(current);
39
+ if (!ids.includes(channelId))
40
+ return noop;
41
+ const rest = ids.filter((id) => id !== channelId);
42
+ return rest.length
43
+ ? { op: 'set', value: rest, result: undefined }
44
+ : { op: 'delete', result: undefined };
45
+ });
46
+ }
47
+ /**
48
+ * Adds `channelId` to the front of the entry under `key` while its stored channel still
49
+ * resumes, so a delayed add after its close leaves it out. A full entry drops channels that no
50
+ * longer resume before the oldest open one, keeping the most recent {@link maxLookupChannels}.
51
+ */
52
+ async function add(key, channelId) {
53
+ for (let attempt = 1;; attempt++) {
54
+ const ids = lookupIds(await base.get(key));
55
+ if (ids.includes(channelId))
56
+ return;
57
+ if (!resumable(await base.get(channelId)))
58
+ return;
59
+ const full = ids.length >= maxLookupChannels;
60
+ const states = full ? await Promise.all(ids.map((id) => base.get(id))) : [];
61
+ const closed = new Set(ids.filter((_, i) => full && !resumable(states[i])));
62
+ const added = await base.update(key, (current) => {
63
+ const listed = lookupIds(current);
64
+ if (listed.includes(channelId))
65
+ return { op: 'noop', result: true };
66
+ // The entry changed since its channels were read: read them again, a few times at most.
67
+ if (attempt < maxLookupAttempts && !sameIds(listed, ids))
68
+ return { op: 'noop', result: false };
69
+ return {
70
+ op: 'set',
71
+ value: [channelId, ...listed.filter((id) => !closed.has(id))].slice(0, maxLookupChannels),
72
+ result: true,
73
+ };
74
+ });
75
+ if (added)
76
+ return;
77
+ }
78
+ }
79
+ /**
80
+ * Moves the lookup entries of a channel from its previous state to its next one. Entries
81
+ * for the next state are written on every write, not only when they change: the channel
82
+ * commits before its entries, so a failed entry write is repaired by the channel's next
83
+ * write instead of staying missing. Writing an entry that is already there changes nothing.
84
+ * Best effort: the channel write has already committed, so a failed entry write must not
85
+ * report it as failed (a charge would apply without its Receipt).
86
+ */
87
+ async function sync(previous, next) {
88
+ const before = lookupKeys(previous);
89
+ const after = lookupKeys(next);
90
+ await Promise.allSettled([
91
+ ...before
92
+ .filter((key) => !after.includes(key))
93
+ .map((key) => remove(key, previous.channelId)),
94
+ ...after.map((key) => add(key, next.channelId)),
95
+ ]);
96
+ }
97
+ const store = {
98
+ ...base,
99
+ async put(key, value) {
100
+ const previous = await base.get(key);
101
+ await base.put(key, value);
102
+ await sync(previous, value);
103
+ },
104
+ async delete(key) {
105
+ const previous = await base.get(key);
106
+ await base.delete(key);
107
+ await sync(previous, null);
108
+ },
109
+ async update(key, fn) {
110
+ // The last call of `fn` is the one the compare-and-swap committed.
111
+ const applied = {
112
+ previous: null,
113
+ };
114
+ const result = await base.update(key, (current) => {
115
+ applied.previous = current;
116
+ applied.change = fn(current);
117
+ return applied.change;
118
+ });
119
+ const { change, previous } = applied;
120
+ if (change && change.op !== 'noop')
121
+ await sync(previous, change.op === 'set' ? change.value : null);
122
+ return result;
123
+ },
124
+ };
125
+ const resolveChannelId = async ({ paymentRequest, source }) => {
126
+ const payer = source ? parsePkhSource(source) : null;
127
+ const { recipient } = paymentRequest;
128
+ if (!payer || !recipient)
129
+ return undefined;
130
+ if (paymentRequest.chainId !== undefined && paymentRequest.chainId !== payer.chainId)
131
+ return undefined;
132
+ const operator = paymentRequest.operator ?? zeroAddress;
133
+ const ids = lookupIds(await base.get(lookupKey({
134
+ chainId: payer.chainId,
135
+ escrowContract: paymentRequest.escrowContract ?? escrowContract,
136
+ payee: recipient,
137
+ operator,
138
+ payer: payer.address,
139
+ token: paymentRequest.currency,
140
+ })));
141
+ // An ID a delayed write left for a channel that has since closed resumes nothing.
142
+ for (const id of ids) {
143
+ const state = await base.get(id);
144
+ if (!resumable(state))
145
+ continue;
146
+ // mppx verifies a challenge without an operator against the zero operator, and only
147
+ // channels without one (legacy escrow channels) are not keyed by theirs.
148
+ if (isPrecompileState(state)
149
+ ? state.operator.toLowerCase() !== operator.toLowerCase()
150
+ : paymentRequest.operator !== undefined)
151
+ continue;
152
+ return id;
153
+ }
154
+ return undefined;
155
+ };
156
+ return { resolveChannelId, store };
157
+ }
158
+ /**
159
+ * Settles the channels of a settlement queue batch on-chain, from the Worker's `queue()`
160
+ * handler. Each message is acknowledged once its channel settled or has nothing left to
161
+ * settle, and when settlement cannot succeed (the channel is gone or has no voucher); any
162
+ * other failure, such as a fee over the fee payer's cap, retries it after 30 s, doubling up
163
+ * to 10 minutes. The {@link ChannelStore} watchdog sends a channel again if its job never settles it.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * import { channels, settle } from 'mppx/cloudflare'
168
+ *
169
+ * export default {
170
+ * async queue(batch, env) {
171
+ * await settle(batch, { account, getClient, store: channels(env.MPPX_CHANNELS).store })
172
+ * },
173
+ * }
174
+ * ```
175
+ */
176
+ export async function settle(batch, parameters) {
177
+ const { getClient, settle: settle_ = settleChannel, store, ...options } = parameters;
178
+ for (const message of batch.messages) {
179
+ try {
180
+ const channelId = message.body
181
+ ?.channelId;
182
+ // A malformed job can never settle: drop it instead of failing the batch.
183
+ if (typeof channelId !== 'string') {
184
+ message.ack();
185
+ continue;
186
+ }
187
+ const state = await store.get(channelId);
188
+ // A duplicate job, such as one the watchdog sent while the first was queued, has nothing left.
189
+ if (!isChannelState(state) || state.finalized || unsettledAmount(state) <= 0n) {
190
+ message.ack();
191
+ continue;
192
+ }
193
+ // The server's own cooperative close captures it: check again once that close lands or expires.
194
+ if (pendingClose(state)) {
195
+ message.retry({ delaySeconds: 60 });
196
+ continue;
197
+ }
198
+ // Another job or a scheduled settlement is settling it: check again once that one is done.
199
+ const leaseOwner = await claim(store, channelId);
200
+ if (!leaseOwner) {
201
+ message.retry({ delaySeconds: 60 });
202
+ continue;
203
+ }
204
+ // Waiting for the receipt can outlast the lease: keep it while the settlement runs, so no
205
+ // other job claims the channel and broadcasts the same voucher.
206
+ const renewal = setInterval(() => {
207
+ void renew(store, channelId, leaseOwner).catch(() => undefined);
208
+ }, settlementLeaseMs / 2);
209
+ try {
210
+ await settle_(store, await getClient({ chainId: state.chainId }), channelId, options);
211
+ }
212
+ finally {
213
+ clearInterval(renewal);
214
+ await release(store, channelId, leaseOwner).catch(() => undefined);
215
+ }
216
+ message.ack();
217
+ }
218
+ catch (error) {
219
+ if (isPermanent(error))
220
+ message.ack();
221
+ else
222
+ message.retry({ delaySeconds: Math.min(30 * 2 ** Math.max(0, message.attempts - 1), 600) });
223
+ }
224
+ }
225
+ }
226
+ const noop = { op: 'noop', result: undefined };
227
+ /** How long a settlement claim holds before another job may take over, as scheduled settlement's. */
228
+ const settlementLeaseMs = 5 * 60_000;
229
+ /**
230
+ * Claims a channel's settlement with the lease scheduled settlement takes, so overlapping jobs,
231
+ * or a job and a scheduled settlement, never broadcast the same voucher twice. Returns the
232
+ * lease's owner, or `undefined` while another one holds a live lease.
233
+ */
234
+ async function claim(store, channelId) {
235
+ const owner = globalThis.crypto.randomUUID();
236
+ const now = Date.now();
237
+ return store.update(channelId, (current) => {
238
+ if (!isChannelState(current))
239
+ return { op: 'noop', result: undefined };
240
+ const lease = current.scheduledSettlementLease;
241
+ if (lease && lease.expiresAt > now)
242
+ return { op: 'noop', result: undefined };
243
+ return {
244
+ op: 'set',
245
+ value: {
246
+ ...current,
247
+ scheduledSettlementLease: { expiresAt: now + settlementLeaseMs, owner },
248
+ },
249
+ result: owner,
250
+ };
251
+ });
252
+ }
253
+ /** Extends a settlement lease {@link claim} took while its owner still holds it. */
254
+ async function renew(store, channelId, owner) {
255
+ await store.update(channelId, (current) => {
256
+ if (!isChannelState(current) || current.scheduledSettlementLease?.owner !== owner)
257
+ return noop;
258
+ return {
259
+ op: 'set',
260
+ value: {
261
+ ...current,
262
+ scheduledSettlementLease: { expiresAt: Date.now() + settlementLeaseMs, owner },
263
+ },
264
+ result: undefined,
265
+ };
266
+ });
267
+ }
268
+ /** Releases a settlement lease {@link claim} took, unless another owner has taken it since. */
269
+ async function release(store, channelId, owner) {
270
+ await store.update(channelId, (current) => {
271
+ if (!isChannelState(current) || current.scheduledSettlementLease?.owner !== owner)
272
+ return noop;
273
+ const { scheduledSettlementLease: _, ...channel } = current;
274
+ return { op: 'set', value: channel, result: undefined };
275
+ });
276
+ }
277
+ /**
278
+ * Whether a settlement failed for good: the channel or its voucher is gone, the account cannot
279
+ * settle it, or the transaction confirmed and only recording it failed.
280
+ */
281
+ function isPermanent(error) {
282
+ return (error instanceof ChannelNotFoundError ||
283
+ error instanceof VerificationFailedError ||
284
+ error instanceof SettlementCheckpointError);
285
+ }
286
+ /**
287
+ * The lookup key of a payer's channel to a payee, token, escrow, operator and chain. The operator
288
+ * is part of it so that channels opened with other operators cannot push a matching one out of
289
+ * the entry's {@link maxLookupChannels}.
290
+ */
291
+ function lookupKey(lookup) {
292
+ const { chainId, escrowContract, operator, payee, payer, token } = lookup;
293
+ return `mppx:session-lookup:v3:${[payer, payee, token, escrowContract, operator].map((address) => address.toLowerCase()).join(':')}:${chainId}`;
294
+ }
295
+ /** Most channels a lookup entry keeps per payer, payee, token, escrow, operator and chain. */
296
+ const maxLookupChannels = 8;
297
+ /** Reads of a full lookup entry's channels before adding to it regardless of concurrent changes. */
298
+ const maxLookupAttempts = 3;
299
+ /** Whether two lookup entries list the same channels in the same order. */
300
+ function sameIds(a, b) {
301
+ return a.length === b.length && a.every((id, i) => id === b[i]);
302
+ }
303
+ /** The channel IDs of a lookup entry, most recent first. */
304
+ function lookupIds(entry) {
305
+ return Array.isArray(entry) ? entry.filter((id) => typeof id === 'string') : [];
306
+ }
307
+ /** Whether a payer may resume a stored channel: open, with no close requested. */
308
+ function resumable(state) {
309
+ return isChannelState(state) && !state.finalized && !state.closeRequestedAt;
310
+ }
311
+ /** The lookup keys of a channel a payer may resume. */
312
+ function lookupKeys(state) {
313
+ if (!resumable(state))
314
+ return [];
315
+ const signers = new Set([state.payer.toLowerCase(), state.authorizedSigner.toLowerCase()]);
316
+ return [...signers].map((payer) => lookupKey({
317
+ chainId: state.chainId,
318
+ escrowContract: state.escrowContract,
319
+ operator: isPrecompileState(state) ? state.operator : zeroAddress,
320
+ payee: state.payee,
321
+ payer,
322
+ token: state.token,
323
+ }));
324
+ }
325
+ //# sourceMappingURL=Channels.js.map
@@ -0,0 +1,5 @@
1
+ /** Durable Object class that stores Tempo session channels and schedules their settlement. */
2
+ export { ChannelStore } from './ChannelStore.js';
3
+ /** Channel store and lookup for `tempo.session()`, and the settlement queue consumer. */
4
+ export { channels, settle } from './Channels.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** Durable Object class that stores Tempo session channels and schedules their settlement. */
2
+ export { ChannelStore } from './ChannelStore.js';
3
+ /** Channel store and lookup for `tempo.session()`, and the settlement queue consumer. */
4
+ export { channels, settle } from './Channels.js';
5
+ //# sourceMappingURL=index.js.map
@@ -1,6 +1,7 @@
1
1
  import * as Challenge from '../Challenge.js';
2
2
  import * as Credential from '../Credential.js';
3
3
  import * as SigningContext from './internal/SigningContext.js';
4
+ const skipPayment = Symbol.for('mppx.fetch.skipPayment');
4
5
  /** x402 credential headers (v2 and v1); a request carrying one is a paid attempt. */
5
6
  const x402CredentialHeaders = ['payment-signature', 'x-payment'];
6
7
  /**
@@ -99,7 +100,12 @@ export function wrapFetch(fetch, client, context) {
99
100
  // A retry may answer a refreshed challenge: bind proofs to the challenge its credential
100
101
  // carries, and let provers prepare for a refreshed 402 of the same method and intent.
101
102
  const challenge = credentialChallenge(request.headers) ?? context?.challenge;
102
- const { response } = await client.send(fetch, request, { ...context, challenge });
103
+ const { response } = await client.send((input, options) => fetch(input, {
104
+ ...options,
105
+ ...(init?.[skipPayment]
106
+ ? { [skipPayment]: true }
107
+ : {}),
108
+ }), request, { ...context, challenge });
103
109
  if (response.status === 402 && challenge)
104
110
  // A 402 without a parseable Payment challenge is returned as is: the caller decides.
105
111
  for (const next of challengesOf(response))
@@ -0,0 +1,21 @@
1
+ /** An event dispatched from a `text/event-stream` body. */
2
+ export type Event = {
3
+ /** The event's data: its `data` field values joined by `\n`. */
4
+ data: string;
5
+ /** The event's type: its last `event` field value, or `'message'` when it has none. */
6
+ event: string;
7
+ };
8
+ /**
9
+ * Creates an incremental parser for a `text/event-stream` body, as the WHATWG HTML
10
+ * spec interprets one. Feed it decoded text chunks split anywhere; it calls `onEvent`
11
+ * for each event a blank line ends. Lines end in `\n`, `\r\n` or `\r`; comments (`:`),
12
+ * `id`, `retry` and unknown fields are ignored, and an event that never ends is not
13
+ * dispatched.
14
+ *
15
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
16
+ */
17
+ export declare function createParser(onEvent: (event: Event) => void): {
18
+ /** Parses the next chunk of the stream. */
19
+ feed(chunk: string): void;
20
+ };
21
+ //# sourceMappingURL=sse.d.ts.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Creates an incremental parser for a `text/event-stream` body, as the WHATWG HTML
3
+ * spec interprets one. Feed it decoded text chunks split anywhere; it calls `onEvent`
4
+ * for each event a blank line ends. Lines end in `\n`, `\r\n` or `\r`; comments (`:`),
5
+ * `id`, `retry` and unknown fields are ignored, and an event that never ends is not
6
+ * dispatched.
7
+ *
8
+ * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
9
+ */
10
+ export function createParser(onEvent) {
11
+ let buffer = '';
12
+ let started = false;
13
+ let skipLineFeed = false;
14
+ let data;
15
+ let event = '';
16
+ function parseLine(line) {
17
+ if (line === '') {
18
+ if (data !== undefined)
19
+ onEvent({ data, event: event || 'message' });
20
+ data = undefined;
21
+ event = '';
22
+ return;
23
+ }
24
+ const colon = line.indexOf(':');
25
+ if (colon === 0)
26
+ return;
27
+ const field = colon === -1 ? line : line.slice(0, colon);
28
+ if (field !== 'data' && field !== 'event')
29
+ return;
30
+ let value = '';
31
+ if (colon !== -1)
32
+ value = line.slice(line.charCodeAt(colon + 1) === 32 ? colon + 2 : colon + 1);
33
+ if (field === 'event')
34
+ event = value;
35
+ else
36
+ data = data === undefined ? value : data + '\n' + value;
37
+ }
38
+ return {
39
+ /** Parses the next chunk of the stream. */
40
+ feed(chunk) {
41
+ if (!started && chunk) {
42
+ started = true;
43
+ if (chunk.startsWith('\uFEFF'))
44
+ chunk = chunk.slice(1);
45
+ }
46
+ // A `\r` ending the last chunk may be the first half of a `\r\n`.
47
+ if (skipLineFeed && chunk) {
48
+ skipLineFeed = false;
49
+ if (chunk.startsWith('\n'))
50
+ chunk = chunk.slice(1);
51
+ }
52
+ buffer += chunk;
53
+ if (chunk.indexOf('\n') === -1 && chunk.indexOf('\r') === -1)
54
+ return;
55
+ // Scan with indexOf rather than a global RegExp: it is several times faster on
56
+ // line-dense bodies.
57
+ let start = 0;
58
+ let lineFeed = buffer.indexOf('\n');
59
+ let carriageReturn = buffer.indexOf('\r');
60
+ while (lineFeed !== -1 || carriageReturn !== -1) {
61
+ const end = lineFeed === -1
62
+ ? carriageReturn
63
+ : carriageReturn === -1
64
+ ? lineFeed
65
+ : Math.min(lineFeed, carriageReturn);
66
+ parseLine(buffer.slice(start, end));
67
+ start = end + 1;
68
+ if (end === carriageReturn) {
69
+ if (start === buffer.length)
70
+ skipLineFeed = true;
71
+ else if (lineFeed === start)
72
+ start++;
73
+ carriageReturn = buffer.indexOf('\r', start);
74
+ }
75
+ if (lineFeed !== -1 && lineFeed < start)
76
+ lineFeed = buffer.indexOf('\n', start);
77
+ }
78
+ buffer = buffer.slice(start);
79
+ },
80
+ };
81
+ }
82
+ //# sourceMappingURL=sse.js.map
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.17.1";
2
+ export declare const version = "0.17.4";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.17.1";
4
+ export declare const sdkIdentifier = "mppx/0.17.4";
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.1';
3
+ export const version = '0.17.4';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -70,8 +70,8 @@ export declare namespace wrap {
70
70
  /** Array of methods to use. Accepts individual clients or tuples (e.g. from `tempo()`). */
71
71
  methods: methods;
72
72
  /**
73
- * Realms allowed to request zero-amount Tempo proofs. An MCP server has no origin that could
74
- * own a realm, so these proofs are refused unless their realm is listed here.
73
+ * Realms allowed to request zero-amount Tempo proofs or session payments. An MCP server has no origin that could
74
+ * own a realm, so these signatures are refused unless their realm is listed here.
75
75
  */
76
76
  trustedTempoProofRealms?: readonly string[] | undefined;
77
77
  };
@@ -1,5 +1,6 @@
1
1
  import * as Challenge from '../../Challenge.js';
2
2
  import * as ProofRealm from '../../client/internal/ProofRealm.js';
3
+ import { adapterPaymentMetaKey } from '../../client/internal/protocols/Mcp.js';
3
4
  import * as Credential from '../../Credential.js';
4
5
  import * as Expires from '../../Expires.js';
5
6
  import * as AcceptPayment from '../../internal/AcceptPayment.js';
@@ -57,9 +58,11 @@ export function wrap(client, config) {
57
58
  enumerable: false,
58
59
  value: (params, resultSchema, options) => {
59
60
  const { context, onPaymentRequired, ...requestOptions } = options ?? {};
60
- return callTool(params, {
61
+ return callTool({ ...params, _meta: { ...params._meta, [adapterPaymentMetaKey]: true } }, {
61
62
  context,
62
- onPaymentRequired: onPaymentRequired === null ? undefined : (onPaymentRequired ?? config.onPaymentRequired),
63
+ onPaymentRequired: onPaymentRequired === null
64
+ ? undefined
65
+ : (onPaymentRequired ?? config.onPaymentRequired),
63
66
  requestOptions: Object.keys(requestOptions).length
64
67
  ? requestOptions
65
68
  : undefined,
@@ -49,6 +49,8 @@ export declare namespace create {
49
49
  docs?: Service.Docs | undefined;
50
50
  /** Custom `fetch` implementation. Defaults to `globalThis.fetch`. */
51
51
  fetch?: typeof globalThis.fetch | undefined;
52
+ /** Maximum request body bytes on every route, including free routes. Defaults to 10 MiB. */
53
+ maxRequestBodySize?: number | undefined;
52
54
  /** Default handler for failed upstream attempts. Services may override it. */
53
55
  onUpstreamError?: Service.UpstreamErrorHandler | undefined;
54
56
  /**
@@ -34,6 +34,9 @@ import * as Service from './Service.js';
34
34
  * ```
35
35
  */
36
36
  export function create(config) {
37
+ const maxRequestBodySize = config.maxRequestBodySize ?? 10 * 1024 * 1024;
38
+ if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 0)
39
+ throw new RangeError('maxRequestBodySize must be a non-negative safe integer');
37
40
  const fetchImpl = config.fetch ?? globalThis.fetch;
38
41
  const replay = config.replay ? Replay.resolve(config.replay) : undefined;
39
42
  const services = new Map(config.services.map((s) => {
@@ -100,8 +103,22 @@ export function create(config) {
100
103
  if (!matched)
101
104
  return new Response('Not Found', { status: 404 });
102
105
  const endpoint = matched.value;
103
- const ctx = { request, service, upstreamPath };
104
106
  const handler = Service.getHandler(endpoint);
107
+ const declaredLength = Number(request.headers.get('content-length'));
108
+ if (Number.isFinite(declaredLength) && declaredLength > maxRequestBodySize)
109
+ return new Response('Payload Too Large', { status: 413 });
110
+ const paymentCredential = handler
111
+ ? Replay.credentialOf(request, credentialHeaders(handler))
112
+ : undefined;
113
+ let boundedBody;
114
+ if ((!handler || paymentCredential) && request.body) {
115
+ const body = await Replay.readBody(request, maxRequestBodySize);
116
+ if (!body)
117
+ return new Response('Payload Too Large', { status: 413 });
118
+ boundedBody = body;
119
+ request = Replay.withBody(request, body);
120
+ }
121
+ const ctx = { request, service, upstreamPath };
105
122
  if (!handler)
106
123
  return proxyUpstream({
107
124
  request,
@@ -116,11 +133,12 @@ export function create(config) {
116
133
  let replayBody;
117
134
  let replayKey;
118
135
  // 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;
136
+ const credential = replay && !fallbackMatch && !requiresIdentity(handler) ? paymentCredential : undefined;
122
137
  if (replay && credential) {
123
- const body = await Replay.readBody(request, replay.maxRequestBodySize);
138
+ const body = boundedBody ??
139
+ (await Replay.readBody(request, Math.min(maxRequestBodySize, replay.maxRequestBodySize)));
140
+ if (body && body.length > replay.maxRequestBodySize)
141
+ return new Response('Payload Too Large', { status: 413 });
124
142
  if (!body)
125
143
  return new Response('Payload Too Large', { status: 413 });
126
144
  replayBody = body;
@@ -179,6 +197,13 @@ export function create(config) {
179
197
  return managementResponse;
180
198
  if (fallbackMatch)
181
199
  return new Response('Method Not Allowed', { status: 405 });
200
+ if (!boundedBody && !replayBody && request.body) {
201
+ const body = await Replay.readBody(request, maxRequestBodySize);
202
+ if (!body)
203
+ return result.withReceipt(new Response('Payload Too Large', { status: 413 }));
204
+ request = Replay.withBody(request, body);
205
+ ctx.request = request;
206
+ }
182
207
  const options = Service.getOptions(endpoint);
183
208
  if (replay && replayKey && replayBody) {
184
209
  const body = replayBody;
@@ -211,7 +236,7 @@ export function create(config) {
211
236
  }
212
237
  return {
213
238
  fetch: handle,
214
- listener: Request.toNodeListener(handle),
239
+ listener: Request.toNodeListener(handle, { maxBodySize: maxRequestBodySize }),
215
240
  };
216
241
  }
217
242
  /** @internal */
@@ -183,7 +183,7 @@ async function keep(stream, maxBodySize) {
183
183
  break;
184
184
  size += value.byteLength;
185
185
  if (size > maxBodySize) {
186
- await reader.cancel();
186
+ void reader.cancel().catch(() => { });
187
187
  return null;
188
188
  }
189
189
  chunks.push(value);
@@ -1876,11 +1876,12 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1876
1876
  }
1877
1877
  mergedHeaders.set('Content-Type', 'text/html; charset=utf-8');
1878
1878
  const firstData = Object.values(dataMap)[0];
1879
+ const entries = await Promise.all(htmlEntries.map(async (entry) => ({
1880
+ challenge: entry.challenge,
1881
+ content: await Html.resolveContent(entry.handler._internal.html.content),
1882
+ })));
1879
1883
  const body = Html.render({
1880
- entries: htmlEntries.map((entry) => ({
1881
- challenge: entry.challenge,
1882
- content: entry.handler._internal.html.content,
1883
- })),
1884
+ entries,
1884
1885
  dataMap,
1885
1886
  formattedAmount: firstData.formattedAmount,
1886
1887
  panels: true,
@@ -98,7 +98,7 @@ export function http(options = {}) {
98
98
  },
99
99
  };
100
100
  return Html.render({
101
- entries: [{ challenge, content: options.html.content }],
101
+ entries: [{ challenge, content: await Html.resolveContent(options.html.content) }],
102
102
  dataMap,
103
103
  formattedAmount: amount,
104
104
  text,
@@ -2,7 +2,11 @@ import type * as Challenge from '../../../Challenge.js';
2
2
  import type * as Method from '../../../Method.js';
3
3
  export type Options = {
4
4
  config: Record<string, unknown>;
5
- content: string;
5
+ /**
6
+ * The payment page's script, or a function that loads it. A loader lets a
7
+ * method import its (large) page bundle only when a page is rendered.
8
+ */
9
+ content: string | (() => Promise<string>);
6
10
  formatAmount: (request: any) => string | Promise<string>;
7
11
  text: Text | undefined;
8
12
  theme: Theme | undefined;
@@ -21,6 +25,8 @@ export type Data<method extends Method.Method = Method.Method, config extends Re
21
25
  };
22
26
  };
23
27
  export { attrs, classNames, ids, params } from './constants.js';
28
+ /** Resolves {@link Options.content}, loading it when it is a loader function. */
29
+ export declare function resolveContent(content: Options['content']): Promise<string>;
24
30
  declare class CssVar {
25
31
  readonly name: string;
26
32
  constructor(token: string);