mppx 0.17.1 → 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 (63) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/Store.d.ts +225 -0
  3. package/dist/Store.js +358 -0
  4. package/dist/cli/cli.js +95 -94
  5. package/dist/cli/plugins/index.js +12 -11
  6. package/dist/cli/sessions/Manager.js +12 -1
  7. package/dist/cli/utils.d.ts +2 -0
  8. package/dist/cli/utils.js +6 -0
  9. package/dist/client/Mppx.d.ts +1 -1
  10. package/dist/client/Mppx.js +7 -3
  11. package/dist/client/internal/Fetch.d.ts +1 -1
  12. package/dist/client/internal/Fetch.js +9 -0
  13. package/dist/client/internal/MethodResponse.d.ts +8 -0
  14. package/dist/client/internal/MethodResponse.js +9 -0
  15. package/dist/client/internal/ProofRealm.d.ts +3 -3
  16. package/dist/client/internal/ProofRealm.js +4 -4
  17. package/dist/client/internal/protocols/Mcp.d.ts +4 -0
  18. package/dist/client/internal/protocols/Mcp.js +27 -14
  19. package/dist/identity/Client.js +7 -1
  20. package/dist/internal/sse.d.ts +21 -0
  21. package/dist/internal/sse.js +65 -0
  22. package/dist/internal/version.d.ts +2 -2
  23. package/dist/internal/version.js +1 -1
  24. package/dist/mcp/client/McpClient.d.ts +2 -2
  25. package/dist/mcp/client/McpClient.js +5 -2
  26. package/dist/proxy/Proxy.d.ts +2 -0
  27. package/dist/proxy/Proxy.js +31 -6
  28. package/dist/proxy/internal/Replay.js +1 -1
  29. package/dist/server/Mppx.js +5 -4
  30. package/dist/server/Transport.js +1 -1
  31. package/dist/server/internal/html/config.d.ts +7 -1
  32. package/dist/server/internal/html/config.js +4 -0
  33. package/dist/stripe/internal/types.d.ts +82 -6
  34. package/dist/stripe/server/Charge.js +1 -2
  35. package/dist/stripe/server/internal/html.gen.js +1 -1
  36. package/dist/tempo/internal/fee-payer.d.ts +85 -10
  37. package/dist/tempo/internal/fee-payer.js +183 -19
  38. package/dist/tempo/legacy/session/Chain.js +2 -0
  39. package/dist/tempo/server/Charge.js +2 -2
  40. package/dist/tempo/server/Subscription.js +1 -0
  41. package/dist/tempo/server/internal/html.gen.js +1 -1
  42. package/dist/tempo/server/internal/transport.d.ts +6 -0
  43. package/dist/tempo/server/internal/transport.js +21 -21
  44. package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
  45. package/dist/tempo/session/client/CredentialState.js +2 -0
  46. package/dist/tempo/session/client/Session.d.ts +11 -1
  47. package/dist/tempo/session/client/Session.js +39 -6
  48. package/dist/tempo/session/client/SessionManager.d.ts +1 -1
  49. package/dist/tempo/session/client/SessionManager.js +8 -4
  50. package/dist/tempo/session/client/Transports.d.ts +2 -0
  51. package/dist/tempo/session/client/Transports.js +4 -1
  52. package/dist/tempo/session/precompile/Chain.js +1 -0
  53. package/dist/tempo/session/server/ChannelStore.d.ts +9 -0
  54. package/dist/tempo/session/server/ChannelStore.js +8 -1
  55. package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
  56. package/dist/tempo/session/server/MeteredStream.js +6 -1
  57. package/dist/tempo/session/server/Sse.d.ts +59 -0
  58. package/dist/tempo/session/server/Sse.js +175 -10
  59. package/dist/zod.d.ts +2 -1
  60. package/dist/zod.js +1 -1
  61. package/package.json +1 -3
  62. package/dist/stripe/server/internal/html/types.d.ts +0 -2
  63. package/dist/stripe/server/internal/html/types.js +0 -2
