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.
- package/CHANGELOG.md +15 -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 +92 -21
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +43 -20
- 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/package.json +1 -1
- 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 +40 -4
- package/src/tempo/internal/fee-payer.ts +105 -21
- package/src/tempo/server/Session.test.ts +760 -2
- package/src/tempo/server/Session.ts +59 -17
- 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/viem/Client.test.ts +52 -1
|
@@ -5,6 +5,7 @@ import { describe, expect, test } from 'vp/test'
|
|
|
5
5
|
import * as Store from '../../../Store.js'
|
|
6
6
|
import { chainId, escrowContract as escrowContractDefaults } from '../../internal/defaults.js'
|
|
7
7
|
import * as ChannelStore from '../../session/ChannelStore.js'
|
|
8
|
+
import { deserializeSessionReceipt } from '../../session/Receipt.js'
|
|
8
9
|
import { sse } from './transport.js'
|
|
9
10
|
|
|
10
11
|
const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
|
|
@@ -38,7 +39,14 @@ function seedChannel(
|
|
|
38
39
|
}))
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function makeChallenge(
|
|
42
|
+
function makeChallenge(
|
|
43
|
+
request: Partial<{
|
|
44
|
+
amount: string
|
|
45
|
+
currency: string
|
|
46
|
+
recipient: string
|
|
47
|
+
unitType: string
|
|
48
|
+
}> = {},
|
|
49
|
+
) {
|
|
42
50
|
return Challenge.from({
|
|
43
51
|
id: challengeId,
|
|
44
52
|
realm: 'test.example.com',
|
|
@@ -48,12 +56,20 @@ function makeChallenge() {
|
|
|
48
56
|
amount: '1000000',
|
|
49
57
|
currency: '0x20c0000000000000000000000000000000000001',
|
|
50
58
|
recipient: '0x0000000000000000000000000000000000000002',
|
|
59
|
+
...request,
|
|
51
60
|
},
|
|
52
61
|
})
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
function makeCredential(
|
|
56
|
-
|
|
64
|
+
function makeCredential(
|
|
65
|
+
request: Partial<{
|
|
66
|
+
amount: string
|
|
67
|
+
currency: string
|
|
68
|
+
recipient: string
|
|
69
|
+
unitType: string
|
|
70
|
+
}> = {},
|
|
71
|
+
) {
|
|
72
|
+
const challenge = makeChallenge(request)
|
|
57
73
|
return Credential.from({
|
|
58
74
|
challenge,
|
|
59
75
|
payload: {
|
|
@@ -65,20 +81,66 @@ function makeCredential() {
|
|
|
65
81
|
})
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
function makeAuthorizedRequest(
|
|
69
|
-
|
|
84
|
+
function makeAuthorizedRequest(
|
|
85
|
+
request: Partial<{
|
|
86
|
+
amount: string
|
|
87
|
+
currency: string
|
|
88
|
+
recipient: string
|
|
89
|
+
unitType: string
|
|
90
|
+
}> = {},
|
|
91
|
+
): Request {
|
|
92
|
+
const credential = makeCredential(request)
|
|
70
93
|
const header = Credential.serialize(credential)
|
|
71
94
|
return new Request('https://test.example.com/session', {
|
|
72
95
|
headers: { Authorization: header },
|
|
73
96
|
})
|
|
74
97
|
}
|
|
75
98
|
|
|
76
|
-
function
|
|
99
|
+
function makeManagementRequest(action: 'close' | 'topUp' = 'close'): Request {
|
|
100
|
+
const credential = Credential.from({
|
|
101
|
+
challenge: makeChallenge(),
|
|
102
|
+
payload:
|
|
103
|
+
action === 'close'
|
|
104
|
+
? {
|
|
105
|
+
action: 'close' as const,
|
|
106
|
+
channelId,
|
|
107
|
+
cumulativeAmount: '10000000',
|
|
108
|
+
signature: '0xdeadbeef',
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
action: 'topUp' as const,
|
|
112
|
+
channelId,
|
|
113
|
+
type: 'transaction' as const,
|
|
114
|
+
transaction: '0xdeadbeef',
|
|
115
|
+
additionalDeposit: '1000000',
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
const header = Credential.serialize(credential)
|
|
119
|
+
return new Request('https://test.example.com/session', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { Authorization: header },
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type ReceiptOverrides = Partial<{
|
|
126
|
+
acceptedCumulative: string
|
|
127
|
+
spent: string
|
|
128
|
+
units: number
|
|
129
|
+
}>
|
|
130
|
+
|
|
131
|
+
function makeReceipt(overrides: ReceiptOverrides = {}) {
|
|
77
132
|
return {
|
|
78
133
|
method: 'tempo',
|
|
134
|
+
intent: 'session' as const,
|
|
79
135
|
status: 'success' as const,
|
|
80
136
|
timestamp: new Date().toISOString(),
|
|
81
137
|
reference: channelId,
|
|
138
|
+
challengeId,
|
|
139
|
+
channelId,
|
|
140
|
+
acceptedCumulative: '10000000',
|
|
141
|
+
spent: '0',
|
|
142
|
+
units: 0,
|
|
143
|
+
...overrides,
|
|
82
144
|
}
|
|
83
145
|
}
|
|
84
146
|
|
|
@@ -95,6 +157,17 @@ async function readResponseText(response: Response): Promise<string> {
|
|
|
95
157
|
return result
|
|
96
158
|
}
|
|
97
159
|
|
|
160
|
+
function readTerminalReceipt(output: string) {
|
|
161
|
+
const receiptRaw = output.split('event: payment-receipt\ndata: ')[1]?.split('\n\n')[0]
|
|
162
|
+
if (!receiptRaw) throw new Error('expected terminal receipt')
|
|
163
|
+
return JSON.parse(receiptRaw) as {
|
|
164
|
+
challengeId: string
|
|
165
|
+
channelId: string
|
|
166
|
+
spent: string
|
|
167
|
+
units?: number
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
98
171
|
describe('sse transport', () => {
|
|
99
172
|
test('getCredential returns null when no Authorization header', () => {
|
|
100
173
|
const store = memoryStore()
|
|
@@ -169,8 +242,7 @@ describe('sse transport', () => {
|
|
|
169
242
|
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
|
170
243
|
|
|
171
244
|
const body = await readResponseText(response)
|
|
172
|
-
const
|
|
173
|
-
const terminalReceipt = JSON.parse(receiptRaw!)
|
|
245
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
174
246
|
|
|
175
247
|
expect(response.headers.get('Payment-Receipt')).toBeNull()
|
|
176
248
|
expect(body).toContain('event: message\ndata: hello\n\n')
|
|
@@ -182,6 +254,92 @@ describe('sse transport', () => {
|
|
|
182
254
|
expect(terminalReceipt.spent).toBe('2000000')
|
|
183
255
|
})
|
|
184
256
|
|
|
257
|
+
test('respondReceipt with AsyncIterable and unitType=request charges once', async () => {
|
|
258
|
+
const store = memoryStore()
|
|
259
|
+
await seedChannel(store, 10000000n)
|
|
260
|
+
const transport = sse({ store })
|
|
261
|
+
const request = makeAuthorizedRequest({ unitType: 'request' })
|
|
262
|
+
|
|
263
|
+
async function* gen() {
|
|
264
|
+
yield 'hello'
|
|
265
|
+
yield 'world'
|
|
266
|
+
yield 'again'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const response = transport.respondReceipt({
|
|
270
|
+
credential: makeCredential({ unitType: 'request' }),
|
|
271
|
+
input: request,
|
|
272
|
+
receipt: makeReceipt(),
|
|
273
|
+
response: gen(),
|
|
274
|
+
challengeId,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const body = await readResponseText(response)
|
|
278
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
279
|
+
const channel = await store.getChannel(channelId)
|
|
280
|
+
|
|
281
|
+
expect(channel!.spent).toBe(1000000n)
|
|
282
|
+
expect(channel!.units).toBe(1)
|
|
283
|
+
expect(terminalReceipt.spent).toBe('1000000')
|
|
284
|
+
expect(terminalReceipt.units).toBe(1)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('respondReceipt with AsyncIterable and non-request unitType still charges per chunk', async () => {
|
|
288
|
+
const store = memoryStore()
|
|
289
|
+
await seedChannel(store, 10000000n)
|
|
290
|
+
const transport = sse({ store })
|
|
291
|
+
const request = makeAuthorizedRequest({ unitType: 'token' })
|
|
292
|
+
|
|
293
|
+
async function* gen() {
|
|
294
|
+
yield 'hello'
|
|
295
|
+
yield 'world'
|
|
296
|
+
yield 'again'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const response = transport.respondReceipt({
|
|
300
|
+
credential: makeCredential({ unitType: 'token' }),
|
|
301
|
+
input: request,
|
|
302
|
+
receipt: makeReceipt(),
|
|
303
|
+
response: gen(),
|
|
304
|
+
challengeId,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const body = await readResponseText(response)
|
|
308
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
309
|
+
const channel = await store.getChannel(channelId)
|
|
310
|
+
|
|
311
|
+
expect(channel!.spent).toBe(3000000n)
|
|
312
|
+
expect(channel!.units).toBe(3)
|
|
313
|
+
expect(terminalReceipt.spent).toBe('3000000')
|
|
314
|
+
expect(terminalReceipt.units).toBe(3)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('respondReceipt with unitType=request does not charge an empty AsyncIterable', async () => {
|
|
318
|
+
const store = memoryStore()
|
|
319
|
+
await seedChannel(store, 10000000n)
|
|
320
|
+
const transport = sse({ store })
|
|
321
|
+
const request = makeAuthorizedRequest({ unitType: 'request' })
|
|
322
|
+
|
|
323
|
+
async function* gen() {}
|
|
324
|
+
|
|
325
|
+
const response = transport.respondReceipt({
|
|
326
|
+
credential: makeCredential({ unitType: 'request' }),
|
|
327
|
+
input: request,
|
|
328
|
+
receipt: makeReceipt(),
|
|
329
|
+
response: gen(),
|
|
330
|
+
challengeId,
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const body = await readResponseText(response)
|
|
334
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
335
|
+
const channel = await store.getChannel(channelId)
|
|
336
|
+
|
|
337
|
+
expect(channel!.spent).toBe(0n)
|
|
338
|
+
expect(channel!.units).toBe(0)
|
|
339
|
+
expect(terminalReceipt.spent).toBe('0')
|
|
340
|
+
expect(terminalReceipt.units).toBe(0)
|
|
341
|
+
})
|
|
342
|
+
|
|
185
343
|
test('respondReceipt with AsyncGeneratorFunction passes stream controller', async () => {
|
|
186
344
|
const store = memoryStore()
|
|
187
345
|
await seedChannel(store, 10000000n)
|
|
@@ -201,6 +359,35 @@ describe('sse transport', () => {
|
|
|
201
359
|
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
|
202
360
|
})
|
|
203
361
|
|
|
362
|
+
test('respondReceipt with AsyncGeneratorFunction and unitType=request preserves manual charge calls', async () => {
|
|
363
|
+
const store = memoryStore()
|
|
364
|
+
await seedChannel(store, 10000000n)
|
|
365
|
+
const transport = sse({ store })
|
|
366
|
+
const request = makeAuthorizedRequest({ unitType: 'request' })
|
|
367
|
+
|
|
368
|
+
const response = transport.respondReceipt({
|
|
369
|
+
credential: makeCredential({ unitType: 'request' }),
|
|
370
|
+
input: request,
|
|
371
|
+
receipt: makeReceipt(),
|
|
372
|
+
response: async function* (stream) {
|
|
373
|
+
await stream.charge()
|
|
374
|
+
yield 'hello'
|
|
375
|
+
await stream.charge()
|
|
376
|
+
yield 'world'
|
|
377
|
+
},
|
|
378
|
+
challengeId,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const body = await readResponseText(response)
|
|
382
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
383
|
+
const channel = await store.getChannel(channelId)
|
|
384
|
+
|
|
385
|
+
expect(channel!.spent).toBe(2000000n)
|
|
386
|
+
expect(channel!.units).toBe(2)
|
|
387
|
+
expect(terminalReceipt.spent).toBe('2000000')
|
|
388
|
+
expect(terminalReceipt.units).toBe(2)
|
|
389
|
+
})
|
|
390
|
+
|
|
204
391
|
test('respondReceipt with upstream SSE Response auto-detects and iterates', async () => {
|
|
205
392
|
const store = memoryStore()
|
|
206
393
|
await seedChannel(store, 10000000n)
|
|
@@ -235,6 +422,81 @@ describe('sse transport', () => {
|
|
|
235
422
|
expect(body).toContain('event: payment-receipt\n')
|
|
236
423
|
})
|
|
237
424
|
|
|
425
|
+
test('respondReceipt with upstream SSE Response and unitType=request charges once', async () => {
|
|
426
|
+
const store = memoryStore()
|
|
427
|
+
await seedChannel(store, 10000000n)
|
|
428
|
+
const transport = sse({ store })
|
|
429
|
+
const request = makeAuthorizedRequest({ unitType: 'request' })
|
|
430
|
+
|
|
431
|
+
const encoder = new TextEncoder()
|
|
432
|
+
const upstream = new Response(
|
|
433
|
+
new ReadableStream({
|
|
434
|
+
start(controller) {
|
|
435
|
+
controller.enqueue(encoder.encode('event: message\ndata: chunk1\n\n'))
|
|
436
|
+
controller.enqueue(encoder.encode('event: message\ndata: chunk2\n\n'))
|
|
437
|
+
controller.enqueue(encoder.encode('event: message\ndata: chunk3\n\n'))
|
|
438
|
+
controller.close()
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
{ headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
const response = transport.respondReceipt({
|
|
445
|
+
credential: makeCredential({ unitType: 'request' }),
|
|
446
|
+
input: request,
|
|
447
|
+
receipt: makeReceipt(),
|
|
448
|
+
response: upstream,
|
|
449
|
+
challengeId,
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const body = await readResponseText(response)
|
|
453
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
454
|
+
const channel = await store.getChannel(channelId)
|
|
455
|
+
|
|
456
|
+
expect(channel!.spent).toBe(1000000n)
|
|
457
|
+
expect(channel!.units).toBe(1)
|
|
458
|
+
expect(body).toContain('event: message\ndata: chunk1\n\n')
|
|
459
|
+
expect(body).toContain('event: message\ndata: chunk2\n\n')
|
|
460
|
+
expect(body).toContain('event: message\ndata: chunk3\n\n')
|
|
461
|
+
expect(terminalReceipt.spent).toBe('1000000')
|
|
462
|
+
expect(terminalReceipt.units).toBe(1)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('respondReceipt with empty upstream SSE Response and unitType=request does not charge', async () => {
|
|
466
|
+
const store = memoryStore()
|
|
467
|
+
await seedChannel(store, 10000000n)
|
|
468
|
+
const transport = sse({ store })
|
|
469
|
+
const request = makeAuthorizedRequest({ unitType: 'request' })
|
|
470
|
+
|
|
471
|
+
const encoder = new TextEncoder()
|
|
472
|
+
const upstream = new Response(
|
|
473
|
+
new ReadableStream({
|
|
474
|
+
start(controller) {
|
|
475
|
+
controller.enqueue(encoder.encode('event: message\ndata: [DONE]\n\n'))
|
|
476
|
+
controller.close()
|
|
477
|
+
},
|
|
478
|
+
}),
|
|
479
|
+
{ headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
const response = transport.respondReceipt({
|
|
483
|
+
credential: makeCredential({ unitType: 'request' }),
|
|
484
|
+
input: request,
|
|
485
|
+
receipt: makeReceipt(),
|
|
486
|
+
response: upstream,
|
|
487
|
+
challengeId,
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const body = await readResponseText(response)
|
|
491
|
+
const terminalReceipt = readTerminalReceipt(body)
|
|
492
|
+
const channel = await store.getChannel(channelId)
|
|
493
|
+
|
|
494
|
+
expect(channel!.spent).toBe(0n)
|
|
495
|
+
expect(channel!.units).toBe(0)
|
|
496
|
+
expect(terminalReceipt.spent).toBe('0')
|
|
497
|
+
expect(terminalReceipt.units).toBe(0)
|
|
498
|
+
})
|
|
499
|
+
|
|
238
500
|
test('respondReceipt with plain Response delegates to base http transport', () => {
|
|
239
501
|
const store = memoryStore()
|
|
240
502
|
const transport = sse({ store })
|
|
@@ -318,27 +580,58 @@ describe('sse transport', () => {
|
|
|
318
580
|
})
|
|
319
581
|
|
|
320
582
|
const body = await response.text()
|
|
583
|
+
const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
|
|
321
584
|
|
|
322
585
|
const channel = await store.getChannel(channelId)
|
|
323
586
|
expect(channel!.spent).toBe(1000000n)
|
|
324
587
|
expect(channel!.units).toBe(1)
|
|
588
|
+
expect(receipt.spent).toBe('1000000')
|
|
589
|
+
expect(receipt.units).toBe(1)
|
|
325
590
|
|
|
326
591
|
expect(JSON.parse(body)).toEqual({ content: 'hello' })
|
|
327
592
|
expect(response.headers.get('Content-Type')).toBe('application/json')
|
|
328
593
|
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
329
594
|
})
|
|
330
595
|
|
|
331
|
-
test('respondReceipt with 204
|
|
596
|
+
test('respondReceipt with 204 content response still deducts from channel', async () => {
|
|
332
597
|
const store = memoryStore()
|
|
333
598
|
await seedChannel(store, 10000000n)
|
|
334
599
|
const transport = sse({ store })
|
|
335
600
|
const request = makeAuthorizedRequest()
|
|
336
601
|
|
|
337
|
-
const
|
|
602
|
+
const contentResponse = new Response(null, { status: 204 })
|
|
338
603
|
const response = transport.respondReceipt({
|
|
339
604
|
credential: makeCredential(),
|
|
340
605
|
input: request,
|
|
341
606
|
receipt: makeReceipt(),
|
|
607
|
+
response: contentResponse,
|
|
608
|
+
challengeId,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
expect(response.status).toBe(204)
|
|
612
|
+
expect(await response.text()).toBe('')
|
|
613
|
+
const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
|
|
614
|
+
|
|
615
|
+
await Promise.resolve()
|
|
616
|
+
|
|
617
|
+
const channel = await store.getChannel(channelId)
|
|
618
|
+
expect(channel!.spent).toBe(1000000n)
|
|
619
|
+
expect(channel!.units).toBe(1)
|
|
620
|
+
expect(receipt.spent).toBe('1000000')
|
|
621
|
+
expect(receipt.units).toBe(1)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('respondReceipt with management response keeps null body and does not deduct', async () => {
|
|
625
|
+
const store = memoryStore()
|
|
626
|
+
await seedChannel(store, 10000000n)
|
|
627
|
+
const transport = sse({ store })
|
|
628
|
+
const request = makeManagementRequest()
|
|
629
|
+
|
|
630
|
+
const managementResponse = new Response(null, { status: 204 })
|
|
631
|
+
const response = transport.respondReceipt({
|
|
632
|
+
credential: Credential.fromRequest(makeManagementRequest())!,
|
|
633
|
+
input: request,
|
|
634
|
+
receipt: makeReceipt(),
|
|
342
635
|
response: managementResponse,
|
|
343
636
|
challengeId,
|
|
344
637
|
})
|
|
@@ -352,6 +645,24 @@ describe('sse transport', () => {
|
|
|
352
645
|
expect(channel!.units).toBe(0)
|
|
353
646
|
})
|
|
354
647
|
|
|
648
|
+
test('respondReceipt rejects replayed plain responses with no remaining balance', async () => {
|
|
649
|
+
const store = memoryStore()
|
|
650
|
+
await seedChannel(store, 10000000n)
|
|
651
|
+
const transport = sse({ store })
|
|
652
|
+
const request = makeAuthorizedRequest()
|
|
653
|
+
|
|
654
|
+
const response = transport.respondReceipt({
|
|
655
|
+
credential: makeCredential(),
|
|
656
|
+
input: request,
|
|
657
|
+
receipt: makeReceipt({ acceptedCumulative: '1000000', spent: '1000000', units: 1 }),
|
|
658
|
+
response: new Response('ok'),
|
|
659
|
+
challengeId,
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
expect(response.status).toBe(402)
|
|
663
|
+
expect(response.headers.get('Payment-Receipt')).toBeNull()
|
|
664
|
+
})
|
|
665
|
+
|
|
355
666
|
test('poll: true strips waitForUpdate from store', async () => {
|
|
356
667
|
const store = memoryStore()
|
|
357
668
|
;(store as any).waitForUpdate = async () => {}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @internal
|
|
7
7
|
*/
|
|
8
|
+
import * as Challenge from '../../../Challenge.js'
|
|
9
|
+
import * as Errors from '../../../Errors.js'
|
|
8
10
|
import * as Transport from '../../../server/Transport.js'
|
|
9
11
|
import * as ChannelStore from '../../session/ChannelStore.js'
|
|
10
12
|
import * as Sse_core from '../../session/Sse.js'
|
|
11
|
-
import type { SessionCredentialPayload } from '../../session/Types.js'
|
|
13
|
+
import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
|
|
12
14
|
|
|
13
15
|
/** SSE transport with Tempo session controller. */
|
|
14
16
|
export type Sse = Transport.Sse<Sse_core.SessionController>
|
|
@@ -41,6 +43,7 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
41
43
|
captureRequest(request) {
|
|
42
44
|
return (
|
|
43
45
|
base.captureRequest?.(request) ?? {
|
|
46
|
+
hasBody: request.body !== null,
|
|
44
47
|
headers: new Headers(request.headers),
|
|
45
48
|
method: request.method,
|
|
46
49
|
url: new URL(request.url),
|
|
@@ -63,6 +66,10 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
63
66
|
if (!payload.channelId) throw new Error('No SSE context available')
|
|
64
67
|
const channelId = payload.channelId
|
|
65
68
|
const tickCost = BigInt(verifiedCredential.challenge.request.amount as string)
|
|
69
|
+
const unitType =
|
|
70
|
+
typeof verifiedCredential.challenge.request.unitType === 'string'
|
|
71
|
+
? verifiedCredential.challenge.request.unitType
|
|
72
|
+
: undefined
|
|
66
73
|
|
|
67
74
|
// Auto-detect upstream SSE responses and parse them into an
|
|
68
75
|
// AsyncIterable so they flow through the metered pipeline.
|
|
@@ -77,9 +84,7 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
77
84
|
// Pass async generator functions directly so Sse.serve gives them
|
|
78
85
|
// a SessionController for manual charge(). Pass raw AsyncIterables
|
|
79
86
|
// as-is so Sse.serve auto-charges per yielded value.
|
|
80
|
-
const generate
|
|
81
|
-
? (resolved as Sse_core.serve.Options['generate'])
|
|
82
|
-
: (resolved as AsyncIterable<string>)
|
|
87
|
+
const generate = resolveMeteredGenerate(resolved, unitType)
|
|
83
88
|
const stream = Sse_core.serve({
|
|
84
89
|
store,
|
|
85
90
|
channelId,
|
|
@@ -101,23 +106,71 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
101
106
|
challengeId: verifiedChallengeId,
|
|
102
107
|
})
|
|
103
108
|
|
|
109
|
+
if (!shouldChargePlainResponse(input, payload)) {
|
|
110
|
+
return baseResponse
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const currentReceipt = receipt as SessionReceipt
|
|
114
|
+
const available = BigInt(currentReceipt.acceptedCumulative) - BigInt(currentReceipt.spent)
|
|
115
|
+
if (available < tickCost) {
|
|
116
|
+
const error = new Errors.InsufficientBalanceError({
|
|
117
|
+
reason: `requested ${tickCost}, available ${available}`,
|
|
118
|
+
})
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify(error.toProblemDetails(verifiedCredential.challenge.id)),
|
|
121
|
+
{
|
|
122
|
+
status: error.status,
|
|
123
|
+
headers: {
|
|
124
|
+
'WWW-Authenticate': Challenge.serialize(verifiedCredential.challenge),
|
|
125
|
+
'Cache-Control': 'no-store',
|
|
126
|
+
'Content-Type': 'application/problem+json',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chargedReceipt: SessionReceipt = {
|
|
133
|
+
...currentReceipt,
|
|
134
|
+
spent: (BigInt(currentReceipt.spent) + tickCost).toString(),
|
|
135
|
+
units: (currentReceipt.units ?? 0) + 1,
|
|
136
|
+
}
|
|
137
|
+
const chargedResponse = base.respondReceipt({
|
|
138
|
+
credential: verifiedCredential,
|
|
139
|
+
envelope,
|
|
140
|
+
input,
|
|
141
|
+
receipt: chargedReceipt,
|
|
142
|
+
response: response as Response,
|
|
143
|
+
challengeId: verifiedChallengeId,
|
|
144
|
+
})
|
|
145
|
+
|
|
104
146
|
// Non-SSE response (e.g. upstream returned JSON instead of event-stream).
|
|
105
147
|
// Need to deduct tickCost so request isn't free.
|
|
106
|
-
//
|
|
107
|
-
// response
|
|
108
|
-
if (isNullBodyStatus(
|
|
109
|
-
|
|
148
|
+
// For null-body statuses, the request shape determines whether the
|
|
149
|
+
// response is management (no charge) or plain content (charge one tick).
|
|
150
|
+
if (isNullBodyStatus(chargedResponse.status)) {
|
|
151
|
+
void ChannelStore.deductFromChannel(store, channelId, tickCost)
|
|
152
|
+
return chargedResponse
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
const stream = new ReadableStream<Uint8Array>({
|
|
113
156
|
async start(controller) {
|
|
114
157
|
// deduction completes before consumer reads
|
|
115
|
-
await ChannelStore.deductFromChannel(store, channelId, tickCost)
|
|
116
|
-
if (!
|
|
158
|
+
const result = await ChannelStore.deductFromChannel(store, channelId, tickCost)
|
|
159
|
+
if (!result.ok) {
|
|
160
|
+
controller.error(
|
|
161
|
+
new Errors.InsufficientBalanceError({
|
|
162
|
+
reason: `requested ${tickCost}, available ${
|
|
163
|
+
result.channel.highestVoucherAmount - result.channel.spent
|
|
164
|
+
}`,
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (!chargedResponse.body) {
|
|
117
170
|
controller.close()
|
|
118
171
|
return
|
|
119
172
|
}
|
|
120
|
-
const reader =
|
|
173
|
+
const reader = chargedResponse.body.getReader()
|
|
121
174
|
try {
|
|
122
175
|
while (true) {
|
|
123
176
|
const { done, value } = await reader.read()
|
|
@@ -131,9 +184,9 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
|
|
|
131
184
|
},
|
|
132
185
|
})
|
|
133
186
|
return new Response(stream, {
|
|
134
|
-
status:
|
|
135
|
-
statusText:
|
|
136
|
-
headers:
|
|
187
|
+
status: chargedResponse.status,
|
|
188
|
+
statusText: chargedResponse.statusText,
|
|
189
|
+
headers: chargedResponse.headers,
|
|
137
190
|
})
|
|
138
191
|
},
|
|
139
192
|
})
|
|
@@ -198,6 +251,40 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
|
|
|
198
251
|
return value !== null && typeof value === 'object' && Symbol.asyncIterator in (value as object)
|
|
199
252
|
}
|
|
200
253
|
|
|
254
|
+
function resolveMeteredGenerate(
|
|
255
|
+
value: AsyncIterable<string> | ((...args: unknown[]) => AsyncIterable<string>),
|
|
256
|
+
unitType: string | undefined,
|
|
257
|
+
): Sse_core.serve.Options['generate'] {
|
|
258
|
+
if (isAsyncGeneratorFunction(value)) return value as Sse_core.serve.Options['generate']
|
|
259
|
+
if (unitType !== 'request') return value as AsyncIterable<string>
|
|
260
|
+
|
|
261
|
+
const iterable = value as AsyncIterable<string>
|
|
262
|
+
return async function* chargeOnce(stream) {
|
|
263
|
+
let charged = false
|
|
264
|
+
for await (const chunk of iterable) {
|
|
265
|
+
if (!charged) {
|
|
266
|
+
await stream.charge()
|
|
267
|
+
charged = true
|
|
268
|
+
}
|
|
269
|
+
yield chunk
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
201
274
|
function isNullBodyStatus(status: number): boolean {
|
|
202
275
|
return [101, 204, 205, 304].includes(status)
|
|
203
276
|
}
|
|
277
|
+
|
|
278
|
+
function shouldChargePlainResponse(
|
|
279
|
+
input: Request,
|
|
280
|
+
payload: Partial<SessionCredentialPayload>,
|
|
281
|
+
): boolean {
|
|
282
|
+
if (payload.action === 'close' || payload.action === 'topUp') return false
|
|
283
|
+
if (input.method !== 'POST') return true
|
|
284
|
+
|
|
285
|
+
const contentLength = input.headers.get('content-length')
|
|
286
|
+
if (contentLength !== null && contentLength !== '0') return true
|
|
287
|
+
if (input.headers.has('transfer-encoding')) return true
|
|
288
|
+
|
|
289
|
+
return false
|
|
290
|
+
}
|
package/src/viem/Client.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createClient, custom, defineChain, type Hex } from 'viem'
|
|
|
2
2
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
3
3
|
import { signTransaction } from 'viem/actions'
|
|
4
4
|
import { tempoLocalnet } from 'viem/chains'
|
|
5
|
-
import { Transaction } from 'viem/tempo'
|
|
5
|
+
import { Account as TempoAccount, Transaction } from 'viem/tempo'
|
|
6
6
|
import { describe, expect, test } from 'vp/test'
|
|
7
7
|
|
|
8
8
|
import * as Client from './Client.js'
|
|
@@ -168,6 +168,57 @@ describe('feePayer transaction serialization', () => {
|
|
|
168
168
|
} as never)
|
|
169
169
|
expect(serverSigned).toMatch(/^0x7[68]/)
|
|
170
170
|
})
|
|
171
|
+
|
|
172
|
+
test('behavior: deserialized + re-signed tx preserves keyAuthorization', async () => {
|
|
173
|
+
const rootAccount = TempoAccount.fromSecp256k1(
|
|
174
|
+
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
|
|
175
|
+
)
|
|
176
|
+
const accessKey = TempoAccount.fromSecp256k1(
|
|
177
|
+
'0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d',
|
|
178
|
+
{ access: rootAccount },
|
|
179
|
+
)
|
|
180
|
+
const feePayerAccount = privateKeyToAccount(
|
|
181
|
+
'0x5de4111afa1a4b94908f83103f52c5de640f0e4f465f975fa6d6640d3c5e3b48',
|
|
182
|
+
)
|
|
183
|
+
const accessKeyClient = createClient({
|
|
184
|
+
account: accessKey,
|
|
185
|
+
chain: tempoLocalnet,
|
|
186
|
+
transport: mockTransport,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const keyAuthorization = await rootAccount.signKeyAuthorization(
|
|
190
|
+
{
|
|
191
|
+
accessKeyAddress: accessKey.accessKeyAddress,
|
|
192
|
+
keyType: accessKey.keyType,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
chainId: BigInt(tempoLocalnet.id),
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const clientSigned = await signTransaction(accessKeyClient, {
|
|
200
|
+
account: accessKey,
|
|
201
|
+
...feePayer_prepared,
|
|
202
|
+
keyAuthorization,
|
|
203
|
+
} as never)
|
|
204
|
+
const deserialized = Transaction.deserialize(
|
|
205
|
+
clientSigned as Transaction.TransactionSerializedTempo,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
expect(deserialized.keyAuthorization).toEqual(keyAuthorization)
|
|
209
|
+
|
|
210
|
+
const serverSigned = await signTransaction(tempoClient, {
|
|
211
|
+
...deserialized,
|
|
212
|
+
account: feePayerAccount,
|
|
213
|
+
feePayer: feePayerAccount,
|
|
214
|
+
feeToken: '0x20c0000000000000000000000000000000000001' as const,
|
|
215
|
+
} as never)
|
|
216
|
+
const serverDeserialized = Transaction.deserialize(
|
|
217
|
+
serverSigned as Transaction.TransactionSerializedTempo,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(serverDeserialized.keyAuthorization).toEqual(keyAuthorization)
|
|
221
|
+
})
|
|
171
222
|
})
|
|
172
223
|
|
|
173
224
|
describe('getResolver serializer injection', () => {
|