mppx 0.13.4 → 0.13.6

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 (73) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/Errors.d.ts +63 -2
  5. package/dist/Errors.js +52 -2
  6. package/dist/Method.d.ts +10 -0
  7. package/dist/cli/cli.js +92 -92
  8. package/dist/cli/plugins/evm.js +2 -2
  9. package/dist/cli/plugins/index.js +12 -12
  10. package/dist/client/Mppx.d.ts +2 -0
  11. package/dist/client/Mppx.js +8 -10
  12. package/dist/client/internal/Fetch.js +107 -8
  13. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  14. package/dist/client/internal/MethodChallenge.js +4 -0
  15. package/dist/client/internal/MethodResponse.d.ts +6 -0
  16. package/dist/client/internal/MethodResponse.js +16 -0
  17. package/dist/client/internal/protocols/Mcp.js +36 -5
  18. package/dist/evm/server/Charge.js +3 -1
  19. package/dist/internal/version.d.ts +2 -2
  20. package/dist/internal/version.js +1 -1
  21. package/dist/internal/warnOnce.d.ts +3 -0
  22. package/dist/internal/warnOnce.js +9 -0
  23. package/dist/server/Mppx.d.ts +5 -0
  24. package/dist/server/Mppx.js +45 -18
  25. package/dist/server/Transport.js +10 -0
  26. package/dist/stripe/server/Methods.js +4 -1
  27. package/dist/tempo/Attribution.d.ts +24 -9
  28. package/dist/tempo/Attribution.js +25 -5
  29. package/dist/tempo/client/Charge.d.ts +116 -10
  30. package/dist/tempo/client/Charge.js +117 -22
  31. package/dist/tempo/client/Methods.d.ts +88 -9
  32. package/dist/tempo/index.d.ts +1 -0
  33. package/dist/tempo/index.js +1 -0
  34. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  35. package/dist/tempo/internal/auto-swap.js +35 -8
  36. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  37. package/dist/tempo/internal/fee-payer.js +20 -8
  38. package/dist/tempo/legacy/session/Chain.js +8 -2
  39. package/dist/tempo/server/Charge.d.ts +11 -0
  40. package/dist/tempo/server/Charge.js +160 -29
  41. package/dist/tempo/server/Methods.js +28 -5
  42. package/dist/tempo/server/Relay.js +6 -22
  43. package/dist/tempo/server/Subscription.js +5 -1
  44. package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
  45. package/dist/tempo/server/internal/fallback-store.js +13 -0
  46. package/dist/tempo/server/internal/html.gen.js +1 -1
  47. package/dist/tempo/server/internal/transport.d.ts +3 -0
  48. package/dist/tempo/server/internal/transport.js +5 -1
  49. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  50. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  51. package/dist/tempo/session/client/ChannelStore.js +47 -0
  52. package/dist/tempo/session/client/CredentialState.js +8 -3
  53. package/dist/tempo/session/client/Session.js +50 -33
  54. package/dist/tempo/session/client/SessionManager.js +186 -65
  55. package/dist/tempo/session/client/Transports.d.ts +6 -0
  56. package/dist/tempo/session/client/Transports.js +52 -7
  57. package/dist/tempo/session/precompile/Chain.d.ts +2 -0
  58. package/dist/tempo/session/precompile/Chain.js +8 -3
  59. package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
  60. package/dist/tempo/session/server/ChannelStore.js +69 -9
  61. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  62. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  63. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  64. package/dist/tempo/session/server/MeteredStream.js +10 -26
  65. package/dist/tempo/session/server/Session.d.ts +29 -2
  66. package/dist/tempo/session/server/Session.js +124 -44
  67. package/dist/tempo/session/server/Settlement.d.ts +9 -0
  68. package/dist/tempo/session/server/Settlement.js +23 -3
  69. package/dist/tempo/session/server/Sse.d.ts +4 -3
  70. package/dist/tempo/session/server/Sse.js +3 -3
  71. package/dist/tempo/session/server/Ws.d.ts +9 -1
  72. package/dist/tempo/session/server/Ws.js +1 -0
  73. package/package.json +1 -1