@@ -4,11 +4,13 @@ import * as ChannelStore from '../../session/server/ChannelStore.js';
4
4
  import type { SettleChargedSessionChannel } from '../../session/server/Settlement.js';
5
5
  import * as Sse_core from '../../session/server/Sse.js';
6
6
  declare const prepaidSessionTick: unique symbol;
7
+ declare const prepaidRefundKey: unique symbol;
7
8
  /** SSE transport with Tempo session controller. */
8
9
  export type Sse = Transport.Sse<Sse_core.SessionController>;
9
10
  /** Receipt marker used to avoid double-charging a request already charged during verification. */
10
11
  export type PrepaidSessionReceipt = SessionReceipt & {
11
12
  [prepaidSessionTick]?: true | undefined;
13
+ [prepaidRefundKey]?: string | undefined;
12
14
  };
13
15
  /** Marks a session receipt as already charged for the current request. */
14
16
  export declare function markPrepaidSessionTick(receipt: SessionReceipt): SessionReceipt;
@@ -42,6 +44,10 @@ export declare namespace sse {
42
44
  * @default false
43
45
  */
44
46
  poll?: boolean | undefined;
47
+ /** Opt in to SDK-owned prepaid credits; leave false with an existing merchant refund handler. */
48
+ autoCreditPrepaid?: boolean | undefined;
49
+ /** Merchant reconciliation hook for prepaid items that fail before delivery; see `Sse.serve.Options.onRefundRequired`. */
50
+ onRefundRequired?: Sse_core.serve.Options['onRefundRequired'];
45
51
  /** Polling interval (in milliseconds). @default 10 */
46
52
  pollingInterval?: number | undefined;
47
53
  };
@@ -14,8 +14,15 @@ import * as ChannelStore from '../../session/server/ChannelStore.js';
14
14
  import * as Sse_core from '../../session/server/Sse.js';
15
15
  import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js';
16
16
  const prepaidSessionTick = Symbol('mppx.prepaidSessionTick');
17
+ const consumedPrepaidReceipts = new WeakSet();
18
+ const prepaidRefundKey = Symbol('mppx.prepaidRefundKey');
17
19
  /** Marks a session receipt as already charged for the current request. */
