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
@@ -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
- const challenge = makeChallenge()
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(): Request {
69
- const credential = makeCredential()
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 makeReceipt() {
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 receiptRaw = body.split('event: payment-receipt\ndata: ')[1]?.split('\n\n')[0]
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 management response keeps null body and receipt', async () => {
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 managementResponse = new Response(null, { status: 204 })
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: Sse_core.serve.Options['generate'] = isAsyncGeneratorFunction(resolved)
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
- // Null-body statuses (e.g. 204 from management actions) cannot carry a
107
- // response body per Fetch/HTTP semantics.
108
- if (isNullBodyStatus(baseResponse.status)) {
109
- return baseResponse
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 (!baseResponse.body) {
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 = baseResponse.body.getReader()
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: baseResponse.status,
135
- statusText: baseResponse.statusText,
136
- headers: baseResponse.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
+ }
@@ -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', () => {