@@ -13,24 +13,33 @@ import * as Errors from '../../../Errors.js';
13
13
  import * as Expires from '../../../Expires.js';
14
14
  import * as Method from '../../../Method.js';
15
15
  import * as ChallengeMeta from '../../../server/internal/challengeMeta.js';
16
- import * as Store from '../../../Store.js';
17
16
  import * as Client from '../../../viem/Client.js';
18
17
  import * as Account from '../../internal/account.js';
19
18
  import * as defaults from '../../internal/defaults.js';
20
19
  import * as Methods from '../../Methods.js';
21
20
  import * as ChargeServer from '../../server/Charge.js';
21
+ import { fallbackStore } from '../../server/internal/fallback-store.js';
22
22
  import * as Transport from '../../server/internal/transport.js';
23
+ import * as Chain from '../precompile/Chain.js';
23
24
  import { tip20ChannelEscrow } from '../precompile/Protocol.js';
24
25
  import { deserializeSnapshot as deserializeSessionSnapshot, serializeSnapshot as serializeSessionSnapshot, } from '../Snapshot.js';
25
26
  import * as ChannelStore from './ChannelStore.js';
26
27
  import { broadcastCredentialPayload, validateCredentialPayload } from './CredentialVerification.js';
27
- import { requireSessionCredentialPayload } from './CredentialVerification.js';
28
+ import { requireSessionCredentialPayload, validateChannelState } from './CredentialVerification.js';
28
29
  import { resolveCredentialVerificationContext, resolveSessionChannelId, resolveSessionSnapshot, resolveSessionPaymentRequest, } from './RequestState.js';
29
30
  import { respondToSessionCredential } from './RequestState.js';
30
- import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, } from './Settlement.js';
31
+ import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, shouldChargeVoucherOnAcceptance, } from './Settlement.js';
31
32
  import { ignoreRetryableSettlementFailure, isSettlementDue, maybeSettleScheduled, reportSessionSettlementFailure, } from './Settlement.js';
32
33
  import { resolveSettlementSchedule, } from './Settlement.js';
33
34
  import * as Ws from './Ws.js';
35
+ const streamVerifiers = new WeakMap();
36
+ /** @internal Reuses a method's escrow verifier when composing shared currency helpers. */
37
+ export function verifyStream(method, channelId) {
38
+ const verify = streamVerifiers.get(method);
39
+ if (!verify)
40
+ throw new Error('Session stream verifier is not configured');
41
+ return verify(channelId);
42
+ }
34
43
  export { settle, settleBatch } from './Settlement.js';
35
44
  function deriveServerDefaults(values) {
36
45
  // `Method.toServer()` models defaults from request input fields. Tempo session
@@ -66,17 +75,14 @@ function createBootstrapChallenge(parameters) {
66
75
  });
67
76
  }
68
77
  function respondBootstrapChallenge(challenge, error) {
69
- const headers = new Headers({
70
- [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
71
- 'Cache-Control': 'no-store',
72
- });
78
+ const headers = new Headers({ 'Cache-Control': 'no-store' });
79
+ // A 403 is final: omit the challenge, as `Transport.http()` does.
80
+ if (error?.status !== 403)
81
+ headers.set(Constants.Headers.wwwAuthenticate, Challenge.serialize(challenge));
73
82
  if (!error)
74
83
  return new Response(null, { status: 402, headers });
75
84
  headers.set('Content-Type', 'application/problem+json');
76
- return new Response(JSON.stringify(error.toProblemDetails(challenge.id)), {
77
- status: error.status,
78
- headers,
79
- });
85
+ return new Response(JSON.stringify(error.toProblemDetails(error.status === 403 ? undefined : challenge.id)), { status: error.status, headers });
80
86
  }
81
87
  function assertBootstrapChallengeMatches(expected, actual) {
82
88
  if (actual.method !== Constants.Methods.tempo || actual.intent !== Constants.Intents.charge)
@@ -164,7 +170,9 @@ async function handleBootstrapPreflight(parameters) {
164
170
  });
165
171
  }
