mppx 0.17.0 → 0.17.3

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 (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Method.d.ts +5 -0
  4. package/dist/Store.d.ts +225 -0
  5. package/dist/Store.js +358 -0
  6. package/dist/cli/cli.js +136 -135
  7. package/dist/cli/plugins/index.js +12 -11
  8. package/dist/cli/sessions/Manager.js +12 -1
  9. package/dist/cli/sessions/store.js +7 -1
  10. package/dist/cli/utils.d.ts +2 -0
  11. package/dist/cli/utils.js +6 -0
  12. package/dist/cli/validate/helpers.d.ts +1 -1
  13. package/dist/cli/validate/helpers.js +14 -2
  14. package/dist/client/Mppx.d.ts +1 -1
  15. package/dist/client/Mppx.js +18 -6
  16. package/dist/client/internal/Fetch.d.ts +3 -1
  17. package/dist/client/internal/Fetch.js +68 -3
  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/identity/Client.js +7 -1
  25. package/dist/internal/AcceptPayment.d.ts +12 -2
  26. package/dist/internal/AcceptPayment.js +12 -3
  27. package/dist/internal/sse.d.ts +21 -0
  28. package/dist/internal/sse.js +65 -0
  29. package/dist/internal/version.d.ts +2 -2
  30. package/dist/internal/version.js +1 -1
  31. package/dist/mcp/client/McpClient.d.ts +2 -2
  32. package/dist/mcp/client/McpClient.js +6 -3
  33. package/dist/proxy/Proxy.d.ts +17 -0
  34. package/dist/proxy/Proxy.js +116 -3
  35. package/dist/proxy/internal/Replay.d.ts +105 -0
  36. package/dist/proxy/internal/Replay.js +257 -0
  37. package/dist/server/Mppx.d.ts +2 -0
  38. package/dist/server/Mppx.js +7 -5
  39. package/dist/server/Transport.js +1 -1
  40. package/dist/server/internal/html/config.d.ts +7 -1
  41. package/dist/server/internal/html/config.js +4 -0
  42. package/dist/stripe/internal/types.d.ts +82 -6
  43. package/dist/stripe/server/Charge.js +1 -2
  44. package/dist/stripe/server/internal/html.gen.js +1 -1
  45. package/dist/tempo/client/Charge.d.ts +9 -0
  46. package/dist/tempo/client/Charge.js +13 -9
  47. package/dist/tempo/client/Methods.d.ts +2 -0
  48. package/dist/tempo/internal/fee-payer.d.ts +85 -10
  49. package/dist/tempo/internal/fee-payer.js +183 -19
  50. package/dist/tempo/legacy/session/Chain.js +2 -0
  51. package/dist/tempo/server/Charge.js +6 -3
  52. package/dist/tempo/server/Subscription.js +1 -0
  53. package/dist/tempo/server/internal/html.gen.js +1 -1
  54. package/dist/tempo/server/internal/transport.d.ts +6 -0
  55. package/dist/tempo/server/internal/transport.js +21 -21
  56. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  57. package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
  58. package/dist/tempo/session/client/ChannelStore.js +5 -0
  59. package/dist/tempo/session/client/CredentialState.js +2 -0
  60. package/dist/tempo/session/client/Session.d.ts +37 -2
  61. package/dist/tempo/session/client/Session.js +111 -7
  62. package/dist/tempo/session/client/SessionManager.d.ts +2 -1
  63. package/dist/tempo/session/client/SessionManager.js +60 -9
  64. package/dist/tempo/session/client/Transports.d.ts +4 -0
  65. package/dist/tempo/session/client/Transports.js +35 -3
  66. package/dist/tempo/session/precompile/Chain.js +1 -0
  67. package/dist/tempo/session/precompile/Voucher.js +20 -7
  68. package/dist/tempo/session/server/ChannelStore.d.ts +9 -0
  69. package/dist/tempo/session/server/ChannelStore.js +8 -1
  70. package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
  71. package/dist/tempo/session/server/MeteredStream.js +6 -1
  72. package/dist/tempo/session/server/Session.d.ts +2 -1
  73. package/dist/tempo/session/server/Session.js +2 -1
  74. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  75. package/dist/tempo/session/server/Settlement.js +2 -2
  76. package/dist/tempo/session/server/Sse.d.ts +59 -0
  77. package/dist/tempo/session/server/Sse.js +175 -10
  78. package/dist/tempo/session/server/Ws.js +29 -12
  79. package/dist/zod.d.ts +2 -1
  80. package/dist/zod.js +1 -1
  81. package/package.json +1 -3
  82. package/dist/stripe/server/internal/html/types.d.ts +0 -2
  83. package/dist/stripe/server/internal/html/types.js +0 -2
@@ -262,10 +262,20 @@ export function requestInitWithAuthorization(input, init, credential, challenge)
262
262
  }, Challenge.credentialHeader(challenge), credential);
