mppx 0.5.13 → 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.
- package/CHANGELOG.md +23 -0
- package/dist/Method.d.ts +5 -2
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +8 -2
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +17 -10
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.js +5 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +4 -0
- package/dist/server/Transport.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +4 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +20 -10
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +99 -23
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +6 -0
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +4 -0
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +79 -48
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +0 -7
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +84 -13
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +5 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +202 -63
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +1 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +38 -15
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/package.json +2 -2
- package/src/Method.ts +5 -2
- package/src/internal/changeset.test.ts +106 -0
- package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +10 -2
- package/src/proxy/Proxy.test.ts +149 -1
- package/src/server/Mppx.test.ts +120 -0
- package/src/server/Mppx.ts +27 -11
- package/src/server/Request.test.ts +46 -1
- package/src/server/Request.ts +6 -1
- package/src/server/Transport.test.ts +2 -0
- package/src/server/Transport.ts +4 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +13 -0
- package/src/tempo/Methods.ts +23 -16
- package/src/tempo/client/SessionManager.ts +32 -9
- package/src/tempo/internal/fee-payer.test.ts +88 -16
- package/src/tempo/internal/fee-payer.ts +118 -23
- package/src/tempo/server/Charge.test.ts +73 -0
- package/src/tempo/server/Charge.ts +6 -0
- package/src/tempo/server/Session.test.ts +934 -47
- package/src/tempo/server/Session.ts +100 -52
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.test.ts +321 -10
- package/src/tempo/server/internal/transport.ts +101 -14
- package/src/tempo/session/Chain.test.ts +225 -2
- package/src/tempo/session/Chain.ts +250 -65
- package/src/tempo/session/ChannelStore.test.ts +23 -0
- package/src/tempo/session/ChannelStore.ts +46 -13
- 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 {
|
|
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'
|
|
@@ -149,6 +154,40 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
149
154
|
expect(ch!.highestVoucherAmount).toBe(1000000n)
|
|
150
155
|
})
|
|
151
156
|
|
|
157
|
+
test('fee-payer policy override is enforced for sponsored open', async () => {
|
|
158
|
+
const salt = nextSalt()
|
|
159
|
+
const { channelId, serializedTransaction } = await signOpenChannel({
|
|
160
|
+
escrow: escrowContract,
|
|
161
|
+
payer,
|
|
162
|
+
payee: recipient,
|
|
163
|
+
token: currency,
|
|
164
|
+
deposit: 10000000n,
|
|
165
|
+
salt,
|
|
166
|
+
feePayer: true,
|
|
167
|
+
})
|
|
168
|
+
const server = createServer({
|
|
169
|
+
feePayer: recipientAccount,
|
|
170
|
+
feePayerPolicy: { maxGas: 1n },
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
server.verify({
|
|
175
|
+
credential: {
|
|
176
|
+
challenge: makeChallenge({ channelId }),
|
|
177
|
+
payload: {
|
|
178
|
+
action: 'open' as const,
|
|
179
|
+
type: 'transaction' as const,
|
|
180
|
+
channelId,
|
|
181
|
+
transaction: serializedTransaction,
|
|
182
|
+
cumulativeAmount: '1000000',
|
|
183
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
request: makeRequest({ feePayer: true }),
|
|
187
|
+
}),
|
|
188
|
+
).rejects.toThrow('gas exceeds sponsor policy')
|
|
189
|
+
})
|
|
190
|
+
|
|
152
191
|
test('rejects open when payee mismatch', async () => {
|
|
153
192
|
const wrongPayee = accounts[3].address
|
|
154
193
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n, {
|
|
@@ -299,6 +338,66 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
299
338
|
expect(ch!.highestVoucherAmount).toBe(1000000n)
|
|
300
339
|
})
|
|
301
340
|
|
|
341
|
+
test('reopen with a case-variant channelId does not reset available balance', async () => {
|
|
342
|
+
let open:
|
|
343
|
+
| {
|
|
344
|
+
channelId: Hex
|
|
345
|
+
serializedTransaction: Hex
|
|
346
|
+
}
|
|
347
|
+
| undefined
|
|
348
|
+
for (let i = 0; i < 10; i++) {
|
|
349
|
+
const candidate = await createSignedOpenTransaction(10000000n)
|
|
350
|
+
if (/[a-f]/.test(candidate.channelId)) {
|
|
351
|
+
open = candidate
|
|
352
|
+
break
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!open) throw new Error('failed to generate channelId with alphabetic hex characters')
|
|
356
|
+
|
|
357
|
+
const { channelId, serializedTransaction } = open
|
|
358
|
+
const caseVariantChannelId = channelId.replace(/[a-f]/, (character) =>
|
|
359
|
+
character.toUpperCase(),
|
|
360
|
+
) as Hex
|
|
361
|
+
const server = createServer()
|
|
362
|
+
|
|
363
|
+
await server.verify({
|
|
364
|
+
credential: {
|
|
365
|
+
challenge: makeChallenge({ id: 'open-1', channelId }),
|
|
366
|
+
payload: {
|
|
367
|
+
action: 'open' as const,
|
|
368
|
+
type: 'transaction' as const,
|
|
369
|
+
channelId,
|
|
370
|
+
transaction: serializedTransaction,
|
|
371
|
+
cumulativeAmount: '5000000',
|
|
372
|
+
signature: await signTestVoucher(channelId, 5000000n),
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
request: makeRequest(),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await charge(store, channelId, 4000000n)
|
|
379
|
+
|
|
380
|
+
const reopenReceipt = (await server.verify({
|
|
381
|
+
credential: {
|
|
382
|
+
challenge: makeChallenge({ id: 'open-2', channelId: caseVariantChannelId }),
|
|
383
|
+
payload: {
|
|
384
|
+
action: 'open' as const,
|
|
385
|
+
type: 'transaction' as const,
|
|
386
|
+
channelId: caseVariantChannelId,
|
|
387
|
+
transaction: serializedTransaction,
|
|
388
|
+
cumulativeAmount: '5000000',
|
|
389
|
+
signature: await signTestVoucher(caseVariantChannelId, 5000000n),
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
request: makeRequest(),
|
|
393
|
+
})) as SessionReceipt
|
|
394
|
+
|
|
395
|
+
expect(reopenReceipt.spent).toBe('4000000')
|
|
396
|
+
await expect(charge(store, caseVariantChannelId, 2000000n)).rejects.toThrow(
|
|
397
|
+
'requested 2000000, available 1000000',
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
302
401
|
test('rejects voucher below settledOnChain', async () => {
|
|
303
402
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
304
403
|
const server = createServer()
|
|
@@ -1036,6 +1135,40 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1036
1135
|
expect(ch!.deposit).toBe(20000000n)
|
|
1037
1136
|
})
|
|
1038
1137
|
|
|
1138
|
+
test('fee-payer policy override is enforced for sponsored topUp', async () => {
|
|
1139
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1140
|
+
const server = createServer({
|
|
1141
|
+
feePayer: recipientAccount,
|
|
1142
|
+
feePayerPolicy: { maxGas: 1n },
|
|
1143
|
+
})
|
|
1144
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
1145
|
+
|
|
1146
|
+
const { serializedTransaction: topUpTx } = await signTopUpChannel({
|
|
1147
|
+
escrow: escrowContract,
|
|
1148
|
+
payer,
|
|
1149
|
+
channelId,
|
|
1150
|
+
token: currency,
|
|
1151
|
+
amount: 10000000n,
|
|
1152
|
+
feePayer: true,
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
await expect(
|
|
1156
|
+
server.verify({
|
|
1157
|
+
credential: {
|
|
1158
|
+
challenge: makeChallenge({ id: 'challenge-topup-policy', channelId }),
|
|
1159
|
+
payload: {
|
|
1160
|
+
action: 'topUp' as const,
|
|
1161
|
+
type: 'transaction' as const,
|
|
1162
|
+
channelId,
|
|
1163
|
+
transaction: topUpTx,
|
|
1164
|
+
additionalDeposit: '10000000',
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
request: makeRequest({ feePayer: true }),
|
|
1168
|
+
}),
|
|
1169
|
+
).rejects.toThrow('gas exceeds sponsor policy')
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1039
1172
|
test('topUp receipt preserves spent and units from prior charges', async () => {
|
|
1040
1173
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1041
1174
|
const server = createServer()
|
|
@@ -2512,7 +2645,7 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2512
2645
|
})
|
|
2513
2646
|
|
|
2514
2647
|
describe('protocol compatibility', () => {
|
|
2515
|
-
test('HEAD voucher
|
|
2648
|
+
test('HEAD voucher request is gated as a non-billable management request', () => {
|
|
2516
2649
|
const server = createServer()
|
|
2517
2650
|
const response = server.respond!({
|
|
2518
2651
|
credential: {
|
|
@@ -2521,14 +2654,137 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2521
2654
|
}),
|
|
2522
2655
|
payload: { action: 'voucher' },
|
|
2523
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
|
+
},
|
|
2524
2665
|
input: new Request('https://api.example.com/resource', {
|
|
2525
2666
|
method: 'HEAD',
|
|
2526
2667
|
}),
|
|
2527
2668
|
} as never)
|
|
2528
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
|
+
|
|
2529
2722
|
expect(response).toBeUndefined()
|
|
2530
2723
|
})
|
|
2531
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
|
+
|
|
2532
2788
|
test('ignores unknown challenge and credential fields for forward compatibility', async () => {
|
|
2533
2789
|
const challenge = Challenge.from({
|
|
2534
2790
|
id: 'forward-compat',
|
|
@@ -2583,6 +2839,99 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2583
2839
|
expect(second.status).toBe(200)
|
|
2584
2840
|
})
|
|
2585
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
|
+
|
|
2586
2935
|
test('does not return Payment-Receipt on verification errors', async () => {
|
|
2587
2936
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2588
2937
|
const handler = createHandler()
|
|
@@ -2619,59 +2968,272 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2619
2968
|
expect(second.challenge.headers.get('Payment-Receipt')).toBeNull()
|
|
2620
2969
|
})
|
|
2621
2970
|
|
|
2622
|
-
test('
|
|
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)
|
|
2623
2974
|
const handler = createHandler()
|
|
2624
2975
|
const route = handler.session({
|
|
2625
|
-
amount: '
|
|
2626
|
-
|
|
2627
|
-
minVoucherDelta: '0.000000000000000001',
|
|
2628
|
-
decimals: 18,
|
|
2976
|
+
amount: '1',
|
|
2977
|
+
decimals: 6,
|
|
2629
2978
|
unitType: 'token',
|
|
2630
2979
|
})
|
|
2631
2980
|
|
|
2632
|
-
const
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
const challenge = Challenge.fromResponse(result.challenge)
|
|
2637
|
-
const request = challenge.request as {
|
|
2638
|
-
amount: string
|
|
2639
|
-
suggestedDeposit: string
|
|
2640
|
-
methodDetails: { minVoucherDelta: string }
|
|
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'))
|
|
2641
2985
|
}
|
|
2642
|
-
expect(request.amount).toBe('1')
|
|
2643
|
-
expect(request.suggestedDeposit).toBe('2')
|
|
2644
|
-
expect(request.methodDetails.minVoucherDelta).toBe('1')
|
|
2645
|
-
})
|
|
2646
|
-
})
|
|
2647
2986
|
|
|
2648
|
-
|
|
2649
|
-
test('ChannelNotFoundError on unknown channel', async () => {
|
|
2650
|
-
const { channelId } = await createSignedOpenTransaction(10000000n)
|
|
2651
|
-
const server = createServer()
|
|
2987
|
+
const issueChallenge = () => route(new Request('https://api.example.com/resource'))
|
|
2652
2988
|
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
challenge: makeChallenge({ channelId }),
|
|
2657
|
-
payload: {
|
|
2658
|
-
action: 'voucher' as const,
|
|
2659
|
-
channelId,
|
|
2660
|
-
cumulativeAmount: '1000000',
|
|
2661
|
-
signature: await signTestVoucher(channelId, 1000000n),
|
|
2662
|
-
},
|
|
2663
|
-
},
|
|
2664
|
-
request: makeRequest(),
|
|
2665
|
-
})
|
|
2666
|
-
expect.unreachable()
|
|
2667
|
-
} catch (e) {
|
|
2668
|
-
expect(e).toBeInstanceOf(ChannelNotFoundError)
|
|
2669
|
-
expect((e as ChannelNotFoundError).status).toBe(410)
|
|
2670
|
-
}
|
|
2671
|
-
})
|
|
2989
|
+
const first = await issueChallenge()
|
|
2990
|
+
expect(first.status).toBe(402)
|
|
2991
|
+
if (first.status !== 402) throw new Error('expected challenge')
|
|
2672
2992
|
|
|
2673
|
-
|
|
2674
|
-
|
|
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
|
+
|
|
3184
|
+
test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
|
|
3185
|
+
const handler = createHandler()
|
|
3186
|
+
const route = handler.session({
|
|
3187
|
+
amount: '0.000000000000000001',
|
|
3188
|
+
suggestedDeposit: '0.000000000000000002',
|
|
3189
|
+
minVoucherDelta: '0.000000000000000001',
|
|
3190
|
+
decimals: 18,
|
|
3191
|
+
unitType: 'token',
|
|
3192
|
+
})
|
|
3193
|
+
|
|
3194
|
+
const result = await route(new Request('https://api.example.com/resource'))
|
|
3195
|
+
expect(result.status).toBe(402)
|
|
3196
|
+
if (result.status !== 402) throw new Error('expected challenge')
|
|
3197
|
+
|
|
3198
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
3199
|
+
const request = challenge.request as {
|
|
3200
|
+
amount: string
|
|
3201
|
+
suggestedDeposit: string
|
|
3202
|
+
methodDetails: { minVoucherDelta: string }
|
|
3203
|
+
}
|
|
3204
|
+
expect(request.amount).toBe('1')
|
|
3205
|
+
expect(request.suggestedDeposit).toBe('2')
|
|
3206
|
+
expect(request.methodDetails.minVoucherDelta).toBe('1')
|
|
3207
|
+
})
|
|
3208
|
+
})
|
|
3209
|
+
|
|
3210
|
+
describe('structured errors', () => {
|
|
3211
|
+
test('ChannelNotFoundError on unknown channel', async () => {
|
|
3212
|
+
const { channelId } = await createSignedOpenTransaction(10000000n)
|
|
3213
|
+
const server = createServer()
|
|
3214
|
+
|
|
3215
|
+
try {
|
|
3216
|
+
await server.verify({
|
|
3217
|
+
credential: {
|
|
3218
|
+
challenge: makeChallenge({ channelId }),
|
|
3219
|
+
payload: {
|
|
3220
|
+
action: 'voucher' as const,
|
|
3221
|
+
channelId,
|
|
3222
|
+
cumulativeAmount: '1000000',
|
|
3223
|
+
signature: await signTestVoucher(channelId, 1000000n),
|
|
3224
|
+
},
|
|
3225
|
+
},
|
|
3226
|
+
request: makeRequest(),
|
|
3227
|
+
})
|
|
3228
|
+
expect.unreachable()
|
|
3229
|
+
} catch (e) {
|
|
3230
|
+
expect(e).toBeInstanceOf(ChannelNotFoundError)
|
|
3231
|
+
expect((e as ChannelNotFoundError).status).toBe(410)
|
|
3232
|
+
}
|
|
3233
|
+
})
|
|
3234
|
+
|
|
3235
|
+
test('InvalidSignatureError has status 402', async () => {
|
|
3236
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
2675
3237
|
const server = createServer()
|
|
2676
3238
|
|
|
2677
3239
|
try {
|
|
@@ -2835,6 +3397,23 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2835
3397
|
expect(result).toBeUndefined()
|
|
2836
3398
|
})
|
|
2837
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
|
+
|
|
2838
3417
|
test('returns 204 for GET with topUp action', () => {
|
|
2839
3418
|
const server = createServer()
|
|
2840
3419
|
const result = server.respond!({
|
|
@@ -2884,6 +3463,23 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2884
3463
|
expect(result).toBeUndefined()
|
|
2885
3464
|
})
|
|
2886
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
|
+
|
|
2887
3483
|
test('returns 204 for voucher POST with content-length: 0', () => {
|
|
2888
3484
|
const server = createServer()
|
|
2889
3485
|
const result = server.respond!({
|
|
@@ -2903,6 +3499,187 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
2903
3499
|
})
|
|
2904
3500
|
})
|
|
2905
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
|
+
|
|
2906
3683
|
describe('SSE', () => {
|
|
2907
3684
|
test('behavior: withReceipt accepts async generator and returns Response', async () => {
|
|
2908
3685
|
const handler = Mppx_server.create({
|
|
@@ -3053,6 +3830,91 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3053
3830
|
expect(persisted?.finalized).toBe(true)
|
|
3054
3831
|
})
|
|
3055
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
|
+
|
|
3056
3918
|
test('handles repeated exhaustion/resume cycles within one stream', async () => {
|
|
3057
3919
|
const backingStore = Store.memory()
|
|
3058
3920
|
const routeHandler = Mppx_server.create({
|
|
@@ -4488,6 +5350,30 @@ describe('session default currency resolution', () => {
|
|
|
4488
5350
|
const challenge = Challenge.fromResponse(result.challenge)
|
|
4489
5351
|
expect(challenge.request.currency).toBe('0xcustom')
|
|
4490
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
|
+
})
|
|
4491
5377
|
})
|
|
4492
5378
|
|
|
4493
5379
|
function nextSalt(): Hex {
|
|
@@ -4514,7 +5400,7 @@ function makeChallenge(opts: { id?: string; channelId: Hex }) {
|
|
|
4514
5400
|
} as Challenge.Challenge<z.output<typeof Methods.session.schema.request>, 'session', 'tempo'>
|
|
4515
5401
|
}
|
|
4516
5402
|
|
|
4517
|
-
function makeRequest() {
|
|
5403
|
+
function makeRequest(overrides: Partial<Record<string, unknown>> = {}) {
|
|
4518
5404
|
return {
|
|
4519
5405
|
amount: '1000000',
|
|
4520
5406
|
unitType: 'token',
|
|
@@ -4523,6 +5409,7 @@ function makeRequest() {
|
|
|
4523
5409
|
recipient: recipient as string,
|
|
4524
5410
|
escrowContract: escrowContract as string,
|
|
4525
5411
|
chainId: chain.id,
|
|
5412
|
+
...overrides,
|
|
4526
5413
|
}
|
|
4527
5414
|
}
|
|
4528
5415
|
|