166
172
  catch (error) {
167
- return respondBootstrapChallenge(challenge, error instanceof Errors.PaymentError ? error : new Errors.InternalPaymentError());
173
+ return respondBootstrapChallenge(challenge, error instanceof Errors.PaymentError
174
+ ? error
175
+ : new Errors.InternalPaymentError({ cause: error }));
168
176
  }
169
177
  const channelId = await resolveSessionChannelId({
170
178
  capturedRequest: parameters.capturedRequest,
@@ -192,10 +200,23 @@ async function handleBootstrapPreflight(parameters) {
192
200
  }
193
201
  return new Response(null, { status: 204, headers });
194
202
  }
203
+ /** Binds a reusable session challenge to its route and channel terms but not to its quoted amount. */
204
+ const reusableSessionBinding = (request) => {
205
+ const { methodDetails } = request;
206
+ return {
207
+ chainId: methodDetails.chainId,
208
+ currency: request.currency,
209
+ escrowContract: methodDetails.escrowContract,
210
+ operator: methodDetails.operator,
211
+ recipient: request.recipient,
212
+ sessionProtocol: methodDetails[Constants.MethodDetailKeys.sessionProtocol],
213
+ unitType: request.unitType,
214
+ };
215
+ };
195
216
  /** Creates a server-side TIP20EscrowChannel precompile session payment method. */
196
217
  export function session(p) {
197
218
  const parameters = p;
198
- const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = Store.memory(), suggestedDeposit, unitType, } = parameters;
219
+ const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = fallbackStore('tempo.session'), suggestedDeposit, unitType, } = parameters;
199
220
  const settlementSchedule = resolveSettlementSchedule(parameters.settlementSchedule, decimals);
200
221
  const onSessionSettlement = parameters.onSessionSettlement;
201
222
  const onSessionSettlementFailure = parameters.onSessionSettlementFailure;
@@ -237,8 +258,33 @@ export function session(p) {
237
258
  // A failed scheduled settlement is reported and retried by the next one; the charged request
238
259
  // stays served because the payer's voucher already covers the charge.
239
260
  const settleCharged = (channel) => settleScheduled(channel).catch(ignoreRetryableSettlementFailure);
261
+ // Stream checks have a bounded TTL even when callers disable validation caching.
262
+ // Cache only successful reads; RPC failures must stop delivery.
263
+ const beforeStreamCharge = async (channelId) => {
264
+ channelId = ChannelStore.normalizeChannelId(channelId);
265
+ const channel = await store.getChannel(channelId);
266
+ if (!channel || channel.finalized || channel.closeRequestedAt !== 0n)
267
+ throw new Errors.ChannelClosedError({ reason: 'stream channel is closed' });
268
+ const now = Date.now();
269
+ const ttl = Math.min(Math.max(channelStateTtl, 0), 5_000);
270
+ const lastVerified = lastOnChainVerified.get(channelId);
271
+ if (lastVerified !== undefined && now >= lastVerified && now - lastVerified < ttl)
272
+ return;
273
+ let state;
274
+ try {
275
+ const client = await getClient({ chainId: channel.chainId });
276
+ state = await Chain.getChannelState(client, channelId, channel.escrowContract);
277
+ }
278
+ catch {
279
+ // Transport errors can include private RPC URLs or authentication details.
280
+ throw new Error('Unable to verify stream escrow');
281
+ }
282
+ validateChannelState(state);
283
+ lastOnChainVerified.set(channelId, now);
284
+ };
240
285
  const serveWebSocket = (options) => Ws.serve({
241
286
  ...options,
287
+ beforeCharge: beforeStreamCharge,
242
288
  onChargeCommitted: settleCharged,
243
289
  store,
244
290
  });
@@ -254,9 +300,11 @@ export function session(p) {
254
300
  });
255
301
  const transport = parameters.sse
