mppx 0.8.11 → 0.8.12

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 (43) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/internal/Fetch.d.ts.map +1 -1
  3. package/dist/client/internal/Fetch.js +35 -6
  4. package/dist/client/internal/Fetch.js.map +1 -1
  5. package/dist/client/internal/MethodResponse.d.ts +23 -0
  6. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  7. package/dist/client/internal/MethodResponse.js +15 -0
  8. package/dist/client/internal/MethodResponse.js.map +1 -0
  9. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  10. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  11. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  12. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  13. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  14. package/dist/tempo/legacy/client/Session.js +7 -0
  15. package/dist/tempo/legacy/client/Session.js.map +1 -1
  16. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  17. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/tempo/server/internal/html.gen.js +1 -1
  19. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  21. package/dist/tempo/session/client/Session.js +53 -1
  22. package/dist/tempo/session/client/Session.js.map +1 -1
  23. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/session/client/SessionManager.js +2 -0
  25. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/session/client/Transports.d.ts +10 -0
  27. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  28. package/dist/tempo/session/client/Transports.js +96 -9
  29. package/dist/tempo/session/client/Transports.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/client/internal/Fetch.test.ts +29 -0
  32. package/src/client/internal/Fetch.ts +51 -6
  33. package/src/client/internal/MethodResponse.ts +39 -0
  34. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  35. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  36. package/src/tempo/legacy/client/Session.test.ts +102 -1
  37. package/src/tempo/legacy/client/Session.ts +10 -0
  38. package/src/tempo/server/internal/html.gen.ts +1 -1
  39. package/src/tempo/session/client/Session.test.ts +83 -1
  40. package/src/tempo/session/client/Session.ts +73 -1
  41. package/src/tempo/session/client/SessionManager.ts +2 -0
  42. package/src/tempo/session/client/Transports.test.ts +75 -0
  43. package/src/tempo/session/client/Transports.ts +108 -11
@@ -10,6 +10,7 @@ import { rpcUrl } from '~test/tempo/rpc.js'
10
10
  import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as Fetch from './Fetch.js'
13
+ import * as MethodResponse from './MethodResponse.js'
13
14
 
14
15
  const realm = 'api.example.com'
15
16
  const secretKey = 'test-secret-key-test-secret-key-32'
@@ -391,6 +392,34 @@ function encodeRawPaymentRequiredHeader(value: unknown): string {
391
392
  return Base64.fromString(JSON.stringify(value))
392
393
  }
393
394
 
