mppx 0.5.7 → 0.5.9

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 (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -0,0 +1,410 @@
1
+ import type { Address } from 'viem'
2
+ import { describe, expect, test } from 'vp/test'
3
+
4
+ import * as Challenge from '../../Challenge.js'
5
+ import * as Credential from '../../Credential.js'
6
+ import * as Store from '../../Store.js'
7
+ import * as ChannelStore from './ChannelStore.js'
8
+ import { createSessionReceipt, serializeSessionReceipt } from './Receipt.js'
9
+ import * as Ws from './Ws.js'
10
+
11
+ const challenge = Challenge.from({
12
+ id: 'challenge-1',
13
+ realm: 'example.test',
14
+ method: 'tempo',
15
+ intent: 'session',
16
+ request: {
17
+ amount: '1',
18
+ currency: '0x20c0000000000000000000000000000000000001',
19
+ recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
20
+ decimals: 6,
21
+ },
22
+ })
23
+
24
+ const channelId = `0x${'11'.repeat(32)}` as const
25
+
26
+ class MockSocket implements Ws.Socket {
27
+ closed = false
28
+ sent: string[] = []
29
+ private listeners = {
30
+ close: new Set<() => void>(),
31
+ error: new Set<() => void>(),
32
+ message: new Set<(data: unknown) => void>(),
33
+ }
34
+
35
+ close() {
36
+ if (this.closed) return
37
+ this.closed = true
38
+ for (const listener of Array.from(this.listeners.close)) listener()
39
+ }
40
+
41
+ off(type: 'close' | 'error' | 'message', listener: (...args: any[]) => void) {
42
+ ;(this.listeners[type] as Set<(...args: any[]) => void>).delete(listener)
43
+ }
44
+
45
+ on(type: 'close' | 'error' | 'message', listener: (...args: any[]) => void) {
46
+ ;(this.listeners[type] as Set<(...args: any[]) => void>).add(listener)
47
+ }
48
+
49
+ receive(data: string) {
50
+ for (const listener of Array.from(this.listeners.message)) listener(data)
51
+ }
52
+
53
+ send(data: string) {
54
+ this.sent.push(data)
55
+ }
56
+ }
57
+
58
+ function makeCredential(
59
+ payload:
60
+ | {
61
+ action: 'open'
62
+ type: 'transaction'
63
+ channelId: `0x${string}`
64
+ transaction: `0x${string}`
65
+ cumulativeAmount: string
66
+ signature: `0x${string}`
67
+ }
68
+ | {
69
+ action: 'topUp'
70
+ type: 'transaction'
71
+ channelId: `0x${string}`
72
+ transaction: `0x${string}`
73
+ additionalDeposit: string
74
+ },
75
+ ) {
76
+ return Credential.serialize(
77
+ Credential.from({
78
+ challenge,
79
+ payload,
80
+ }),
81
+ )
82
+ }
83
+
84
+ function sleep(ms: number) {
85
+ return new Promise((resolve) => setTimeout(resolve, ms))
86
+ }
87
+
88
+ function memoryChannelStore(): ChannelStore.ChannelStore {
89
+ const channels = new Map()
90
+ return {
91
+ async getChannel(id) {
92
+ return channels.get(id) ?? null
93
+ },
94
+ async updateChannel(id, fn) {
95
+ const result = fn(channels.get(id) ?? null)
96
+ if (result) channels.set(id, result)
97
+ else channels.delete(id)
98
+ return result
99
+ },
100
+ }
101
+ }
102
+
103
+ function seedChannel(
104
+ store: ChannelStore.ChannelStore,
105
+ balance: bigint,
106
+ ): Promise<ChannelStore.State | null> {
107
+ return store.updateChannel(channelId, () => ({
108
+ channelId,
109
+ payer: '0x0000000000000000000000000000000000000001' as Address,
110
+ payee: '0x0000000000000000000000000000000000000002' as Address,
111
+ token: '0x0000000000000000000000000000000000000003' as Address,
112
+ authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
113
+ chainId: 42431,
114
+ escrowContract: '0x0000000000000000000000000000000000000005' as Address,
115
+ deposit: balance,
116
+ settledOnChain: 0n,
117
+ highestVoucherAmount: balance,
118
+ highestVoucher: null,
119
+ spent: 0n,
120
+ units: 0,
121
+ closeRequestedAt: 0n,
122
+ finalized: false,
123
+ createdAt: new Date().toISOString(),
124
+ }))
125
+ }
126
+
127
+ describe('parseMessage', () => {
128
+ test('rejects non-object data in payment-receipt', () => {
129
+ expect(Ws.parseMessage('{"mpp":"payment-receipt","data":true}')).toBeNull()
130
+ expect(Ws.parseMessage('{"mpp":"payment-receipt","data":42}')).toBeNull()
131
+ expect(Ws.parseMessage('{"mpp":"payment-receipt","data":"hello"}')).toBeNull()
132
+ })
133
+
134
+ test('rejects non-object data in payment-need-voucher', () => {
135
+ expect(Ws.parseMessage('{"mpp":"payment-need-voucher","data":true}')).toBeNull()
136
+ expect(Ws.parseMessage('{"mpp":"payment-need-voucher","data":42}')).toBeNull()
137
+ })
138
+
139
+ test('rejects non-object data in payment-close-ready', () => {
140
+ expect(Ws.parseMessage('{"mpp":"payment-close-ready","data":"nope"}')).toBeNull()
141
+ expect(Ws.parseMessage('{"mpp":"payment-close-ready","data":[]}')).toBeNull()
142
+ })
143
+
144
+ test('rejects objects missing required fields', () => {
145
+ expect(Ws.parseMessage('{"mpp":"payment-receipt","data":{"foo":"bar"}}')).toBeNull()
146
+ expect(Ws.parseMessage('{"mpp":"payment-need-voucher","data":{"channelId":"0x01"}}')).toBeNull()
147
+ expect(Ws.parseMessage('{"mpp":"payment-receipt","data":{"challengeId":"x"}}')).toBeNull()
148
+ })
149
+
150
+ test('accepts well-formed payment-receipt', () => {
151
+ const receipt = {
152
+ mpp: 'payment-receipt',
153
+ data: {
154
+ method: 'tempo',
155
+ intent: 'session',
156
+ status: 'success',
157
+ timestamp: new Date().toISOString(),
158
+ reference: '0x01',
159
+ challengeId: 'c1',
160
+ channelId: '0x02',
161
+ acceptedCumulative: '100',
162
+ spent: '50',
163
+ units: 1,
164
+ },
165
+ }
166
+ const parsed = Ws.parseMessage(JSON.stringify(receipt))
167
+ expect(parsed?.mpp).toBe('payment-receipt')
168
+ })
169
+
170
+ test('accepts well-formed payment-need-voucher', () => {
171
+ const event = {
172
+ mpp: 'payment-need-voucher',
173
+ data: {
174
+ channelId: '0x01',
175
+ requiredCumulative: '200',
176
+ acceptedCumulative: '100',
177
+ deposit: '1000',
178
+ },
179
+ }
180
+ const parsed = Ws.parseMessage(JSON.stringify(event))
181
+ expect(parsed?.mpp).toBe('payment-need-voucher')
182
+ })
183
+ })
184
+
185
+ describe('isows', () => {
186
+ test('wraps application payloads in an explicit message envelope', async () => {
187
+ const socket = new MockSocket()
188
+
189
+ await Ws.serve({
190
+ socket,
191
+ store: Store.memory(),
192
+ url: 'ws://example.test/stream',
193
+ route: async () => ({
194
+ status: 200,
195
+ withReceipt(response = new Response(null, { status: 204 })) {
196
+ response.headers.set(
197
+ 'Payment-Receipt',
198
+ serializeSessionReceipt(
199
+ createSessionReceipt({
200
+ challengeId: challenge.id,
201
+ channelId,
202
+ acceptedCumulative: 1n,
203
+ spent: 0n,
204
+ units: 0,
205
+ }),
206
+ ),
207
+ )
208
+ return response
209
+ },
210
+ }),
211
+ generate: async function* () {
212
+ yield '{"mpp":"payment-need-voucher","data":{"requiredCumulative":"9"}}'
213
+ },
214
+ })
215
+
216
+ socket.receive(
217
+ Ws.formatAuthorizationMessage(
218
+ makeCredential({
219
+ action: 'open',
220
+ channelId,
221
+ cumulativeAmount: '1',
222
+ signature: `0x${'77'.repeat(65)}`,
223
+ transaction: '0x01',
224
+ type: 'transaction',
225
+ }),
226
+ ),
227
+ )
228
+
229
+ await sleep(10)
230
+
231
+ const applicationFrame = socket.sent
232
+ .map((message) => Ws.parseMessage(message))
233
+ .find((message) => message?.mpp === 'message')
234
+
235
+ expect(applicationFrame).toEqual({
236
+ mpp: 'message',
237
+ data: '{"mpp":"payment-need-voucher","data":{"requiredCumulative":"9"}}',
238
+ })
239
+ })
240
+
241
+ test('caps queued payment work and closes noisy sockets', async () => {
242
+ const socket = new MockSocket()
243
+ let routeCalls = 0
244
+
245
+ await Ws.serve({
246
+ socket,
247
+ store: Store.memory(),
248
+ url: 'ws://example.test/stream',
249
+ route: async () => {
250
+ routeCalls++
251
+ await sleep(20)
252
+ return {
253
+ status: 200,
254
+ withReceipt(response = new Response(null, { status: 204 })) {
255
+ response.headers.set(
256
+ 'Payment-Receipt',
257
+ serializeSessionReceipt(
258
+ createSessionReceipt({
259
+ challengeId: challenge.id,
260
+ channelId,
261
+ acceptedCumulative: 1n,
262
+ spent: 0n,
263
+ units: 0,
264
+ }),
265
+ ),
266
+ )
267
+ return response
268
+ },
269
+ }
270
+ },
271
+ generate: async function* () {},
272
+ })
273
+
274
+ const topUp = Ws.formatAuthorizationMessage(
275
+ makeCredential({
276
+ action: 'topUp',
277
+ channelId,
278
+ additionalDeposit: '1',
279
+ transaction: '0x01',
280
+ type: 'transaction',
281
+ }),
282
+ )
283
+
284
+ for (let i = 0; i < 40; i++) socket.receive(topUp)
285
+
286
+ await sleep(100)
287
+
288
+ expect(socket.closed).toBe(true)
289
+ expect(routeCalls).toBeLessThan(40)
290
+ expect(
291
+ socket.sent.some((message) => message.includes('too many queued payment messages')),
292
+ ).toBe(true)
293
+ })
294
+
295
+ test('rejects credentials whose amount does not match the expected amount', async () => {
296
+ const socket = new MockSocket()
297
+
298
+ await Ws.serve({
299
+ socket,
300
+ store: Store.memory(),
301
+ url: 'ws://example.test/stream',
302
+ amount: '999',
303
+ route: async () => ({
304
+ status: 200,
305
+ withReceipt(response = new Response(null, { status: 204 })) {
306
+ response.headers.set(
307
+ 'Payment-Receipt',
308
+ serializeSessionReceipt(
309
+ createSessionReceipt({
310
+ challengeId: challenge.id,
311
+ channelId,
312
+ acceptedCumulative: 1n,
313
+ spent: 0n,
314
+ units: 0,
315
+ }),
316
+ ),
317
+ )
318
+ return response
319
+ },
320
+ }),
321
+ generate: async function* () {
322
+ yield 'should-not-reach'
323
+ },
324
+ })
325
+
326
+ socket.receive(
327
+ Ws.formatAuthorizationMessage(
328
+ makeCredential({
329
+ action: 'open',
330
+ channelId,
331
+ cumulativeAmount: '1',
332
+ signature: `0x${'77'.repeat(65)}`,
333
+ transaction: '0x01',
334
+ type: 'transaction',
335
+ }),
336
+ ),
337
+ )
338
+
339
+ await sleep(10)
340
+
341
+ expect(socket.closed).toBe(true)
342
+ expect(
343
+ socket.sent.some((m) => m.includes('credential amount does not match this endpoint')),
344
+ ).toBe(true)
345
+ expect(socket.sent.some((m) => m.includes('should-not-reach'))).toBe(false)
346
+ })
347
+
348
+ test('drops reserved charges when the stream ends without delivering a chunk', async () => {
349
+ const socket = new MockSocket()
350
+ const store = memoryChannelStore()
351
+ await seedChannel(store, 1n)
352
+
353
+ await Ws.serve({
354
+ socket,
355
+ store,
356
+ url: 'ws://example.test/stream',
357
+ route: async () => ({
358
+ status: 200,
359
+ withReceipt(response = new Response(null, { status: 204 })) {
360
+ response.headers.set(
361
+ 'Payment-Receipt',
362
+ serializeSessionReceipt(
363
+ createSessionReceipt({
364
+ challengeId: challenge.id,
365
+ channelId,
366
+ acceptedCumulative: 1n,
367
+ spent: 0n,
368
+ units: 0,
369
+ }),
370
+ ),
371
+ )
372
+ return response
373
+ },
374
+ }),
375
+ generate: async function* (stream) {
376
+ await stream.charge()
377
+ yield* []
378
+ },
379
+ })
380
+
381
+ socket.receive(
382
+ Ws.formatAuthorizationMessage(
383
+ makeCredential({
384
+ action: 'open',
385
+ channelId,
386
+ cumulativeAmount: '1',
387
+ signature: `0x${'77'.repeat(65)}`,
388
+ transaction: '0x01',
389
+ type: 'transaction',
390
+ }),
391
+ ),
392
+ )
393
+
394
+ await sleep(10)
395
+
396
+ const closeReady = socket.sent
397
+ .map((message) => Ws.parseMessage(message))
398
+ .find((message) => message?.mpp === 'payment-close-ready')
399
+
400
+ expect(closeReady?.mpp).toBe('payment-close-ready')
401
+ if (closeReady?.mpp === 'payment-close-ready') {
402
+ expect(closeReady.data.spent).toBe('0')
403
+ expect(closeReady.data.units).toBe(0)
404
+ }
405
+
406
+ const channel = await store.getChannel(channelId)
407
+ expect(channel?.spent).toBe(0n)
408
+ expect(channel?.units).toBe(0)
409
+ })
410
+ })