256
302
  ? Transport.sse({
303
+ ...(typeof parameters.sse === 'object' ? parameters.sse : {}),
304
+ beforeCharge: beforeStreamCharge,
305
+ reusableChallenges: parameters.reusableChallenges,
257
306
  settleCharged,
258
307
  store,
259
- ...(typeof parameters.sse === 'object' ? parameters.sse : undefined),
260
308
  })
261
309
  : undefined;
262
310
  const validateCredential = async ({ credential, operation, request, }) => {
@@ -268,24 +316,31 @@ export function session(p) {
268
316
  minVoucherDelta: parameters.minVoucherDelta,
269
317
  request,
270
318
  });
271
- const details = await validateCredentialPayload({
272
- account,
273
- allowPendingClose: operation === 'broadcast',
274
- challenge: credential.challenge,
275
- channelStateTtl,
276
- chainId: context.chainId,
277
- client: context.client,
278
- credentialSource: credential.source,
279
- escrow: context.escrow,
280
- expectedOperator: context.methodDetails.operator,
281
- feePayer: context.feePayer,
282
- feePayerPolicy: parameters.feePayerPolicy,
283
- feeToken: context.methodDetails.feeToken,
284
- lastOnChainVerified,
285
- minVoucherDelta: context.minVoucherDelta,
286
- payload,
287
- store,
288
- });
319
+ // A broadcast voucher is checked against the stored channel by the update that accepts it.
320
+ const details = operation === 'broadcast' && payload.action === 'voucher'
321
+ ? {
322
+ action: payload.action,
323
+ channelId: ChannelStore.normalizeChannelId(payload.channelId),
324
+ }
325
+ : await validateCredentialPayload({
326
+ account,
327
+ allowPendingClose: operation === 'broadcast',
328
+ challenge: credential.challenge,
329
+ channelStateTtl,
330
+ chainId: context.chainId,
331
+ client: context.client,
332
+ credentialSource: credential.source,
333
+ escrow: context.escrow,
334
+ expectedOperator: context.methodDetails.operator,
335
+ feePayer: context.feePayer,
336
+ feePayerPolicy: parameters.feePayerPolicy,
337
+ feeToken: context.methodDetails.feeToken,
338
+ lastOnChainVerified,
339
+ minVoucherDelta: context.minVoucherDelta,
340
+ payload,
341
+ store,
342
+ voucherStateMaxAge: parameters.voucherStateMaxAge,
343
+ });
289
344
  return {
290
345
  challenge: credential.challenge,
291
346
  credential: { ...credential, payload },
@@ -306,9 +361,34 @@ export function session(p) {
306
361
  minVoucherDelta: parameters.minVoucherDelta,
307
362
  request,
308
363
  });
364
+ const chargeAmount = parameters.chargeOnAcceptance &&
365
+ shouldChargeVoucherOnAcceptance({
366
+ capturedRequest: envelope?.capturedRequest,
367
+ payloadAction: payload.action,
368
+ unitType: context.request.unitType,
369
+ })
370
+ ? BigInt(context.request.amount ?? challenge.request.amount)
371
+ : undefined;
372
+ const acceptance = {};
373
+ const settleAcceptedCharge = (channel) => maybeSettleScheduled({
374
+ account,
375
+ client: context.client,
376
+ ...(context.feePayer ? { feePayer: context.feePayer } : {}),
377
+ feePayerPolicy: parameters.feePayerPolicy,
378
+ feeToken: parameters.feeToken,
379
+ onSessionSettlement,
380
+ onSessionSettlementFailure,
381
+ schedule: settlementSchedule,
382
+ store,
383
+ channel,
384
+ }).catch(ignoreRetryableSettlementFailure);
309
385
  const sessionReceipt = await broadcastCredentialPayload({
310
386
  account,
311
387
  challenge,
388
+ chargeAmount,
389
+ onCharged: (channel) => {
390
+ acceptance.charged = channel;
391
+ },
312
392
  channelStateTtl,
313
393
  chainId: context.chainId,
314
394
  client: context.client,
@@ -329,7 +409,16 @@ export function session(p) {
329
409
  sseEnabled: Boolean(parameters.sse),
330
410
  }),
331
411
  store,
412
+ voucherStateMaxAge: parameters.voucherStateMaxAge,
332
413
  });
