mppx 0.8.1 → 0.8.2

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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/Errors.d.ts +13 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +12 -0
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +35 -4
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/plugins/tempo.d.ts +15 -0
  11. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  12. package/dist/cli/plugins/tempo.js +124 -14
  13. package/dist/cli/plugins/tempo.js.map +1 -1
  14. package/dist/client/Mppx.d.ts +2 -0
  15. package/dist/client/Mppx.d.ts.map +1 -1
  16. package/dist/client/Mppx.js +2 -1
  17. package/dist/client/Mppx.js.map +1 -1
  18. package/dist/client/Transport.d.ts.map +1 -1
  19. package/dist/client/Transport.js +11 -12
  20. package/dist/client/Transport.js.map +1 -1
  21. package/dist/client/internal/Fetch.d.ts +2 -0
  22. package/dist/client/internal/Fetch.d.ts.map +1 -1
  23. package/dist/client/internal/Fetch.js +55 -50
  24. package/dist/client/internal/Fetch.js.map +1 -1
  25. package/dist/client/internal/protocols/Mcp.d.ts +6 -0
  26. package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
  27. package/dist/client/internal/protocols/Mcp.js +28 -10
  28. package/dist/client/internal/protocols/Mcp.js.map +1 -1
  29. package/dist/client/internal/protocols/X402.d.ts.map +1 -1
  30. package/dist/client/internal/protocols/X402.js +18 -12
  31. package/dist/client/internal/protocols/X402.js.map +1 -1
  32. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  33. package/dist/stripe/server/internal/html.gen.js +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +57 -1
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  42. package/dist/tempo/session/client/SessionManager.js +47 -5
  43. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  44. package/dist/x402/Header.d.ts +10 -1
  45. package/dist/x402/Header.d.ts.map +1 -1
  46. package/dist/x402/Header.js +27 -1
  47. package/dist/x402/Header.js.map +1 -1
  48. package/package.json +3 -3
  49. package/src/Errors.test.ts +28 -0
  50. package/src/Errors.ts +19 -0
  51. package/src/cli/cli.test.ts +194 -0
  52. package/src/cli/cli.ts +43 -7
  53. package/src/cli/plugins/tempo.ts +160 -17
  54. package/src/client/Mppx.test-d.ts +9 -0
  55. package/src/client/Mppx.test.ts +41 -0
  56. package/src/client/Mppx.ts +4 -0
  57. package/src/client/Transport.test.ts +124 -0
  58. package/src/client/Transport.ts +11 -9
  59. package/src/client/internal/Fetch.test-d.ts +9 -0
  60. package/src/client/internal/Fetch.test.ts +186 -4
  61. package/src/client/internal/Fetch.ts +85 -76
  62. package/src/client/internal/protocols/Mcp.ts +39 -15
  63. package/src/client/internal/protocols/X402.ts +9 -5
  64. package/src/server/Mppx.test.ts +3 -0
  65. package/src/stripe/server/internal/html.gen.ts +1 -1
  66. package/src/tempo/internal/fee-payer.test.ts +106 -0
  67. package/src/tempo/internal/fee-payer.ts +71 -1
  68. package/src/tempo/server/internal/html/package.json +2 -2
  69. package/src/tempo/server/internal/html.gen.ts +1 -1
  70. package/src/tempo/session/client/SessionManager.test.ts +57 -4
  71. package/src/tempo/session/client/SessionManager.ts +50 -6
  72. package/src/x402/Header.ts +45 -0
@@ -466,6 +466,53 @@ describe('Session', () => {
466
466
  expect(s.channelId).not.toBe(storedChannelId)
467
467
  })
468
468
 