395
+ describe('Fetch.from: method responses', () => {
396
+ test('handles successful paid responses without touching free responses', async () => {
397
+ const method = { ...noopMethod }
398
+ const handle = vi.fn(
399
+ async ({ response }: MethodResponse.HandlerParameters) =>
400
+ new Response(`handled:${await response.text()}`, response),
401
+ )
402
+ MethodResponse.register(method, handle)
403
+
404
+ let paidResponse: Response | undefined
405
+ const mockFetch: typeof globalThis.fetch = async (input, init) => {
406
+ if (input.toString().endsWith('/free')) return new Response('free')
407
+ if (!new Headers(init?.headers).has('Authorization')) return make402()
408
+ paidResponse = new Response('paid')
409
+ vi.spyOn(paidResponse, 'clone')
410
+ return paidResponse
411
+ }
412
+ const fetch = Fetch.from({ fetch: mockFetch, methods: [method] })
413
+
414
+ expect(await (await fetch('https://example.com/free')).text()).toBe('free')
415
+ expect(handle).not.toHaveBeenCalled()
416
+ expect(await (await fetch('https://example.com/paid')).text()).toBe('handled:paid')
417
+ expect(handle).toHaveBeenCalledOnce()
418
+ expect(handle).toHaveBeenCalledWith(expect.objectContaining({ credential: 'credential' }))
419
+ expect(paidResponse?.clone).not.toHaveBeenCalled()
420
+ })
421
+ })
422
+
394
423
  describe('Fetch.from: init passthrough (non-402)', () => {
395
424
  test('preserves init object identity while adding Accept-Payment', async () => {
396
425
  const receivedInits: (RequestInit | undefined)[] = []
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
6
6
  import type * as Method from '../../Method.js'
7
7
  import type * as z from '../../zod.js'
8
8
  import * as Transport from '../Transport.js'
9
+ import * as MethodResponse from './MethodResponse.js'
9
10
 
10
11
  // We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
11
12
  // even across multiple module instances/bundles. This lets restore() avoid clobbering
@@ -18,6 +19,7 @@ type WrappedFetch = typeof globalThis.fetch & {
18
19
  }
19
20
 
20
21
  let originalFetch: typeof globalThis.fetch | undefined
22
+ const eventObserverChecks = new WeakMap<object, (name: keyof ClientEventMap) => boolean>()
21
23
 
22
24
  export type ClientEventMap<
23
25
  methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
@@ -174,7 +176,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
174
176
  // which can duplicate retries and make restore semantics fragile.
175
177
  const baseFetch = unwrapFetch(fetch)
176
178
 
177
- const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
179
+ const request = async (
180
+ input: RequestInfo | URL,
181
+ init: from.RequestInit<methods> | undefined,
182
+ canRefetch: boolean,
183
+ ): Promise<Response> => {
178
184
  const callerHeaders = getCallerHeaders(input, init?.headers)
179
185
  const hasExplicitAcceptPayment = callerHeaders.has(Constants.Headers.acceptPayment)
180
186
  const paymentPreferences = resolvePaymentPreferences(callerHeaders, resolvedAcceptPayment)
@@ -272,8 +278,13 @@ export function from<const methods extends readonly Method.AnyClient[]>(
272
278
  }),
273
279
  )
274
280
 
281
+ const paymentInput = resolvePaymentRetryInput(
282
+ response,
283
+ initialRequest.input,
284
+ initialRequest.input,
285
+ )
275
286
  response = await baseFetch(
276
- resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input),
287
+ paymentInput,
277
288
  transport.setCredential(
278
289
  {
279
290
  ...fetchInit,
@@ -283,7 +294,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
283
294
  { challenge: selectedChallenge },
284
295
  ),
285
296
  )
286
- if (response.ok)
297
+ // Cloning an unobserved stream would tee and buffer it indefinitely.
298
+ if (response.ok && hasEventObservers(events, 'payment.response'))
287
299
  await events.emit(
288
300
  'payment.response',
289
301
  createPaymentResponsePayload({
@@ -295,8 +307,23 @@ export function from<const methods extends readonly Method.AnyClient[]>(
295
307
  response,
296
308
  }),
297
309
  )
298
- if (!(await transport.isPaymentRequired(response, transportRequest as never)))
299
- return response
310
+ if (!(await transport.isPaymentRequired(response, transportRequest as never))) {
311
+ if (!response.ok) return response
312
+ return MethodResponse.handle(selected.method, {
313
+ challenge: selectedChallenge,
314
+ credential,
315
+ fetch: baseFetch,
316
+ headers: initialRequest.headers,
317
+ input: paymentInput,
318
+ ...(canRefetch
319
+ ? {
320
+ refetch: () => request(cloneRequestInput(initialRequest.input), init, false),
321
+ }
322
+ : {}),
323
+ response,
324
+ signal: resolveRequestSignal(input, init),
325
+ })
326
+ }
300
327
  }
301
328
  return response
302
329
  } catch (error) {
@@ -315,6 +342,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
315
342
  throw error
316
343
  }
317
344
  }
345
+ const wrappedFetch = (input: RequestInfo | URL, init?: from.RequestInit<methods>) =>
346
+ request(input, init, true)
318
347
 
319
348
  // Record the wrapped target so future polyfill() / restore() calls can detect origin
320
349
  // and safely unwrap only mppx-installed wrappers.
@@ -491,7 +520,7 @@ export function createEventDispatcher<
491
520
  }
492
521
  }
493
522
 