263
263
  }
264
264
  /** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
265
+ /** Returns whether a value is a `Request`, including one from another realm such as an iframe. */
266
+ export function isRequest(value) {
267
+ return (value instanceof Request ||
268
+ (typeof value === 'object' &&
269
+ value !== null &&
270
+ !(value instanceof URL) &&
271
+ typeof value.url === 'string' &&
272
+ typeof value.method === 'string' &&
273
+ typeof value.clone === 'function'));
274
+ }
265
275
  export function managementInput(input) {
266
276
  try {
267
277
  const base = typeof location === 'undefined' ? undefined : location.href;
268
- const url = input instanceof Request
278
+ const url = isRequest(input)
269
279
  ? new URL(input.url)
270
280
  : input instanceof URL
271
281
  ? new URL(input)
@@ -321,6 +331,7 @@ export async function postTopUp(parameters) {
321
331
  challenge: parameters.challenge,
322
332
  context: {
323
333
  action: 'topUp',
334
+ ...(parameters.autoSwap === undefined ? {} : { autoSwap: parameters.autoSwap }),
324
335
  channelId,
325
336
  descriptor: channel.descriptor,
326
337
  additionalDepositRaw: parameters.additionalDeposit.toString(),
@@ -456,6 +467,21 @@ export async function closeHttpSession(parameters) {
456
467
  }
457
468
  let closeChallenge = parameters.target.challenge;
458
469
  let signedCloseAmount = parameters.signedCloseAmount;
470
+ const request = isRequest(parameters.lastUrl) ? parameters.lastUrl : undefined;
471
+ // Retain transport policy without inheriting the resource body or cancellation. Redirects stay
472
+ // manual so the credential never follows one.
473
+ const requestOptions = request
474
+ ? {
475
+ cache: request.cache,
476
+ credentials: request.credentials,
477
+ integrity: request.integrity,
478
+ keepalive: request.keepalive,
479
+ // Fetch rejects `navigate` in a request init; a navigation becomes `same-origin`, as Fetch does.
480
+ mode: request.mode === 'navigate' ? 'same-origin' : request.mode,
481
+ referrer: request.referrer,
482
+ referrerPolicy: request.referrerPolicy,
483
+ }
484
+ : undefined;
459
485
  const postClose = async (challenge, refreshed = false) => {
460
486
  closeChallenge = challenge;
461
487
  signedCloseAmount =
@@ -467,7 +493,11 @@ export async function closeHttpSession(parameters) {
467
493
  descriptor: parameters.target.channel.descriptor,
468
494
  cumulativeAmountRaw: signedCloseAmount,
469
495
  });
470
- return parameters.fetch(parameters.lastUrl, {
496
+ // Close is a separate management POST, independent of the resource Request.
497
+ // A Request's URL is absolute. Pass other inputs unchanged so the configured fetch resolves
498
+ // relative URLs exactly as it did for the paid resource.
499
+ return parameters.fetch(request ? managementInput(request) : parameters.lastUrl, {
500
+ ...requestOptions,
471
501
  method: 'POST',
472
502
  redirect: 'manual',
473
503
  headers: managementHeaders(challenge, credential, parameters.lastUrl, parameters.headers),
@@ -497,7 +527,9 @@ export async function closeHttpSession(parameters) {
497
527
  challengeId: closeChallenge.id,
498
528
  channelId: parameters.target.channelId,
499
529
  expectedCloseAmount: signedCloseAmount,
500
- minimumSpent: parameters.getMinimumSpent?.() ?? 0n,
530
+ // HTTP credits can reduce previously acknowledged spend. Authorization and receipt scope
531
+ // remain bound to the exact close credential; a lower final charge benefits the payer.
532
+ minimumSpent: 0n,
501
533
  receipt,
502
534
  }))
503
535
  throw new Error('Session close response included a mismatched payment receipt.');
@@ -451,6 +451,7 @@ export async function sendCredentialTransaction(parameters) {
451
451
  ...(allowedFeeTokens?.[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}),
452
452
  };
453
453
  const completed = await FeePayer.preflightSponsorship({
454
+ policy: feePayerPolicy,
454
455
  transaction: sponsorshipTransaction,
455
456
  simulate: (request) => simulateTempoTransaction(client, request),
456
457
  async complete() {
@@ -67,6 +67,10 @@ function getVoucherPayload(verifyingContract, chainId, voucher) {
67
67
  function isPrimitiveEnvelope(type) {
68
68
  return type === 'secp256k1' || type === 'p256' || type === 'webAuthn';
69
69
  }
70
+ /** Detects a viem/tempo access key account the same way `Account.signVoucher` does. */
71
+ function isAccessKeyAccount(account) {
72
+ return account.source === 'accessKey' && 'accessKeyAddress' in account;
73
+ }
70
74
  function signCanonicalTempoVoucher(account, parameters) {
71
75
  // viem/tempo's canonical TIP-1034 voucher signer accepts Tempo account
72
76
  // extensions that are wider than viem's base Account type. Keep that
@@ -109,13 +113,22 @@ export async function signVoucher(client, account, voucher, verifyingContract, c
109
113
  * Signs a distinct EIP-712 authorization for cooperatively closing a channel.
110
114
  */
111
115
  export async function signCloseAuthorization(client, account, authorization, verifyingContract, chainId) {
112
- const signature = await signTypedData(client, {
113
- account,
114
- domain: getVoucherDomain(verifyingContract, chainId),
115
- types: closeAuthorizationTypes,
116
- primaryType: 'CloseAuthorization',
117
- message: authorization,
118
- });
116
+ // `signTypedData` wraps access key signatures in a keychain envelope, which
117
+ // TIP-1034 rejects. Sign the hash raw instead, as viem/tempo's
118
+ // `Account.signVoucher` does for vouchers: the access key is the channel's
119
+ // authorized signer.
120
+ const signature = isAccessKeyAccount(account)
121
+ ? await account.sign({
122
+ hash: getCloseAuthorizationPayload(verifyingContract, chainId, authorization),
123
+ raw: true,
124
+ })
125
+ : await signTypedData(client, {
126
+ account,
127
+ domain: getVoucherDomain(verifyingContract, chainId),
128
+ types: closeAuthorizationTypes,
129
+ primaryType: 'CloseAuthorization',
130
+ message: authorization,
131
+ });
119
132
  const envelope = SignatureEnvelope.from(signature);
120
133
  if (!isPrimitiveEnvelope(envelope.type))
121
134
  throw new Error(`TIP-1034 close authorizations require a TIP-1020 primitive signature; received "${envelope.type}".`);
@@ -292,6 +292,13 @@ export interface BaseState {
292
292
  pendingSettlement?: PendingSettlement | undefined;
293
293
  /** Outcomes of keyed charges and credits, by `charge:<key>` and `credit:<key>`, until they expire. */
294
294
  idempotencyMarkers?: Record<string, IdempotencyMarker> | undefined;
295
+ /** Durable prepaid delivery obligations. Pending entries require delivery reconciliation, never blind refunds. */
296
+ prepaidRefunds?: Record<string, {
297
+ amount: string;
298
+ units: number;
299
+ status: 'pending' | 'failed';
300
+ createdAt: number;
301
+ }> | undefined;
295
302
  /** First block at which the close supervisor read this channel open on-chain. */
296
303
  closeSupervisedFromBlock?: bigint | undefined;
297
304
  }
@@ -500,6 +507,8 @@ export type ChargeOptions = {
500
507
  };
501
508
  /** Options for returning an amount to a channel at most once per key. */
502
509
  export type CreditOptions = {
510
+ /** Maximum live idempotency markers; existing keys remain replayable at capacity. */
511
+ maxMarkers?: number | undefined;
503
512
  /**
504
513
  * Key of the credit. Its first outcome, credited or declined, is final: a retry with the same key
505
514
  * and terms returns it without crediting again, and the same key with other terms throws
@@ -684,17 +684,22 @@ export async function creditChannel(store, channelId, amount, options) {
684
684
  throw new Error('Credit amount must be positive.');
685
685
  if (!Number.isSafeInteger(units) || units < 0)
686
686
  throw new Error('`units` must be a non-negative integer.');
687
+ const maxMarkers = options.maxMarkers ?? 1024;
688
+ if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1)
689
+ throw new Error('maxMarkers must be a positive safe integer');
687
690
  const now = Date.now();
688
691
  const expiresAt = now + (options.retention ?? defaultIdempotencyRetention);
689
692
  let result;
690
693
  try {
691
- result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units }));
694
+ result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units, maxMarkers }));
692
695
  }
693
696
  catch (error) {
694
697
  return { status: 'unknown', error };
695
698
  }
696
699
  if (!result)
697
700
  throw new ChannelNotFoundError({ reason: 'channel not found' });
701
+ if (result === 'capacity')
702
+ throw new Error('Credit idempotency marker capacity reached; reconcile manually');
698
703
  if (result === 'conflict')
699
704
  throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'credit' });
700
705
  return result;
@@ -718,6 +723,8 @@ function planCredit(current, terms) {
718
723
  : { status: 'declined', reason: recorded.outcome, channel: current, replayed: true },
719
724
  };
720
725
  }
726
+ if (Object.keys(markers).length >= terms.maxMarkers)
727
+ return { op: 'noop', result: 'capacity' };
721
728
  const reason = creditDeclineReason(current, amount, now);
722
729
  const marker = {
723
730
  amount,
@@ -33,6 +33,10 @@ export type MeteredStreamOptions = {
33
33
  generate: SessionStreamGenerator;
34
34
  /** Verifies that the channel can still fund work and delivery; failures stop the stream. */
35
35
  beforeCharge?: (() => MaybePromise<void>) | undefined;
36
+ /** Charges automatic streams once per request instead of once per value. */
37
+ chargeOnce?: boolean | undefined;
38
+ /** Runs when an explicit manual charge consumes a prepaid unit. */
39
+ onPrepaidChargeCommitted?: (() => void) | undefined;
36
40
  /** Runs after a nonzero reserved charge has been committed. */
37
41
  onChargeCommitted?: ((channel: ChannelStore.State) => MaybePromise<unknown>) | undefined;
38
42
  /** Store polling interval when `waitForUpdate` is unavailable. */
@@ -9,6 +9,7 @@ export async function* meterIterable(options) {
9
9
  channelId: options.channelId,
10
10
  reservationId: globalThis.crypto.randomUUID(),
11
11
  };
12
+ let charged = false;
12
13
  let pending = false;
13
14
  const releaseOnAbort = () => {
14
15
  void releaseReservedCharges(reservation).catch(() => { });
@@ -22,6 +23,8 @@ export async function* meterIterable(options) {
22
23
  signal.throwIfAborted();
23
24
  if (amount === undefined && prepaidUnits > 0) {
24
25
  prepaidUnits -= 1;
26
+ if (manual)
27
+ options.onPrepaidChargeCommitted?.();
25
28
  return;
26
29
  }
27
30
  if ((amount ?? options.tickCost) === 0n)
@@ -67,8 +70,10 @@ export async function* meterIterable(options) {
67
70
  await commit();
68
71
  break;
69
72
  }
70
- if (!manual)
73
+ if (!manual && (!options.chargeOnce || !charged)) {
71
74
  await charge();
75
+ charged = true;
76
+ }
72
77
  await commit();
73
78
  yield value;
74
79
  }
@@ -184,7 +184,8 @@ export declare namespace session {
184
184
  /**
185
185
  * Charges request-priced content (`unitType: 'request'`) in the same store update that accepts
186
186
  * its voucher, before the response, instead of in a second update when the response is
187
- * delivered. The server must then refund requests it cannot serve.
187
+ * delivered. Always enabled for SSE request-priced content, including POST requests that
188
+ * return plain responses. The server must refund requests it cannot serve.
188
189
  */
189
190
  chargeOnAcceptance?: boolean | undefined;
190
191
  /**
@@ -363,7 +363,7 @@ export function session(p) {
363
363
  minVoucherDelta: parameters.minVoucherDelta,
364
364
  request,
365
365
  });
366
- const chargeAmount = parameters.chargeOnAcceptance &&
366
+ const chargeAmount = (parameters.chargeOnAcceptance || parameters.sse) &&
367
367
  shouldChargeVoucherOnAcceptance({
368
368
  capturedRequest: envelope?.capturedRequest,
369
369
  payloadAction: payload.action,
@@ -433,6 +433,7 @@ export function session(p) {
433
433
  getRequestAmount: () => BigInt(context.request.amount ?? challenge.request.amount),
434
434
  sseEnabled: Boolean(parameters.sse),
435
435
  markPrepaidReceipt: Transport.markPrepaidSessionTick,
436
+ unitType: context.request.unitType,
436
437
  charge: (channelId, requestAmount) => chargeSessionChannel({ store, channelId, amount: requestAmount }),
437
438
  settleCharged: settleAcceptedCharge,
438
439
  });
@@ -167,13 +167,15 @@ export type ApplyVerifiedHttpAccountingParameters = {
167
167
  receipt: SessionReceipt;
168
168
  /** Marks an SSE receipt whose first content unit was charged during verification. */
169
169
  markPrepaidReceipt?: ((receipt: SessionReceipt) => SessionReceipt) | undefined;
170
- /** Whether SSE transport is enabled. SSE accounting is stream-driven, not HTTP-response-driven. */
170
+ /** Pricing unit; request-priced content is charged before handing control to the application. */
171
+ unitType?: unknown;
172
+ /** Whether SSE transport is enabled. Non-request units are charged as the stream advances. */
171
173
  sseEnabled: boolean;
172
174
  /** Runs optional server settlement policy after a successful content charge. */
173
175
  settleCharged: SettleChargedSessionChannel;
174
176
  };
175
177
  /** Returns whether an accepted credential will authorize and charge an HTTP content response. */
176
- export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled'>): boolean;
178
+ export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled' | 'unitType'>): boolean;
177
179
  /**
178
180
  * Returns whether a voucher pays for its request in the store update that accepts it. A
179
181
  * request-priced content request is charged exactly once, so this is the charge the HTTP
@@ -161,12 +161,12 @@ export async function releaseScheduledSettlement(parameters) {
161
161
  }
162
162
  /** Returns whether an accepted credential will authorize and charge an HTTP content response. */
163
163
  export function shouldApplyVerifiedHttpAccounting(parameters) {
164
- const { capturedRequest, payloadAction, sseEnabled } = parameters;
164
+ const { capturedRequest, payloadAction, sseEnabled, unitType } = parameters;
165
165
  if (!capturedRequest)
166
166
  return false;
167
167
  if (payloadAction !== 'open' && payloadAction !== 'voucher')
168
168
  return false;
169
- if (sseEnabled && capturedRequest.method === 'POST')
169
+ if (sseEnabled && capturedRequest.method === 'POST' && unitType !== 'request')
170
170
  return false;
171
171
  return isSessionContentRequest(capturedRequest);
172
172
  }
@@ -1,6 +1,41 @@
1
1
  import type { Hex } from 'viem';
2
2
  import * as ChannelStore from './ChannelStore.js';
3
3
  import { type MeteredStreamOptions, type SessionController } from './MeteredStream.js';
4
+ /** A failed prepaid delivery that needs merchant reconciliation. */
5
+ export type PrepaidRefund = {
6
+ /** Channel that paid for the undelivered items. */
7
+ channelId: Hex;
8
+ /** Undelivered prepaid amount in raw token units; use externalRefundAmount for transfers. */
9
+ amount: bigint;
10
+ /** Captured share safe to refund externally, or null until reconciliation establishes it. */
11
+ externalRefundAmount: bigint | null;
12
+ /** Charge units included in the credit attempt. */
13
+ units: number;
14
+ /** Use this key for both credit retries and an idempotent merchant refund. */
15
+ idempotencyKey: string;
16
+ /** An unknown credit must be reconciled with the same key before refunding externally. */
17
+ credit: {
18
+ status: 'failed';
19
+ error: unknown;
20
+ } | Exclude<ChannelStore.CreditResult, {
21
+ status: 'credited';
22
+ }>;
23
+ };
24
+ /** Exposes durable retry information when no refund hook is configured or it fails. */
25
+ export declare class PrepaidRefundError extends Error {
26
+ readonly refund: PrepaidRefund;
27
+ readonly name = "PrepaidRefundError";
28
+ constructor(refund: PrepaidRefund);
29
+ }
30
+ /** Maximum retained automatic refund operations per channel. Never evict live retry keys. */
31
+ export declare const maxPrepaidRefunds = 128;
32
+ /**
33
+ * Persist a delivery obligation before returning a managed response. Failed hook deliveries remain
34
+ * in `channel.prepaidRefunds` for a merchant worker. Pending entries require manual delivery
35
+ * reconciliation; failed entries may retry the credit with their original terms within 24 hours.
36
+ * Acknowledging the hook means the merchant has durably accepted responsibility for the job.
37
+ */
38
+ export declare function preparePrepaidRefund(options: serve.Options): Promise<void>;
4
39
  /**
5
40
  * SSE (Server-Sent Events) utilities for metered streaming payments.
6
41
  *
@@ -31,6 +66,9 @@ export type { SessionController } from './MeteredStream.js';
31
66
  * `stream.charge()` resolves, retaining them on cancellation or failure.
32
67
  * 5. On generator completion, emits a final `event: payment-receipt`.
33
68
  *
69
+ * With `autoCreditPrepaid`, a failure before a prepaid item is enqueued attempts an idempotent credit.
70
+ * If credit is declined or uncertain, `onRefundRequired` handles merchant reconciliation.
71
+ *
34
72
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
35
73
  */
36
74
  export declare function serve(options: serve.Options): ReadableStream<Uint8Array>;
@@ -42,12 +80,33 @@ export declare namespace serve {
42
80
  challengeId: string;
43
81
  tickCost: bigint;
44
82
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
83
+ /** Charge automatic iterables once per request; explicit manual charges are unaffected. */
84
+ chargeOnce?: boolean | undefined;
45
85
  /** Verifies live escrow before charging and delivering stream items. */
46
86
  beforeCharge?: MeteredStreamOptions['beforeCharge'];
47
87
  /** Callback invoked after each nonzero stream charge is committed. */
48
88
  onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
49
89
  pollIntervalMs?: number | undefined;
90
+ /** Opt in to SDK-owned credits. Leave disabled when the merchant already refunds failed requests. @default false */
91
+ autoCreditPrepaid?: boolean | undefined;
92
+ /** Units charged before streaming. */
50
93
  prepaidUnits?: number | undefined;
94
+ /** Stable per-charge key used for automatic credit and merchant refund retries. Defaults to a random stream key. */
95
+ refundIdempotencyKey?: string | undefined;
96
+ /**
97
+ * Persist or perform an idempotent refund when a failed prepaid delivery cannot be credited.
98
+ * For `credit.status === 'unknown'`, retry `Session.Server.credit` with the supplied key,
99
+ * amount and units before refunding externally; the credit may already have committed.
100
+ * Retry within the credit marker retention (24 hours); later retries need manual reconciliation.
101
+ * Only transfer `externalRefundAmount` when non-null; closing, pending settlement and definite
102
+ * failures require reconciliation first. `failed` is a definite non-credit, not an uncertain write.
103
+ * Successful return must mean durable acceptance. Missing/throwing hooks leave obligations in
104
+ * `channel.prepaidRefunds`; run a recovery worker and reconcile pending delivery records manually.
105
+ * Records are bounded and never evicted while unresolved. A full ledger rejects new streams.
106
+ * Hook failures surface a `PrepaidRefundError` containing the retry information.
107
+ * Delivered items, explicit manual charges and client cancellation are not automatically refunded.
108
+ */
109
+ onRefundRequired?: ((refund: PrepaidRefund) => void | Promise<void>) | undefined;
51
110
  signal?: AbortSignal | undefined;
52
111
  };
53
112
  }
@@ -1,7 +1,58 @@
1
1
  import * as Constants from '../../../Constants.js';
2
2
  import * as Credential from '../../../Credential.js';
3
3
  import { createSessionReceipt, extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptEvent, readSessionChallengeAmount, requireSessionCredentialContext, } from '../precompile/Protocol.js';
4
+ import * as ChannelStore from './ChannelStore.js';
4
5
  import { meterIterable, } from './MeteredStream.js';
6
+ /** Exposes durable retry information when no refund hook is configured or it fails. */
7
+ export class PrepaidRefundError extends Error {
8
+ refund;
9
+ name = 'PrepaidRefundError';
10
+ constructor(refund) {
11
+ super('Prepaid stream delivery failed and requires merchant refund reconciliation.');
12
+ this.refund = refund;
13
+ }
14
+ }
15
+ /** Maximum retained automatic refund operations per channel. Never evict live retry keys. */
16
+ export const maxPrepaidRefunds = 128;
17
+ /**
18
+ * Persist a delivery obligation before returning a managed response. Failed hook deliveries remain
19
+ * in `channel.prepaidRefunds` for a merchant worker. Pending entries require manual delivery
20
+ * reconciliation; failed entries may retry the credit with their original terms within 24 hours.
21
+ * Acknowledging the hook means the merchant has durably accepted responsibility for the job.
22
+ */
23
+ export async function preparePrepaidRefund(options) {
24
+ if (!options.autoCreditPrepaid || !options.prepaidUnits || options.tickCost === 0n)
25
+ return;
26
+ if (!options.refundIdempotencyKey)
27
+ throw new Error('Automatic refunds require a stable refundIdempotencyKey');
28
+ const key = options.refundIdempotencyKey;
29
+ const amount = (BigInt(options.prepaidUnits) * options.tickCost).toString();
30
+ await options.store.updateChannel(options.channelId, (current) => {
31
+ if (!current)
32
+ throw new Error('Cannot prepare prepaid refund: channel not found');
33
+ const existing = current.prepaidRefunds?.[key];
34
+ if (existing) {
35
+ if (existing.amount !== amount || existing.units !== options.prepaidUnits)
36
+ throw new Error('Prepaid refund key conflicts with an existing delivery');
37
+ return current;
38
+ }
39
+ const liveMarkers = Object.values(current.idempotencyMarkers ?? {}).filter((marker) => marker.expiresAt > Date.now()).length;
40
+ if (Object.keys(current.prepaidRefunds ?? {}).length + liveMarkers >= maxPrepaidRefunds)
41
+ throw new Error('Prepaid refund capacity reached; reconcile outstanding deliveries');
42
+ return {
43
+ ...current,
44
+ prepaidRefunds: {
45
+ ...current.prepaidRefunds,
46
+ [key]: {
47
+ amount,
48
+ units: options.prepaidUnits,
49
+ status: 'pending',
50
+ createdAt: Date.now(),
51
+ },
52
+ },
53
+ };
54
+ });
55
+ }
5
56
  /**
6
57
  * SSE (Server-Sent Events) utilities for metered streaming payments.
7
58
  *
@@ -30,6 +81,9 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
30
81
  * `stream.charge()` resolves, retaining them on cancellation or failure.
31
82
  * 5. On generator completion, emits a final `event: payment-receipt`.
32
83
  *
84
+ * With `autoCreditPrepaid`, a failure before a prepaid item is enqueued attempts an idempotent credit.
85
+ * If credit is declined or uncertain, `onRefundRequired` handles merchant reconciliation.
86
+ *
33
87
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
34
88
  */
35
89
  export function serve(options) {
@@ -38,11 +92,100 @@ export function serve(options) {
38
92
  const abortController = new AbortController();
39
93
  let streamController;
40
94
  let terminal = false;
95
+ let preparation = Promise.resolve();
96
+ let termination;
97
+ let committedPrepaidAwaitingDelivery = 0;
98
+ let undeliveredPrepaidUnits = options.prepaidUnits ?? 0;
99
+ if (!Number.isSafeInteger(undeliveredPrepaidUnits) || undeliveredPrepaidUnits < 0)
100
+ throw new Error('prepaidUnits must be a non-negative safe integer');
101
+ const refundIdempotencyKey = options.refundIdempotencyKey ?? `stream:${globalThis.crypto.randomUUID()}`;
102
+ ChannelStore.assertIdempotencyOptions({ idempotencyKey: refundIdempotencyKey });
103
+ const refundOptions = { ...options, refundIdempotencyKey };
104
+ const clearObligation = async () => {
105
+ if (!options.autoCreditPrepaid)
106
+ return;
107
+ await store.updateChannel(channelId, (current) => {
108
+ if (!current?.prepaidRefunds?.[refundIdempotencyKey])
109
+ return current;
110
+ const prepaidRefunds = { ...current.prepaidRefunds };
111
+ delete prepaidRefunds[refundIdempotencyKey];
112
+ return { ...current, prepaidRefunds };
113
+ });
114
+ };
115
+ const creditFailedDelivery = async () => {
116
+ if (!options.autoCreditPrepaid)
117
+ return;
118
+ if (undeliveredPrepaidUnits === 0 || tickCost === 0n) {
119
+ await clearObligation();
120
+ return;
121
+ }
122
+ const recordFailure = await store
123
+ .updateChannel(channelId, (current) => {
124
+ if (!current?.prepaidRefunds?.[refundIdempotencyKey])
125
+ throw new Error('Prepaid refund obligation missing');
126
+ return {
127
+ ...current,
128
+ prepaidRefunds: {
129
+ ...current.prepaidRefunds,
130
+ [refundIdempotencyKey]: {
131
+ ...current.prepaidRefunds[refundIdempotencyKey],
132
+ status: 'failed',
133
+ amount: (BigInt(undeliveredPrepaidUnits) * tickCost).toString(),
134
+ units: undeliveredPrepaidUnits,
135
+ },
136
+ },
137
+ };
138
+ })
139
+ .then(() => undefined, (error) => ({ error }));
140
+ const amount = BigInt(undeliveredPrepaidUnits) * tickCost;
141
+ const result = recordFailure
142
+ ? { status: 'failed', error: recordFailure.error }
143
+ : await ChannelStore.creditChannel(store, channelId, amount, {
144
+ idempotencyKey: refundIdempotencyKey,
145
+ units: undeliveredPrepaidUnits,
146
+ maxMarkers: maxPrepaidRefunds,
147
+ }).catch((error) => ({ status: 'failed', error }));
148
+ if (result.status === 'credited') {
149
+ await clearObligation();
150
+ return;
151
+ }
152
+ let externalRefundAmount = null;
153
+ if (result.status === 'declined' && result.reason === 'finalized') {
154
+ const captured = result.channel.settledOnChain - (result.channel.spent - amount);
155
+ externalRefundAmount = captured < 0n ? 0n : captured > amount ? amount : captured;
156
+ if (externalRefundAmount === 0n) {
157
+ await clearObligation();
158
+ return;
159
+ }
160
+ }
161
+ const refund = {
162
+ channelId,
163
+ amount,
164
+ externalRefundAmount,
165
+ units: undeliveredPrepaidUnits,
166
+ idempotencyKey: refundIdempotencyKey,
167
+ credit: result,
168
+ };
169
+ if (!options.onRefundRequired)
170
+ throw new PrepaidRefundError(refund);
171
+ try {
172
+ await options.onRefundRequired(refund);
173
+ await clearObligation();
174
+ }
175
+ catch {
176
+ throw new PrepaidRefundError(refund);
177
+ }
178
+ };
41
179
  const iterator = meterIterable({
42
180
  store,
43
181
  channelId,
44
182
  tickCost,
45
183
  generate,
184
+ chargeOnce: options.chargeOnce,
185
+ onPrepaidChargeCommitted() {
186
+ undeliveredPrepaidUnits = Math.max(0, undeliveredPrepaidUnits - 1);
187
+ committedPrepaidAwaitingDelivery += 1;
188
+ },
46
189
  beforeCharge: options.beforeCharge,
47
190
  onChargeCommitted: options.onChargeCommitted,
48
191
  pollIntervalMs,
@@ -54,23 +197,33 @@ export function serve(options) {
54
197
  const cleanup = () => signal?.removeEventListener('abort', abort);
55
198
  const terminate = (reason) => {
56
199
  if (terminal)
57
- return;
200
+ return termination;
58
201
  terminal = true;
59
202
  abortController.abort(reason);
60
203
  cleanup();
61
204
  void Promise.resolve()
62
205
  .then(() => iterator.return?.(undefined))
63
206
  .catch(() => { });
64
- try {
65
- streamController?.close();
66
- }
67
- catch { }
207
+ termination = preparation
208
+ .catch(() => { })
209
+ .then(clearObligation)
210
+ .finally(() => {
211
+ try {
212
+ streamController?.close();
213
+ }
214
+ catch { }
215
+ });
216
+ return termination;
217
+ };
218
+ const abort = () => {
219
+ void terminate(signal?.reason)?.catch(() => { });
68
220
  };
69
- const abort = () => terminate(signal?.reason);
70
221
  signal?.addEventListener('abort', abort, { once: true });
71
222
  return new ReadableStream({
72
- start(controller) {
223
+ async start(controller) {
73
224
  streamController = controller;
225
+ preparation = preparePrepaidRefund(refundOptions);
226
+ await preparation;
74
227
  if (signal?.aborted)
75
228
  abort();
76
229
  },
@@ -86,8 +239,13 @@ export function serve(options) {
86
239
  return;
87
240
  const value = next.value;
88
241
  controller.enqueue(encoder.encode(formatMessageEvent(value)));
242
+ if (committedPrepaidAwaitingDelivery > 0)
243
+ committedPrepaidAwaitingDelivery -= 1;
244
+ else
245
+ undeliveredPrepaidUnits = Math.max(0, undeliveredPrepaidUnits - 1);
89
246
  return;
90
247
  }
248
+ await clearObligation();
91
249
  if (!abortController.signal.aborted) {
92
250
  const channel = await store.getChannel(channelId);
93
251
  if (channel) {
@@ -114,14 +272,21 @@ export function serve(options) {
114
272
  return;
115
273
  terminal = true;
116
274
  cleanup();
117
- if (!abortController.signal.aborted)
118
- controller.error(e);
275
+ if (!abortController.signal.aborted) {
276
+ try {
277
+ await creditFailedDelivery();
278
+ controller.error(e);
279
+ }
280
+ catch (refundError) {
281
+ controller.error(new AggregateError([e, refundError], 'Stream delivery and prepaid refund failed.'));
282
+ }
283
+ }
119
284
  else
120
285
  controller.close();
121
286
  }
122
287
  },
123
288
  cancel(reason) {
124
- terminate(reason);
289
+ return terminate(reason);
125
290
  },
126
291
  });
127
292
  }