mppx 0.3.4 → 0.3.5

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.
@@ -344,3 +344,176 @@ describe('receipt handling', () => {
344
344
  expect(result.status).toBe(200)
345
345
  })
346
346
  })
347
+
348
+ describe('withReceipt', () => {
349
+ const mockCharge = Method.from({
350
+ name: 'mock',
351
+ intent: 'charge',
352
+ schema: {
353
+ credential: {
354
+ payload: z.object({ token: z.string() }),
355
+ },
356
+ request: z.object({
357
+ amount: z.string(),
358
+ currency: z.string(),
359
+ decimals: z.number(),
360
+ recipient: z.string(),
361
+ }),
362
+ },
363
+ })
364
+
365
+ function mockReceipt() {
366
+ return {
367
+ method: 'mock',
368
+ reference: 'tx-ref',
369
+ status: 'success' as const,
370
+ timestamp: new Date().toISOString(),
371
+ }
372
+ }
373
+
374
+ test('attaches Payment-Receipt header to response', async () => {
375
+ const mockMethod = Method.toServer(mockCharge, {
376
+ async verify() {
377
+ return mockReceipt()
378
+ },
379
+ })
380
+
381
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
382
+ const handle = handler.charge({
383
+ amount: '1000',
384
+ currency: '0x0000000000000000000000000000000000000001',
385
+ decimals: 6,
386
+ expires: new Date(Date.now() + 60_000).toISOString(),
387
+ recipient: '0x0000000000000000000000000000000000000002',
388
+ })
389
+
390
+ const firstResult = await handle(new Request('https://example.com/resource'))
391
+ expect(firstResult.status).toBe(402)
392
+ if (firstResult.status !== 402) throw new Error()
393
+
394
+ const challenge = Challenge.fromResponse(firstResult.challenge)
395
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
396
+
397
+ const result = await handle(
398
+ new Request('https://example.com/resource', {
399
+ headers: { Authorization: Credential.serialize(credential) },
400
+ }),
401
+ )
402
+ expect(result.status).toBe(200)
403
+ if (result.status !== 200) throw new Error()
404
+
405
+ const response = result.withReceipt(Response.json({ data: 'ok' }))
406
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
407
+ const body = await response.json()
408
+ expect(body).toEqual({ data: 'ok' })
409
+ })
410
+
411
+ test('throws when called without response arg and no management response', async () => {
412
+ const mockMethod = Method.toServer(mockCharge, {
413
+ async verify() {
414
+ return mockReceipt()
415
+ },
416
+ })
417
+
418
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
419
+ const handle = handler.charge({
420
+ amount: '1000',
421
+ currency: '0x0000000000000000000000000000000000000001',
422
+ decimals: 6,
423
+ expires: new Date(Date.now() + 60_000).toISOString(),
424
+ recipient: '0x0000000000000000000000000000000000000002',
425
+ })
426
+
427
+ const firstResult = await handle(new Request('https://example.com/resource'))
428
+ if (firstResult.status !== 402) throw new Error()
429
+
430
+ const challenge = Challenge.fromResponse(firstResult.challenge)
431
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
432
+
433
+ const result = await handle(
434
+ new Request('https://example.com/resource', {
435
+ headers: { Authorization: Credential.serialize(credential) },
436
+ }),
437
+ )
438
+ expect(result.status).toBe(200)
439
+ if (result.status !== 200) throw new Error()
440
+
441
+ expect(() => result.withReceipt()).toThrow('withReceipt() requires a response argument')
442
+ })
443
+
444
+ test('returns management response when respond hook returns Response', async () => {
445
+ const mockMethodWithRespond = Method.toServer(mockCharge, {
446
+ async verify() {
447
+ return mockReceipt()
448
+ },
449
+ respond() {
450
+ return new Response(null, { status: 204 })
451
+ },
452
+ })
453
+
454
+ const handler = Mppx.create({ methods: [mockMethodWithRespond], realm, secretKey })
455
+ const handle = handler.charge({
456
+ amount: '1000',
457
+ currency: '0x0000000000000000000000000000000000000001',
458
+ decimals: 6,
459
+ expires: new Date(Date.now() + 60_000).toISOString(),
460
+ recipient: '0x0000000000000000000000000000000000000002',
461
+ })
462
+
463
+ const firstResult = await handle(new Request('https://example.com/resource'))
464
+ if (firstResult.status !== 402) throw new Error()
465
+
466
+ const challenge = Challenge.fromResponse(firstResult.challenge)
467
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
468
+
469
+ const result = await handle(
470
+ new Request('https://example.com/resource', {
471
+ headers: { Authorization: Credential.serialize(credential) },
472
+ }),
473
+ )
474
+ expect(result.status).toBe(200)
475
+ if (result.status !== 200) throw new Error()
476
+
477
+ const response = result.withReceipt()
478
+ expect(response.status).toBe(204)
479
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
480
+ })
481
+
482
+ test('toNodeListener sets Payment-Receipt header on 200', async () => {
483
+ const mockMethod = Method.toServer(mockCharge, {
484
+ async verify() {
485
+ return mockReceipt()
486
+ },
487
+ })
488
+
489
+ const handler = Mppx.create({ methods: [mockMethod], realm, secretKey })
490
+
491
+ const server = await Http.createServer(async (req, res) => {
492
+ const result = await Mppx.toNodeListener(
493
+ handler.charge({
494
+ amount: '1000',
495
+ currency: '0x0000000000000000000000000000000000000001',
496
+ decimals: 6,
497
+ expires: new Date(Date.now() + 60_000).toISOString(),
498
+ recipient: '0x0000000000000000000000000000000000000002',
499
+ }),
500
+ )(req, res)
501
+ if (result.status === 402) return
502
+ res.end('OK')
503
+ })
504
+
505
+ const firstResponse = await fetch(server.url)
506
+ expect(firstResponse.status).toBe(402)
507
+
508
+ const challenge = Challenge.fromResponse(firstResponse)
509
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
510
+
511
+ const response = await fetch(server.url, {
512
+ headers: { Authorization: Credential.serialize(credential) },
513
+ })
514
+ expect(response.status).toBe(200)
515
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
516
+
517
+ server.close()
518
+ })
519
+ })
@@ -126,14 +126,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
126
126
  const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
