mppx 0.5.14 → 0.5.16

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 (65) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/Method.d.ts +5 -2
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  6. package/dist/mcp-sdk/server/Transport.js +8 -2
  7. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  8. package/dist/server/Mppx.d.ts.map +1 -1
  9. package/dist/server/Mppx.js +17 -10
  10. package/dist/server/Mppx.js.map +1 -1
  11. package/dist/server/Request.js +5 -1
  12. package/dist/server/Request.js.map +1 -1
  13. package/dist/server/Transport.d.ts.map +1 -1
  14. package/dist/server/Transport.js +4 -0
  15. package/dist/server/Transport.js.map +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +4 -2
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/client/SessionManager.js +20 -10
  25. package/dist/tempo/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  27. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  28. package/dist/tempo/internal/fee-payer.js +92 -21
  29. package/dist/tempo/internal/fee-payer.js.map +1 -1
  30. package/dist/tempo/server/Session.d.ts.map +1 -1
  31. package/dist/tempo/server/Session.js +43 -20
  32. package/dist/tempo/server/Session.js.map +1 -1
  33. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  34. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  35. package/dist/tempo/server/internal/html.gen.js +1 -1
  36. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  37. package/dist/tempo/server/internal/transport.d.ts +0 -7
  38. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/transport.js +84 -13
  40. package/dist/tempo/server/internal/transport.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/Method.ts +5 -2
  43. package/src/internal/changeset.test.ts +106 -0
  44. package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
  45. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  46. package/src/mcp-sdk/server/Transport.ts +10 -2
  47. package/src/proxy/Proxy.test.ts +149 -1
  48. package/src/server/Mppx.test.ts +120 -0
  49. package/src/server/Mppx.ts +27 -11
  50. package/src/server/Request.test.ts +46 -1
  51. package/src/server/Request.ts +6 -1
  52. package/src/server/Transport.test.ts +2 -0
  53. package/src/server/Transport.ts +4 -0
  54. package/src/stripe/server/internal/html.gen.ts +1 -1
  55. package/src/tempo/Methods.test.ts +13 -0
  56. package/src/tempo/Methods.ts +23 -16
  57. package/src/tempo/client/SessionManager.ts +32 -9
  58. package/src/tempo/internal/fee-payer.test.ts +40 -4
  59. package/src/tempo/internal/fee-payer.ts +105 -21
  60. package/src/tempo/server/Session.test.ts +760 -2
  61. package/src/tempo/server/Session.ts +59 -17
  62. package/src/tempo/server/internal/html.gen.ts +1 -1
  63. package/src/tempo/server/internal/transport.test.ts +321 -10
  64. package/src/tempo/server/internal/transport.ts +101 -14
  65. package/src/viem/Client.test.ts +52 -1
@@ -2,7 +2,11 @@ import * as node_http from 'node:http'
2
2
 
3
3
  import type { z } from 'mppx'
4
4
  import { Challenge, Credential } from 'mppx'
5
- import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
5
+ import {
6
+ Mppx as Mppx_server,
7
+ Transport as ServerTransport,
8
+ tempo as tempo_server,
9
+ } from 'mppx/server'
6
10
  import { Base64 } from 'ox'