469
+ test('rolls back an optimistic open when a replacement challenge does not reference it', async () => {
470
+ const { store, delete: remove } = makeChannelStore()
471
+ const postedPayloads: SessionCredentialPayload[] = []
472
+ const challenge = (feePayer: boolean) =>
473
+ makeChallenge({
474
+ methodDetails: {
475
+ escrowContract: tip20ChannelEscrow,
476
+ chainId: 4217,
477
+ sessionProtocol: Constants.SessionProtocols.v2,
478
+ feePayer,
479
+ },
480
+ })
481
+ let openCount = 0
482
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
483
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
484
+ const payload = authorization
485
+ ? Credential.deserialize<SessionCredentialPayload>(authorization).payload
486
+ : undefined
487
+ if (payload) postedPayloads.push(payload)
488
+
489
+ if (!payload) return Promise.resolve(make402Response(challenge(true)))
490
+ if (payload.action === 'open') {
491
+ openCount++
492
+ if (openCount === 1) return Promise.resolve(make402Response(challenge(false)))
493
+ return Promise.resolve(makeOkResponse())
494
+ }
495
+ return Promise.resolve(new Response('unexpected voucher', { status: 500 }))
496
+ })
497
+ const s = sessionManager({
498
+ account,
499
+ client,
500
+ fetch: mockFetch as typeof globalThis.fetch,
501
+ maxDeposit: '10',
502
+ channelStore: store,
503
+ })
504
+
505
+ const response = await s.fetch('https://api.example.com/data')
506
+
507
+ expect(response.status).toBe(200)
508
+ // A replacement challenge with no snapshot means the server did not
509
+ // acknowledge the optimistic open, so the next credential must open again.
510
+ expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open'])
511
+ expect(postedPayloads[0]?.channelId).not.toBe(postedPayloads[1]?.channelId)
512
+ expect(remove).toHaveBeenCalledOnce()
513
+ expect(s.channelId).toBe(postedPayloads[1]?.channelId)
514
+ })
515
+
469
516
  test('does not bootstrap when disabled', async () => {
470
517
  const mockFetch = vi.fn().mockResolvedValue(makeOkResponse())
471
518
  const s = sessionManager({
@@ -686,6 +733,7 @@ describe('Session', () => {
686
733
  methodDetails: {
687
734
  escrowContract: tip20ChannelEscrow,
688
735
  chainId: 4217,
736
+ sessionProtocol: Constants.SessionProtocols.v2,
689
737
  sessionSnapshot: {
690
738
  acceptedCumulative: '2000000',
691
739
  chainId: 4217,
@@ -887,8 +935,7 @@ describe('Session', () => {
887
935
  }
888
936
  if (callCount === 1)
889
937
  return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '3000000' })))
890
- if (callCount === 3) return Promise.resolve(make402Response())
891
- if (callCount === 4) return Promise.resolve(make402Response())
938
+ if (callCount >= 3 && callCount <= 6) return Promise.resolve(make402Response())
892
939
  return Promise.resolve(makeOkResponse())
893
940
  })
894
941
 
@@ -907,8 +954,14 @@ describe('Session', () => {
907
954
  expect(s.cumulative).toBe(1000000n)
908
955
 
909
956
  await s.topUp('1')
910
- expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'voucher', 'topUp'])
911
- const topUpPayload = postedPayloads[2]
957
+ expect(postedPayloads.map((payload) => payload.action)).toEqual([
958
+ 'open',
959
+ 'voucher',
960
+ 'voucher',
961
+ 'voucher',
962
+ 'topUp',
963
+ ])
964
+ const topUpPayload = postedPayloads[4]
912
965
  if (topUpPayload?.action !== 'topUp') throw new Error('expected top-up payload')
913
966
  expect(topUpPayload.additionalDeposit).toBe('1000000')
914
967
  })
@@ -201,9 +201,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
201
201
 
202
202
  // Tracks one fetch's channel reuse so stale stored entries can be evicted once.
203
203
  type ChannelUse = {
204
+ challengesReceived: number
205
+ created: Map<string, ChannelEntry>
204
206
  seenExisting: Set<string>
205
- createdKeys: Set<string>
207
+ previous: RuntimeSnapshot
206
208
  resumed: ChannelEntry | undefined
209
+ trackCreates: boolean
207
210
  }
208
211
  let channelUse: ChannelUse | undefined
209
212
 
@@ -219,14 +222,15 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
219
222
  const entry = await getReusable(key)
220
223
  if (entry && channelUse) {
221
224
  channelUse.seenExisting.add(key)
222
- if (!channelUse.createdKeys.has(key)) channelUse.resumed ??= entry
225
+ if (!channelUse.created.has(key)) channelUse.resumed ??= entry
223
226
  }
224
227
  return entry
225
228
  },
