mppx 0.17.0 → 0.17.1

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 (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Method.d.ts +5 -0
  4. package/dist/cli/cli.js +133 -133
  5. package/dist/cli/plugins/index.js +11 -11
  6. package/dist/cli/sessions/store.js +7 -1
  7. package/dist/cli/validate/helpers.d.ts +1 -1
  8. package/dist/cli/validate/helpers.js +14 -2
  9. package/dist/client/Mppx.js +11 -3
  10. package/dist/client/internal/Fetch.d.ts +2 -0
  11. package/dist/client/internal/Fetch.js +59 -3
  12. package/dist/internal/AcceptPayment.d.ts +12 -2
  13. package/dist/internal/AcceptPayment.js +12 -3
  14. package/dist/internal/version.d.ts +2 -2
  15. package/dist/internal/version.js +1 -1
  16. package/dist/mcp/client/McpClient.js +1 -1
  17. package/dist/proxy/Proxy.d.ts +15 -0
  18. package/dist/proxy/Proxy.js +89 -1
  19. package/dist/proxy/internal/Replay.d.ts +105 -0
  20. package/dist/proxy/internal/Replay.js +257 -0
  21. package/dist/server/Mppx.d.ts +2 -0
  22. package/dist/server/Mppx.js +2 -1
  23. package/dist/tempo/client/Charge.d.ts +9 -0
  24. package/dist/tempo/client/Charge.js +13 -9
  25. package/dist/tempo/client/Methods.d.ts +2 -0
  26. package/dist/tempo/server/Charge.js +4 -1
  27. package/dist/tempo/server/internal/html.gen.js +1 -1
  28. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  29. package/dist/tempo/session/client/ChannelStore.js +5 -0
  30. package/dist/tempo/session/client/Session.d.ts +26 -1
  31. package/dist/tempo/session/client/Session.js +72 -1
  32. package/dist/tempo/session/client/SessionManager.d.ts +1 -0
  33. package/dist/tempo/session/client/SessionManager.js +54 -7
  34. package/dist/tempo/session/client/Transports.d.ts +2 -0
  35. package/dist/tempo/session/client/Transports.js +31 -2
  36. package/dist/tempo/session/precompile/Voucher.js +20 -7
  37. package/dist/tempo/session/server/Session.d.ts +2 -1
  38. package/dist/tempo/session/server/Session.js +2 -1
  39. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  40. package/dist/tempo/session/server/Settlement.js +2 -2
  41. package/dist/tempo/session/server/Ws.js +29 -12
  42. package/package.json +1 -1
@@ -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. */
@@ -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.');
@@ -134,7 +134,20 @@ export declare namespace session {
134
134
  autoSwap?: AutoSwap.resolve.Value | undefined;
135
135
  /** Pluggable persistence for reusable channels. Defaults to an in-memory store. */
136
136
  channelStore?: ChannelStore | undefined;
137
- /** Token decimals for parsing human-readable amounts (default: 6). */
137
+ /**
138
+ * Allowlist of TIP-20 currency addresses this client will pay sessions in, compared
139
+ * case-insensitively. Offers in other currencies are skipped during selection and
140
+ * rejected before signing; an empty list rejects all. Required with `maxAmount` or
141
+ * `maxAtomicAmount`, which are denominated in the challenge currency.
142
+ */
143
+ currencies?: readonly Address[] | undefined;
144
+ /**
145
+ * Allowlist of session recipients (the channel payee), compared case-insensitively. Offers
146
+ * to other recipients, or without one, are skipped during selection and rejected before
147
+ * signing, as for charge. Explicit closes are exempt.
148
+ */
149
+ expectedRecipients?: readonly Address[] | undefined;
150
+ /** Token decimals for parsing human-readable amounts (default: 6). `maxAmount` requires it explicitly. */
138
151
  decimals?: number | undefined;
139
152
  /** Exact TIP20EscrowChannel address pin. Takes precedence over `allowCustomEscrow`. */
140
153
  escrow?: Address | undefined;
@@ -144,6 +157,18 @@ export declare namespace session {
144
157
  * challenge or resolved client must select an allowed chain.
145
158
  */
146
159
  allowedChainIds?: readonly number[] | undefined;
160
+ /**
161
+ * Maximum display-unit amount (e.g. `'1.5'`) the client is willing to pay per session
162
+ * unit (the challenge `amount`), parsed with an explicit `decimals`. Values with more
163
+ * fractional digits than `decimals` are rejected rather than rounded. Larger offers are
164
+ * skipped during selection and rejected before signing. Requires `currencies`.
165
+ */
166
+ maxAmount?: string | undefined;
167
+ /**
168
+ * Maximum amount the client is willing to pay per session unit, in raw base units of the
169
+ * challenge currency. Checked like `maxAmount`. Requires `currencies`.
170
+ */
171
+ maxAtomicAmount?: string | undefined;
147
172
  /** Maximum channel deposit in human-readable units. Caps server-suggested opens and automatic top-ups. */
148
173
  maxDeposit?: string | undefined;
149
174
  /**
@@ -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,
@@ -112,10 +173,19 @@ export function session(parameters = {}) {
112
173
  resolved,
113
174
  });
114
175
  const method = Method.toClient(Methods.session, {
115
- canHandleChallenge: ({ challenge }) => isTip1034SessionChallenge(challenge),
176
+ // Skip offers that violate client policy so an acceptable offer can be chosen. An explicit
177
+ // close, when the caller supplies it before selection, stays available for any offer. Flows
178
+ // that learn the context only after selection defer the policy to createCredential.
179
+ canHandleChallenge: ({ challenge, context }) => isTip1034SessionChallenge(challenge) &&
180
+ (isExplicitClose(context) ||
181
+ context === AcceptPayment.deferredContext ||
182
+ satisfiesSpendingPolicy(policy, challenge.request)),
116
183
  context: sessionContextSchema,
117
184
  async createCredential(parameters) {
118
185
  const { challenge, context } = parameters;
186
+ // Closing settles what the channel already spent, so it stays available for any offer.
187
+ if (context?.action !== 'close')
188
+ assertSpendingPolicy(policy, challenge.request);
119
189
  const resolved = await resolveChallengeContext({
120
190
  allowCustomEscrow,
121
191
  challenge,
@@ -252,6 +322,7 @@ export function session(parameters = {}) {
252
322
  const sessionContext = context === undefined ? undefined : sessionContextSchema.parse(context);
253
323
  if (hasSessionAction(sessionContext))
254
324
  return;
325
+ assertSpendingPolicy(policy, challenge.request);
255
326
  const resolved = await resolveChallengeContext({
256
327
  allowCustomEscrow,
257
328
  challenge,
@@ -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;
@@ -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 !== undefined && 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,8 @@ export function sessionManager(parameters) {
252
290
  resolveAccount: parameters.resolveAccount,
253
291
  });
254
292
  const wrappedFetch = Fetch.from({
255
- fetch: config.fetch,
293
+ fetch: guardedFetch,
294
+ identity: inheritedIdentity,
256
295
  methods: [method],
257
296
  onChallenge: async (challenge, _helpers) => {
258
297
  if (!isTempoSessionChallenge(challenge))
@@ -334,9 +373,9 @@ export function sessionManager(parameters) {
334
373
  return undefined;
335
374
  if (runtime.channel?.opened)
336
375
  return undefined;
337
- const requestHeaders = input instanceof Request ? input.headers : undefined;
376
+ const requestHeaders = isRequest(input) ? input.headers : undefined;
338
377
  const { body: _body, method: _method, ...bootstrapInit } = init ?? {};
339
- const bootstrapInput = input instanceof Request ? input.url : input;
378
+ const bootstrapInput = isRequest(input) ? input.url : input;
340
379
  const headInit = {
341
380
  ...bootstrapInit,
342
381
  method: 'HEAD',
@@ -646,6 +685,7 @@ export function sessionManager(parameters) {
646
685
  // corrupt each other's runtime and channel tracking.
647
686
  if (channelUse)
648
687
  throw new Error('SessionManager: a request is already in flight; concurrent requests on one manager are not supported');
688
+ bindOrigin(input);
649
689
  runtime.lastUrl = input;
650
690
  runtime.lastHeaders = init?.headers;
651
691
  const previous = captureRuntimeStateSnapshot({
@@ -818,6 +858,7 @@ export function sessionManager(parameters) {
818
858
  throw new Error('No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.');
819
859
  }
820
860
  const probeUrl = webSocketProbeUrl(input);
861
+ bindOrigin(probeUrl);
821
862
  const signalInit = init?.signal ? { signal: init.signal } : undefined;
822
863
  await bootstrapSession(probeUrl, signalInit);
823
864
  // Cold starts resume from `channelStore` after the probe's 402.
@@ -906,9 +947,15 @@ export function sessionManager(parameters) {
906
947
  };
907
948
  registerSessionManagerInternals(self, {
908
949
  consumeSseResponse(input, response, options) {
950
+ const origin = bindOrigin(input);
951
+ if (response.url && new URL(response.url).origin !== origin)
952
+ throw new Error('SessionManager: session response origin mismatch');
909
953
  return consumeSseSessionResponse(input, response, options, sseDriver);
910
954
  },
911
955
  rehydrate({ channel, challenge, input, spent }) {
956
+ const origin = bindOrigin(input);
957
+ if (channel.origin !== undefined && channel.origin !== origin)
958
+ throw new Error('SessionManager: stored channel origin mismatch');
912
959
  if (!channel.opened)
913
960
  throw new Error('Cannot restore a closed session channel.');
914
961
  if (!isTempoSessionChallenge(challenge)) {
@@ -282,6 +282,8 @@ export declare function getSessionSnapshot(challenge: TempoSessionChallenge): Se
282
282
  /** Merges request headers and sets the challenge's credential header for a retry. */
283
283
  export declare function requestInitWithAuthorization(input: RequestInfo | URL, init: RequestInit | undefined, credential: string, challenge: TempoSessionChallenge): RequestInit;
284
284
  /** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
285
+ /** Returns whether a value is a `Request`, including one from another realm such as an iframe. */
286
+ export declare function isRequest(value: unknown): value is Request;
285
287
  export declare function managementInput(input: RequestInfo | URL): RequestInfo | URL;
286
288
  /** Converts a WebSocket URL into the HTTP URL used for its payment challenge probe. */
287
289
  export declare function webSocketProbeUrl(input: string | URL): URL;
@@ -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)
@@ -456,6 +466,21 @@ export async function closeHttpSession(parameters) {
456
466
  }
457
467
  let closeChallenge = parameters.target.challenge;
458
468
  let signedCloseAmount = parameters.signedCloseAmount;
469
+ const request = isRequest(parameters.lastUrl) ? parameters.lastUrl : undefined;
470
+ // Retain transport policy without inheriting the resource body or cancellation. Redirects stay
471
+ // manual so the credential never follows one.
472
+ const requestOptions = request
473
+ ? {
474
+ cache: request.cache,
475
+ credentials: request.credentials,
476
+ integrity: request.integrity,
477
+ keepalive: request.keepalive,
478
+ // Fetch rejects `navigate` in a request init; a navigation becomes `same-origin`, as Fetch does.
479
+ mode: request.mode === 'navigate' ? 'same-origin' : request.mode,
480
+ referrer: request.referrer,
481
+ referrerPolicy: request.referrerPolicy,
482
+ }
483
+ : undefined;
459
484
  const postClose = async (challenge, refreshed = false) => {
460
485
  closeChallenge = challenge;
461
486
  signedCloseAmount =
@@ -467,7 +492,11 @@ export async function closeHttpSession(parameters) {
467
492
  descriptor: parameters.target.channel.descriptor,
468
493
  cumulativeAmountRaw: signedCloseAmount,
469
494
  });
470
- return parameters.fetch(parameters.lastUrl, {
495
+ // Close is a separate management POST, independent of the resource Request.
496
+ // A Request's URL is absolute. Pass other inputs unchanged so the configured fetch resolves
497
+ // relative URLs exactly as it did for the paid resource.
498
+ return parameters.fetch(request ? managementInput(request) : parameters.lastUrl, {
499
+ ...requestOptions,
471
500
  method: 'POST',
472
501
  redirect: 'manual',
473
502
  headers: managementHeaders(challenge, credential, parameters.lastUrl, parameters.headers),
@@ -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}".`);
@@ -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
  }
@@ -40,7 +40,32 @@ export async function serve(options) {
40
40
  runtime.closed = true;
41
41
  abortController.abort();
42
42
  unsubscribe();
43
- await Promise.resolve(socket.close(code, reason));
43
+ await closeSocket(code, reason);
44
+ };
45
+ const closeSocket = async (code, reason) => {
46
+ try {
47
+ await socket.close(code, reason);
48
+ }
49
+ catch {
50
+ // Some adapters reject a close even after the session has stopped.
51
+ // Retry without application data and contain both sync and async errors.
52
+ try {
53
+ await socket.close();
54
+ }
55
+ catch { }
56
+ }
57
+ };
58
+ const rejectAndClose = (message, status, code) => {
59
+ runtime.closed = true;
60
+ abortController.abort();
61
+ unsubscribe();
62
+ // Queue the small final diagnostic synchronously before close changes socket state.
63
+ // Do not wait for an adapter's send promise: a stalled peer must still be closed.
64
+ try {
65
+ void Promise.resolve(socket.send(formatErrorMessage({ message, status }))).catch(() => { });
66
+ }
67
+ catch { }
68
+ void closeSocket(code, message);
44
69
  };
45
70
  const sendCloseReady = async () => {
46
71
  if (runtime.closeReadySent || !runtime.streamContext || runtime.closed)
@@ -129,7 +154,7 @@ export async function serve(options) {
129
154
  message: authorizationResult.message,
130
155
  status: authorizationResult.responseStatus,
131
156
  }));
132
- await close(1008, authorizationResult.message);
157
+ await close(1008, 'payment authorization rejected');
133
158
  return;
134
159
  }
135
160
  const { receipt } = authorizationResult;
@@ -169,11 +194,7 @@ export async function serve(options) {
169
194
  return;
170
195
  const messageBytes = frameByteLength(payload);
171
196
  if (messageBytes !== null && messageBytes > maxIncomingMessageBytes) {
172
- runtime.closed = true;
173
- abortController.abort();
174
- unsubscribe();
175
- void send(socket, formatErrorMessage({ message: 'payment message is too large', status: 413 })).catch(() => { });
176
- void Promise.resolve(socket.close(1009, 'payment message is too large')).catch(() => { });
197
+ rejectAndClose('payment message is too large', 413, 1009);
177
198
  return;
178
199
  }
179
200
  const raw = toText(payload);
@@ -194,11 +215,7 @@ export async function serve(options) {
194
215
  if (!work)
195
216
  return;
196
217
  if (runtime.queuedActions >= maxQueuedPaymentMessages) {
197
- void sendFrame(formatErrorMessage({
198
- message: 'too many queued payment messages',
199
- status: 429,
200
- })).catch(() => { });
201
- void close(1008, 'too many queued payment messages');
218
+ rejectAndClose('too many queued payment messages', 429, 1008);
202
219
  return;
203
220
  }
204
221
  runtime.queuedActions++;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.17.0",
4
+ "version": "0.17.1",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",