18
20
  export function markPrepaidSessionTick(receipt) {
21
+ if (hasPrepaidSessionTick(receipt))
22
+ return receipt;
23
+ Object.defineProperty(receipt, prepaidRefundKey, {
24
+ value: `stream:${globalThis.crypto.randomUUID()}`,
25
+ });
19
26
  Object.defineProperty(receipt, prepaidSessionTick, {
20
27
  configurable: false,
21
28
  enumerable: false,
@@ -65,6 +72,11 @@ export function sse(options) {
65
72
  return base.respondChallenge(options);
66
73
  },
67
74
  async respondReceipt({ credential, envelope, receipt, response, challengeId, input }) {
75
+ if (hasPrepaidSessionTick(receipt)) {
76
+ if (consumedPrepaidReceipts.has(receipt))
77
+ throw new Error('Prepaid receipt already used; verify a new payment before retrying delivery');
78
+ consumedPrepaidReceipts.add(receipt);
79
+ }
68
80
  const verifiedCredential = envelope?.credential ?? credential;
69
81
  const verifiedChallengeId = envelope?.challenge.id ?? challengeId;
70
82
  const verifiedRequest = envelope?.request ?? verifiedCredential.challenge.request;
@@ -86,20 +98,25 @@ export function sse(options) {
86
98
  // Pass async generator functions directly so Sse.serve gives them
87
99
  // a SessionController for manual charge(). Pass raw AsyncIterables
88
100
  // as-is so Sse.serve auto-charges per yielded value.
89
- const generate = resolveMeteredGenerate(resolved, unitType);
90
- const stream = Sse_core.serve({
101
+ const generate = resolved;
102
+ const streamOptions = {
91
103
  store,
92
104
  channelId,
93
105
  challengeId: verifiedChallengeId,
94
106
  tickCost,
95
107
  pollIntervalMs: pollingInterval,
96
108
  generate,
109
+ chargeOnce: unitType === 'request',
97
110
  beforeCharge: () => options.beforeCharge?.(channelId),
98
111
  onChargeCommitted: options.settleCharged,
99
112
  prepaidUnits: hasPrepaidSessionTick(receipt) ? 1 : 0,
113
+ refundIdempotencyKey: receipt[prepaidRefundKey],
114
+ onRefundRequired: options.onRefundRequired,
115
+ autoCreditPrepaid: options.autoCreditPrepaid,
100
116
  signal: input.signal,
101
- });
102
- return Sse_core.toResponse(stream);
117
+ };
118
+ await Sse_core.preparePrepaidRefund(streamOptions);
119
+ return Sse_core.toResponse(Sse_core.serve(streamOptions));
103
120
  }
104
121
  const baseResponse = base.respondReceipt({
105
122
  credential: verifiedCredential,
@@ -246,23 +263,6 @@ function isAsyncGeneratorFunction(value) {
246
263
  function isAsyncIterable(value) {
247
264
  return value !== null && typeof value === 'object' && Symbol.asyncIterator in value;
248
265
  }
249
- function resolveMeteredGenerate(value, unitType) {
250
- if (isAsyncGeneratorFunction(value))
251
- return value;
252
- if (unitType !== 'request')
253
- return value;
254
- const iterable = value;
255
- return async function* chargeOnce(stream) {
256
- let charged = false;
257
- for await (const chunk of iterable) {
258
- if (!charged) {
259
- await stream.charge();
260
- charged = true;
261
- }
262
- yield chunk;
263
- }
264
- };
265
- }
266
266
  function isNullBodyStatus(status) {
267
267
  return [101, 204, 205, 304].includes(status);
268
268
  }
@@ -15,6 +15,8 @@ export type ChannelStore = {
15
15
  };
16
16
  /** Channel persistence and update notification for credential results. */
17
17
  export type ChannelSink = {
18
+ /** Origin attached to newly created or recovered channels before persistence. */
19
+ origin?: string | undefined;
18
20
  store: ChannelStore;
19
21
  notifyUpdate: (entry: ChannelEntry) => void;
20
22
  };
@@ -25,6 +25,8 @@ export function readCredentialCumulativeAmount(payload) {
25
25
  * can react to the close.
26
26
  */
27
27
  async function storeChannelEntry(sink, entry) {
28
+ if (sink.origin !== undefined)
29
+ entry = { ...entry, origin: sink.origin };
28
30
  if (entry.opened)
29
31
  await persistChannel(sink.store, entry);
30
32
  else
@@ -132,7 +132,17 @@ export declare namespace session {
132
132
  allowCustomEscrow?: boolean | undefined;
133
133
  /** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
134
134
  autoSwap?: AutoSwap.resolve.Value | undefined;
135
- /** Pluggable persistence for reusable channels. Defaults to an in-memory store. */
135
+ /**
136
+ * HTTP origin authorized for credential creation and server-snapshot recovery.
137
+ * Fetch supplies the request origin, but recovering an unknown channel from its snapshot
138
+ * requires this explicit origin pin or a caller-supplied descriptor.
139
+ */
140
+ origin?: string | undefined;
141
+ /**
142
+ * Pluggable persistence for reusable channels. Defaults to an in-memory store.
143
+ * Use a separate store per HTTP origin: each payment scope holds one channel, and a
144
+ * channel already bound to another origin is rejected rather than overwritten or reused.
145
+ */
136
146
  channelStore?: ChannelStore | undefined;
137
147
  /**
138
148
  * Allowlist of TIP-20 currency addresses this client will pay sessions in, compared
@@ -147,6 +147,9 @@ export function session(parameters = {}) {
147
147
  const getAccount = Account.getResolver({ account });
148
148
  const maxDeposit = maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined;
149
149
  const topUpAmount = topUpAmountParameter !== undefined ? parseUnits(topUpAmountParameter, decimals) : undefined;
150
+ const configuredOrigin = parameters.origin === undefined ? undefined : new URL(parameters.origin);
151
+ if (configuredOrigin && !['http:', 'https:'].includes(configuredOrigin.protocol))
152
+ throw new Error('Session origins require HTTP or HTTPS');
150
153
  const store = channelStore ?? createChannelStore();
151
154
  const sink = { store, notifyUpdate: (entry) => onChannelUpdate?.(entry) };
152
155
  const resolveCredentialAccount = async (resolved, context, entry) => {
@@ -194,6 +197,13 @@ export function session(parameters = {}) {
194
197
  getClient,
195
198
  });
196
199
  const attempt = MethodResponse.getAttempt(parameters);
200
+ const origin = MethodResponse.getOrigin(parameters) ?? configuredOrigin?.origin;
201
+ if (origin !== undefined &&
202
+ (origin === 'null' || !['http:', 'https:'].includes(new URL(origin).protocol)))
203
+ throw new Error('Session payments require an HTTP or HTTPS origin');
204
+ if (configuredOrigin && origin !== configuredOrigin.origin)
205
+ throw new Error('Session channel origin mismatch');
206
+ const scopedSink = { ...sink, origin };
197
207
  let release = () => { };
198
208
  try {
199
209
  const automatic = !hasSessionAction(context) && context?.descriptor === undefined;
@@ -203,10 +213,16 @@ export function session(parameters = {}) {
203
213
  await attempt.prepare();
204
214
  }
205
215
  let entry = await store.get(resolved.key);
216
+ if (entry && entry.origin !== origin)
217
+ throw new Error('Session channel origin mismatch');
218
+ if (origin && !entry && resolved.snapshot && !context?.descriptor && !configuredOrigin)
219
+ throw new Error('Session snapshot recovery requires an explicitly configured origin or descriptor');
206
220
  let plan = await resolveCredentialPlan(resolved, context, entry);
207
221
  if (!automatic && attempt && plan.type === 'open') {
208
222
  release = await lockChannel(store, resolved.key);
209
223
  const nextEntry = await store.get(resolved.key);
224
+ if (nextEntry && nextEntry.origin !== origin)
225
+ throw new Error('Session channel origin mismatch');
210
226
  if (nextEntry?.channelId !== entry?.channelId) {
211
227
  entry = nextEntry;
212
228
  if (entry?.opened) {
@@ -224,13 +240,13 @@ export function session(parameters = {}) {
224
240
  store: {
225
241
  get: (key) => store.get(key),
226
242
  async set(next) {
227
- pendingOpen = next;
243
+ pendingOpen = origin ? { ...next, origin } : next;
228
244
  },
229
245
  delete: (key) => store.delete(key),
230
246
  },
231
247
  notifyUpdate() { },
232
248
  }
233
- : sink;
249
+ : scopedSink;
234
250
  const payload = await executeCredentialPlan(plan, credentialSink, AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies));
235
251
  const credential = await serializeCredential(challenge, payload, resolved.chainId, plan.account);
236
252
  if (attempt && pendingOpen) {
@@ -265,7 +281,12 @@ export function session(parameters = {}) {
265
281
  }
266
282
  },
267
283
  });
268
- const topUpChannelIfNeeded = async ({ challenge, channel, deposit, fetch, input, requiredCumulative, signal, }) => {
284
+ function createSessionCredential(challenge, context, input) {
285
+ const parameters = { challenge, context };
286
+ MethodResponse.attachOrigin(parameters, resolveInputOrigin(input));
287
+ return method.createCredential(parameters);
288
+ }
289
+ const topUpChannelIfNeeded = async ({ autoSwap, challenge, channel, deposit, fetch, input, requiredCumulative, signal, }) => {
269
290
  return withFundingLock(store, entryKey(channel), async () => {
270
291
  signal?.throwIfAborted();
271
292
  const fundingChannel = await store.get(entryKey(channel));
@@ -285,11 +306,12 @@ export function session(parameters = {}) {
285
306
  });
286
307
  if (additionalDeposit > 0n)
287
308
  await postTopUp({
309
+ autoSwap,
288
310
  additionalDeposit,
289
311
  challenge,
290
312
  channel,
291
313
  channelId: channel.channelId,
292
- createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
314
+ createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
293
315
  onUncertainFunding: async (entry) => {
294
316
  const current = await store.get(entryKey(entry));
295
317
  const unresolved = {
@@ -333,6 +355,9 @@ export function session(parameters = {}) {
333
355
  const channel = await store.get(resolved.key);
334
356
  if (!channel?.opened)
335
357
  return;
358
+ const origin = resolveInputOrigin(input);
359
+ if (channel.origin !== origin)
360
+ throw new Error('Session channel origin mismatch');
336
361
  const snapshot = resolved.snapshot?.channelId.toLowerCase() === channel.channelId.toLowerCase()
337
362
  ? resolved.snapshot
338
363
  : undefined;
@@ -347,6 +372,7 @@ export function session(parameters = {}) {
347
372
  if (!canSignDescriptor(account, channel.descriptor))
348
373
  return;
349
374
  await topUpChannelIfNeeded({
375
+ autoSwap: sessionContext?.autoSwap,
350
376
  challenge,
351
377
  channel,
352
378
  deposit,
@@ -356,7 +382,7 @@ export function session(parameters = {}) {
356
382
  signal,
357
383
  });
358
384
  });
359
- return MethodResponse.register(method, async ({ challenge, credential, fetch, headers, input, refetch, response, signal }) => {
385
+ return MethodResponse.register(method, async ({ challenge, context, credential, fetch, headers, input, refetch, response, signal, }) => {
360
386
  if (!isTip1034SessionChallenge(challenge))
361
387
  return response;
362
388
  if (!isEventStream(response)) {
@@ -376,13 +402,14 @@ export function session(parameters = {}) {
376
402
  let channel = await store.get(channelKey);
377
403
  const driver = {
378
404
  assertVoucherWithinLocalLimit: (cumulativeAmount) => assertWithinMaxDeposit(cumulativeAmount, maxDeposit),
379
- createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
405
+ createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
380
406
  fetch,
381
407
  getChannel: () => channel ?? null,
382
408
  async topUpIfNeeded({ deposit, requiredCumulative, signal: topUpSignal }) {
383
409
  if (!channel)
384
410
  return;
385
411
  await topUpChannelIfNeeded({
412
+ autoSwap: context === undefined ? undefined : sessionContextSchema.parse(context).autoSwap,
386
413
  challenge,
387
414
  channel,
388
415
  deposit: channel.deposit > deposit ? channel.deposit : deposit,
@@ -401,4 +428,10 @@ export function session(parameters = {}) {
401
428
  });
402
429
  });
403
430
  }
431
+ function resolveInputOrigin(input) {
432
+ const url = new URL(input instanceof Request ? input.url : input, typeof globalThis.location === 'undefined' ? undefined : globalThis.location.href);
433
+ if (!['http:', 'https:'].includes(url.protocol))
434
+ throw new Error('Session payments require an HTTP or HTTPS origin');
435
+ return url.origin;
436
+ }
404
437
  //# sourceMappingURL=Session.js.map
@@ -76,7 +76,7 @@ export declare namespace sessionManager {
76
76
  /** Enables same-route HEAD bootstrap from a server session snapshot before opening a new channel. */
77
77
  bootstrap?: boolean | undefined;
78
78
  /**
79
- * Server origins allowed to request bootstrap proofs for a realm other than their hostname,
79
+ * Server origins allowed to request bootstrap proofs or session payments for a realm other than their hostname,
80
80
  * mapped to that realm, such as `{ 'https://api.example.com': 'MPP Payment' }`.
81
81
  */
82
82
  trustedTempoProofRealms?: ProofRealm.TrustedRealms | undefined;
@@ -146,7 +146,7 @@ export function sessionManager(parameters) {
146
146
  async function getReusable(key) {
147
147
  const entry = await backing.get(key);
148
148
  if (entry?.opened && !ignoredChannelIds.has(entry.channelId)) {
149
- if (entry.origin !== undefined && entry.origin !== sessionOrigin)
149
+ if (entry.origin !== sessionOrigin)
150
150
  throw new Error('SessionManager: stored channel origin mismatch');
151
151
  return entry;
152
152
  }
@@ -290,6 +290,7 @@ export function sessionManager(parameters) {
290
290
  resolveAccount: parameters.resolveAccount,
291
291
  });
292
292
  const wrappedFetch = Fetch.from({
293
+ trustedTempoProofRealms: parameters.trustedTempoProofRealms,
293
294
  fetch: guardedFetch,
294
295
  identity: inheritedIdentity,
295
296
  methods: [method],
@@ -316,10 +317,13 @@ export function sessionManager(parameters) {
316
317
  },
317
318
  });
318
319
  function createSessionCredential(challenge, context) {
319
- return method.createCredential({
320
+ const parameters = {
320
321
  challenge,
321
322
  context: mergeSessionCredentialContext(credentialContext, context),
322
- });
323
+ };
324
+ if (sessionOrigin)
325
+ MethodResponse.attachOrigin(parameters, sessionOrigin);
326
+ return method.createCredential(parameters);
323
327
  }
324
328
  function updateSpentFromReceipt(receipt) {
325
329
  applySessionReceiptToRuntime({
@@ -954,7 +958,7 @@ export function sessionManager(parameters) {
954
958
  },
955
959
  rehydrate({ channel, challenge, input, spent }) {
956
960
  const origin = bindOrigin(input);
957
- if (channel.origin !== undefined && channel.origin !== origin)
961
+ if (channel.origin !== origin)
958
962
  throw new Error('SessionManager: stored channel origin mismatch');
959
963
  if (!channel.opened)
960
964
  throw new Error('Cannot restore a closed session channel.');
@@ -201,6 +201,8 @@ export type TempoSessionChallenge = Challenge.Challenge<z.output<typeof Methods.
201
201
  export type CreateSessionCredential = (challenge: TempoSessionChallenge, context: SessionContext) => Promise<string>;
202
202
  /** Inputs for posting a precompile session top-up credential. */
203
203
  export type PostTopUpParameters = {
204
+ /** Request-local swap policy; undefined inherits the method default. */
205
+ autoSwap?: SessionContext['autoSwap'] | undefined;
204
206
  /** Additional deposit in raw token units. */
205
207
  additionalDeposit: bigint;
206
208
  /** Challenge used to authorize the management request. */
@@ -331,6 +331,7 @@ export async function postTopUp(parameters) {
331
331
  challenge: parameters.challenge,
332
332
  context: {
333
333
  action: 'topUp',
334
+ ...(parameters.autoSwap === undefined ? {} : { autoSwap: parameters.autoSwap }),
334
335
  channelId,
335
336
  descriptor: channel.descriptor,
336
337
  additionalDepositRaw: parameters.additionalDeposit.toString(),
@@ -526,7 +527,9 @@ export async function closeHttpSession(parameters) {
526
527
  challengeId: closeChallenge.id,
527
528
  channelId: parameters.target.channelId,
528
529
  expectedCloseAmount: signedCloseAmount,
529
- 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,
530
533
  receipt,
531
534
  }))
532
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() {
@@ -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
  }
@@ -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
  }