414
+ if (acceptance.charged) {
415
+ const settlementTxHash = await settleAcceptedCharge(acceptance.charged);
416
+ const receipt = {
417
+ ...sessionReceipt,
418
+ ...(settlementTxHash ? { txHash: settlementTxHash } : {}),
419
+ };
420
+ return parameters.sse ? Transport.markPrepaidSessionTick(receipt) : receipt;
421
+ }
333
422
  return applyVerifiedHttpAccounting({
334
423
  capturedRequest: envelope?.capturedRequest,
335
424
  payloadAction: payload.action,
@@ -338,18 +427,7 @@ export function session(p) {
338
427
  sseEnabled: Boolean(parameters.sse),
339
428
  markPrepaidReceipt: Transport.markPrepaidSessionTick,
340
429
  charge: (channelId, requestAmount) => chargeSessionChannel({ store, channelId, amount: requestAmount }),
341
- settleCharged: (channel) => maybeSettleScheduled({
342
- account,
343
- client: context.client,
344
- ...(context.feePayer ? { feePayer: context.feePayer } : {}),
345
- feePayerPolicy: parameters.feePayerPolicy,
346
- feeToken: parameters.feeToken,
347
- onSessionSettlement,
348
- onSessionSettlementFailure,
349
- schedule: settlementSchedule,
350
- store,
351
- channel,
352
- }).catch(ignoreRetryableSettlementFailure),
430
+ settleCharged: settleAcceptedCharge,
353
431
  });
354
432
  };
355
433
  const method = Method.toServer(Methods.session, {
@@ -365,6 +443,7 @@ export function session(p) {
365
443
  unitType,
366
444
  }),
367
445
  extensions: { serveWebSocket, settleScheduled },
446
+ ...(parameters.reusableChallenges ? { stableBinding: reusableSessionBinding } : {}),
368
447
  transport: deriveTransport(transport),
369
448
  preflight: parameters.bootstrap
370
449
  ? async ({ capturedRequest, credential, expires, input, options, realm, secretKey }) => {
@@ -431,6 +510,7 @@ export function session(p) {
431
510
  });
432
511
  },
433
512
  });
513
+ streamVerifiers.set(method, beforeStreamCharge);
434
514
  return method;
435
515
  }