127
127
 
128
128
  return (options) => {
129
- const meta = {
129
+ const methodMeta = {
130
130
  ...method,
131
131
  ...defaults,
132
132
  ...options,
133
133
  }
134
134
  return Object.assign(
135
135
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
136
- const { description, ...rest } = options
136
+ const { description, meta, ...rest } = options
137
137
  const expires = 'expires' in options ? (options.expires as string | undefined) : undefined
138
138
 
139
139
  // Merge defaults with per-request options
@@ -164,6 +164,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
164
164
  const challenge = Challenge.fromMethod(method, {
165
165
  description,
166
166
  expires,
167
+ meta,
167
168
  realm,
168
169
  request,
169
170
  secretKey,
@@ -261,7 +262,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
261
262
  },
262
263
  }
263
264
  },
264
- { _internal: meta },
265
+ { _internal: methodMeta },
265
266
  )
266
267
  }
267
268
  }
@@ -309,6 +310,8 @@ declare namespace MethodFn {
309
310
  description?: string | undefined
310
311
  /** Optional challenge expiration timestamp (ISO 8601). */
311
312
  expires?: string | undefined
313
+ /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
314
+ meta?: Record<string, string> | undefined
312
315
  } & Method.WithDefaults<z.input<method['schema']['request']>, defaults>
313
316
 
314
317
  export type Response<transport extends Transport.AnyTransport = Transport.Http> =
