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
@@ -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
  }
@@ -19,6 +19,8 @@ export type TempoChannelCall = {
19
19
  };
20
20
  /** Client-side cached channel metadata used for automatic voucher management. */
21
21
  export type ChannelEntry = {
22
+ /** HTTP origin bound by a session manager; absent on legacy or standalone method entries. */
23
+ origin?: string | undefined;
22
24
  /** TIP-1034 channel ID derived from descriptor, escrow, and chain ID. */
23
25
  channelId: Hex.Hex;
24
26
  /** Highest cumulative amount this client has locally authorized. */
@@ -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
  };
@@ -91,6 +91,11 @@ export function withFundingLock(store, key, operation) {
91
91
  export async function persistChannel(store, entry) {
92
92
  await exclusive(store, `write:${entryKey(entry)}`, async () => {
93
93
  const current = await store.get(entryKey(entry));
94
+ if (current?.origin !== undefined) {
95
+ if (entry.origin !== undefined && entry.origin !== current.origin)
96
+ throw new Error('SessionManager: stored channel origin mismatch');
97
+ entry.origin = current.origin;
98
+ }
94
99
  if (current?.uncertainTopUp) {
95
100
  if (current.channelId !== entry.channelId)
96
101
  throw new Error('Session funding outcome is unknown; reconcile the pending payment before replacing the channel.');
@@ -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,9 +132,32 @@ 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
- /** Token decimals for parsing human-readable amounts (default: 6). */
147
+ /**
148
+ * Allowlist of TIP-20 currency addresses this client will pay sessions in, compared
149
+ * case-insensitively. Offers in other currencies are skipped during selection and
150
+ * rejected before signing; an empty list rejects all. Required with `maxAmount` or
151
+ * `maxAtomicAmount`, which are denominated in the challenge currency.
152
+ */
153
+ currencies?: readonly Address[] | undefined;
154
+ /**
155
+ * Allowlist of session recipients (the channel payee), compared case-insensitively. Offers
156
+ * to other recipients, or without one, are skipped during selection and rejected before
157
+ * signing, as for charge. Explicit closes are exempt.
158
+ */
159
+ expectedRecipients?: readonly Address[] | undefined;
160
+ /** Token decimals for parsing human-readable amounts (default: 6). `maxAmount` requires it explicitly. */
138
161
  decimals?: number | undefined;
139
162
  /** Exact TIP20EscrowChannel address pin. Takes precedence over `allowCustomEscrow`. */
140
163
  escrow?: Address | undefined;
@@ -144,6 +167,18 @@ export declare namespace session {
144
167
  * challenge or resolved client must select an allowed chain.
145
168
  */
146
169
  allowedChainIds?: readonly number[] | undefined;
170
+ /**
171
+ * Maximum display-unit amount (e.g. `'1.5'`) the client is willing to pay per session
172
+ * unit (the challenge `amount`), parsed with an explicit `decimals`. Values with more
173
+ * fractional digits than `decimals` are rejected rather than rounded. Larger offers are
174
+ * skipped during selection and rejected before signing. Requires `currencies`.
175
+ */
176
+ maxAmount?: string | undefined;
177
+ /**
178
+ * Maximum amount the client is willing to pay per session unit, in raw base units of the
179
+ * challenge currency. Checked like `maxAmount`. Requires `currencies`.
180
+ */
181
+ maxAtomicAmount?: string | undefined;
147
182
  /** Maximum channel deposit in human-readable units. Caps server-suggested opens and automatic top-ups. */
148
183
  maxDeposit?: string | undefined;
149
184
  /**
@@ -4,6 +4,7 @@ import * as MethodChallenge from '../../../client/internal/MethodChallenge.js';
4
4
  import * as MethodResponse from '../../../client/internal/MethodResponse.js';
5
5
  import * as Constants from '../../../Constants.js';
6
6
  import * as Credential from '../../../Credential.js';
7
+ import * as AcceptPayment from '../../../internal/AcceptPayment.js';
7
8
  import * as Method from '../../../Method.js';
8
9
  import * as Account from '../../../viem/Account.js';
9
10
  import * as Client from '../../../viem/Client.js';
@@ -69,6 +70,65 @@ function acknowledgesOpen(outcome, challenge, opened) {
69
70
  return matchesOpen(opened, getSessionSnapshot(candidate));
70
71
  }) ?? false);
71
72
  }
73
+ /** Validates and parses the client's session spending options once, at construction. */
74
+ function resolveSpendingPolicy(parameters) {
75
+ const { currencies, decimals, expectedRecipients, maxAmount, maxAtomicAmount } = parameters;
76
+ // Caps are denominated in the challenge currency, so they require a currency pin.
77
+ if ((maxAmount !== undefined || maxAtomicAmount !== undefined) && currencies === undefined)
78
+ throw new Error('Tempo session maxAmount and maxAtomicAmount require currencies.');
79
+ return {
80
+ currencies: currencies?.map((currency) => currency.toLowerCase()),
81
+ expectedRecipients: expectedRecipients
82
+ ? new Set(expectedRecipients.map((recipient) => recipient.toLowerCase()))
83
+ : undefined,
84
+ maxAmount: parseMaxAmount(maxAmount, decimals),
85
+ maxAtomicAmount: maxAtomicAmount === undefined ? undefined : BigInt(maxAtomicAmount),
86
+ };
87
+ }
88
+ /** Parses a display-unit cap, rejecting values that `parseUnits` would round. */
89
+ function parseMaxAmount(maxAmount, decimals) {
90
+ if (maxAmount === undefined)
91
+ return undefined;
92
+ if (decimals === undefined)
93
+ throw new Error('Tempo session maxAmount requires currency decimals.');
94
+ const fraction = maxAmount.split('.')[1]?.replace(/0+$/, '') ?? '';
95
+ if (fraction.length > decimals)
96
+ throw new Error(`Tempo session maxAmount ${maxAmount} has more fractional digits than ${decimals} decimals allow.`);
97
+ return parseUnits(maxAmount, decimals);
98
+ }
99
+ /**
100
+ * Rejects a session challenge whose currency, recipient or per-unit amount violates the client
101
+ * policy. Runs during challenge selection, in the automatic top-up hook, and before any signing work.
102
+ */
103
+ function assertSpendingPolicy(policy, request) {
104
+ if (policy.currencies && !policy.currencies.includes(request.currency.toLowerCase()))
105
+ throw new Error(`Tempo session currency is not allowed: ${request.currency}.`);
106
+ // The recipient is the channel payee, so it must be one the client expects, as for charge.
107
+ if (policy.expectedRecipients &&
108
+ (!request.recipient || !policy.expectedRecipients.has(request.recipient.toLowerCase())))
109
+ throw new Error(`Unexpected session recipient: ${request.recipient}`);
110
+ const amount = BigInt(request.amount);
111
+ if (policy.maxAtomicAmount !== undefined && amount > policy.maxAtomicAmount)
112
+ throw new Error('Tempo session amount exceeds maxAtomicAmount.');
113
+ if (policy.maxAmount !== undefined && amount > policy.maxAmount)
114
+ throw new Error('Tempo session amount exceeds maxAmount.');
115
+ }
116
+ /** Returns whether a session challenge satisfies the client policy. */
117
+ function satisfiesSpendingPolicy(policy, request) {
118
+ try {
119
+ assertSpendingPolicy(policy, request);
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
126
+ /** Returns whether a caller-supplied credential context asks to close a channel. */
127
+ function isExplicitClose(context) {
128
+ return (typeof context === 'object' &&
129
+ context !== null &&
130
+ context.action === 'close');
131
+ }
72
132
  /**
73
133
  * Creates the low-level TIP-1034 session payment method for use with `Mppx.create()`.
74
134
  *
@@ -78,6 +138,7 @@ function acknowledgesOpen(outcome, challenge, opened) {
78
138
  */
79
139
  export function session(parameters = {}) {
80
140
  const { account, allowCustomEscrow = false, autoSwap: autoSwapParameter, channelStore, decimals = defaults.decimals, escrow: escrowOverride, allowedChainIds, getClient: getClientParameter, maxDeposit: maxDepositParameter, topUpAmount: topUpAmountParameter, onChannelUpdate, resolveAccount, } = parameters;
141
+ const policy = resolveSpendingPolicy(parameters);
81
142
  const getClient = Client.getResolver({
82
143
  chain: tempo_chain,
83
144
  getClient: getClientParameter,
@@ -86,6 +147,9 @@ export function session(parameters = {}) {
86
147
  const getAccount = Account.getResolver({ account });
87
148
  const maxDeposit = maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined;
88
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');
89
153
  const store = channelStore ?? createChannelStore();
90
154
  const sink = { store, notifyUpdate: (entry) => onChannelUpdate?.(entry) };
91
155
  const resolveCredentialAccount = async (resolved, context, entry) => {
@@ -112,10 +176,19 @@ export function session(parameters = {}) {
112
176
  resolved,
113
177
  });
114
178
  const method = Method.toClient(Methods.session, {
115
- canHandleChallenge: ({ challenge }) => isTip1034SessionChallenge(challenge),
179
+ // Skip offers that violate client policy so an acceptable offer can be chosen. An explicit
180
+ // close, when the caller supplies it before selection, stays available for any offer. Flows
181
+ // that learn the context only after selection defer the policy to createCredential.
182
+ canHandleChallenge: ({ challenge, context }) => isTip1034SessionChallenge(challenge) &&
183
+ (isExplicitClose(context) ||
184
+ context === AcceptPayment.deferredContext ||
185
+ satisfiesSpendingPolicy(policy, challenge.request)),
116
186
  context: sessionContextSchema,
117
187
  async createCredential(parameters) {
118
188
  const { challenge, context } = parameters;
189
+ // Closing settles what the channel already spent, so it stays available for any offer.
190
+ if (context?.action !== 'close')
191
+ assertSpendingPolicy(policy, challenge.request);
119
192
  const resolved = await resolveChallengeContext({
120
193
  allowCustomEscrow,
121
194
  challenge,
@@ -124,6 +197,13 @@ export function session(parameters = {}) {
124
197
  getClient,
125
198
  });
126
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 };
127
207
  let release = () => { };
128
208
  try {
129
209
  const automatic = !hasSessionAction(context) && context?.descriptor === undefined;
@@ -133,10 +213,16 @@ export function session(parameters = {}) {
133
213
  await attempt.prepare();
134
214
  }
135
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');
136
220
  let plan = await resolveCredentialPlan(resolved, context, entry);
137
221
  if (!automatic && attempt && plan.type === 'open') {
138
222
  release = await lockChannel(store, resolved.key);
139
223
  const nextEntry = await store.get(resolved.key);
224
+ if (nextEntry && nextEntry.origin !== origin)
225
+ throw new Error('Session channel origin mismatch');
140
226
  if (nextEntry?.channelId !== entry?.channelId) {
141
227
  entry = nextEntry;
142
228
  if (entry?.opened) {
@@ -154,13 +240,13 @@ export function session(parameters = {}) {
154
240
  store: {
155
241
  get: (key) => store.get(key),
156
242
  async set(next) {
157
- pendingOpen = next;
243
+ pendingOpen = origin ? { ...next, origin } : next;
158
244
  },
159
245
  delete: (key) => store.delete(key),
160
246
  },
161
247
  notifyUpdate() { },
162
248
  }
163
- : sink;
249
+ : scopedSink;
164
250
  const payload = await executeCredentialPlan(plan, credentialSink, AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies));
165
251
  const credential = await serializeCredential(challenge, payload, resolved.chainId, plan.account);
166
252
  if (attempt && pendingOpen) {
@@ -195,7 +281,12 @@ export function session(parameters = {}) {
195
281
  }
196
282
  },
197
283
  });
198
- 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, }) => {
199
290
  return withFundingLock(store, entryKey(channel), async () => {
200
291
  signal?.throwIfAborted();
201
292
  const fundingChannel = await store.get(entryKey(channel));
@@ -215,11 +306,12 @@ export function session(parameters = {}) {
215
306
  });
216
307
  if (additionalDeposit > 0n)
217
308
  await postTopUp({
309
+ autoSwap,
218
310
  additionalDeposit,
219
311
  challenge,
220
312
  channel,
221
313
  channelId: channel.channelId,
222
- createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
314
+ createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
223
315
  onUncertainFunding: async (entry) => {
224
316
  const current = await store.get(entryKey(entry));
225
317
  const unresolved = {
@@ -252,6 +344,7 @@ export function session(parameters = {}) {
252
344
  const sessionContext = context === undefined ? undefined : sessionContextSchema.parse(context);
253
345
  if (hasSessionAction(sessionContext))
254
346
  return;
347
+ assertSpendingPolicy(policy, challenge.request);
255
348
  const resolved = await resolveChallengeContext({
256
349
  allowCustomEscrow,
257
350
  challenge,
@@ -262,6 +355,9 @@ export function session(parameters = {}) {
262
355
  const channel = await store.get(resolved.key);
263
356
  if (!channel?.opened)
264
357
  return;
358
+ const origin = resolveInputOrigin(input);
359
+ if (channel.origin !== origin)
360
+ throw new Error('Session channel origin mismatch');
265
361
  const snapshot = resolved.snapshot?.channelId.toLowerCase() === channel.channelId.toLowerCase()
266
362
  ? resolved.snapshot
267
363
  : undefined;
@@ -276,6 +372,7 @@ export function session(parameters = {}) {
276
372
  if (!canSignDescriptor(account, channel.descriptor))
277
373
  return;
278
374
  await topUpChannelIfNeeded({
375
+ autoSwap: sessionContext?.autoSwap,
279
376
  challenge,
280
377
  channel,
281
378
  deposit,
@@ -285,7 +382,7 @@ export function session(parameters = {}) {
285
382
  signal,
286
383
  });
287
384
  });
288
- 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, }) => {
289
386
  if (!isTip1034SessionChallenge(challenge))
290
387
  return response;
291
388
  if (!isEventStream(response)) {
@@ -305,13 +402,14 @@ export function session(parameters = {}) {
305
402
  let channel = await store.get(channelKey);
306
403
  const driver = {
307
404
  assertVoucherWithinLocalLimit: (cumulativeAmount) => assertWithinMaxDeposit(cumulativeAmount, maxDeposit),
308
- createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
405
+ createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
309
406
  fetch,
310
407
  getChannel: () => channel ?? null,
311
408
  async topUpIfNeeded({ deposit, requiredCumulative, signal: topUpSignal }) {
312
409
  if (!channel)
313
410
  return;
314
411
  await topUpChannelIfNeeded({
412
+ autoSwap: context === undefined ? undefined : sessionContextSchema.parse(context).autoSwap,
315
413
  challenge,
316
414
  channel,
317
415
  deposit: channel.deposit > deposit ? channel.deposit : deposit,
@@ -330,4 +428,10 @@ export function session(parameters = {}) {
330
428
  });
331
429
  });
332
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
+ }
333
437
  //# sourceMappingURL=Session.js.map
@@ -60,6 +60,7 @@ export declare function mergeSessionCredentialContext(credentialContext: unknown
60
60
  * channel state management and credential creation, and to `Fetch.from`
61
61
  * for the 402 challenge/retry flow.
62
62
  *
63
+ * Each manager is pinned to its first request origin. Use a separate manager per origin.
63
64
  * `channelStore` can persist reusable channels between manager instances.
64
65
  */
65
66
  export declare function sessionManager(parameters: sessionManager.Parameters): SessionManager;
@@ -75,7 +76,7 @@ export declare namespace sessionManager {
75
76
  /** Enables same-route HEAD bootstrap from a server session snapshot before opening a new channel. */
76
77
  bootstrap?: boolean | undefined;
77
78
  /**
78
- * 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,
79
80
  * mapped to that realm, such as `{ 'https://api.example.com': 'MPP Payment' }`.
80
81
  */
81
82
  trustedTempoProofRealms?: ProofRealm.TrustedRealms | undefined;
@@ -7,6 +7,7 @@ import * as MethodResponse from '../../../client/internal/MethodResponse.js';
7
7
  import * as ProofRealm from '../../../client/internal/ProofRealm.js';
8
8
  import { setCredentialHeader } from '../../../client/internal/protocols/Shared.js';
9
9
  import * as Constants from '../../../Constants.js';
10
+ import * as IdentityClient from '../../../identity/Client.js';
10
11
  import * as Account from '../../../viem/Account.js';
11
12
  import * as Client from '../../../viem/Client.js';
12
13
  import { charge as chargePlugin } from '../../client/Charge.js';
@@ -23,7 +24,7 @@ import { resolveCloseTarget } from './Runtime.js';
23
24
  import { assertVoucherWithinLocalLimit as assertVoucherWithinLocalAuthorization } from './Runtime.js';
24
25
  import { applySessionReceiptToRuntime, captureRuntimeSnapshot as captureRuntimeStateSnapshot, computeFallbackCloseAmount, createSessionManagerRuntime, dispatchSessionEvent, restoreCumulativeAuthorization, restoreRuntimeSnapshot as restoreRuntimeStateSnapshot, resolveAutomaticTopUp, } from './Runtime.js';
25
26
  import { closeSocketSession } from './Runtime.js';
26
- import { closeHttpSession, getSessionSnapshot, isTempoSessionChallenge, postTopUp, retryHttpPaymentRequired, webSocketProbeUrl, } from './Transports.js';
27
+ import { closeHttpSession, getSessionSnapshot, isTempoSessionChallenge, isRequest, postTopUp, retryHttpPaymentRequired, webSocketProbeUrl, } from './Transports.js';
27
28
  import { WebSocketReadyState, } from './Transports.js';
28
29
  import { consumeSseSessionResponse, openSseSession } from './Transports.js';
29
30
  import { UncertainFundingError, applyTopUpResult, resolveManualTopUp, } from './Transports.js';
@@ -47,7 +48,7 @@ function isZeroAmountChargeChallenge(challenge) {
47
48
  function requestInitWithSessionHint(input, init, channelId) {
48
49
  if (!channelId)
49
50
  return init;
50
- const requestHeaders = input instanceof Request ? input.headers : undefined;
51
+ const requestHeaders = isRequest(input) ? input.headers : undefined;
51
52
  const headers = {
52
53
  ...Fetch.normalizeHeaders(requestHeaders),
53
54
  ...Fetch.normalizeHeaders(init?.headers),
@@ -88,6 +89,7 @@ function resolveSessionManagerConfig(parameters) {
88
89
  * channel state management and credential creation, and to `Fetch.from`
89
90
  * for the 402 challenge/retry flow.
90
91
  *
92
+ * Each manager is pinned to its first request origin. Use a separate manager per origin.
91
93
  * `channelStore` can persist reusable channels between manager instances.
92
94
  */
93
95
  export function sessionManager(parameters) {
@@ -96,6 +98,34 @@ export function sessionManager(parameters) {
96
98
  Client.assertAllowedChainId(parameters.allowedChainIds, clientChainId);
97
99
  const allowCustomEscrow = parameters.allowCustomEscrow ?? false;
98
100
  const config = resolveSessionManagerConfig(parameters);
101
+ let sessionOrigin;
102
+ function bindOrigin(input) {
103
+ const url = new URL(isRequest(input) ? input.url : String(input), typeof location === 'undefined' ? undefined : location.href);
104
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
105
+ throw new Error('SessionManager: session payments require an HTTP origin');
106
+ const origin = url.origin;
107
+ if (sessionOrigin !== undefined && sessionOrigin !== origin)
108
+ throw new Error('SessionManager: session payment origin mismatch');
109
+ sessionOrigin = origin;
110
+ return origin;
111
+ }
112
+ const inheritedIdentity = Fetch.identityOf(config.fetch);
113
+ const rawFetch = Fetch.unwrapFetch(config.fetch);
114
+ config.fetch = async (input, init) => {
115
+ const origin = bindOrigin(input);
116
+ const response = await rawFetch(input, {
117
+ ...init,
118
+ redirect: (init?.redirect ?? (isRequest(input) ? input.redirect : undefined)) === 'manual'
119
+ ? 'manual'
120
+ : 'error',
121
+ });
122
+ if (response.url && new URL(response.url).origin !== origin)
123
+ throw new Error('SessionManager: session response origin mismatch');
124
+ return response;
125
+ };
126
+ const guardedFetch = config.fetch;
127
+ if (inheritedIdentity)
128
+ config.fetch = IdentityClient.wrapFetch(guardedFetch, inheritedIdentity);
99
129
  const getClient = Client.getResolver({
100
130
  chain: tempo_chain,
101
131
  getClient: parameters.client ? () => parameters.client : parameters.getClient,
@@ -115,11 +145,19 @@ export function sessionManager(parameters) {
115
145
  /** Returns the backing entry for `key` only when it is open and not ignored. */
116
146
  async function getReusable(key) {
117
147
  const entry = await backing.get(key);
118
- if (entry?.opened && !ignoredChannelIds.has(entry.channelId))
148
+ if (entry?.opened && !ignoredChannelIds.has(entry.channelId)) {
149
+ if (entry.origin !== sessionOrigin)
150
+ throw new Error('SessionManager: stored channel origin mismatch');
119
151
  return entry;
152
+ }
120
153
  return undefined;
121
154
  }
122
- const persistChannel = (entry) => persistStoredChannel(backing, entry);
155
+ const persistChannel = (entry) => {
156
+ if (entry.origin !== undefined && entry.origin !== sessionOrigin)
157
+ throw new Error('SessionManager: stored channel origin mismatch');
158
+ entry.origin = sessionOrigin;
159
+ return persistStoredChannel(backing, entry);
160
+ };
123
161
  const store = {
124
162
  async get(key) {
125
163
  const entry = await getReusable(key);
@@ -252,7 +290,9 @@ export function sessionManager(parameters) {
252
290
  resolveAccount: parameters.resolveAccount,
253
291
  });
254
292
  const wrappedFetch = Fetch.from({
255
- fetch: config.fetch,
293
+ trustedTempoProofRealms: parameters.trustedTempoProofRealms,
294
+ fetch: guardedFetch,
295
+ identity: inheritedIdentity,
256
296
  methods: [method],
257
297
  onChallenge: async (challenge, _helpers) => {
258
298
  if (!isTempoSessionChallenge(challenge))
@@ -277,10 +317,13 @@ export function sessionManager(parameters) {
277
317
  },
278
318
  });
279
319
  function createSessionCredential(challenge, context) {
280
- return method.createCredential({
320
+ const parameters = {
281
321
  challenge,
282
322
  context: mergeSessionCredentialContext(credentialContext, context),
283
- });
323
+ };
324
+ if (sessionOrigin)
325
+ MethodResponse.attachOrigin(parameters, sessionOrigin);
326
+ return method.createCredential(parameters);
284
327
  }
285
328
  function updateSpentFromReceipt(receipt) {
286
329
  applySessionReceiptToRuntime({
@@ -334,9 +377,9 @@ export function sessionManager(parameters) {
334
377
  return undefined;
335
378
  if (runtime.channel?.opened)
336
379
  return undefined;
337
- const requestHeaders = input instanceof Request ? input.headers : undefined;
380
+ const requestHeaders = isRequest(input) ? input.headers : undefined;
338
381
  const { body: _body, method: _method, ...bootstrapInit } = init ?? {};
339
- const bootstrapInput = input instanceof Request ? input.url : input;
382
+ const bootstrapInput = isRequest(input) ? input.url : input;
340
383
  const headInit = {
341
384
  ...bootstrapInit,
342
385
  method: 'HEAD',
@@ -646,6 +689,7 @@ export function sessionManager(parameters) {
646
689
  // corrupt each other's runtime and channel tracking.
647
690
  if (channelUse)
648
691
  throw new Error('SessionManager: a request is already in flight; concurrent requests on one manager are not supported');
692
+ bindOrigin(input);
649
693
  runtime.lastUrl = input;
650
694
  runtime.lastHeaders = init?.headers;
651
695
  const previous = captureRuntimeStateSnapshot({
@@ -818,6 +862,7 @@ export function sessionManager(parameters) {
818
862
  throw new Error('No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.');
819
863
  }
820
864
  const probeUrl = webSocketProbeUrl(input);
865
+ bindOrigin(probeUrl);
821
866
  const signalInit = init?.signal ? { signal: init.signal } : undefined;
822
867
  await bootstrapSession(probeUrl, signalInit);
823
868
  // Cold starts resume from `channelStore` after the probe's 402.
@@ -906,9 +951,15 @@ export function sessionManager(parameters) {
906
951
  };
907
952
  registerSessionManagerInternals(self, {
908
953
  consumeSseResponse(input, response, options) {
954
+ const origin = bindOrigin(input);
955
+ if (response.url && new URL(response.url).origin !== origin)
956
+ throw new Error('SessionManager: session response origin mismatch');
909
957
  return consumeSseSessionResponse(input, response, options, sseDriver);
910
958
  },
911
959
  rehydrate({ channel, challenge, input, spent }) {
960
+ const origin = bindOrigin(input);
961
+ if (channel.origin !== origin)
962
+ throw new Error('SessionManager: stored channel origin mismatch');
912
963
  if (!channel.opened)
913
964
  throw new Error('Cannot restore a closed session channel.');
914
965
  if (!isTempoSessionChallenge(challenge)) {
@@ -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. */
@@ -282,6 +284,8 @@ export declare function getSessionSnapshot(challenge: TempoSessionChallenge): Se
282
284
  /** Merges request headers and sets the challenge's credential header for a retry. */
283
285
  export declare function requestInitWithAuthorization(input: RequestInfo | URL, init: RequestInit | undefined, credential: string, challenge: TempoSessionChallenge): RequestInit;
284
286
  /** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
287
+ /** Returns whether a value is a `Request`, including one from another realm such as an iframe. */
288
+ export declare function isRequest(value: unknown): value is Request;
285
289
  export declare function managementInput(input: RequestInfo | URL): RequestInfo | URL;
286
290
  /** Converts a WebSocket URL into the HTTP URL used for its payment challenge probe. */
287
291
  export declare function webSocketProbeUrl(input: string | URL): URL;