436
516
  (function (session) {
@@ -174,6 +174,15 @@ export type ApplyVerifiedHttpAccountingParameters = {
174
174
  };
175
175
  /** Returns whether an accepted credential will authorize and charge an HTTP content response. */
176
176
  export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled'>): boolean;
177
+ /**
178
+ * Returns whether a voucher pays for its request in the store update that accepts it. A
179
+ * request-priced content request is charged exactly once, so this is the charge the HTTP
180
+ * accounting or the streamed response would make, without a second store update; the receipt is
181
+ * then marked prepaid so an SSE response does not charge again.
182
+ */
183
+ export declare function shouldChargeVoucherOnAcceptance(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction'> & {
184
+ unitType: unknown;
185
+ }): boolean;
177
186
  /** Applies the default HTTP content charge after a session credential has been accepted. */
178
187
  export declare function applyVerifiedHttpAccounting(parameters: ApplyVerifiedHttpAccountingParameters): Promise<SessionReceipt>;
179
188
  /** Atomically deducts spend from a channel and maps store failures to typed session errors. */
@@ -1,4 +1,4 @@
1
- import { BaseError as viem_BaseError, HttpRequestError, InternalRpcError, isAddress, isAddressEqual, LimitExceededRpcError, parseUnits, ResourceUnavailableRpcError, RpcRequestError, SocketClosedError, TimeoutError, WaitForTransactionReceiptTimeoutError, WebSocketRequestError, zeroAddress, } from 'viem';
1
+ import { BaseError as viem_BaseError, ExecutionRevertedError, HttpRequestError, InternalRpcError, isAddress, isAddressEqual, LimitExceededRpcError, parseUnits, ResourceUnavailableRpcError, RpcRequestError, SocketClosedError, TimeoutError, WaitForTransactionReceiptTimeoutError, WebSocketRequestError, zeroAddress, } from 'viem';
2
2
  import { BadRequestError, ChannelClosedError, ChannelNotFoundError, InsufficientBalanceError, VerificationFailedError, } from '../../../Errors.js';
3
3
  import { isSessionContentRequest } from '../../server/internal/request-body.js';
4
4
  import * as Chain from '../precompile/Chain.js';
@@ -171,6 +171,18 @@ export function shouldApplyVerifiedHttpAccounting(parameters) {
171
171
  return false;
172
172
  return isSessionContentRequest(capturedRequest);
173
173
  }
174
+ /**
175
+ * Returns whether a voucher pays for its request in the store update that accepts it. A
176
+ * request-priced content request is charged exactly once, so this is the charge the HTTP
177
+ * accounting or the streamed response would make, without a second store update; the receipt is
178
+ * then marked prepaid so an SSE response does not charge again.
179
+ */
180
+ export function shouldChargeVoucherOnAcceptance(parameters) {
181
+ const { capturedRequest, payloadAction, unitType } = parameters;
182
+ if (!capturedRequest || payloadAction !== 'voucher' || unitType !== 'request')
183
+ return false;
184
+ return isSessionContentRequest(capturedRequest);
185
+ }
174
186
  /** Applies the default HTTP content charge after a session credential has been accepted. */
175
187
  export async function applyVerifiedHttpAccounting(parameters) {
176
188
  const { receipt, sseEnabled } = parameters;
@@ -307,7 +319,10 @@ export async function settle(store_, client, channelId_, options) {
307
319
  sender: account?.address,
308
320
  });
309
321
  const amount = uint96(channel.highestVoucher.cumulativeAmount);
310
- const txHash = await Chain.settleOnChain(client, channel.descriptor, amount, channel.highestVoucher.signature, escrow, account
322
+ const { signature } = channel.highestVoucher;
323
+ // A replica behind the open or top-up that admitted this voucher rejects the settle while
324
+ // preparing it (e.g. `ChannelNotFound`); nothing was broadcast, so retry until it catches up.
325
+ const txHash = await Chain.readbackWithRetry(() => Chain.settleOnChain(client, channel.descriptor, amount, signature, escrow, account
311
326
  ? {
312
327
  account,
313
328
  ...(options?.feePayer ? { feePayer: options.feePayer } : {}),
@@ -315,7 +330,7 @@ export async function settle(store_, client, channelId_, options) {
315
330
  ...(options?.feeToken ? { feeToken: options.feeToken } : {}),
316
331
  candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token],
317
332
  }
318
- : undefined);
333
+ : undefined), { shouldRetry: isExecutionRevert });
319
334
  const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
320
335
  const settled = readSettledReceiptFields(Chain.getChannelEvent(receipt, 'Settled', channelId));
321
336
  const { deltaPaid, newSettled } = settled;
@@ -389,6 +404,11 @@ export function ignoreRetryableSettlementFailure(error) {
389
404
  return undefined;
390
405
  throw error;
391
406
  }
407
+ /** Whether a settlement failed because the node's execution reverted it. */
408
+ function isExecutionRevert(error) {
409
+ return (error instanceof viem_BaseError &&
410
+ Boolean(error.walk((cause) => cause instanceof ExecutionRevertedError)));
411
+ }
392
412
  function isRetryableSettlementFailure(error) {
393
413
  if (error instanceof SettlementLeaseError)
394
414
  return true;
@@ -27,9 +27,8 @@ export type { SessionController } from './MeteredStream.js';
27
27
  * 2. If balance is sufficient, emits `event: message` with the value.
28
28
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
29
29
  * and polls store until the client tops up the channel.
30
- * 4. Commits the reserved charge immediately before the chunk is emitted. If a
31
- * manual generator completes after charging without yielding another chunk,
32
- * commits that terminal charge on successful completion.
30
+ * 4. Commits automatic charges before emission and manual charges before
31
+ * `stream.charge()` resolves, retaining them on cancellation or failure.
33
32
  * 5. On generator completion, emits a final `event: payment-receipt`.
34
33
  *
35
34
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -43,6 +42,8 @@ export declare namespace serve {
43
42
  challengeId: string;
44
43
  tickCost: bigint;
45
44
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
45
+ /** Verifies live escrow before charging and delivering stream items. */
46
+ beforeCharge?: MeteredStreamOptions['beforeCharge'];
46
47
  /** Callback invoked after each nonzero stream charge is committed. */
47
48
  onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
48
49
  pollIntervalMs?: number | undefined;
@@ -26,9 +26,8 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
26
26
  * 2. If balance is sufficient, emits `event: message` with the value.
27
27
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
28
28
  * and polls store until the client tops up the channel.
29
- * 4. Commits the reserved charge immediately before the chunk is emitted. If a
30
- * manual generator completes after charging without yielding another chunk,
31
- * commits that terminal charge on successful completion.
29
+ * 4. Commits automatic charges before emission and manual charges before
30
+ * `stream.charge()` resolves, retaining them on cancellation or failure.
32
31
  * 5. On generator completion, emits a final `event: payment-receipt`.
33
32
  *
34
33
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -44,6 +43,7 @@ export function serve(options) {
44
43
  channelId,
45
44
  tickCost,
46
45
  generate,
46
+ beforeCharge: options.beforeCharge,
47
47
  onChargeCommitted: options.onChargeCommitted,
48
48
  pollIntervalMs,
49
49
  prepaidUnits: options.prepaidUnits,
@@ -1,3 +1,4 @@
1
+ import { type SessionCredentialContext } from '../precompile/Protocol.js';
1
2
  import * as ChannelStore from './ChannelStore.js';
2
3
  import type { MeteredStreamOptions, SessionController } from './MeteredStream.js';
3
4
  import type { SettleChargedSessionChannel } from './Settlement.js';
@@ -16,6 +17,11 @@ export type SessionRouteResult = {
16
17
  };
17
18
  /** HTTP route used to perform a WebSocket payment probe. */
18
19
  export type SessionRoute = (request: Request) => Promise<SessionRouteResult>;
20
+ type StreamContext = {
21
+ challengeId: string;
22
+ channelId: SessionCredentialContext['channelId'];
23
+ tickCost: bigint;
24
+ };
19
25
  /**
20
26
  * Bridge a WebSocket connection to a Tempo session payment flow.
21
27
  *
@@ -47,7 +53,9 @@ export declare namespace serve {
47
53
  /** Payment route handler. Receives synthetic `POST` requests with only
48
54
  * the `Authorization` header — no cookies, bodies, or upgrade headers. */
49
55
  route: SessionRoute;
50
- /** Optional low-level hook invoked after each committed stream charge. */
56
+ /** Verifies live escrow before charging and delivering stream items. */
57
+ beforeCharge?: ((channelId: StreamContext['channelId']) => void | Promise<void>) | undefined;
58
+ /** Callback invoked after each nonzero stream charge is committed. */
51
59
  onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
52
60
  /** @deprecated Use `onChargeCommitted`. */
53
61
  settleScheduled?: SettleChargedSessionChannel | undefined;
@@ -65,6 +65,7 @@ export async function serve(options) {
65
65
  channelId: context.channelId,
66
66
  tickCost: context.tickCost,
67
67
  generate,
68
+ beforeCharge: () => options.beforeCharge?.(context.channelId),
68
69
  onChargeCommitted: options.onChargeCommitted ?? options.settleScheduled,
69
70
  pollIntervalMs,
70
71
  signal: abortController.signal,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.13.4",
4
+ "version": "0.13.6",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",