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.
- package/README.md +0 -52
- package/dist/Challenge.d.ts +8 -0
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +20 -4
- package/dist/Challenge.js.map +1 -1
- package/dist/cli.js +193 -66
- package/dist/cli.js.map +1 -1
- package/dist/server/Mppx.d.ts +2 -0
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +4 -3
- package/dist/server/Mppx.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +201 -11
- package/src/Challenge.ts +34 -4
- package/src/Store.test.ts +93 -0
- package/src/cli.test.ts +233 -37
- package/src/cli.ts +229 -79
- package/src/client/Transport.test.ts +4 -4
- package/src/internal/env.test.ts +42 -0
- package/src/server/Mppx.test.ts +173 -0
- package/src/server/Mppx.ts +6 -3
- package/src/server/Transport.test.ts +5 -5
- package/src/tempo/server/Session.test.ts +52 -0
- package/src/tempo/server/internal/transport.test.ts +285 -0
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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": "
|
|
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="
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
+
})
|