494
- return {
523
+ const dispatcher: ClientEventDispatcher<methods, response> = {
495
524
  async emit(name, payload) {
496
525
  switch (name) {
497
526
  case 'challenge.received': {
@@ -525,6 +554,15 @@ export function createEventDispatcher<
525
554
  },
526
555
  on,
527
556
  }
557
+ eventObserverChecks.set(
558
+ dispatcher,
559
+ (name) => handlers[name as keyof typeof handlers].size > 0 || handlers['*'].size > 0,
560
+ )
561
+ return dispatcher
562
+ }
563
+
564
+ function hasEventObservers(dispatcher: ClientEventDispatcher, name: keyof ClientEventMap): boolean {
565
+ return eventObserverChecks.get(dispatcher)?.(name) ?? true
528
566
  }
529
567
 
530
568
  async function emitChallengeReceived<methods extends readonly Method.AnyClient[], response>(
@@ -812,6 +850,13 @@ function cloneRequestInput(input: RequestInfo | URL): RequestInfo | URL {
812
850
  }
813
851
  }
814
852
 
853
+ function resolveRequestSignal(
854
+ input: RequestInfo | URL,
855
+ init: RequestInit | undefined,
856
+ ): AbortSignal | undefined {
857
+ return init?.signal ?? (input instanceof Request ? input.signal : undefined)
858
+ }
859
+
815
860
  /** @internal */
816
861
  function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
817
862
  return Boolean((fetch as WrappedFetch)[MPPX_FETCH_WRAPPER])
@@ -0,0 +1,39 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type { MaybePromise } from '../../internal/types.js'
3
+ import type * as Method from '../../Method.js'
4
+
5
+ const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+
7
+ /** Inputs available when a client method handles a successful paid response. */
8
+ export type HandlerParameters = {
9
+ challenge: Challenge.Challenge
10
+ credential: string
11
+ fetch: typeof globalThis.fetch
12
+ headers: Headers
13
+ input: RequestInfo | URL
14
+ refetch?: (() => Promise<Response>) | undefined
15
+ response: Response
16
+ signal?: AbortSignal | undefined
17
+ }
18
+
19
+ /** Internal client-method response adapter. */
20
+ export type Handler = (parameters: HandlerParameters) => MaybePromise<Response>
21
+
22
+ /** Registers an internal response adapter without changing the public method shape. */
23
+ export function register<const method extends Method.AnyClient>(
24
+ method: method,
25
+ handler: Handler,
26
+ ): method {
27
+ handlers.set(method, handler)
28
+ return method
29
+ }
30
+
31
+ /** Removes response handling from a method whose caller owns the response lifecycle. */
32
+ export function unregister(method: Method.AnyClient): void {
33
+ handlers.delete(method)
34
+ }
35
+
36
+ /** Lets the selected client method handle a successful paid response. */
37
+ export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response> {
38
+ return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response)
39
+ }
@@ -340,6 +340,27 @@ describe.runIf(isLocalnet)('tryRecoverChannel', () => {
340
340
  expect(result!.opened).toBe(true)
341
341
  expect(result!.escrowContract).toBe(escrow)
342
342
  expect(result!.chainId).toBe(chain.id)
343
+ // Channel opened with a zero authorizedSigner: the escrow verifies
344
+ // vouchers against the payer, so the recovered authority is the payer.
345
+ expect(result!.authorizedSigner.toLowerCase()).toBe(payer.address.toLowerCase())
346
+ })
347
+
348
+ test('recovers the on-chain authorizedSigner when set', async () => {
349
+ const authorizedSigner = accounts[4].address
350
+ const { channelId: signerChannelId } = await openChannel({
351
+ escrow,
352
+ payer,
353
+ payee,
354
+ token: currency,
355
+ deposit: 10_000_000n,
356
+ salt: Hex.random(32) as `0x${string}`,
357
+ authorizedSigner,
358
+ })
359
+
360
+ const result = await tryRecoverChannel(client, escrow, signerChannelId, chain.id)
361
+
362
+ expect(result).not.toBeUndefined()
363
+ expect(result!.authorizedSigner.toLowerCase()).toBe(authorizedSigner.toLowerCase())
343
364
  })
344
365
 
345
366
  test('returns undefined for non-existent channel', async () => {
@@ -27,6 +27,8 @@ import { signVoucher } from '../session/Voucher.js'
27
27
 
28
28
  /** Cached channel metadata used by the legacy auto-driving session client. */
29
29
  export type ChannelEntry = {
30
+ /** Voucher authority the channel was opened with. */
31
+ authorizedSigner: Address
30
32
  /** Legacy contract-backed channel ID. */
31
33
  channelId: Hex.Hex
32
34
  /** Salt used to derive the channel ID. */
@@ -209,6 +211,7 @@ export async function createOpenPayload(
209
211
 
210
212
  return {
211
213
  entry: {
214
+ authorizedSigner,
212
215
  channelId,
213
216
  salt,
214
217
  cumulativeAmount: initialAmount,
@@ -249,6 +252,10 @@ export async function tryRecoverChannel(
249
252
 
250
253
  if (onChain.deposit > 0n && !onChain.finalized) {
251
254
  return {
255
+ // A zero on-chain authorizedSigner means the escrow verifies vouchers
256
+ // against the payer, so record the payer as the voucher authority.
257
+ authorizedSigner:
258
+ BigInt(onChain.authorizedSigner) === 0n ? onChain.payer : onChain.authorizedSigner,
252
259
  channelId,
253
260
  salt: '0x' as Hex.Hex,
254
261
  cumulativeAmount: onChain.settled,
@@ -1,7 +1,16 @@
1
1
  import { SignatureEnvelope } from 'ox/tempo'
2
- import { type Address, createClient, decodeFunctionData, erc20Abi, type Hex, http } from 'viem'
2
+ import {
3
+ type Address,
4
+ createClient,
5
+ custom,
6
+ decodeFunctionData,
7
+ erc20Abi,
8
+ type Hex,
9
+ http,
10
+ } from 'viem'
3
11
  import { privateKeyToAccount } from 'viem/accounts'
4
12
  import { Account as TempoAccount, Addresses, Transaction, WebCryptoP256 } from 'viem/tempo'
13
+ import { tempoModerato } from 'viem/tempo/chains'
5
14
  import { beforeAll, describe, expect, test } from 'vp/test'
6
15
  import { tempoNetwork } from '~test/config.js'
7
16
  import { deployEscrow, openChannel } from '~test/tempo/legacy/session.js'
@@ -374,6 +383,98 @@ describe('session (pure)', () => {
374
383
  expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
375
384
  })
376
385
  })
386
+
387
+ describe('auto-manage channel reuse', () => {
388
+ const payerAccount = privateKeyToAccount(
389
+ '0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d',
390
+ )
391
+ const signerA = TempoAccount.fromSecp256k1(
392
+ '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
393
+ { access: payerAccount },
394
+ )
395
+ const signerB = TempoAccount.fromSecp256k1(
396
+ '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
397
+ { access: payerAccount },
398
+ )
399
+
400
+ function makeAutoClient(account: typeof signerA) {
401
+ return createClient({
402
+ account,
403
+ chain: tempoModerato,
404
+ transport: custom({
405
+ async request({ method }: { method: string }) {
406
+ if (method === 'eth_chainId') return `0x${tempoModerato.id.toString(16)}`
407
+ if (method === 'eth_fillTransaction')
408
+ return {
409
+ raw: '0x',
410
+ tx: {
411
+ chainId: `0x${tempoModerato.id.toString(16)}`,
412
+ from: payerAccount.address,
413
+ gas: '0x5208',
414
+ input: '0x',
415
+ maxFeePerGas: '0x1',
416
+ maxPriorityFeePerGas: '0x1',
417
+ nonce: '0x0',
418
+ to: escrowAddress,
419
+ type: '0x2',
420
+ value: '0x0',
421
+ },
422
+ }
423
+ throw new Error(`unexpected rpc request: ${method}`)
424
+ },
425
+ }),
426
+ })
427
+ }
428
+
429
+ test('reuses the cached channel while the voucher signer is unchanged', async () => {
430
+ const method = session({
431
+ getClient: () => makeAutoClient(signerA),
432
+ maxDeposit: '10',
433
+ })
434
+
435
+ const first = deserializePayload(
436
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
437
+ )
438
+ const second = deserializePayload(
439
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
440
+ )
441
+
442
+ expect(first.payload.action).toBe('open')
443
+ expect(second.payload.action).toBe('voucher')
444
+ expect(second.payload.channelId).toBe(first.payload.channelId)
445
+ })
446
+
447
+ test('opens a new channel instead of reusing when the authorizedSigner changes', async () => {
448
+ let account = signerA
449
+ const method = session({
450
+ getClient: () => makeAutoClient(account),
451
+ maxDeposit: '10',
452
+ })
453
+
454
+ const first = deserializePayload(
455
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
456
+ )
457
+
458
+ account = signerB
459
+
460
+ const second = deserializePayload(
461
+ await method.createCredential({ challenge: makeChallenge(), context: {} }),
462
+ )
463
+
464
+ expect(first.payload.action).toBe('open')
465
+ if (first.payload.action !== 'open') throw new Error('expected open payload')
466
+ expect(first.payload.authorizedSigner?.toLowerCase()).toBe(
467
+ signerA.accessKeyAddress.toLowerCase(),
468
+ )
469
+
470
+ expect(second.payload.action).toBe('open')
471
+ if (second.payload.action !== 'open') throw new Error('expected open payload')
472
+ expect(second.payload.authorizedSigner?.toLowerCase()).toBe(
473
+ signerB.accessKeyAddress.toLowerCase(),
474
+ )
475
+ expect(second.payload.channelId).not.toBe(first.payload.channelId)
476
+ })
477
+ })
377
478
  })
378
479
 
379
480
  describe.runIf(isLocalnet)('session (on-chain)', () => {
@@ -175,6 +175,16 @@ export function session(parameters: session.Parameters = {}) {
175
175
  }
176
176
  }
177
177
 
178
+ // A channel only admits vouchers from the authorizedSigner it was opened
179
+ // with, so a cached or recovered entry stops being reusable once the
180
+ // resolved signer changes. Fall through to open a fresh channel instead
181
+ // of emitting a voucher the escrow would reject.
182
+ if (
183
+ entry?.opened &&
184
+ entry.authorizedSigner.toLowerCase() !== getAccountSignerAddress(voucherSigner).toLowerCase()
185
+ )
186
+ entry = undefined
187
+
178
188
  let payload: LegacySessionCredentialPayload
179
189
 
180
190
  if (entry?.opened) {