@@ -43,7 +43,7 @@ describe('http', () => {
43
43
  {
44
44
  "challenge": {
45
45
  "expires": "2025-01-01T00:00:00.000Z",
46
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
46
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
47
47
  "intent": "charge",
48
48
  "method": "tempo",
49
49
  "realm": "api.example.com",
@@ -93,7 +93,7 @@ describe('http', () => {
93
93
  {
94
94
  "headers": {
95
95
  "cache-control": "no-store",
96
- "www-authenticate": "Payment id="N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJleHBpcmVzIjoiMjAyNS0wMS0wMVQwMDowMDowMC4wMDBaIiwicmVjaXBpZW50IjoiMHg3NDJkMzVDYzY2MzRDMDUzMjkyNWEzYjg0NEJjOWU3NTk1ZjhmRTAwIn0", expires="2025-01-01T00:00:00.000Z"",
96
+ "www-authenticate": "Payment id="4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJleHBpcmVzIjoiMjAyNS0wMS0wMVQwMDowMDowMC4wMDBaIiwicmVjaXBpZW50IjoiMHg3NDJkMzVDYzY2MzRDMDUzMjkyNWEzYjg0NEJjOWU3NTk1ZjhmRTAwIn0", expires="2025-01-01T00:00:00.000Z"",
97
97
  },
98
98
  "status": 402,
99
99
  }
@@ -183,7 +183,7 @@ describe('mcp', () => {
183
183
  {
184
184
  "challenge": {
185
185
  "expires": "2025-01-01T00:00:00.000Z",
186
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
186
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
187
187
  "intent": "charge",
188
188
  "method": "tempo",
189
189
  "realm": "api.example.com",
@@ -221,7 +221,7 @@ describe('mcp', () => {
221
221
  "challenges": [
222
222
  {
223
223
  "expires": "2025-01-01T00:00:00.000Z",
224
- "id": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
224
+ "id": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
225
225
  "intent": "charge",
226
226
  "method": "tempo",
227
227
  "realm": "api.example.com",
@@ -262,7 +262,7 @@ describe('mcp', () => {
262
262
  "result": {
263
263
  "_meta": {
264
264
  "org.paymentauth/receipt": {
265
- "challengeId": "N_Q_IM9V5tO3JMcOTniz7anX81m7MdEp4aLW9q5KNK0",
265
+ "challengeId": "4XKyFaMO73Ypu-wOofzu3F8pRIt8vb7zxmWB2GgHAsE",
266
266
  "method": "tempo",
267
267
  "reference": "0xtxhash",
268
268
  "status": "success",
@@ -1099,6 +1099,58 @@ describe('session', () => {
1099
1099
  } as any)
1100
1100
  expect(result).toBeUndefined()
1101
1101
  })
1102
+
1103
+ test('returns undefined for voucher POST with content-length > 0 (content request)', () => {
1104
+ const server = createServer()
1105
+ const result = server.respond!({
1106
+ credential: {
1107
+ challenge: makeChallenge({
1108
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1109
+ }),
1110
+ payload: { action: 'voucher' },
1111
+ },
1112
+ input: new Request('http://localhost', {
1113
+ method: 'POST',
1114
+ headers: { 'content-length': '42' },
1115
+ }),
1116
+ } as any)
1117
+ expect(result).toBeUndefined()
1118
+ })
1119
+
1120
+ test('returns undefined for voucher POST with transfer-encoding header (content request)', () => {
1121
+ const server = createServer()
1122
+ const result = server.respond!({
1123
+ credential: {
1124
+ challenge: makeChallenge({
1125
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1126
+ }),
1127
+ payload: { action: 'voucher' },
1128
+ },
1129
+ input: new Request('http://localhost', {
1130
+ method: 'POST',
1131
+ headers: { 'transfer-encoding': 'chunked' },
1132
+ }),
1133
+ } as any)
1134
+ expect(result).toBeUndefined()
1135
+ })
1136
+
1137
+ test('returns 204 for voucher POST with content-length: 0', () => {
1138
+ const server = createServer()
1139
+ const result = server.respond!({
1140
+ credential: {
1141
+ challenge: makeChallenge({
1142
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1143
+ }),
1144
+ payload: { action: 'voucher' },
1145
+ },
1146
+ input: new Request('http://localhost', {
1147
+ method: 'POST',
1148
+ headers: { 'content-length': '0' },
1149
+ }),
1150
+ } as any)
1151
+ expect(result).toBeInstanceOf(Response)
1152
+ expect((result as Response).status).toBe(204)
1153
+ })
1102
1154
  })
1103
1155
 
1104
1156
  describe('SSE', () => {
@@ -0,0 +1,285 @@
1
+ import { Challenge, Credential } from 'mppx'
2
+ import type { Address, Hex } from 'viem'
3
+ import { describe, expect, test } from 'vitest'
4
+ import * as Store from '../../../Store.js'
5
+ import * as ChannelStore from '../../session/ChannelStore.js'
6
+ import { sse } from './transport.js'
7
+
8
+ const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
9
+ const challengeId = 'challenge-1'
10
+
11
+ function memoryStore() {
12
+ return ChannelStore.fromStore(Store.memory())
13
+ }
14
+
15
+ function seedChannel(
16
+ storage: ChannelStore.ChannelStore,
17
+ balance: bigint,
18
+ ): Promise<ChannelStore.State | null> {
19
+ return storage.updateChannel(channelId, () => ({
20
+ channelId,
21
+ payer: '0x0000000000000000000000000000000000000001' as Address,
22
+ payee: '0x0000000000000000000000000000000000000002' as Address,
23
+ token: '0x0000000000000000000000000000000000000003' as Address,
24
+ authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
25
+ chainId: 42431,
26
+ escrowContract: '0x542831e3E4Ace07559b7C8787395f4Fb99F70787' as Address,
27
+ deposit: balance,
28
+ settledOnChain: 0n,
29
+ highestVoucherAmount: balance,
30
+ highestVoucher: null,
31
+ spent: 0n,
32
+ units: 0,
33
+ finalized: false,
34
+ createdAt: new Date().toISOString(),
35
+ }))
36
+ }
37
+
38
+ function makeChallenge() {
39
+ return Challenge.from({
40
+ id: challengeId,
41
+ realm: 'test.example.com',
42
+ method: 'tempo',
43
+ intent: 'session',
44
+ request: {
45
+ amount: '1000000',
46
+ currency: '0x20c0000000000000000000000000000000000001',
47
+ recipient: '0x0000000000000000000000000000000000000002',
48
+ },
49
+ })
50
+ }
51
+
52
+ function makeCredential() {
53
+ const challenge = makeChallenge()
54
+ return Credential.from({
55
+ challenge,
56
+ payload: {
57
+ action: 'voucher',
58
+ channelId,
59
+ cumulativeAmount: '1000000',
60
+ signature: '0xdeadbeef',
61
+ },
62
+ })
63
+ }
64
+
65
+ function makeAuthorizedRequest(): Request {
66
+ const credential = makeCredential()
67
+ const header = Credential.serialize(credential)
68
+ return new Request('https://test.example.com/session', {
69
+ headers: { Authorization: header },
70
+ })
71
+ }
72
+
73
+ function makeReceipt() {
74
+ return {
75
+ method: 'tempo',
76
+ status: 'success' as const,
77
+ timestamp: new Date().toISOString(),
78
+ reference: channelId,
79
+ }
80
+ }
81
+
82
+ describe('sse transport', () => {
83
+ test('getCredential returns null when no Authorization header', () => {
84
+ const store = memoryStore()
85
+ const transport = sse({ store })
86
+ const request = new Request('https://test.example.com/session')
87
+ expect(transport.getCredential(request)).toBeNull()
88
+ })
89
+
90
+ test('getCredential returns credential from Authorization header', () => {
91
+ const store = memoryStore()
92
+ const transport = sse({ store })
93
+ const request = makeAuthorizedRequest()
94
+ const credential = transport.getCredential(request)
95
+ expect(credential).not.toBeNull()
96
+ expect(credential!.challenge.id).toBe(challengeId)
97
+ expect((credential!.payload as any).channelId).toBe(channelId)
98
+ })
99
+
100
+ test('getCredential captures SSE context in contextMap', async () => {
101
+ const store = memoryStore()
102
+ await seedChannel(store, 10000000n)
103
+ const transport = sse({ store })
104
+
105
+ const request = makeAuthorizedRequest()
106
+ transport.getCredential(request)
107
+
108
+ async function* gen() {
109
+ yield 'test'
110
+ }
111
+
112
+ const response = transport.respondReceipt({
113
+ receipt: makeReceipt(),
114
+ response: gen(),
115
+ challengeId,
116
+ })
117
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
118
+ })
119
+
120
+ test('respondChallenge delegates to base http transport', async () => {
121
+ const store = memoryStore()
122
+ const transport = sse({ store })
123
+ const challenge = makeChallenge()
124
+
125
+ const response = await transport.respondChallenge({
126
+ challenge,
127
+ input: new Request('https://test.example.com/session'),
128
+ })
129
+ expect(response).toBeInstanceOf(Response)
130
+ expect(response.status).toBe(402)
131
+ expect(response.headers.get('WWW-Authenticate')).toContain('Payment')
132
+ })
133
+
134
+ test('respondReceipt with AsyncIterable produces SSE response', async () => {
135
+ const store = memoryStore()
136
+ await seedChannel(store, 10000000n)
137
+ const transport = sse({ store })
138
+
139
+ transport.getCredential(makeAuthorizedRequest())
140
+
141
+ async function* gen() {
142
+ yield 'hello'
143
+ yield 'world'
144
+ }
145
+
146
+ const response = transport.respondReceipt({
147
+ receipt: makeReceipt(),
148
+ response: gen(),
149
+ challengeId,
150
+ })
151
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
152
+ })
153
+
154
+ test('respondReceipt with AsyncGeneratorFunction passes stream controller', async () => {
155
+ const store = memoryStore()
156
+ await seedChannel(store, 10000000n)
157
+ const transport = sse({ store })
158
+
159
+ transport.getCredential(makeAuthorizedRequest())
160
+
161
+ const response = transport.respondReceipt({
162
+ receipt: makeReceipt(),
163
+ response: async function* (stream) {
164
+ await stream.charge()
165
+ yield 'hello'
166
+ },
167
+ challengeId,
168
+ })
169
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
170
+ })
171
+
172
+ test('respondReceipt with upstream SSE Response auto-detects and iterates', async () => {
173
+ const store = memoryStore()
174
+ await seedChannel(store, 10000000n)
175
+ const transport = sse({ store })
176
+
177
+ transport.getCredential(makeAuthorizedRequest())
178
+
179
+ const encoder = new TextEncoder()
180
+ const upstream = new Response(
181
+ new ReadableStream({
182
+ start(controller) {
183
+ controller.enqueue(encoder.encode('event: message\ndata: chunk1\n\n'))
184
+ controller.enqueue(encoder.encode('event: message\ndata: chunk2\n\n'))
185
+ controller.close()
186
+ },
187
+ }),
188
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
189
+ )
190
+
191
+ const response = transport.respondReceipt({
192
+ receipt: makeReceipt(),
193
+ response: upstream,
194
+ challengeId,
195
+ })
196
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
197
+ })
198
+
199
+ test('respondReceipt with plain Response delegates to base http transport', () => {
200
+ const store = memoryStore()
201
+ const transport = sse({ store })
202
+ const receipt = makeReceipt()
203
+
204
+ const plainResponse = new Response('ok', {
205
+ headers: { 'Content-Type': 'application/json' },
206
+ })
207
+
208
+ const response = transport.respondReceipt({
209
+ receipt,
210
+ response: plainResponse,
211
+ challengeId,
212
+ })
213
+ expect(response).toBeInstanceOf(Response)
214
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
215
+ })
216
+
217
+ test('respondReceipt cleans up contextMap after use', async () => {
218
+ const store = memoryStore()
219
+ await seedChannel(store, 10000000n)
220
+ const transport = sse({ store })
221
+
222
+ transport.getCredential(makeAuthorizedRequest())
223
+
224
+ async function* gen() {
225
+ yield 'first'
226
+ }
227
+
228
+ transport.respondReceipt({
229
+ receipt: makeReceipt(),
230
+ response: gen(),
231
+ challengeId,
232
+ })
233
+
234
+ async function* gen2() {
235
+ yield 'second'
236
+ }
237
+
238
+ expect(() =>
239
+ transport.respondReceipt({
240
+ receipt: makeReceipt(),
241
+ response: gen2(),
242
+ challengeId,
243
+ }),
244
+ ).toThrow('No SSE context available')
245
+ })
246
+
247
+ test('respondReceipt throws when no SSE context available', () => {
248
+ const store = memoryStore()
249
+ const transport = sse({ store })
250
+
251
+ async function* gen() {
252
+ yield 'hello'
253
+ }
254
+
255
+ expect(() =>
256
+ transport.respondReceipt({
257
+ receipt: makeReceipt(),
258
+ response: gen(),
259
+ challengeId,
260
+ }),
261
+ ).toThrow('No SSE context available')
262
+ })
263
+
264
+ test('poll: true strips waitForUpdate from store', async () => {
265
+ const store = memoryStore()
266
+ ;(store as any).waitForUpdate = async () => {}
267
+ await seedChannel(store, 10000000n)
268
+
269
+ const transport = sse({ store, poll: true })
270
+
271
+ transport.getCredential(makeAuthorizedRequest())
272
+
273
+ async function* gen() {
274
+ yield 'test'
275
+ }
276
+
277
+ const response = transport.respondReceipt({
278
+ receipt: makeReceipt(),
279
+ response: gen(),
280
+ challengeId,
281
+ })
282
+ expect(response.headers.get('Content-Type')).toContain('text/event-stream')
283
+ expect(transport.name).toBe('sse')
284
+ })
285
+ })