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
@@ -1,3 +1,5 @@
1
+ import * as node_http from 'node:http'
2
+
1
3
  import type { z } from 'mppx'
2
4
  import { Challenge, Credential } from 'mppx'
3
5
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
@@ -13,7 +15,9 @@ import {
13
15
  import { waitForTransactionReceipt } from 'viem/actions'
14
16
  import { Addresses } from 'viem/tempo'
15
17
  import { beforeAll, beforeEach, describe, expect, expectTypeOf, test } from 'vp/test'
18
+ import { WebSocketServer } from 'ws'
16
19
  import { nodeEnv } from '~test/config.js'
20
+ import * as Http from '~test/Http.js'
17
21
 
18
22
  const isLocalnet = nodeEnv === 'localnet'
19
23
  import {
@@ -32,6 +36,7 @@ import {
32
36
  InsufficientBalanceError,
33
37
  InvalidSignatureError,
34
38
  } from '../../Errors.js'
39
+ import * as NodeRequest from '../../server/Request.js'
35
40
  import * as Store from '../../Store.js'
36
41
  import { sessionManager } from '../client/SessionManager.js'
37
42
  import {
@@ -42,6 +47,7 @@ import type * as Methods from '../Methods.js'
42
47
  import * as ChannelStore from '../session/ChannelStore.js'
43
48
  import type { SessionReceipt } from '../session/Types.js'
44
49
  import { signVoucher } from '../session/Voucher.js'
50
+ import * as TempoWs from '../session/Ws.js'
45
51
  import { charge, session, settle } from './Session.js'
46
52
 
47
53
  const payer = accounts[2]
@@ -61,7 +67,7 @@ beforeAll(async () => {
61
67
  })
62
68
 
63
69
  describe.runIf(isLocalnet)('session', () => {
64
- let rawStore: Store.Store
70
+ let rawStore: Store.AtomicStore
65
71
  let store: ChannelStore.ChannelStore
66
72
 
67
73
  beforeEach(() => {
@@ -82,7 +88,7 @@ describe.runIf(isLocalnet)('session', () => {
82
88
  }
83
89
 
84
90
  function createServerWithStore(
85
- customStore: Store.Store,
91
+ customStore: Store.AtomicStore,
86
92
  overrides: Partial<session.Parameters> = {},
87
93
  ) {
88
94
  return session({
@@ -379,7 +385,9 @@ describe.runIf(isLocalnet)('session', () => {
379
385
  })
380
386
 
381
387
  // 2. Settle on-chain so settled becomes 5000000
382
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
388
+ const settleTxHash = await settle(store, client, channelId, {
389
+ escrowContract,
390
+ })
383
391
  await waitForTransactionReceipt(client, { hash: settleTxHash })
384
392
  expect((await store.getChannel(channelId))!.settledOnChain).toBe(5000000n)
385
393
 
@@ -436,7 +444,9 @@ describe.runIf(isLocalnet)('session', () => {
436
444
  request: makeRequest(),
437
445
  })
438
446
 
439
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
447
+ const settleTxHash = await settle(store, client, channelId, {
448
+ escrowContract,
449
+ })
440
450
  await waitForTransactionReceipt(client, { hash: settleTxHash })
441
451
  await store.updateChannel(channelId, () => null)
442
452
 
@@ -791,7 +801,10 @@ describe.runIf(isLocalnet)('session', () => {
791
801
  const leakedSig = await signTestVoucher(victimChannelId, 5000000n)
792
802
  await server.verify({
793
803
  credential: {
794
- challenge: makeChallenge({ id: 'c-victim', channelId: victimChannelId }),
804
+ challenge: makeChallenge({
805
+ id: 'c-victim',
806
+ channelId: victimChannelId,
807
+ }),
795
808
  payload: {
796
809
  action: 'voucher' as const,
797
810
  channelId: victimChannelId,
@@ -823,7 +836,10 @@ describe.runIf(isLocalnet)('session', () => {
823
836
  await expect(
824
837
  server.verify({
825
838
  credential: {
826
- challenge: makeChallenge({ id: 'c-attack', channelId: victimChannelId }),
839
+ challenge: makeChallenge({
840
+ id: 'c-attack',
841
+ channelId: victimChannelId,
842
+ }),
827
843
  payload: {
828
844
  action: 'open' as const,
829
845
  type: 'transaction' as const,
@@ -913,7 +929,10 @@ describe.runIf(isLocalnet)('session', () => {
913
929
  // Now switch to a large TTL so subsequent vouchers use the cached path.
914
930
  // The persisted closeRequestedAt should cause rejection without an
915
931
  // on-chain re-query.
916
- const server2 = createServer({ channelStateTtl: 60_000, store: rawStore })
932
+ const server2 = createServer({
933
+ channelStateTtl: 60_000,
934
+ store: rawStore,
935
+ })
917
936
  await expect(
918
937
  server2.verify({
919
938
  credential: {
@@ -945,7 +964,10 @@ describe.runIf(isLocalnet)('session', () => {
945
964
  await expect(
946
965
  server.verify({
947
966
  credential: {
948
- challenge: makeChallenge({ id: 'challenge-after-settle', channelId }),
967
+ challenge: makeChallenge({
968
+ id: 'challenge-after-settle',
969
+ channelId,
970
+ }),
949
971
  payload: {
950
972
  action: 'voucher' as const,
951
973
  channelId,
@@ -1223,7 +1245,9 @@ describe.runIf(isLocalnet)('session', () => {
1223
1245
  await openServerChannel(server, channelId, serializedTransaction)
1224
1246
 
1225
1247
  // Settle on-chain so settled becomes 1000000
1226
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
1248
+ const settleTxHash = await settle(store, client, channelId, {
1249
+ escrowContract,
1250
+ })
1227
1251
  await waitForTransactionReceipt(client, { hash: settleTxHash })
1228
1252
 
1229
1253
  // Try to close with voucher == on-chain settled — should be rejected
@@ -1694,7 +1718,9 @@ describe.runIf(isLocalnet)('session', () => {
1694
1718
  request: makeRequest(),
1695
1719
  })
1696
1720
 
1697
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
1721
+ const settleTxHash = await settle(store, client, channelId, {
1722
+ escrowContract,
1723
+ })
1698
1724
  expect(settleTxHash).toMatch(/^0x/)
1699
1725
 
1700
1726
  const ch = await store.getChannel(channelId)
@@ -1732,7 +1758,9 @@ describe.runIf(isLocalnet)('session', () => {
1732
1758
  })
1733
1759
 
1734
1760
  // Settle on-chain so onChain.settled = 5000000.
1735
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
1761
+ const settleTxHash = await settle(store, client, channelId, {
1762
+ escrowContract,
1763
+ })
1736
1764
  await waitForTransactionReceipt(client, { hash: settleTxHash })
1737
1765
  expect((await store.getChannel(channelId))!.settledOnChain).toBe(5000000n)
1738
1766
 
@@ -1786,7 +1814,9 @@ describe.runIf(isLocalnet)('session', () => {
1786
1814
  request: makeRequest(),
1787
1815
  })
1788
1816
 
1789
- const settleTxHash2 = await settle(store, client, channelId, { escrowContract })
1817
+ const settleTxHash2 = await settle(store, client, channelId, {
1818
+ escrowContract,
1819
+ })
1790
1820
  await waitForTransactionReceipt(client, { hash: settleTxHash2 })
1791
1821
 
1792
1822
  // Wipe store.
@@ -1839,7 +1869,9 @@ describe.runIf(isLocalnet)('session', () => {
1839
1869
  })
1840
1870
 
1841
1871
  // Settle on-chain so onChain.settled = 3000000.
1842
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
1872
+ const settleTxHash = await settle(store, client, channelId, {
1873
+ escrowContract,
1874
+ })
1843
1875
  await waitForTransactionReceipt(client, { hash: settleTxHash })
1844
1876
 
1845
1877
  // Store still has the old record — settledOnChain is correct after settle.
@@ -1892,7 +1924,9 @@ describe.runIf(isLocalnet)('session', () => {
1892
1924
  })
1893
1925
 
1894
1926
  // Settle on-chain so onChain.settled = 5M.
1895
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
1927
+ const settleTxHash = await settle(store, client, channelId, {
1928
+ escrowContract,
1929
+ })
1896
1930
  await waitForTransactionReceipt(client, { hash: settleTxHash })
1897
1931
 
1898
1932
  // Store record still exists (no store loss), but spent is 0.
@@ -1948,7 +1982,9 @@ describe.runIf(isLocalnet)('session', () => {
1948
1982
  request: makeRequest(),
1949
1983
  })
1950
1984
 
1951
- const settleTxHash3 = await settle(store, client, channelId, { escrowContract })
1985
+ const settleTxHash3 = await settle(store, client, channelId, {
1986
+ escrowContract,
1987
+ })
1952
1988
  await waitForTransactionReceipt(client, { hash: settleTxHash3 })
1953
1989
  await store.updateChannel(channelId, () => null)
1954
1990
 
@@ -1993,7 +2029,9 @@ describe.runIf(isLocalnet)('session', () => {
1993
2029
  request: makeRequest(),
1994
2030
  })
1995
2031
 
1996
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
2032
+ const settleTxHash = await settle(store, client, channelId, {
2033
+ escrowContract,
2034
+ })
1997
2035
  await waitForTransactionReceipt(client, { hash: settleTxHash })
1998
2036
  await store.updateChannel(channelId, () => null)
1999
2037
 
@@ -2057,7 +2095,9 @@ describe.runIf(isLocalnet)('session', () => {
2057
2095
  request: makeRequest(),
2058
2096
  })
2059
2097
 
2060
- const settleTxHash = await settle(store, client, channelId, { escrowContract })
2098
+ const settleTxHash = await settle(store, client, channelId, {
2099
+ escrowContract,
2100
+ })
2061
2101
  await waitForTransactionReceipt(client, { hash: settleTxHash })
2062
2102
  await store.updateChannel(channelId, () => null)
2063
2103
 
@@ -2120,7 +2160,10 @@ describe.runIf(isLocalnet)('session', () => {
2120
2160
  await expect(
2121
2161
  server.verify({
2122
2162
  credential: {
2123
- challenge: makeChallenge({ id: 'voucher-invalid-compact', channelId }),
2163
+ challenge: makeChallenge({
2164
+ id: 'voucher-invalid-compact',
2165
+ channelId,
2166
+ }),
2124
2167
  payload: {
2125
2168
  action: 'voucher' as const,
2126
2169
  channelId,
@@ -2159,7 +2202,10 @@ describe.runIf(isLocalnet)('session', () => {
2159
2202
  amounts.map(async (amount, index) =>
2160
2203
  server.verify({
2161
2204
  credential: {
2162
- challenge: makeChallenge({ id: `voucher-concurrency-${index}`, channelId }),
2205
+ challenge: makeChallenge({
2206
+ id: `voucher-concurrency-${index}`,
2207
+ channelId,
2208
+ }),
2163
2209
  payload: {
2164
2210
  action: 'voucher' as const,
2165
2211
  channelId,
@@ -2232,7 +2278,10 @@ describe.runIf(isLocalnet)('session', () => {
2232
2278
 
2233
2279
  await healthyServer.verify({
2234
2280
  credential: {
2235
- challenge: makeChallenge({ id: 'open-before-topup-crash', channelId }),
2281
+ challenge: makeChallenge({
2282
+ id: 'open-before-topup-crash',
2283
+ channelId,
2284
+ }),
2236
2285
  payload: {
2237
2286
  action: 'open' as const,
2238
2287
  type: 'transaction' as const,
@@ -2278,7 +2327,10 @@ describe.runIf(isLocalnet)('session', () => {
2278
2327
 
2279
2328
  await healthyServer.verify({
2280
2329
  credential: {
2281
- challenge: makeChallenge({ id: 'reopen-after-topup-crash', channelId }),
2330
+ challenge: makeChallenge({
2331
+ id: 'reopen-after-topup-crash',
2332
+ channelId,
2333
+ }),
2282
2334
  payload: {
2283
2335
  action: 'open' as const,
2284
2336
  type: 'transaction' as const,
@@ -2414,7 +2466,10 @@ describe.runIf(isLocalnet)('session', () => {
2414
2466
 
2415
2467
  await server.verify({
2416
2468
  credential: {
2417
- challenge: makeChallenge({ id: 'open-before-external-close', channelId }),
2469
+ challenge: makeChallenge({
2470
+ id: 'open-before-external-close',
2471
+ channelId,
2472
+ }),
2418
2473
  payload: {
2419
2474
  action: 'open' as const,
2420
2475
  type: 'transaction' as const,
@@ -2439,7 +2494,10 @@ describe.runIf(isLocalnet)('session', () => {
2439
2494
  await expect(
2440
2495
  server.verify({
2441
2496
  credential: {
2442
- challenge: makeChallenge({ id: 'close-after-external-finalize', channelId }),
2497
+ challenge: makeChallenge({
2498
+ id: 'close-after-external-finalize',
2499
+ channelId,
2500
+ }),
2443
2501
  payload: {
2444
2502
  action: 'close' as const,
2445
2503
  channelId,
@@ -2463,7 +2521,9 @@ describe.runIf(isLocalnet)('session', () => {
2463
2521
  }),
2464
2522
  payload: { action: 'voucher' },
2465
2523
  },
2466
- input: new Request('https://api.example.com/resource', { method: 'HEAD' }),
2524
+ input: new Request('https://api.example.com/resource', {
2525
+ method: 'HEAD',
2526
+ }),
2467
2527
  } as never)
2468
2528
 
2469
2529
  expect(response).toBeUndefined()
@@ -2487,7 +2547,11 @@ describe.runIf(isLocalnet)('session', () => {
2487
2547
 
2488
2548
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2489
2549
  const handler = createHandler()
2490
- const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' })
2550
+ const route = handler.session({
2551
+ amount: '1',
2552
+ decimals: 6,
2553
+ unitType: 'token',
2554
+ })
2491
2555
 
2492
2556
  const first = await route(new Request('https://api.example.com/resource'))
2493
2557
  if (first.status !== 402) throw new Error('expected challenge')
@@ -2522,7 +2586,11 @@ describe.runIf(isLocalnet)('session', () => {
2522
2586
  test('does not return Payment-Receipt on verification errors', async () => {
2523
2587
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2524
2588
  const handler = createHandler()
2525
- const route = handler.session({ amount: '1', decimals: 6, unitType: 'token' })
2589
+ const route = handler.session({
2590
+ amount: '1',
2591
+ decimals: 6,
2592
+ unitType: 'token',
2593
+ })
2526
2594
 
2527
2595
  const first = await route(new Request('https://api.example.com/resource'))
2528
2596
  if (first.status !== 402) throw new Error('expected challenge')
@@ -3059,6 +3127,58 @@ describe.runIf(isLocalnet)('session', () => {
3059
3127
  expect(closeReceipt?.spent).toBe('4000000')
3060
3128
  })
3061
3129
 
3130
+ test('refuses SSE voucher requests beyond local maxDeposit', async () => {
3131
+ const backingStore = Store.memory()
3132
+ const routeHandler = Mppx_server.create({
3133
+ methods: [
3134
+ tempo_server.session({
3135
+ store: backingStore,
3136
+ getClient: () => client,
3137
+ account: recipientAccount,
3138
+ currency,
3139
+ escrowContract,
3140
+ chainId: chain.id,
3141
+ sse: true,
3142
+ }),
3143
+ ],
3144
+ realm: 'api.example.com',
3145
+ secretKey: 'secret',
3146
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3147
+
3148
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3149
+ const request = new Request(input, init)
3150
+ const result = await routeHandler(request)
3151
+ if (result.status === 402) return result.challenge
3152
+
3153
+ if (request.headers.get('Accept')?.includes('text/event-stream')) {
3154
+ return result.withReceipt(async function* (stream) {
3155
+ await stream.charge()
3156
+ yield 'chunk-1'
3157
+ await stream.charge()
3158
+ yield 'chunk-2'
3159
+ })
3160
+ }
3161
+
3162
+ return result.withReceipt(new Response('ok'))
3163
+ }
3164
+
3165
+ const manager = sessionManager({
3166
+ account: payer,
3167
+ client,
3168
+ escrowContract,
3169
+ fetch,
3170
+ maxDeposit: '1',
3171
+ })
3172
+
3173
+ const stream = await manager.sse('https://api.example.com/stream')
3174
+ await expect(
3175
+ (async () => {
3176
+ for await (const _chunk of stream) {
3177
+ }
3178
+ })(),
3179
+ ).rejects.toThrow('requested voucher amount 2000000 exceeds local maxDeposit 1000000')
3180
+ })
3181
+
3062
3182
  test('behavior: charge withReceipt returns Response', async () => {
3063
3183
  const handler = Mppx_server.create({
3064
3184
  methods: [tempo_server.charge({ account: accounts[0], currency: asset })],
@@ -3077,6 +3197,851 @@ describe.runIf(isLocalnet)('session', () => {
3077
3197
  }
3078
3198
  })
3079
3199
  })
3200
+
3201
+ describe('WebSocket', () => {
3202
+ test('open -> stream -> need-voucher -> resume -> close', async () => {
3203
+ const backingStore = Store.memory()
3204
+ const routeHandler = Mppx_server.create({
3205
+ methods: [
3206
+ tempo_server.session({
3207
+ store: backingStore,
3208
+ getClient: () => client,
3209
+ account: recipientAccount,
3210
+ currency,
3211
+ escrowContract,
3212
+ chainId: chain.id,
3213
+ }),
3214
+ ],
3215
+ realm: 'api.example.com',
3216
+ secretKey: 'secret',
3217
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3218
+
3219
+ let voucherUpdates = 0
3220
+ const route = async (request: Request) => {
3221
+ if (request.method === 'POST' && request.headers.has('Authorization')) {
3222
+ try {
3223
+ const credential = Credential.fromRequest<any>(request)
3224
+ if (credential.payload?.action === 'voucher') voucherUpdates++
3225
+ } catch {}
3226
+ }
3227
+ return routeHandler(request)
3228
+ }
3229
+
3230
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3231
+ const result = await route(request)
3232
+ if (result.status === 402) return result.challenge
3233
+ return result.withReceipt(new Response('ok'))
3234
+ })
3235
+
3236
+ const nodeServer = node_http.createServer(httpHandler)
3237
+ const wsServer = new WebSocketServer({ noServer: true })
3238
+
3239
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3240
+ const { port } = nodeServer.address() as { port: number }
3241
+ const server = Http.wrapServer(nodeServer, {
3242
+ port,
3243
+ url: `http://localhost:${port}`,
3244
+ })
3245
+
3246
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3247
+ void TempoWs.serve({
3248
+ socket,
3249
+ store: backingStore,
3250
+ url: `${server.url}/ws`,
3251
+ route,
3252
+ generate: async function* (stream: TempoWs.SessionController) {
3253
+ await stream.charge()
3254
+ yield 'chunk-1'
3255
+ await stream.charge()
3256
+ yield 'chunk-2'
3257
+ await stream.charge()
3258
+ yield 'chunk-3'
3259
+ },
3260
+ })
3261
+ })
3262
+
3263
+ nodeServer.on('upgrade', (req, socket, head) => {
3264
+ if (req.url !== '/ws') {
3265
+ socket.destroy()
3266
+ return
3267
+ }
3268
+
3269
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3270
+ wsServer.emit('connection', websocket, req)
3271
+ })
3272
+ })
3273
+
3274
+ try {
3275
+ const manager = sessionManager({
3276
+ account: payer,
3277
+ client,
3278
+ escrowContract,
3279
+ fetch: globalThis.fetch,
3280
+ maxDeposit: '3',
3281
+ })
3282
+
3283
+ const ws = await manager.ws(`ws://localhost:${port}/ws`)
3284
+ const chunks: string[] = []
3285
+
3286
+ await new Promise<void>((resolve, reject) => {
3287
+ ws.addEventListener('message', (event) => {
3288
+ if (typeof event.data !== 'string') return
3289
+ chunks.push(event.data)
3290
+ })
3291
+ ws.addEventListener('close', () => resolve(), { once: true })
3292
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
3293
+ once: true,
3294
+ })
3295
+ })
3296
+
3297
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
3298
+ expect(voucherUpdates).toBeGreaterThan(0)
3299
+
3300
+ const closeReceipt = await manager.close()
3301
+ expect(closeReceipt?.status).toBe('success')
3302
+ expect(closeReceipt?.spent).toBe('3000000')
3303
+
3304
+ const channelId = manager.channelId
3305
+ expect(channelId).toBeTruthy()
3306
+
3307
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!)
3308
+ expect(persisted?.finalized).toBe(true)
3309
+ } finally {
3310
+ wsServer.close()
3311
+ server.close()
3312
+ }
3313
+ })
3314
+
3315
+ test('treats control-shaped application payloads as content', async () => {
3316
+ const backingStore = Store.memory()
3317
+ const routeHandler = Mppx_server.create({
3318
+ methods: [
3319
+ tempo_server.session({
3320
+ store: backingStore,
3321
+ getClient: () => client,
3322
+ account: recipientAccount,
3323
+ currency,
3324
+ escrowContract,
3325
+ chainId: chain.id,
3326
+ }),
3327
+ ],
3328
+ realm: 'api.example.com',
3329
+ secretKey: 'secret',
3330
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3331
+
3332
+ let voucherUpdates = 0
3333
+ const route = async (request: Request) => {
3334
+ if (request.method === 'POST' && request.headers.has('Authorization')) {
3335
+ try {
3336
+ const credential = Credential.fromRequest<any>(request)
3337
+ if (credential.payload?.action === 'voucher') voucherUpdates++
3338
+ } catch {}
3339
+ }
3340
+ return routeHandler(request)
3341
+ }
3342
+
3343
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3344
+ const result = await route(request)
3345
+ if (result.status === 402) return result.challenge
3346
+ return result.withReceipt(new Response('ok'))
3347
+ })
3348
+
3349
+ const nodeServer = node_http.createServer(httpHandler)
3350
+ const wsServer = new WebSocketServer({ noServer: true })
3351
+
3352
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3353
+ const { port } = nodeServer.address() as { port: number }
3354
+ const server = Http.wrapServer(nodeServer, {
3355
+ port,
3356
+ url: `http://localhost:${port}`,
3357
+ })
3358
+
3359
+ const controlLookingChunk = JSON.stringify({
3360
+ mpp: 'payment-need-voucher',
3361
+ data: {
3362
+ channelId: '0x' + 'aa'.repeat(32),
3363
+ requiredCumulative: '9000000',
3364
+ acceptedCumulative: '0',
3365
+ deposit: '9000000',
3366
+ },
3367
+ })
3368
+
3369
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3370
+ void TempoWs.serve({
3371
+ socket,
3372
+ store: backingStore,
3373
+ url: `${server.url}/ws`,
3374
+ route,
3375
+ generate: async function* (stream: TempoWs.SessionController) {
3376
+ await stream.charge()
3377
+ yield controlLookingChunk
3378
+ },
3379
+ })
3380
+ })
3381
+
3382
+ nodeServer.on('upgrade', (req, socket, head) => {
3383
+ if (req.url !== '/ws') {
3384
+ socket.destroy()
3385
+ return
3386
+ }
3387
+
3388
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3389
+ wsServer.emit('connection', websocket, req)
3390
+ })
3391
+ })
3392
+
3393
+ try {
3394
+ const manager = sessionManager({
3395
+ account: payer,
3396
+ client,
3397
+ escrowContract,
3398
+ fetch: globalThis.fetch,
3399
+ maxDeposit: '1',
3400
+ })
3401
+
3402
+ const ws = await manager.ws(`ws://localhost:${port}/ws`)
3403
+ const chunks: string[] = []
3404
+
3405
+ await new Promise<void>((resolve, reject) => {
3406
+ ws.addEventListener('message', (event) => {
3407
+ if (typeof event.data !== 'string') return
3408
+ chunks.push(event.data)
3409
+ })
3410
+ ws.addEventListener('close', () => resolve(), { once: true })
3411
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
3412
+ once: true,
3413
+ })
3414
+ })
3415
+
3416
+ expect(chunks).toEqual([controlLookingChunk])
3417
+ expect(voucherUpdates).toBe(0)
3418
+
3419
+ const closeReceipt = await manager.close()
3420
+ expect(closeReceipt?.status).toBe('success')
3421
+ expect(closeReceipt?.spent).toBe('1000000')
3422
+ } finally {
3423
+ wsServer.close()
3424
+ server.close()
3425
+ }
3426
+ })
3427
+
3428
+ test('close() stops the stream and application listeners never receive payment control frames', async () => {
3429
+ const backingStore = Store.memory()
3430
+ const routeHandler = Mppx_server.create({
3431
+ methods: [
3432
+ tempo_server.session({
3433
+ store: backingStore,
3434
+ getClient: () => client,
3435
+ account: recipientAccount,
3436
+ currency,
3437
+ escrowContract,
3438
+ chainId: chain.id,
3439
+ }),
3440
+ ],
3441
+ realm: 'api.example.com',
3442
+ secretKey: 'secret',
3443
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3444
+
3445
+ const route = (request: Request) => routeHandler(request)
3446
+
3447
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3448
+ const result = await route(request)
3449
+ if (result.status === 402) return result.challenge
3450
+ return result.withReceipt(new Response('ok'))
3451
+ })
3452
+
3453
+ const nodeServer = node_http.createServer(httpHandler)
3454
+ const wsServer = new WebSocketServer({ noServer: true })
3455
+
3456
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3457
+ const { port } = nodeServer.address() as { port: number }
3458
+ const server = Http.wrapServer(nodeServer, {
3459
+ port,
3460
+ url: `http://localhost:${port}`,
3461
+ })
3462
+
3463
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3464
+ void TempoWs.serve({
3465
+ socket,
3466
+ store: backingStore,
3467
+ url: `${server.url}/ws`,
3468
+ route,
3469
+ generate: async function* (stream: TempoWs.SessionController) {
3470
+ await stream.charge()
3471
+ yield 'chunk-1'
3472
+ await new Promise((resolve) => setTimeout(resolve, 50))
3473
+ await stream.charge()
3474
+ yield 'chunk-2'
3475
+ await stream.charge()
3476
+ yield 'chunk-3'
3477
+ },
3478
+ })
3479
+ })
3480
+
3481
+ nodeServer.on('upgrade', (req, socket, head) => {
3482
+ if (req.url !== '/ws') {
3483
+ socket.destroy()
3484
+ return
3485
+ }
3486
+
3487
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3488
+ wsServer.emit('connection', websocket, req)
3489
+ })
3490
+ })
3491
+
3492
+ try {
3493
+ const manager = sessionManager({
3494
+ account: payer,
3495
+ client,
3496
+ escrowContract,
3497
+ fetch: globalThis.fetch,
3498
+ maxDeposit: '3',
3499
+ })
3500
+
3501
+ let receiptCount = 0
3502
+ let closePromise: Promise<SessionReceipt | undefined> | undefined
3503
+ const ws = await manager.ws(`ws://localhost:${port}/ws`, {
3504
+ onReceipt() {
3505
+ receiptCount++
3506
+ if (receiptCount === 2 && !closePromise) closePromise = manager.close()
3507
+ },
3508
+ })
3509
+
3510
+ const chunks: string[] = []
3511
+ await new Promise<void>((resolve, reject) => {
3512
+ ws.addEventListener('message', (event) => {
3513
+ if (typeof event.data !== 'string') return
3514
+ chunks.push(event.data)
3515
+ })
3516
+ ws.addEventListener('close', () => resolve(), { once: true })
3517
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
3518
+ once: true,
3519
+ })
3520
+ })
3521
+
3522
+ const closeReceipt = await closePromise
3523
+ expect(closeReceipt?.status).toBe('success')
3524
+ expect(closeReceipt?.txHash).toBeTruthy()
3525
+ expect(closeReceipt?.spent).toBe('2000000')
3526
+ expect(chunks).toEqual(['chunk-1', 'chunk-2'])
3527
+
3528
+ const channelId = manager.channelId
3529
+ expect(channelId).toBeTruthy()
3530
+
3531
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!)
3532
+ expect(persisted?.finalized).toBe(true)
3533
+ } finally {
3534
+ wsServer.close()
3535
+ server.close()
3536
+ }
3537
+ })
3538
+
3539
+ test('rejects websocket receipts bound to a different channel', async () => {
3540
+ const routeHandler = Mppx_server.create({
3541
+ methods: [
3542
+ tempo_server.session({
3543
+ store: Store.memory(),
3544
+ getClient: () => client,
3545
+ account: recipientAccount,
3546
+ currency,
3547
+ escrowContract,
3548
+ chainId: chain.id,
3549
+ }),
3550
+ ],
3551
+ realm: 'api.example.com',
3552
+ secretKey: 'secret',
3553
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3554
+
3555
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3556
+ const result = await routeHandler(request)
3557
+ if (result.status === 402) return result.challenge
3558
+ return result.withReceipt(new Response('ok'))
3559
+ })
3560
+
3561
+ const nodeServer = node_http.createServer(httpHandler)
3562
+ const wsServer = new WebSocketServer({ noServer: true })
3563
+ const wrongChannelId = `0x${'11'.repeat(32)}` as Hex
3564
+
3565
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3566
+ const { port } = nodeServer.address() as { port: number }
3567
+ const server = Http.wrapServer(nodeServer, {
3568
+ port,
3569
+ url: `http://localhost:${port}`,
3570
+ })
3571
+
3572
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3573
+ socket.once('message', (data) => {
3574
+ const raw = data.toString()
3575
+ const message = TempoWs.parseMessage(raw)
3576
+ if (!message || message.mpp !== 'authorization') return
3577
+
3578
+ const credential = Credential.deserialize<any>(message.authorization)
3579
+ socket.send(
3580
+ TempoWs.formatReceiptMessage({
3581
+ method: 'tempo',
3582
+ intent: 'session',
3583
+ status: 'success',
3584
+ timestamp: new Date().toISOString(),
3585
+ reference: wrongChannelId,
3586
+ challengeId: credential.challenge.id,
3587
+ channelId: wrongChannelId,
3588
+ acceptedCumulative: '1000000',
3589
+ spent: '0',
3590
+ units: 0,
3591
+ }),
3592
+ )
3593
+ })
3594
+ })
3595
+
3596
+ nodeServer.on('upgrade', (req, socket, head) => {
3597
+ if (req.url !== '/ws') {
3598
+ socket.destroy()
3599
+ return
3600
+ }
3601
+
3602
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3603
+ wsServer.emit('connection', websocket, req)
3604
+ })
3605
+ })
3606
+
3607
+ try {
3608
+ const manager = sessionManager({
3609
+ account: payer,
3610
+ client,
3611
+ escrowContract,
3612
+ fetch: globalThis.fetch,
3613
+ maxDeposit: '1',
3614
+ })
3615
+
3616
+ await expect(manager.ws(`ws://localhost:${port}/ws`)).rejects.toThrow(
3617
+ 'received mismatched payment-receipt frame',
3618
+ )
3619
+ } finally {
3620
+ wsServer.close()
3621
+ server.close()
3622
+ }
3623
+ })
3624
+
3625
+ test('refuses websocket voucher requests beyond local maxDeposit', async () => {
3626
+ const backingStore = Store.memory()
3627
+ const routeHandler = Mppx_server.create({
3628
+ methods: [
3629
+ tempo_server.session({
3630
+ store: backingStore,
3631
+ getClient: () => client,
3632
+ account: recipientAccount,
3633
+ currency,
3634
+ escrowContract,
3635
+ chainId: chain.id,
3636
+ }),
3637
+ ],
3638
+ realm: 'api.example.com',
3639
+ secretKey: 'secret',
3640
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3641
+
3642
+ const route = (request: Request) => routeHandler(request)
3643
+
3644
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3645
+ const result = await route(request)
3646
+ if (result.status === 402) return result.challenge
3647
+ return result.withReceipt(new Response('ok'))
3648
+ })
3649
+
3650
+ const nodeServer = node_http.createServer(httpHandler)
3651
+ const wsServer = new WebSocketServer({ noServer: true })
3652
+
3653
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3654
+ const { port } = nodeServer.address() as { port: number }
3655
+ const server = Http.wrapServer(nodeServer, {
3656
+ port,
3657
+ url: `http://localhost:${port}`,
3658
+ })
3659
+
3660
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3661
+ void TempoWs.serve({
3662
+ socket,
3663
+ store: backingStore,
3664
+ url: `${server.url}/ws`,
3665
+ route,
3666
+ generate: async function* (stream: TempoWs.SessionController) {
3667
+ await stream.charge()
3668
+ yield 'chunk-1'
3669
+ await stream.charge()
3670
+ yield 'chunk-2'
3671
+ },
3672
+ })
3673
+ })
3674
+
3675
+ nodeServer.on('upgrade', (req, socket, head) => {
3676
+ if (req.url !== '/ws') {
3677
+ socket.destroy()
3678
+ return
3679
+ }
3680
+
3681
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3682
+ wsServer.emit('connection', websocket, req)
3683
+ })
3684
+ })
3685
+
3686
+ try {
3687
+ const manager = sessionManager({
3688
+ account: payer,
3689
+ client,
3690
+ escrowContract,
3691
+ fetch: globalThis.fetch,
3692
+ maxDeposit: '1',
3693
+ })
3694
+
3695
+ const ws = await manager.ws(`ws://localhost:${port}/ws`)
3696
+ const closeEvent = await new Promise<{ code: number; reason: string }>(
3697
+ (resolve, reject) => {
3698
+ ws.addEventListener(
3699
+ 'close',
3700
+ (event) => resolve({ code: event.code, reason: event.reason }),
3701
+ { once: true },
3702
+ )
3703
+ ws.addEventListener('error', () => reject(new Error('websocket stream failed')), {
3704
+ once: true,
3705
+ })
3706
+ },
3707
+ )
3708
+
3709
+ expect(closeEvent.code).toBe(3008)
3710
+ expect(closeEvent.reason).toBe(
3711
+ 'requested voucher amount 2000000 exceeds local maxDeposit 1000000',
3712
+ )
3713
+ } finally {
3714
+ wsServer.close()
3715
+ server.close()
3716
+ }
3717
+ })
3718
+
3719
+ test('rejects close-ready receipts beyond local voucher state', async () => {
3720
+ const routeHandler = Mppx_server.create({
3721
+ methods: [
3722
+ tempo_server.session({
3723
+ store: Store.memory(),
3724
+ getClient: () => client,
3725
+ account: recipientAccount,
3726
+ currency,
3727
+ escrowContract,
3728
+ chainId: chain.id,
3729
+ }),
3730
+ ],
3731
+ realm: 'api.example.com',
3732
+ secretKey: 'secret',
3733
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3734
+
3735
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3736
+ const result = await routeHandler(request)
3737
+ if (result.status === 402) return result.challenge
3738
+ return result.withReceipt(new Response('ok'))
3739
+ })
3740
+
3741
+ const nodeServer = node_http.createServer(httpHandler)
3742
+ const wsServer = new WebSocketServer({ noServer: true })
3743
+
3744
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3745
+ const { port } = nodeServer.address() as { port: number }
3746
+ const server = Http.wrapServer(nodeServer, {
3747
+ port,
3748
+ url: `http://localhost:${port}`,
3749
+ })
3750
+
3751
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3752
+ let openCredential: any
3753
+
3754
+ socket.on('message', (data) => {
3755
+ const raw = data.toString()
3756
+ const message = TempoWs.parseMessage(raw)
3757
+ if (!message) return
3758
+
3759
+ if (message.mpp === 'authorization') {
3760
+ openCredential = Credential.deserialize<any>(message.authorization)
3761
+ socket.send(
3762
+ TempoWs.formatReceiptMessage({
3763
+ method: 'tempo',
3764
+ intent: 'session',
3765
+ status: 'success',
3766
+ timestamp: new Date().toISOString(),
3767
+ reference: openCredential.payload.channelId,
3768
+ challengeId: openCredential.challenge.id,
3769
+ channelId: openCredential.payload.channelId,
3770
+ acceptedCumulative: openCredential.payload.cumulativeAmount,
3771
+ spent: '0',
3772
+ units: 0,
3773
+ }),
3774
+ )
3775
+ return
3776
+ }
3777
+
3778
+ if (message.mpp === 'payment-close-request' && openCredential) {
3779
+ socket.send(
3780
+ TempoWs.formatCloseReadyMessage({
3781
+ method: 'tempo',
3782
+ intent: 'session',
3783
+ status: 'success',
3784
+ timestamp: new Date().toISOString(),
3785
+ reference: openCredential.payload.channelId,
3786
+ challengeId: openCredential.challenge.id,
3787
+ channelId: openCredential.payload.channelId,
3788
+ acceptedCumulative: openCredential.payload.cumulativeAmount,
3789
+ spent: '9000000',
3790
+ units: 1,
3791
+ }),
3792
+ )
3793
+ }
3794
+ })
3795
+ })
3796
+
3797
+ nodeServer.on('upgrade', (req, socket, head) => {
3798
+ if (req.url !== '/ws') {
3799
+ socket.destroy()
3800
+ return
3801
+ }
3802
+
3803
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3804
+ wsServer.emit('connection', websocket, req)
3805
+ })
3806
+ })
3807
+
3808
+ try {
3809
+ const manager = sessionManager({
3810
+ account: payer,
3811
+ client,
3812
+ escrowContract,
3813
+ fetch: globalThis.fetch,
3814
+ maxDeposit: '1',
3815
+ })
3816
+
3817
+ await manager.ws(`ws://localhost:${port}/ws`)
3818
+ await expect(manager.close()).rejects.toThrow(
3819
+ 'received payment-close-ready beyond local voucher state',
3820
+ )
3821
+ } finally {
3822
+ wsServer.close()
3823
+ server.close()
3824
+ }
3825
+ })
3826
+
3827
+ test('fallback close after socket death signs for delivered amount, not full voucher', async () => {
3828
+ const backingStore = Store.memory()
3829
+ const routeHandler = Mppx_server.create({
3830
+ methods: [
3831
+ tempo_server.session({
3832
+ store: backingStore,
3833
+ getClient: () => client,
3834
+ account: recipientAccount,
3835
+ currency,
3836
+ escrowContract,
3837
+ chainId: chain.id,
3838
+ }),
3839
+ ],
3840
+ realm: 'api.example.com',
3841
+ secretKey: 'secret',
3842
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3843
+
3844
+ const route = (request: Request) => routeHandler(request)
3845
+
3846
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3847
+ const result = await route(request)
3848
+ if (result.status === 402) return result.challenge
3849
+ return result.withReceipt(new Response('ok'))
3850
+ })
3851
+
3852
+ const nodeServer = node_http.createServer(httpHandler)
3853
+ const wsServer = new WebSocketServer({ noServer: true })
3854
+ let serverSocket: import('ws').WebSocket | null = null
3855
+
3856
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3857
+ const { port } = nodeServer.address() as { port: number }
3858
+ const server = Http.wrapServer(nodeServer, {
3859
+ port,
3860
+ url: `http://localhost:${port}`,
3861
+ })
3862
+
3863
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3864
+ serverSocket = socket
3865
+ void TempoWs.serve({
3866
+ socket,
3867
+ store: backingStore,
3868
+ url: `${server.url}/ws`,
3869
+ route,
3870
+ generate: async function* (stream: TempoWs.SessionController) {
3871
+ await stream.charge()
3872
+ yield 'chunk-1'
3873
+ await stream.charge()
3874
+ yield 'chunk-2'
3875
+ await new Promise((resolve) => setTimeout(resolve, 60_000))
3876
+ },
3877
+ })
3878
+ })
3879
+
3880
+ nodeServer.on('upgrade', (req, socket, head) => {
3881
+ if (req.url !== '/ws') {
3882
+ socket.destroy()
3883
+ return
3884
+ }
3885
+
3886
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
3887
+ wsServer.emit('connection', websocket, req)
3888
+ })
3889
+ })
3890
+
3891
+ try {
3892
+ const manager = sessionManager({
3893
+ account: payer,
3894
+ client,
3895
+ escrowContract,
3896
+ fetch: globalThis.fetch,
3897
+ maxDeposit: '3',
3898
+ })
3899
+
3900
+ const ws = await manager.ws(`ws://localhost:${port}/ws`)
3901
+ const chunks: string[] = []
3902
+
3903
+ await new Promise<void>((resolve) => {
3904
+ ws.addEventListener('message', (event) => {
3905
+ if (typeof event.data !== 'string') return
3906
+ chunks.push(event.data)
3907
+ if (chunks.length === 2) serverSocket?.terminate()
3908
+ })
3909
+ ws.addEventListener('close', () => resolve(), { once: true })
3910
+ })
3911
+
3912
+ expect(chunks).toEqual(['chunk-1', 'chunk-2'])
3913
+
3914
+ const closeReceipt = await manager.close()
3915
+ expect(closeReceipt).toBeDefined()
3916
+
3917
+ const settledAmount = BigInt(closeReceipt!.spent)
3918
+ const deliveredCost = 2n * 1000000n
3919
+ expect(settledAmount).toBeLessThanOrEqual(deliveredCost)
3920
+ expect(settledAmount).toBeGreaterThan(0n)
3921
+
3922
+ const fullDeposit = 3000000n
3923
+ expect(settledAmount).toBeLessThan(fullDeposit)
3924
+ } finally {
3925
+ wsServer.close()
3926
+ server.close()
3927
+ }
3928
+ })
3929
+
3930
+ test('rejects tx-bearing open receipts replayed during websocket close', async () => {
3931
+ const routeHandler = Mppx_server.create({
3932
+ methods: [
3933
+ tempo_server.session({
3934
+ store: Store.memory(),
3935
+ getClient: () => client,
3936
+ account: recipientAccount,
3937
+ currency,
3938
+ escrowContract,
3939
+ chainId: chain.id,
3940
+ }),
3941
+ ],
3942
+ realm: 'api.example.com',
3943
+ secretKey: 'secret',
3944
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3945
+
3946
+ const httpHandler = NodeRequest.toNodeListener(async (request) => {
3947
+ const result = await routeHandler(request)
3948
+ if (result.status === 402) return result.challenge
3949
+ return result.withReceipt(new Response('ok'))
3950
+ })
3951
+
3952
+ const nodeServer = node_http.createServer(httpHandler)
3953
+ const wsServer = new WebSocketServer({ noServer: true })
3954
+
3955
+ await new Promise<void>((resolve) => nodeServer.listen(0, resolve))
3956
+ const { port } = nodeServer.address() as { port: number }
3957
+ const server = Http.wrapServer(nodeServer, {
3958
+ port,
3959
+ url: `http://localhost:${port}`,
3960
+ })
3961
+
3962
+ wsServer.on('connection', (socket: import('ws').WebSocket) => {
3963
+ let openCredential: any
3964
+ let openReceipt: SessionReceipt | undefined
3965
+
3966
+ socket.on('message', (data) => {
3967
+ const raw = data.toString()
3968
+ const message = TempoWs.parseMessage(raw)
3969
+ if (!message) return
3970
+
3971
+ if (message.mpp === 'authorization') {
3972
+ const credential = Credential.deserialize<any>(message.authorization)
3973
+ if (credential.payload?.action === 'close') {
3974
+ if (openReceipt) socket.send(TempoWs.formatReceiptMessage(openReceipt))
3975
+ return
3976
+ }
3977
+
3978
+ openCredential = credential
3979
+ openReceipt = {
3980
+ method: 'tempo',
3981
+ intent: 'session',
3982
+ status: 'success',
3983
+ timestamp: new Date().toISOString(),
3984
+ reference: credential.payload.channelId,
3985
+ challengeId: credential.challenge.id,
3986
+ channelId: credential.payload.channelId,
3987
+ acceptedCumulative: credential.payload.cumulativeAmount,
3988
+ spent: '0',
3989
+ units: 0,
3990
+ txHash: `0x${'12'.repeat(32)}` as Hex,
3991
+ }
3992
+ socket.send(TempoWs.formatReceiptMessage(openReceipt))
3993
+ return
3994
+ }
3995
+
3996
+ if (message.mpp === 'payment-close-request' && openCredential) {
3997
+ socket.send(
3998
+ TempoWs.formatCloseReadyMessage({
3999
+ method: 'tempo',
4000
+ intent: 'session',
4001
+ status: 'success',
4002
+ timestamp: new Date().toISOString(),
4003
+ reference: openCredential.payload.channelId,
4004
+ challengeId: openCredential.challenge.id,
4005
+ channelId: openCredential.payload.channelId,
4006
+ acceptedCumulative: openCredential.payload.cumulativeAmount,
4007
+ spent: '0',
4008
+ units: 0,
4009
+ }),
4010
+ )
4011
+ }
4012
+ })
4013
+ })
4014
+
4015
+ nodeServer.on('upgrade', (req, socket, head) => {
4016
+ if (req.url !== '/ws') {
4017
+ socket.destroy()
4018
+ return
4019
+ }
4020
+
4021
+ wsServer.handleUpgrade(req, socket, head, (websocket: import('ws').WebSocket) => {
4022
+ wsServer.emit('connection', websocket, req)
4023
+ })
4024
+ })
4025
+
4026
+ try {
4027
+ const manager = sessionManager({
4028
+ account: payer,
4029
+ client,
4030
+ escrowContract,
4031
+ fetch: globalThis.fetch,
4032
+ maxDeposit: '1',
4033
+ })
4034
+
4035
+ await manager.ws(`ws://localhost:${port}/ws`)
4036
+ await expect(manager.close()).rejects.toThrow(
4037
+ 'received mismatched payment-close receipt frame',
4038
+ )
4039
+ } finally {
4040
+ wsServer.close()
4041
+ server.close()
4042
+ }
4043
+ })
4044
+ })
3080
4045
  })
3081
4046
 
3082
4047
  describe('monotonicity and TOCTOU (unit tests)', () => {
@@ -3113,7 +4078,11 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
3113
4078
 
3114
4079
  test('charge does not allow highestVoucherAmount to decrease', async () => {
3115
4080
  const store = ChannelStore.fromStore(Store.memory())
3116
- await seedChannel(store, { highestVoucherAmount: 5000000n, spent: 0n, units: 0 })
4081
+ await seedChannel(store, {
4082
+ highestVoucherAmount: 5000000n,
4083
+ spent: 0n,
4084
+ units: 0,
4085
+ })
3117
4086
 
3118
4087
  const channel = await charge(store, testChannelId, 1000000n)
3119
4088
  expect(channel.spent).toBe(1000000n)
@@ -3154,7 +4123,11 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
3154
4123
 
3155
4124
  test('acceptVoucher is monotonic — lower value does not decrease highestVoucherAmount', async () => {
3156
4125
  const store = ChannelStore.fromStore(Store.memory())
3157
- await seedChannel(store, { highestVoucherAmount: 5000000n, spent: 2000000n, units: 3 })
4126
+ await seedChannel(store, {
4127
+ highestVoucherAmount: 5000000n,
4128
+ spent: 2000000n,
4129
+ units: 3,
4130
+ })
3158
4131
 
3159
4132
  const channel = await store.updateChannel(testChannelId, (existing) => {
3160
4133
  if (!existing) return null
@@ -3579,7 +4552,9 @@ async function createSignedOpenTransaction(
3579
4552
  token: currency,
3580
4553
  deposit,
3581
4554
  salt,
3582
- ...(opts?.authorizedSigner !== undefined && { authorizedSigner: opts.authorizedSigner }),
4555
+ ...(opts?.authorizedSigner !== undefined && {
4556
+ authorizedSigner: opts.authorizedSigner,
4557
+ }),
3583
4558
  })
3584
4559
  return { channelId, serializedTransaction }
3585
4560
  }
@@ -3595,36 +4570,58 @@ function mutateSignature(signature: Hex): Hex {
3595
4570
  return `${signature.slice(0, -1)}${replacement}` as Hex
3596
4571
  }
3597
4572
 
3598
- function withFaultHooks(store: Store.Store, options: { failPutAt: number }) {
3599
- let putCalls = 0
3600
- return Store.from({
4573
+ function withFaultHooks(store: Store.AtomicStore, options: { failPutAt: number }) {
4574
+ let writeCalls = 0
4575
+
4576
+ function maybeFail(key: string) {
4577
+ writeCalls++
4578
+ if (writeCalls === options.failPutAt)
4579
+ throw new Error(`simulated store crash before persisting key ${key}`)
4580
+ }
4581
+
4582
+ return Store.from<Store.AtomicStore>({
3601
4583
  get: (key) => store.get(key),
3602
- delete: (key) => store.delete(key),
4584
+ delete: async (key) => {
4585
+ maybeFail(key)
4586
+ await store.delete(key)
4587
+ },
3603
4588
  put: async (key, value) => {
3604
- putCalls++
3605
- if (putCalls === options.failPutAt)
3606
- throw new Error(`simulated store crash before persisting key ${key}`)
4589
+ maybeFail(key)
3607
4590
  await store.put(key, value)
3608
4591
  },
4592
+ update(key, fn) {
4593
+ return store.update(key, (current) => {
4594
+ const change = fn(current)
4595
+ if (change.op !== 'noop') maybeFail(key)
4596
+ return change
4597
+ })
4598
+ },
3609
4599
  })
3610
4600
  }
3611
4601
 
3612
- function withReadDropHooks(store: Store.Store) {
4602
+ function withReadDropHooks(store: Store.AtomicStore) {
3613
4603
  const pending = new Map<string, number>()
3614
- const wrapped = Store.from({
4604
+
4605
+ function readOrDrop<value>(key: string, current: value | null) {
4606
+ const remaining = pending.get(key)
4607
+ if (remaining === undefined) return current
4608
+ if (remaining === 0) {
4609
+ pending.delete(key)
4610
+ return null
4611
+ }
4612
+ pending.set(key, remaining - 1)
4613
+ return current
4614
+ }
4615
+
4616
+ const wrapped = Store.from<Store.AtomicStore>({
3615
4617
  async get(key) {
3616
- const remaining = pending.get(key)
3617
- if (remaining !== undefined) {
3618
- if (remaining === 0) {
3619
- pending.delete(key)
3620
- return null
3621
- }
3622
- pending.set(key, remaining - 1)
3623
- }
3624
- return store.get(key)
4618
+ return readOrDrop(key, await store.get(key))
3625
4619
  },
3626
4620
  put: (key, value) => store.put(key, value),
3627
4621
  delete: (key) => store.delete(key),
4622
+ update(key, fn) {
4623
+ return store.update(key, (current) => fn(readOrDrop(key, current)))
4624
+ },
3628
4625
  })
3629
4626
  return {
3630
4627
  store: wrapped,