226
229
  async set(entry) {
227
230
  const key = entryKey(entry)
228
231
  if (entry.opened) ignoredChannelIds.delete(entry.channelId)
229
- if (channelUse && !channelUse.seenExisting.has(key)) channelUse.createdKeys.add(key)
232
+ if (channelUse?.trackCreates && !channelUse.seenExisting.has(key))
233
+ channelUse.created.set(key, entry)
230
234
  await backing.set(entry)
231
235
  },
232
236
  delete: (key) => backing.delete(key),
@@ -238,6 +242,31 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
238
242
  await Promise.resolve(backing.delete(entryKey(entry))).catch(() => undefined)
239
243
  }
240
244
 
245
+ async function rollbackCreatedChannelsForRetry() {
246
+ const use = channelUse
247
+ if (!use?.created.size) return
248
+ for (const [key, entry] of use.created) {
249
+ ignoredChannelIds.add(entry.channelId)
250
+ await Promise.resolve(backing.delete(key)).catch(() => undefined)
251
+ }
252
+ use.created.clear()
253
+ restoreRuntime(use.previous)
254
+ }
255
+
256
+ function referencesCreatedChannel(challenge: Challenge.Challenge): boolean {
257
+ const snapshot = Constants.getMethodDetail<{ channelId?: unknown }>(
258
+ challenge.request.methodDetails,
259
+ Constants.MethodDetailKeys.sessionSnapshot,
260
+ )
261
+ if (typeof snapshot?.channelId !== 'string') return false
262
+ const use = channelUse
263
+ if (!use?.created.size) return false
264
+ for (const entry of use.created.values()) {
265
+ if (entry.channelId.toLowerCase() === snapshot.channelId.toLowerCase()) return true
266
+ }
267
+ return false
268
+ }
269
+
241
270
  function dispatch(event: Parameters<typeof dispatchSessionEvent>[1]) {
242
271
  return dispatchSessionEvent(runtime, event)
243
272
  }
@@ -273,6 +302,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
273
302
  methods: [method],
