mppx 0.4.11 → 0.5.0
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 +21 -0
- package/dist/Expires.d.ts +7 -0
- package/dist/Expires.d.ts.map +1 -1
- package/dist/Expires.js +21 -0
- package/dist/Expires.js.map +1 -1
- package/dist/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +55 -7
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +3 -3
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +18 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +28 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +24 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +51 -9
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +18 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/internal/proof.d.ts +23 -0
- package/dist/tempo/internal/proof.d.ts.map +1 -0
- package/dist/tempo/internal/proof.js +17 -0
- package/dist/tempo/internal/proof.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +20 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +180 -103
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +20 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -3
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/Expires.ts +25 -0
- package/src/cli/cli.test.ts +230 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- package/src/middlewares/elysia.test.ts +127 -4
- package/src/middlewares/express.test.ts +120 -54
- package/src/middlewares/hono.test.ts +73 -34
- package/src/middlewares/nextjs.test.ts +159 -36
- package/src/server/Mppx.test.ts +373 -0
- package/src/server/Mppx.ts +64 -10
- package/src/stripe/server/Charge.ts +3 -7
- package/src/tempo/Methods.test.ts +105 -0
- package/src/tempo/Methods.ts +54 -17
- package/src/tempo/client/Charge.ts +67 -11
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.test.ts +66 -0
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/internal/proof.test.ts +36 -0
- package/src/tempo/internal/proof.ts +19 -0
- package/src/tempo/server/Charge.test.ts +593 -1
- package/src/tempo/server/Charge.ts +233 -126
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +1152 -54
- package/src/tempo/server/Session.ts +26 -17
- package/src/tempo/server/internal/transport.test.ts +32 -0
- package/src/tempo/session/Chain.test.ts +60 -5
- package/src/tempo/session/Chain.ts +30 -14
- package/src/tempo/session/Sse.test.ts +31 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http'
|
|
2
2
|
|
|
3
|
-
import { Receipt } from 'mppx'
|
|
3
|
+
import { Challenge, Credential, Receipt } from 'mppx'
|
|
4
4
|
import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
|
|
5
5
|
import { Mppx, discovery } from 'mppx/nextjs'
|
|
6
6
|
import { tempo as tempo_server } from 'mppx/server'
|
|
@@ -8,7 +8,7 @@ import type { Address } from 'viem'
|
|
|
8
8
|
import { Addresses } from 'viem/tempo'
|
|
9
9
|
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
10
10
|
import { deployEscrow } from '~test/tempo/session.js'
|
|
11
|
-
import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
|
|
11
|
+
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
|
|
12
12
|
|
|
13
13
|
function createServer(handler: (request: Request) => Promise<Response> | Response) {
|
|
14
14
|
return new Promise<{ url: string; close: () => void }>((resolve) => {
|
|
@@ -36,13 +36,14 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
|
|
|
36
36
|
|
|
37
37
|
const secretKey = 'test-secret-key'
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
function createChargeHarness(feePayer: boolean) {
|
|
40
40
|
const mppx = Mppx.create({
|
|
41
41
|
methods: [
|
|
42
42
|
tempo_server.charge({
|
|
43
43
|
getClient: () => client,
|
|
44
44
|
currency: asset,
|
|
45
45
|
account: accounts[0],
|
|
46
|
+
...(feePayer ? { feePayer: true } : {}),
|
|
46
47
|
}),
|
|
47
48
|
],
|
|
48
49
|
secretKey,
|
|
@@ -58,7 +59,13 @@ describe('charge', () => {
|
|
|
58
59
|
],
|
|
59
60
|
})
|
|
60
61
|
|
|
62
|
+
return { fetch, mppx }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('charge', () => {
|
|
61
66
|
test('returns 402 when no credential', async () => {
|
|
67
|
+
const { mppx } = createChargeHarness(false)
|
|
68
|
+
|
|
62
69
|
const handler = mppx.charge({ amount: '1' })(() =>
|
|
63
70
|
Response.json({ fortune: 'You will be rich' }),
|
|
64
71
|
)
|
|
@@ -72,6 +79,8 @@ describe('charge', () => {
|
|
|
72
79
|
})
|
|
73
80
|
|
|
74
81
|
test('returns 200 with receipt on valid payment', async () => {
|
|
82
|
+
const { fetch, mppx } = createChargeHarness(false)
|
|
83
|
+
|
|
75
84
|
const handler = mppx.charge({ amount: '1' })(() =>
|
|
76
85
|
Response.json({ fortune: 'You will be rich' }),
|
|
77
86
|
)
|
|
@@ -90,7 +99,108 @@ describe('charge', () => {
|
|
|
90
99
|
server.close()
|
|
91
100
|
})
|
|
92
101
|
|
|
102
|
+
test('fee payer: returns 200 with receipt on valid payment', async () => {
|
|
103
|
+
const { fetch, mppx } = createChargeHarness(true)
|
|
104
|
+
|
|
105
|
+
const handler = mppx.charge({ amount: '1' })(() =>
|
|
106
|
+
Response.json({ fortune: 'You will be rich' }),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const server = await createServer(handler)
|
|
110
|
+
const response = await fetch(server.url)
|
|
111
|
+
expect(response.status).toBe(200)
|
|
112
|
+
expect(Receipt.fromResponse(response).status).toBe('success')
|
|
113
|
+
|
|
114
|
+
server.close()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('zero-amount charge creates a proof credential and receipt', async () => {
|
|
118
|
+
const { fetch, mppx } = createChargeHarness(false)
|
|
119
|
+
|
|
120
|
+
const handler = mppx.charge({ amount: '0' })((request) =>
|
|
121
|
+
Response.json({ payer: request.headers.get('Authorization') }),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const server = await createServer(handler)
|
|
125
|
+
|
|
126
|
+
const challengeResponse = await globalThis.fetch(server.url)
|
|
127
|
+
expect(challengeResponse.status).toBe(402)
|
|
128
|
+
|
|
129
|
+
const response = await fetch(server.url)
|
|
130
|
+
expect(response.status).toBe(200)
|
|
131
|
+
|
|
132
|
+
const body = (await response.json()) as { payer: string }
|
|
133
|
+
const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(body.payer)
|
|
134
|
+
expect(credential.challenge.request.amount).toBe('0')
|
|
135
|
+
expect(credential.payload.type).toBe('proof')
|
|
136
|
+
expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${accounts[1].address}`)
|
|
137
|
+
|
|
138
|
+
const receipt = Receipt.fromResponse(response)
|
|
139
|
+
expect(receipt.reference).toBe(credential.challenge.id)
|
|
140
|
+
|
|
141
|
+
server.close()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('zero-amount charge with testnet currency omission creates a proof credential', async () => {
|
|
145
|
+
const isTestnet = true
|
|
146
|
+
const mainnetCurrency = '0x20C000000000000000000000b9537d11c60E8b50' as const
|
|
147
|
+
|
|
148
|
+
const mppx = Mppx.create({
|
|
149
|
+
methods: [
|
|
150
|
+
tempo_server.charge({
|
|
151
|
+
account: accounts[0],
|
|
152
|
+
getClient: () => client,
|
|
153
|
+
...(isTestnet ? {} : { currency: mainnetCurrency }),
|
|
154
|
+
recipient: accounts[0].address,
|
|
155
|
+
testnet: isTestnet,
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
secretKey,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const { fetch } = Mppx_client.create({
|
|
162
|
+
polyfill: false,
|
|
163
|
+
methods: [
|
|
164
|
+
tempo_client.charge({
|
|
165
|
+
account: accounts[1],
|
|
166
|
+
getClient: () => client,
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const handler = mppx.charge({ amount: '0', chainId: chain.id })((request) =>
|
|
172
|
+
Response.json({ payer: request.headers.get('Authorization') }),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const server = await createServer(handler)
|
|
176
|
+
|
|
177
|
+
const challengeResponse = await globalThis.fetch(server.url)
|
|
178
|
+
expect(challengeResponse.status).toBe(402)
|
|
179
|
+
|
|
180
|
+
const challenge = Challenge.fromResponse(challengeResponse, {
|
|
181
|
+
methods: [tempo_client.charge()],
|
|
182
|
+
})
|
|
183
|
+
expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
|
|
184
|
+
|
|
185
|
+
const response = await fetch(server.url)
|
|
186
|
+
expect(response.status).toBe(200)
|
|
187
|
+
|
|
188
|
+
const body = (await response.json()) as { payer: string }
|
|
189
|
+
const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(body.payer)
|
|
190
|
+
expect(credential.challenge.request.amount).toBe('0')
|
|
191
|
+
expect(credential.challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
|
|
192
|
+
expect(credential.payload.type).toBe('proof')
|
|
193
|
+
expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${accounts[1].address}`)
|
|
194
|
+
|
|
195
|
+
const receipt = Receipt.fromResponse(response)
|
|
196
|
+
expect(receipt.reference).toBe(credential.challenge.id)
|
|
197
|
+
|
|
198
|
+
server.close()
|
|
199
|
+
})
|
|
200
|
+
|
|
93
201
|
test('serves /openapi.json from a handler-derived route config', async () => {
|
|
202
|
+
const { mppx } = createChargeHarness(false)
|
|
203
|
+
|
|
94
204
|
const pay = mppx.charge({ amount: '1' })
|
|
95
205
|
const server = await createServer(
|
|
96
206
|
discovery(mppx, {
|
|
@@ -119,13 +229,7 @@ describe('charge', () => {
|
|
|
119
229
|
describe('session', () => {
|
|
120
230
|
let escrowContract: Address
|
|
121
231
|
|
|
122
|
-
|
|
123
|
-
escrowContract = await deployEscrow()
|
|
124
|
-
await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
|
|
125
|
-
await fundAccount({ address: accounts[2].address, token: asset })
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('returns 402 when no credential', async () => {
|
|
232
|
+
function createSessionHarness(feePayer: boolean) {
|
|
129
233
|
const mppx = Mppx.create({
|
|
130
234
|
methods: [
|
|
131
235
|
tempo_server.session({
|
|
@@ -133,12 +237,38 @@ describe('session', () => {
|
|
|
133
237
|
account: accounts[0],
|
|
134
238
|
currency: asset,
|
|
135
239
|
escrowContract,
|
|
136
|
-
|
|
240
|
+
...(feePayer ? { feePayer: accounts[1] } : {}),
|
|
241
|
+
} as any),
|
|
137
242
|
],
|
|
138
243
|
secretKey,
|
|
139
244
|
})
|
|
140
245
|
|
|
141
|
-
const
|
|
246
|
+
const { fetch } = Mppx_client.create({
|
|
247
|
+
polyfill: false,
|
|
248
|
+
methods: [
|
|
249
|
+
sessionIntent({
|
|
250
|
+
account: accounts[2],
|
|
251
|
+
deposit: '10',
|
|
252
|
+
getClient: () => client,
|
|
253
|
+
}),
|
|
254
|
+
],
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return { fetch, mppx }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
beforeAll(async () => {
|
|
261
|
+
escrowContract = await deployEscrow()
|
|
262
|
+
await fundAccount({ address: accounts[1].address, token: Addresses.pathUsd })
|
|
263
|
+
await fundAccount({ address: accounts[1].address, token: asset })
|
|
264
|
+
await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
|
|
265
|
+
await fundAccount({ address: accounts[2].address, token: asset })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('returns 402 when no credential', async () => {
|
|
269
|
+
const { mppx } = createSessionHarness(false)
|
|
270
|
+
|
|
271
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
142
272
|
Response.json({ data: 'streamed' }),
|
|
143
273
|
)
|
|
144
274
|
|
|
@@ -151,31 +281,9 @@ describe('session', () => {
|
|
|
151
281
|
})
|
|
152
282
|
|
|
153
283
|
test('returns 200 with receipt on valid payment', async () => {
|
|
154
|
-
const mppx =
|
|
155
|
-
methods: [
|
|
156
|
-
tempo_server.session({
|
|
157
|
-
getClient: () => client,
|
|
158
|
-
account: accounts[0],
|
|
159
|
-
currency: asset,
|
|
160
|
-
escrowContract,
|
|
161
|
-
feePayer: true,
|
|
162
|
-
}),
|
|
163
|
-
],
|
|
164
|
-
secretKey,
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
const { fetch } = Mppx_client.create({
|
|
168
|
-
polyfill: false,
|
|
169
|
-
methods: [
|
|
170
|
-
sessionIntent({
|
|
171
|
-
account: accounts[2],
|
|
172
|
-
deposit: '10',
|
|
173
|
-
getClient: () => client,
|
|
174
|
-
}),
|
|
175
|
-
],
|
|
176
|
-
})
|
|
284
|
+
const { fetch, mppx } = createSessionHarness(false)
|
|
177
285
|
|
|
178
|
-
const handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
|
|
286
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
179
287
|
Response.json({ data: 'streamed' }),
|
|
180
288
|
)
|
|
181
289
|
|
|
@@ -192,4 +300,19 @@ describe('session', () => {
|
|
|
192
300
|
|
|
193
301
|
server.close()
|
|
194
302
|
})
|
|
303
|
+
|
|
304
|
+
test('fee payer: returns 200 with receipt on valid payment', async () => {
|
|
305
|
+
const { fetch, mppx } = createSessionHarness(true)
|
|
306
|
+
|
|
307
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
308
|
+
Response.json({ data: 'streamed' }),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const server = await createServer(handler)
|
|
312
|
+
const response = await fetch(server.url)
|
|
313
|
+
expect(response.status).toBe(200)
|
|
314
|
+
expect(Receipt.fromResponse(response).status).toBe('success')
|
|
315
|
+
|
|
316
|
+
server.close()
|
|
317
|
+
})
|
|
195
318
|
})
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -422,6 +422,92 @@ describe('request handler', () => {
|
|
|
422
422
|
`)
|
|
423
423
|
expect((body as { detail: string }).detail).toContain('Payment expired at')
|
|
424
424
|
})
|
|
425
|
+
test('returns 402 when credential challenge has no expires (fail-closed)', async () => {
|
|
426
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
427
|
+
amount: '1000',
|
|
428
|
+
currency: asset,
|
|
429
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
430
|
+
recipient: accounts[0].address,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// Get a valid challenge from the server to capture the exact request shape
|
|
434
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
435
|
+
expect(firstResult.status).toBe(402)
|
|
436
|
+
if (firstResult.status !== 402) throw new Error()
|
|
437
|
+
|
|
438
|
+
const serverChallenge = Challenge.fromResponse(firstResult.challenge)
|
|
439
|
+
|
|
440
|
+
// Re-create the same challenge WITHOUT expires, with a valid HMAC
|
|
441
|
+
const { expires: _, ...rest } = serverChallenge
|
|
442
|
+
const challengeNoExpires = Challenge.from({
|
|
443
|
+
secretKey,
|
|
444
|
+
realm: rest.realm,
|
|
445
|
+
method: rest.method,
|
|
446
|
+
intent: rest.intent,
|
|
447
|
+
request: rest.request,
|
|
448
|
+
...(rest.opaque && { meta: rest.opaque }),
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const credential = Credential.from({
|
|
452
|
+
challenge: challengeNoExpires,
|
|
453
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const result = await handle(
|
|
457
|
+
new Request('https://example.com/resource', {
|
|
458
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
459
|
+
}),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
expect(result.status).toBe(402)
|
|
463
|
+
if (result.status !== 402) throw new Error()
|
|
464
|
+
|
|
465
|
+
const body = (await result.challenge.json()) as { title: string; detail: string }
|
|
466
|
+
expect(body.title).toBe('Invalid Challenge')
|
|
467
|
+
expect(body.detail).toContain('missing required expires')
|
|
468
|
+
})
|
|
469
|
+
test('returns 402 when credential challenge has malformed expires', async () => {
|
|
470
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
471
|
+
amount: '1000',
|
|
472
|
+
currency: asset,
|
|
473
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
474
|
+
recipient: accounts[0].address,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Get a valid challenge from the server to capture the exact request shape
|
|
478
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
479
|
+
expect(firstResult.status).toBe(402)
|
|
480
|
+
if (firstResult.status !== 402) throw new Error()
|
|
481
|
+
|
|
482
|
+
const serverChallenge = Challenge.fromResponse(firstResult.challenge)
|
|
483
|
+
|
|
484
|
+
// Re-create the challenge with a valid HMAC but inject a malformed expires
|
|
485
|
+
// by patching the challenge object after construction (bypasses zod at build time).
|
|
486
|
+
const challengeMalformed = {
|
|
487
|
+
...serverChallenge,
|
|
488
|
+
expires: 'not-a-timestamp',
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const credential = Credential.from({
|
|
492
|
+
challenge: challengeMalformed as any,
|
|
493
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// Credential.serialize does not re-validate, so the malformed expires
|
|
497
|
+
// reaches the server. Deserialization rejects it via zod schema.
|
|
498
|
+
const result = await handle(
|
|
499
|
+
new Request('https://example.com/resource', {
|
|
500
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
501
|
+
}),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
expect(result.status).toBe(402)
|
|
505
|
+
if (result.status !== 402) throw new Error()
|
|
506
|
+
|
|
507
|
+
const body = (await result.challenge.json()) as { title: string; detail: string }
|
|
508
|
+
expect(body.title).toBe('Malformed Credential')
|
|
509
|
+
})
|
|
510
|
+
|
|
425
511
|
test('returns 402 when payload schema validation fails', async () => {
|
|
426
512
|
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
427
513
|
amount: '1000',
|
|
@@ -1567,6 +1653,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
|
|
|
1567
1653
|
// The result should be 200 (matched to cheap), not routed to expensive.
|
|
1568
1654
|
expect(result.status).toBe(200)
|
|
1569
1655
|
})
|
|
1656
|
+
|
|
1657
|
+
test('rejects no-splits credential replayed at splits route', async () => {
|
|
1658
|
+
// Method whose schema transform moves splits into methodDetails.
|
|
1659
|
+
const splitsMethod = Method.from({
|
|
1660
|
+
name: 'mock',
|
|
1661
|
+
intent: 'charge',
|
|
1662
|
+
schema: {
|
|
1663
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1664
|
+
request: z.pipe(
|
|
1665
|
+
z.object({
|
|
1666
|
+
amount: z.string(),
|
|
1667
|
+
currency: z.string(),
|
|
1668
|
+
decimals: z.number(),
|
|
1669
|
+
recipient: z.string(),
|
|
1670
|
+
splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
|
|
1671
|
+
}),
|
|
1672
|
+
z.transform(({ amount, currency, decimals, recipient, splits }) => ({
|
|
1673
|
+
methodDetails: {
|
|
1674
|
+
amount: String(Number(amount) * 10 ** decimals),
|
|
1675
|
+
currency,
|
|
1676
|
+
recipient,
|
|
1677
|
+
...(splits && { splits }),
|
|
1678
|
+
},
|
|
1679
|
+
})),
|
|
1680
|
+
),
|
|
1681
|
+
},
|
|
1682
|
+
})
|
|
1683
|
+
|
|
1684
|
+
const splitsServerMethod = Method.toServer(splitsMethod, {
|
|
1685
|
+
async verify() {
|
|
1686
|
+
return mockReceipt()
|
|
1687
|
+
},
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
|
|
1691
|
+
|
|
1692
|
+
// Get a challenge from a route with no splits
|
|
1693
|
+
const noSplitsHandle = handler.charge({
|
|
1694
|
+
amount: '1',
|
|
1695
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1696
|
+
decimals: 6,
|
|
1697
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1698
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1699
|
+
})
|
|
1700
|
+
const noSplitsResult = await noSplitsHandle(new Request('https://example.com/no-splits'))
|
|
1701
|
+
expect(noSplitsResult.status).toBe(402)
|
|
1702
|
+
if (noSplitsResult.status !== 402) throw new Error()
|
|
1703
|
+
|
|
1704
|
+
const noSplitsChallenge = Challenge.fromResponse(noSplitsResult.challenge)
|
|
1705
|
+
const credential = Credential.from({
|
|
1706
|
+
challenge: noSplitsChallenge,
|
|
1707
|
+
payload: { token: 'valid' },
|
|
1708
|
+
})
|
|
1709
|
+
|
|
1710
|
+
// Present at a route that requires splits
|
|
1711
|
+
const splitsHandle = handler.charge({
|
|
1712
|
+
amount: '1',
|
|
1713
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1714
|
+
decimals: 6,
|
|
1715
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1716
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1717
|
+
splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
|
|
1718
|
+
})
|
|
1719
|
+
const result = await splitsHandle(
|
|
1720
|
+
new Request('https://example.com/with-splits', {
|
|
1721
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1722
|
+
}),
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
expect(result.status).toBe(402)
|
|
1726
|
+
})
|
|
1570
1727
|
})
|
|
1571
1728
|
|
|
1572
1729
|
describe('withReceipt', () => {
|
|
@@ -1741,3 +1898,219 @@ describe('withReceipt', () => {
|
|
|
1741
1898
|
server.close()
|
|
1742
1899
|
})
|
|
1743
1900
|
})
|
|
1901
|
+
|
|
1902
|
+
describe('realm auto-detection', () => {
|
|
1903
|
+
beforeEach(() => {
|
|
1904
|
+
// Clear all env vars that Env.get('realm') probes so realm falls through to request detection
|
|
1905
|
+
for (const name of [
|
|
1906
|
+
'MPP_REALM',
|
|
1907
|
+
'FLY_APP_NAME',
|
|
1908
|
+
'HEROKU_APP_NAME',
|
|
1909
|
+
'RAILWAY_PUBLIC_DOMAIN',
|
|
1910
|
+
'RENDER_EXTERNAL_HOSTNAME',
|
|
1911
|
+
'VERCEL_URL',
|
|
1912
|
+
'WEBSITE_HOSTNAME',
|
|
1913
|
+
])
|
|
1914
|
+
vi.stubEnv(name, '')
|
|
1915
|
+
})
|
|
1916
|
+
|
|
1917
|
+
afterEach(() => {
|
|
1918
|
+
vi.unstubAllEnvs()
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
const mockMethod = Method.toServer(
|
|
1922
|
+
Method.from({
|
|
1923
|
+
name: 'mock',
|
|
1924
|
+
intent: 'charge',
|
|
1925
|
+
schema: {
|
|
1926
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1927
|
+
request: z.object({ amount: z.string(), currency: z.string(), recipient: z.string() }),
|
|
1928
|
+
},
|
|
1929
|
+
}),
|
|
1930
|
+
{
|
|
1931
|
+
async verify() {
|
|
1932
|
+
return {
|
|
1933
|
+
method: 'mock',
|
|
1934
|
+
reference: 'ref',
|
|
1935
|
+
status: 'success' as const,
|
|
1936
|
+
timestamp: new Date().toISOString(),
|
|
1937
|
+
}
|
|
1938
|
+
},
|
|
1939
|
+
},
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
test.each([
|
|
1943
|
+
{ url: 'https://mpp.dev/resource', expected: 'mpp.dev' },
|
|
1944
|
+
{ url: 'https://api.example.com/v1/resource', expected: 'api.example.com' },
|
|
1945
|
+
{ url: 'https://localhost:8787/resource', expected: 'localhost' },
|
|
1946
|
+
{ url: 'https://MPP.DEV/resource', expected: 'mpp.dev' },
|
|
1947
|
+
{ url: 'http://staging.mpp.dev:3000/api', expected: 'staging.mpp.dev' },
|
|
1948
|
+
])('derives realm "$expected" from $url', async ({ url, expected }) => {
|
|
1949
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
1950
|
+
|
|
1951
|
+
const result = await handler.charge({
|
|
1952
|
+
amount: '100',
|
|
1953
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1954
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1955
|
+
})(new Request(url))
|
|
1956
|
+
|
|
1957
|
+
expect(result.status).toBe(402)
|
|
1958
|
+
if (result.status !== 402) throw new Error()
|
|
1959
|
+
|
|
1960
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
1961
|
+
expect(challenge.realm).toBe(expected)
|
|
1962
|
+
})
|
|
1963
|
+
|
|
1964
|
+
test('credential verifies across different casing of same host', async () => {
|
|
1965
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
1966
|
+
|
|
1967
|
+
const chargeOpts = {
|
|
1968
|
+
amount: '100',
|
|
1969
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1970
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Get challenge with uppercase host
|
|
1974
|
+
const result = await handler.charge(chargeOpts)(new Request('https://MPP.DEV/resource'))
|
|
1975
|
+
expect(result.status).toBe(402)
|
|
1976
|
+
if (result.status !== 402) throw new Error()
|
|
1977
|
+
|
|
1978
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
1979
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
1980
|
+
|
|
1981
|
+
// Verify with lowercase host — should match since both normalize
|
|
1982
|
+
const verifyResult = await handler.charge(chargeOpts)(
|
|
1983
|
+
new Request('https://mpp.dev/resource', {
|
|
1984
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1985
|
+
}),
|
|
1986
|
+
)
|
|
1987
|
+
expect(verifyResult.status).toBe(200)
|
|
1988
|
+
})
|
|
1989
|
+
|
|
1990
|
+
test('explicit realm takes precedence over request url', async () => {
|
|
1991
|
+
const handler = Mppx.create({ methods: [mockMethod], realm: 'explicit.example.com', secretKey })
|
|
1992
|
+
|
|
1993
|
+
const request = new Request('https://other.example.com/resource')
|
|
1994
|
+
const result = await handler.charge({
|
|
1995
|
+
amount: '100',
|
|
1996
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1997
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1998
|
+
})(request)
|
|
1999
|
+
|
|
2000
|
+
expect(result.status).toBe(402)
|
|
2001
|
+
if (result.status !== 402) throw new Error()
|
|
2002
|
+
|
|
2003
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2004
|
+
expect(challenge.realm).toBe('explicit.example.com')
|
|
2005
|
+
})
|
|
2006
|
+
|
|
2007
|
+
test('challenge and verification use same auto-detected realm', async () => {
|
|
2008
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
2009
|
+
|
|
2010
|
+
const url = 'https://mpp.dev/resource'
|
|
2011
|
+
|
|
2012
|
+
// Get challenge
|
|
2013
|
+
const result = await handler.charge({
|
|
2014
|
+
amount: '100',
|
|
2015
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2016
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2017
|
+
})(new Request(url))
|
|
2018
|
+
|
|
2019
|
+
expect(result.status).toBe(402)
|
|
2020
|
+
if (result.status !== 402) throw new Error()
|
|
2021
|
+
|
|
2022
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2023
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
2024
|
+
|
|
2025
|
+
// Replay with credential from same host — should verify
|
|
2026
|
+
const verifyResult = await handler.charge({
|
|
2027
|
+
amount: '100',
|
|
2028
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2029
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2030
|
+
})(new Request(url, { headers: { Authorization: Credential.serialize(credential) } }))
|
|
2031
|
+
|
|
2032
|
+
expect(verifyResult.status).toBe(200)
|
|
2033
|
+
})
|
|
2034
|
+
|
|
2035
|
+
test('credential from one host rejected at different host', async () => {
|
|
2036
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
2037
|
+
|
|
2038
|
+
// Get challenge from host A
|
|
2039
|
+
const result = await handler.charge({
|
|
2040
|
+
amount: '100',
|
|
2041
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2042
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2043
|
+
})(new Request('https://host-a.example.com/resource'))
|
|
2044
|
+
|
|
2045
|
+
expect(result.status).toBe(402)
|
|
2046
|
+
if (result.status !== 402) throw new Error()
|
|
2047
|
+
|
|
2048
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2049
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
2050
|
+
|
|
2051
|
+
// Present at host B — realm mismatch should reject
|
|
2052
|
+
const verifyResult = await handler.charge({
|
|
2053
|
+
amount: '100',
|
|
2054
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2055
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2056
|
+
})(
|
|
2057
|
+
new Request('https://host-b.example.com/resource', {
|
|
2058
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
2059
|
+
}),
|
|
2060
|
+
)
|
|
2061
|
+
|
|
2062
|
+
expect(verifyResult.status).toBe(402)
|
|
2063
|
+
})
|
|
2064
|
+
|
|
2065
|
+
test('realm undefined on handler when not explicitly set', () => {
|
|
2066
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
2067
|
+
expect(handler.realm).toBeUndefined()
|
|
2068
|
+
})
|
|
2069
|
+
|
|
2070
|
+
test('falls back to default realm when input has no url', async () => {
|
|
2071
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
2072
|
+
const handle = handler.charge({
|
|
2073
|
+
amount: '100',
|
|
2074
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2075
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2076
|
+
})
|
|
2077
|
+
|
|
2078
|
+
// Simulate a non-HTTP input with no .url — should warn and use fallback
|
|
2079
|
+
const result = await handle({} as any)
|
|
2080
|
+
expect(result.status).toBe(402)
|
|
2081
|
+
if (result.status !== 402) throw new Error()
|
|
2082
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2083
|
+
expect(challenge.realm).toBe('MPP Payment')
|
|
2084
|
+
})
|
|
2085
|
+
|
|
2086
|
+
test('cross-host rejection reports realm mismatch', async () => {
|
|
2087
|
+
const handler = Mppx.create({ methods: [mockMethod], secretKey })
|
|
2088
|
+
|
|
2089
|
+
const result = await handler.charge({
|
|
2090
|
+
amount: '100',
|
|
2091
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2092
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2093
|
+
})(new Request('https://host-a.example.com/resource'))
|
|
2094
|
+
|
|
2095
|
+
expect(result.status).toBe(402)
|
|
2096
|
+
if (result.status !== 402) throw new Error()
|
|
2097
|
+
|
|
2098
|
+
const challenge = Challenge.fromResponse(result.challenge)
|
|
2099
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
2100
|
+
|
|
2101
|
+
const verifyResult = await handler.charge({
|
|
2102
|
+
amount: '100',
|
|
2103
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
2104
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
2105
|
+
})(
|
|
2106
|
+
new Request('https://host-b.example.com/resource', {
|
|
2107
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
2108
|
+
}),
|
|
2109
|
+
)
|
|
2110
|
+
|
|
2111
|
+
expect(verifyResult.status).toBe(402)
|
|
2112
|
+
if (verifyResult.status !== 402) throw new Error()
|
|
2113
|
+
const body = (await verifyResult.challenge.json()) as { detail: string }
|
|
2114
|
+
expect(body.detail).toContain('realm')
|
|
2115
|
+
})
|
|
2116
|
+
})
|