7
11
  import {
8
12
  type Address,
@@ -45,6 +49,7 @@ import {
45
49
  } from '../internal/defaults.js'
46
50
  import type * as Methods from '../Methods.js'
47
51
  import * as ChannelStore from '../session/ChannelStore.js'
52
+ import { deserializeSessionReceipt } from '../session/Receipt.js'
48
53
  import type { SessionReceipt } from '../session/Types.js'
49
54
  import { signVoucher } from '../session/Voucher.js'
50
55
  import * as TempoWs from '../session/Ws.js'
@@ -2640,7 +2645,7 @@ describe.runIf(isLocalnet)('session', () => {
2640
2645
  })
2641
2646
 
2642
2647
  describe('protocol compatibility', () => {
2643
- test('HEAD voucher management request falls through to content handler', () => {
2648
+ test('HEAD voucher request is gated as a non-billable management request', () => {
2644
2649
  const server = createServer()
2645
2650
  const response = server.respond!({
2646
2651
  credential: {
@@ -2649,14 +2654,137 @@ describe.runIf(isLocalnet)('session', () => {
2649
2654
  }),
2650
2655
  payload: { action: 'voucher' },
2651
2656
  },
2657
+ envelope: {
2658
+ capturedRequest: {
2659
+ hasBody: false,
2660
+ headers: new Headers(),
2661
+ method: 'HEAD',
2662
+ url: new URL('https://api.example.com/resource'),
2663
+ },
2664
+ },
2652
2665
  input: new Request('https://api.example.com/resource', {
2653
2666
  method: 'HEAD',
2654
2667
  }),
2655
2668
  } as never)
2656
2669
 
2670
+ expect(response).toBeInstanceOf(Response)
2671
+ expect((response as Response).status).toBe(204)
2672
+ })
2673
+
2674
+ test('captured request classification wins over the raw input method', () => {
2675
+ const server = createServer()
2676
+ const response = server.respond!({
2677
+ credential: {
2678
+ challenge: makeChallenge({
2679
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
2680
+ }),
2681
+ payload: { action: 'voucher' },
2682
+ },
2683
+ envelope: {
2684
+ capturedRequest: {
2685
+ hasBody: false,
2686
+ headers: new Headers(),
2687
+ method: 'POST',
2688
+ url: new URL('mcp://request/tools%2Fcall'),
2689
+ },
2690
+ },
2691
+ input: new Request('https://api.example.com/resource', {
2692
+ method: 'GET',
2693
+ }),
2694
+ } as never)
2695
+
2696
+ expect(response).toBeInstanceOf(Response)
2697
+ expect((response as Response).status).toBe(204)
2698
+ })
2699
+
2700
+ test('captured request body metadata wins over a bodyless raw input', () => {
2701
+ const server = createServer()
2702
+ const response = server.respond!({
2703
+ credential: {
2704
+ challenge: makeChallenge({
2705
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
2706
+ }),
2707
+ payload: { action: 'voucher' },
2708
+ },
2709
+ envelope: {
2710
+ capturedRequest: {
2711
+ hasBody: true,
2712
+ headers: new Headers(),
2713
+ method: 'POST',
2714
+ url: new URL('mcp://request/tools%2Fcall'),
2715
+ },
2716
+ },
2717
+ input: new Request('https://api.example.com/resource', {
2718
+ method: 'POST',
2719
+ }),
2720
+ } as never)
2721
+
2657
2722
  expect(response).toBeUndefined()
2658
2723
  })
2659
2724
 
2725
+ test('bills repeated MCP voucher replays using the captured request snapshot', async () => {
2726
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(5000000n)
2727
+ const server = createServer()
2728
+ const mcpCapturedRequest = await ServerTransport.mcp().captureRequest?.({
2729
+ id: 1,
2730
+ jsonrpc: '2.0',
2731
+ method: 'tools/call',
2732
+ params: {},
2733
+ })
2734
+ if (!mcpCapturedRequest) throw new Error('missing MCP captured request')
2735
+
2736
+ const openChallenge = makeChallenge({ id: 'mcp-open', channelId })
2737
+ const openCredential = {
2738
+ challenge: openChallenge,
2739
+ payload: {
2740
+ action: 'open' as const,
2741
+ type: 'transaction' as const,
2742
+ channelId,
2743
+ transaction: serializedTransaction,
2744
+ cumulativeAmount: '2000000',
2745
+ signature: await signTestVoucher(channelId, 2000000n),
2746
+ },
2747
+ }
2748
+ const openReceipt = (await server.verify({
2749
+ credential: openCredential,
2750
+ envelope: {
2751
+ capturedRequest: mcpCapturedRequest,
2752
+ challenge: openChallenge,
2753
+ credential: openCredential,
2754
+ },
2755
+ request: makeRequest({ amount: '1' }),
2756
+ } as never)) as SessionReceipt
2757
+ expect(openReceipt.spent).toBe('1000000')
2758
+ expect(openReceipt.units).toBe(1)
2759
+
2760
+ const replayChallenge = makeChallenge({ id: 'mcp-replay', channelId })
2761
+ const replayCredential = {
2762
+ challenge: replayChallenge,
2763
+ payload: {
2764
+ action: 'voucher' as const,
2765
+ channelId,
2766
+ cumulativeAmount: '2000000',
2767
+ signature: await signTestVoucher(channelId, 2000000n),
2768
+ },
2769
+ }
2770
+ const replayReceipt = (await server.verify({
2771
+ credential: replayCredential,
2772
+ envelope: {
2773
+ capturedRequest: mcpCapturedRequest,
2774
+ challenge: replayChallenge,
2775
+ credential: replayCredential,
2776
+ },
2777
+ request: makeRequest({ amount: '1' }),
2778
+ } as never)) as SessionReceipt
2779
+
2780
+ expect(replayReceipt.spent).toBe('2000000')
2781
+ expect(replayReceipt.units).toBe(2)
2782
+
2783
+ const channel = await store.getChannel(channelId)
2784
+ expect(channel?.spent).toBe(2000000n)
2785
+ expect(channel?.units).toBe(2)
2786
+ })
2787
+
2660
2788
  test('ignores unknown challenge and credential fields for forward compatibility', async () => {
2661
2789
  const challenge = Challenge.from({
2662
2790
  id: 'forward-compat',
@@ -2711,6 +2839,99 @@ describe.runIf(isLocalnet)('session', () => {
2711
2839
  expect(second.status).toBe(200)
2712
2840
  })
2713
2841
 
2842
+ test('plain HTTP session charges content requests and rejects same-voucher replay', async () => {
2843
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2844
+ const handler = createHandler()
2845
+ const route = handler.session({
2846
+ amount: '1',
2847
+ decimals: 6,
2848
+ unitType: 'token',
2849
+ })
2850
+
2851
+ const first = await route(new Request('https://api.example.com/resource'))
2852
+ if (first.status !== 402) throw new Error('expected challenge')
2853
+ const issuedChallenge = Challenge.fromResponse(first.challenge)
2854
+
2855
+ const authorization = Credential.serialize({
2856
+ challenge: issuedChallenge,
2857
+ payload: {
2858
+ action: 'open',
2859
+ type: 'transaction',
2860
+ channelId,
2861
+ transaction: serializedTransaction,
2862
+ cumulativeAmount: '1000000',
2863
+ signature: await signTestVoucher(channelId, 1000000n),
2864
+ },
2865
+ })
2866
+
2867
+ const second = await route(
2868
+ new Request('https://api.example.com/resource', {
2869
+ headers: { Authorization: authorization },
2870
+ }),
2871
+ )
2872
+ if (second.status !== 200) throw new Error('expected paid response')
2873
+
2874
+ const paidResponse = second.withReceipt(new Response('ok'))
2875
+ const receipt = deserializeSessionReceipt(paidResponse.headers.get('Payment-Receipt')!)
2876
+ expect(receipt.spent).toBe('1000000')
2877
+ expect(receipt.units).toBe(1)
2878
+
2879
+ const persisted = await store.getChannel(channelId)
2880
+ expect(persisted?.spent).toBe(1000000n)
2881
+ expect(persisted?.units).toBe(1)
2882
+
2883
+ const replay = await route(
2884
+ new Request('https://api.example.com/resource', {
2885
+ headers: { Authorization: authorization },
2886
+ }),
2887
+ )
2888
+ expect(replay.status).toBe(402)
2889
+ })
2890
+
2891
+ test('sessionManager fetch/close tracks spent from plain HTTP receipts', async () => {
2892
+ const handler = createHandler()
2893
+ const route = handler.session({
2894
+ amount: '1',
2895
+ decimals: 6,
2896
+ unitType: 'token',
2897
+ })
2898
+
2899
+ let contentRequests = 0
2900
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
2901
+ const request = new Request(input, init)
2902
+ const result = await route(request)
2903
+ if (result.status === 402) return result.challenge
2904
+ if (request.method === 'GET') contentRequests++
2905
+ return result.withReceipt(new Response('ok'))
2906
+ }
2907
+
2908
+ const manager = sessionManager({
2909
+ account: payer,
2910
+ client,
2911
+ escrowContract,
2912
+ fetch,
2913
+ maxDeposit: '3',
2914
+ })
2915
+
2916
+ const first = await manager.fetch('https://api.example.com/resource')
2917
+ expect(first.status).toBe(200)
2918
+ expect(first.receipt?.spent).toBe('1000000')
2919
+ expect(first.receipt?.units).toBe(1)
2920
+
2921
+ const second = await manager.fetch('https://api.example.com/resource')
2922
+ expect(second.status).toBe(200)
2923
+ expect(second.receipt?.spent).toBe('2000000')
2924
+ expect(second.receipt?.units).toBe(2)
2925
+
2926
+ const closeReceipt = await manager.close()
2927
+ expect(closeReceipt?.status).toBe('success')
2928
+ expect(closeReceipt?.spent).toBe('2000000')
2929
+ expect(contentRequests).toBe(2)
2930
+
2931
+ const persisted = await store.getChannel(manager.channelId!)
2932
+ expect(persisted?.finalized).toBe(true)
2933
+ })
2934
+
2714
2935
  test('does not return Payment-Receipt on verification errors', async () => {
2715
2936
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2716
2937
  const handler = createHandler()
@@ -2747,6 +2968,219 @@ describe.runIf(isLocalnet)('session', () => {
2747
2968
  expect(second.challenge.headers.get('Payment-Receipt')).toBeNull()
2748
2969
  })
2749
2970
 
2971
+ test('default HTTP GET flow does not serve a replayed voucher without advancing accounting', async () => {
2972
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2973
+ const signature = await signTestVoucher(channelId, 1000000n)
2974
+ const handler = createHandler()
2975
+ const route = handler.session({
2976
+ amount: '1',
2977
+ decimals: 6,
2978
+ unitType: 'token',
2979
+ })
2980
+
2981
+ const serve = async (request: Request) => {
2982
+ const result = await route(request)
2983
+ if (result.status === 402) return result.challenge
2984
+ return result.withReceipt(new Response('paid-content'))
2985
+ }
2986
+
2987
+ const issueChallenge = () => route(new Request('https://api.example.com/resource'))
2988
+
2989
+ const first = await issueChallenge()
2990
+ expect(first.status).toBe(402)
2991
+ if (first.status !== 402) throw new Error('expected challenge')
2992
+
2993
+ const open = await serve(
2994
+ new Request('https://api.example.com/resource', {
2995
+ headers: {
2996
+ Authorization: Credential.serialize({
2997
+ challenge: Challenge.fromResponse(first.challenge),
2998
+ payload: {
2999
+ action: 'open',
3000
+ type: 'transaction',
3001
+ channelId,
3002
+ transaction: serializedTransaction,
3003
+ cumulativeAmount: '1000000',
3004
+ signature,
3005
+ },
3006
+ }),
3007
+ },
3008
+ }),
3009
+ )
3010
+ expect(open.status).toBe(200)
3011
+ expect(await open.text()).toBe('paid-content')
3012
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3013
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3014
+ expect(openReceipt.spent).toBe('1000000')
3015
+ expect(openReceipt.units).toBe(1)
3016
+
3017
+ const replayChallenge = await issueChallenge()
3018
+ expect(replayChallenge.status).toBe(402)
3019
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3020
+
3021
+ const replay = await serve(
3022
+ new Request('https://api.example.com/resource', {
3023
+ headers: {
3024
+ Authorization: Credential.serialize({
3025
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3026
+ payload: {
3027
+ action: 'voucher',
3028
+ channelId,
3029
+ cumulativeAmount: '1000000',
3030
+ signature,
3031
+ },
3032
+ }),
3033
+ },
3034
+ }),
3035
+ )
3036
+ expect(replay.status).toBe(402)
3037
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3038
+ })
3039
+
3040
+ test('default HTTP POST content flow does not serve a replayed voucher without advancing accounting', async () => {
3041
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3042
+ const signature = await signTestVoucher(channelId, 1000000n)
3043
+ const handler = createHandler()
3044
+ const route = handler.session({
3045
+ amount: '1',
3046
+ decimals: 6,
3047
+ unitType: 'token',
3048
+ })
3049
+
3050
+ const serve = async (request: Request) => {
3051
+ const result = await route(request)
3052
+ if (result.status === 402) return result.challenge
3053
+ return result.withReceipt(new Response('paid-content'))
3054
+ }
3055
+
3056
+ const makeRequest = (authorization?: string) =>
3057
+ new Request('https://api.example.com/resource', {
3058
+ method: 'POST',
3059
+ body: '{}',
3060
+ headers: {
3061
+ 'content-length': '2',
3062
+ 'content-type': 'application/json',
3063
+ ...(authorization ? { Authorization: authorization } : {}),
3064
+ },
3065
+ })
3066
+
3067
+ const first = await route(makeRequest())
3068
+ expect(first.status).toBe(402)
3069
+ if (first.status !== 402) throw new Error('expected challenge')
3070
+
3071
+ const open = await serve(
3072
+ makeRequest(
3073
+ Credential.serialize({
3074
+ challenge: Challenge.fromResponse(first.challenge),
3075
+ payload: {
3076
+ action: 'open',
3077
+ type: 'transaction',
3078
+ channelId,
3079
+ transaction: serializedTransaction,
3080
+ cumulativeAmount: '1000000',
3081
+ signature,
3082
+ },
3083
+ }),
3084
+ ),
3085
+ )
3086
+ expect(open.status).toBe(200)
3087
+ expect(await open.text()).toBe('paid-content')
3088
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3089
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3090
+ expect(openReceipt.spent).toBe('1000000')
3091
+ expect(openReceipt.units).toBe(1)
3092
+
3093
+ const replayChallenge = await route(makeRequest())
3094
+ expect(replayChallenge.status).toBe(402)
3095
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3096
+
3097
+ const replay = await serve(
3098
+ makeRequest(
3099
+ Credential.serialize({
3100
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3101
+ payload: {
3102
+ action: 'voucher',
3103
+ channelId,
3104
+ cumulativeAmount: '1000000',
3105
+ signature,
3106
+ },
3107
+ }),
3108
+ ),
3109
+ )
3110
+ expect(replay.status).toBe(402)
3111
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3112
+ })
3113
+
3114
+ test('default HTTP POST content flow with body and no body headers does not serve a replayed voucher', async () => {
3115
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3116
+ const signature = await signTestVoucher(channelId, 1000000n)
3117
+ const handler = createHandler()
3118
+ const route = handler.session({
3119
+ amount: '1',
3120
+ decimals: 6,
3121
+ unitType: 'token',
3122
+ })
3123
+
3124
+ const serve = async (request: Request) => {
3125
+ const result = await route(request)
3126
+ if (result.status === 402) return result.challenge
3127
+ return result.withReceipt(new Response('paid-content'))
3128
+ }
3129
+
3130
+ const makeRequest = (authorization?: string) =>
3131
+ new Request('https://api.example.com/resource', {
3132
+ method: 'POST',
3133
+ body: '{}',
3134
+ ...(authorization ? { headers: { Authorization: authorization } } : {}),
3135
+ })
3136
+
3137
+ const first = await route(makeRequest())
3138
+ expect(first.status).toBe(402)
3139
+ if (first.status !== 402) throw new Error('expected challenge')
3140
+
3141
+ const open = await serve(
3142
+ makeRequest(
3143
+ Credential.serialize({
3144
+ challenge: Challenge.fromResponse(first.challenge),
3145
+ payload: {
3146
+ action: 'open',
3147
+ type: 'transaction',
3148
+ channelId,
3149
+ transaction: serializedTransaction,
3150
+ cumulativeAmount: '1000000',
3151
+ signature,
3152
+ },
3153
+ }),
3154
+ ),
3155
+ )
3156
+ expect(open.status).toBe(200)
3157
+ expect(await open.text()).toBe('paid-content')
3158
+ const openReceipt = deserializeSessionReceipt(open.headers.get('Payment-Receipt') as string)
3159
+ expect(openReceipt.acceptedCumulative).toBe('1000000')
3160
+ expect(openReceipt.spent).toBe('1000000')
3161
+ expect(openReceipt.units).toBe(1)
3162
+
3163
+ const replayChallenge = await route(makeRequest())
3164
+ expect(replayChallenge.status).toBe(402)
3165
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3166
+
3167
+ const replay = await serve(
3168
+ makeRequest(
3169
+ Credential.serialize({
3170
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3171
+ payload: {
3172
+ action: 'voucher',
3173
+ channelId,
3174
+ cumulativeAmount: '1000000',
3175
+ signature,
3176
+ },
3177
+ }),
3178
+ ),
3179
+ )
3180
+ expect(replay.status).toBe(402)
3181
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
3182
+ })
3183
+
2750
3184
  test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
2751
3185
  const handler = createHandler()
2752
3186
  const route = handler.session({
@@ -2963,6 +3397,23 @@ describe.runIf(isLocalnet)('session', () => {
2963
3397
  expect(result).toBeUndefined()
2964
3398
  })
2965
3399
 
3400
+ test('returns undefined for open POST with body and no body headers (content request)', () => {
3401
+ const server = createServer()
3402
+ const result = server.respond!({
3403
+ credential: {
3404
+ challenge: makeChallenge({
3405
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
3406
+ }),
3407
+ payload: { action: 'open' },
3408
+ },
3409
+ input: new Request('http://localhost', {
3410
+ method: 'POST',
3411
+ body: '{}',
3412
+ }),
3413
+ } as any)
3414
+ expect(result).toBeUndefined()
3415
+ })
3416
+
2966
3417
  test('returns 204 for GET with topUp action', () => {
2967
3418
  const server = createServer()
2968
3419
  const result = server.respond!({
@@ -3012,6 +3463,23 @@ describe.runIf(isLocalnet)('session', () => {
3012
3463
  expect(result).toBeUndefined()
3013
3464
  })
3014
3465
 
3466
+ test('returns undefined for voucher POST with body and no body headers (content request)', () => {
3467
+ const server = createServer()
3468
+ const result = server.respond!({
3469
+ credential: {
3470
+ challenge: makeChallenge({
3471
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
3472
+ }),
3473
+ payload: { action: 'voucher' },
3474
+ },
3475
+ input: new Request('http://localhost', {
3476
+ method: 'POST',
3477
+ body: '{}',
3478
+ }),
3479
+ } as any)
3480
+ expect(result).toBeUndefined()
3481
+ })
3482
+
3015
3483
  test('returns 204 for voucher POST with content-length: 0', () => {
3016
3484
  const server = createServer()
3017
3485
  const result = server.respond!({
@@ -3031,6 +3499,187 @@ describe.runIf(isLocalnet)('session', () => {
3031
3499
  })
3032
3500
  })
3033
3501
 
3502
+ describe('HTTP session manager', () => {
3503
+ test('tracks spent from HTTP error receipts and closes at that amount', async () => {
3504
+ const backingStore = Store.memory()
3505
+ const routeHandler = Mppx_server.create({
3506
+ methods: [
3507
+ tempo_server.session({
3508
+ store: backingStore,
3509
+ getClient: () => client,
3510
+ account: recipientAccount,
3511
+ currency,
3512
+ escrowContract,
3513
+ chainId: chain.id,
3514
+ }),
3515
+ ],
3516
+ realm: 'api.example.com',
3517
+ secretKey: 'secret',
3518
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3519
+
3520
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3521
+ const request = new Request(input, init)
3522
+ const result = await routeHandler(request)
3523
+ if (result.status === 402) return result.challenge
3524
+ return result.withReceipt(new Response('upstream failed', { status: 500 }))
3525
+ }
3526
+
3527
+ const manager = sessionManager({
3528
+ account: payer,
3529
+ client,
3530
+ escrowContract,
3531
+ fetch,
3532
+ maxDeposit: '2',
3533
+ })
3534
+
3535
+ const response = await manager.fetch('https://api.example.com/resource')
3536
+ expect(response.status).toBe(500)
3537
+ expect(response.receipt?.spent).toBe('1000000')
3538
+ expect(response.receipt?.units).toBe(1)
3539
+
3540
+ const closeReceipt = await manager.close()
3541
+ expect(closeReceipt?.status).toBe('success')
3542
+ expect(closeReceipt?.spent).toBe('1000000')
3543
+ })
3544
+
3545
+ test('tracks spent from null-body HTTP content receipts and closes at that amount', async () => {
3546
+ const backingStore = Store.memory()
3547
+ const routeHandler = Mppx_server.create({
3548
+ methods: [
3549
+ tempo_server.session({
3550
+ store: backingStore,
3551
+ getClient: () => client,
3552
+ account: recipientAccount,
3553
+ currency,
3554
+ escrowContract,
3555
+ chainId: chain.id,
3556
+ }),
3557
+ ],
3558
+ realm: 'api.example.com',
3559
+ secretKey: 'secret',
3560
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3561
+
3562
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3563
+ const request = new Request(input, init)
3564
+ const result = await routeHandler(request)
3565
+ if (result.status === 402) return result.challenge
3566
+ return result.withReceipt(new Response(null, { status: 204 }))
3567
+ }
3568
+
3569
+ const manager = sessionManager({
3570
+ account: payer,
3571
+ client,
3572
+ escrowContract,
3573
+ fetch,
3574
+ maxDeposit: '2',
3575
+ })
3576
+
3577
+ const response = await manager.fetch('https://api.example.com/resource')
3578
+ expect(response.status).toBe(204)
3579
+ expect(await response.text()).toBe('')
3580
+ expect(response.receipt?.spent).toBe('1000000')
3581
+ expect(response.receipt?.units).toBe(1)
3582
+
3583
+ const closeReceipt = await manager.close()
3584
+ expect(closeReceipt?.status).toBe('success')
3585
+ expect(closeReceipt?.spent).toBe('1000000')
3586
+ })
3587
+
3588
+ test('throws when fallback close returns a non-ok response', async () => {
3589
+ const backingStore = Store.memory()
3590
+ const routeHandler = Mppx_server.create({
3591
+ methods: [
3592
+ tempo_server.session({
3593
+ store: backingStore,
3594
+ getClient: () => client,
3595
+ account: recipientAccount,
3596
+ currency,
3597
+ escrowContract,
3598
+ chainId: chain.id,
3599
+ }),
3600
+ ],
3601
+ realm: 'api.example.com',
3602
+ secretKey: 'secret',
3603
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3604
+
3605
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3606
+ const request = new Request(input, init)
3607
+ const action = request.headers.has('Authorization')
3608
+ ? Credential.fromRequest<any>(request).payload?.action
3609
+ : undefined
3610
+ const result = await routeHandler(request)
3611
+ if (result.status === 402) return result.challenge
3612
+ if (action === 'close') {
3613
+ return new Response('close failed', {
3614
+ status: 500,
3615
+ headers: { 'WWW-Authenticate': 'Payment error="close_failed"' },
3616
+ })
3617
+ }
3618
+ return result.withReceipt(new Response('ok'))
3619
+ }
3620
+
3621
+ const manager = sessionManager({
3622
+ account: payer,
3623
+ client,
3624
+ escrowContract,
3625
+ fetch,
3626
+ maxDeposit: '2',
3627
+ })
3628
+
3629
+ const response = await manager.fetch('https://api.example.com/resource')
3630
+ expect(response.status).toBe(200)
3631
+ expect(response.receipt?.spent).toBe('1000000')
3632
+
3633
+ await expect(manager.close()).rejects.toThrow(
3634
+ 'Close request failed with status 500: close failed [WWW-Authenticate: Payment error="close_failed"]',
3635
+ )
3636
+ })
3637
+
3638
+ test('sse transport charges plain HTTP fallback responses and closes at receipt.spent', async () => {
3639
+ const backingStore = Store.memory()
3640
+ const routeHandler = Mppx_server.create({
3641
+ methods: [
3642
+ tempo_server.session({
3643
+ store: backingStore,
3644
+ getClient: () => client,
3645
+ account: recipientAccount,
3646
+ currency,
3647
+ escrowContract,
3648
+ chainId: chain.id,
3649
+ sse: true,
3650
+ }),
3651
+ ],
3652
+ realm: 'api.example.com',
3653
+ secretKey: 'secret',
3654
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
3655
+
3656
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3657
+ const request = new Request(input, init)
3658
+ const result = await routeHandler(request)
3659
+ if (result.status === 402) return result.challenge
3660
+ return result.withReceipt(new Response('ok'))
3661
+ }
3662
+
3663
+ const manager = sessionManager({
3664
+ account: payer,
3665
+ client,
3666
+ escrowContract,
3667
+ fetch,
3668
+ maxDeposit: '2',
3669
+ })
3670
+
3671
+ const response = await manager.fetch('https://api.example.com/resource')
3672
+ expect(response.status).toBe(200)
3673
+ expect(await response.text()).toBe('ok')
3674
+ expect(response.receipt?.spent).toBe('1000000')
3675
+ expect(response.receipt?.units).toBe(1)
3676
+
3677
+ const closeReceipt = await manager.close()
3678
+ expect(closeReceipt?.status).toBe('success')
3679
+ expect(closeReceipt?.spent).toBe('1000000')
3680
+ })
3681
+ })
3682
+
3034
3683
  describe('SSE', () => {
3035
3684
  test('behavior: withReceipt accepts async generator and returns Response', async () => {
3036
3685
  const handler = Mppx_server.create({
@@ -3181,6 +3830,91 @@ describe.runIf(isLocalnet)('session', () => {
3181
3830
  expect(persisted?.finalized).toBe(true)
3182
3831
  })
3183
3832
 
3833
+ test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
3834
+ const backingStore = Store.memory()
3835
+ const routeHandler = Mppx_server.create({
3836
+ methods: [
3837
+ tempo_server.session({
3838
+ store: backingStore,
3839
+ getClient: () => client,
3840
+ account: recipientAccount,
3841
+ currency,
3842
+ escrowContract,
3843
+ chainId: chain.id,
3844
+ sse: true,
3845
+ }),
3846
+ ],
3847
+ realm: 'api.example.com',
3848
+ secretKey: 'secret',
3849
+ }).session({ amount: '1', decimals: 6, unitType: 'request' })
3850
+
3851
+ let voucherPosts = 0
3852
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3853
+ const request = new Request(input, init)
3854
+ let action: 'open' | 'topUp' | 'voucher' | 'close' | undefined
3855
+
3856
+ if (request.method === 'POST' && request.headers.has('Authorization')) {
3857
+ try {
3858
+ const credential = Credential.fromRequest<any>(request)
3859
+ action = credential.payload?.action
3860
+ if (action === 'voucher') voucherPosts++
3861
+ } catch {}
3862
+ }
3863
+
3864
+ const result = await routeHandler(request)
3865
+ if (result.status === 402) return result.challenge
3866
+
3867
+ if (action === 'voucher') {
3868
+ return new Response(null, { status: 200 })
3869
+ }
3870
+
3871
+ if (request.headers.get('Accept')?.includes('text/event-stream')) {
3872
+ const encoder = new TextEncoder()
3873
+ const upstream = new Response(
3874
+ new ReadableStream({
3875
+ start(controller) {
3876
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-1\n\n'))
3877
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-2\n\n'))
3878
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-3\n\n'))
3879
+ controller.close()
3880
+ },
3881
+ }),
3882
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
3883
+ )
3884
+ return result.withReceipt(upstream)
3885
+ }
3886
+
3887
+ return result.withReceipt(new Response('ok'))
3888
+ }
3889
+
3890
+ const manager = sessionManager({
3891
+ account: payer,
3892
+ client,
3893
+ escrowContract,
3894
+ fetch,
3895
+ maxDeposit: '1',
3896
+ })
3897
+
3898
+ const chunks: string[] = []
3899
+ const stream = await manager.sse('https://api.example.com/stream')
3900
+ for await (const chunk of stream) chunks.push(chunk)
3901
+
3902
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
3903
+ expect(voucherPosts).toBe(0)
3904
+
3905
+ const closeReceipt = await manager.close()
3906
+ expect(closeReceipt?.status).toBe('success')
3907
+ expect(closeReceipt?.spent).toBe('1000000')
3908
+
3909
+ const channelId = manager.channelId
3910
+ expect(channelId).toBeTruthy()
3911
+
3912
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId!)
3913
+ expect(persisted?.spent).toBe(1000000n)
3914
+ expect(persisted?.units).toBe(1)
3915
+ expect(persisted?.finalized).toBe(true)
3916
+ })
3917
+
3184
3918
  test('handles repeated exhaustion/resume cycles within one stream', async () => {
3185
3919
  const backingStore = Store.memory()
3186
3920
  const routeHandler = Mppx_server.create({
@@ -4616,6 +5350,30 @@ describe('session default currency resolution', () => {
4616
5350
  const challenge = Challenge.fromResponse(result.challenge)
4617
5351
  expect(challenge.request.currency).toBe('0xcustom')
4618
5352
  })
5353
+
5354
+ test('handler.session throws for zero-amount routes', () => {
5355
+ const handler = Mppx_server.create({
5356
+ methods: [
5357
+ tempo_server.session({
5358
+ store: Store.memory(),
5359
+ getClient: () => mockClient,
5360
+ account: mockAccount,
5361
+ escrowContract: '0x0000000000000000000000000000000000000002',
5362
+ chainId: 4217,
5363
+ }),
5364
+ ],
5365
+ realm: 'api.example.com',
5366
+ secretKey: 'secret',
5367
+ })
5368
+
5369
+ expect(() =>
5370
+ (handler.session as Function)({
5371
+ amount: '0',
5372
+ decimals: 6,
5373
+ unitType: 'token',
5374
+ }),
5375
+ ).toThrow('Session amount must be greater than 0')
5376
+ })
4619
5377
  })
4620
5378
 
4621
5379
  function nextSalt(): Hex {