274
303
  onChallenge: async (challenge, _helpers) => {
275
304
  if (!isTempoSessionChallenge(challenge)) return undefined
305
+ const use = channelUse
306
+ const isRepeatedRetryChallenge =
307
+ use && use.challengesReceived > 0 && challenge.id === runtime.lastChallenge?.id
308
+ if (!referencesCreatedChannel(challenge)) {
309
+ if (use?.created.size) await rollbackCreatedChannelsForRetry()
310
+ else if (isRepeatedRetryChallenge) restoreRuntime(use.previous)
311
+ }
312
+ if (use) use.challengesReceived++
276
313
  runtime.lastChallenge = challenge
277
314
  dispatch({ type: 'challengeReceived', challengeId: challenge.id })
278
315
  if (runtime.channel?.opened && runtime.lastUrl) {
@@ -498,9 +535,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
498
535
  throw new Error(
499
536
  'SessionManager: a request is already in flight; concurrent requests on one manager are not supported',
500
537
  )
501
- const use: ChannelUse = { seenExisting: new Set(), createdKeys: new Set(), resumed: undefined }
502
- channelUse = use
503
-
504
538
  runtime.lastUrl = input
505
539
 
506
540
  const previous = captureRuntimeStateSnapshot({
@@ -508,12 +542,22 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
508
542
  spent: runtime.spent,
509
543
  state: runtime.state,
510
544
  })
545
+ const use: ChannelUse = {
546
+ challengesReceived: 0,
547
+ created: new Map(),
548
+ previous,
549
+ seenExisting: new Set(),
550
+ resumed: undefined,
551
+ trackCreates: false,
552
+ }
553
+ channelUse = use
511
554
 
512
555
  // Cold starts resume from `channelStore` after the 402 reveals the scope.
513
556
  const liveHint = runtime.channel?.opened ? runtime.channel.channelId : undefined
514
557
 
515
558
  try {
516
559
  await bootstrapSession(input, init)
560
+ use.trackCreates = true
517
561
 
518
562
  let effectiveInit = requestInitWithSessionHint(input, init, liveHint)
519
563
  // Stored channels may be stale, so retry once after evicting the resumed entry.
@@ -1,11 +1,18 @@
1
+ import { Base64 } from 'ox'
2
+
1
3
  import * as HeaderCodec from '../internal/HeaderCodec.js'
2
4
  import {
5
+ ExtensionsSchema,
3
6
  PaymentPayloadSchema,
4
7
  PaymentRequiredSchema,
8
+ ResourceInfoSchema,
5
9
  SettleResponseSchema,
10
+ type Extensions,
6
11
  type PaymentPayload,
7
12
  type PaymentRequired,
13
+ type ResourceInfo,
8
14
  type SettleResponse,
15
+ type Version,
9
16
  } from './Types.js'
10
17
 
11
18
  const paymentRequired = HeaderCodec.createJson(PaymentRequiredSchema)
@@ -19,6 +26,24 @@ export const encodePaymentRequired: (paymentRequired: PaymentRequired) => string
19
26
  /** Decodes an x402 `PAYMENT-REQUIRED` header value. */
20
27
  export const decodePaymentRequired: (value: string) => PaymentRequired = paymentRequired.decode
21
28
 
29
+ /** Tolerant x402 `PAYMENT-REQUIRED` envelope used before filtering supported accepts. @internal */
30
+ export type PaymentRequiredEnvelope = {
31
+ accepts: unknown[]
32
+ extensions?: Extensions | undefined
33
+ resource: ResourceInfo
34
+ x402Version: Version
35
+ }
36
+
37
+ /** Decodes only the x402 `PAYMENT-REQUIRED` envelope, leaving accepts unvalidated. @internal */
38
+ export function decodePaymentRequiredEnvelope(value: string): PaymentRequiredEnvelope {
39
+ try {
40
+ const parsed = JSON.parse(Base64.toString(value)) as unknown
41
+ return parsePaymentRequiredEnvelope(parsed)
42
+ } catch {
43
+ throw new HeaderCodec.InvalidJsonHeaderError()
44
+ }
45
+ }
46
+
22
47
  /** Encodes an x402 payment payload for the `PAYMENT-SIGNATURE` header. */
23
48
  export const encodePaymentSignature: (paymentPayload: PaymentPayload) => string =
24
49
  paymentSignature.encode
@@ -32,3 +57,23 @@ export const encodePaymentResponse: (paymentResponse: SettleResponse) => string
32
57
 
33
58
  /** Decodes an x402 `PAYMENT-RESPONSE` header value. */
34
59
  export const decodePaymentResponse: (value: string) => SettleResponse = paymentResponse.decode
60
+
61
+ const parsePaymentRequiredEnvelope = (value: unknown): PaymentRequiredEnvelope => {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value))
63
+ throw new HeaderCodec.InvalidJsonHeaderError()
64
+
65
+ const record = value as Record<string, unknown>
66
+ if (record.x402Version !== 2 || !Array.isArray(record.accepts))
67
+ throw new HeaderCodec.InvalidJsonHeaderError()
68
+
69
+ const resource = ResourceInfoSchema.parse(record.resource)
70
+ const extensions =
71
+ record.extensions === undefined ? undefined : ExtensionsSchema.parse(record.extensions)
72
+
73
+ return {
74
+ accepts: record.accepts,
75
+ ...(extensions ? { extensions } : {}),
76
+ resource,
77
+ x402Version: 2,
78
+ }
79
+ }