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.
Files changed (89) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/internal/env.d.ts +1 -1
  7. package/dist/internal/env.d.ts.map +1 -1
  8. package/dist/internal/env.js +2 -6
  9. package/dist/internal/env.js.map +1 -1
  10. package/dist/internal/types.d.ts +23 -0
  11. package/dist/internal/types.d.ts.map +1 -1
  12. package/dist/server/Mppx.d.ts +1 -1
  13. package/dist/server/Mppx.d.ts.map +1 -1
  14. package/dist/server/Mppx.js +55 -7
  15. package/dist/server/Mppx.js.map +1 -1
  16. package/dist/stripe/server/Charge.d.ts.map +1 -1
  17. package/dist/stripe/server/Charge.js +3 -3
  18. package/dist/stripe/server/Charge.js.map +1 -1
  19. package/dist/tempo/Methods.d.ts +18 -0
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +28 -3
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/Charge.d.ts +24 -0
  24. package/dist/tempo/client/Charge.d.ts.map +1 -1
  25. package/dist/tempo/client/Charge.js +51 -9
  26. package/dist/tempo/client/Charge.js.map +1 -1
  27. package/dist/tempo/client/Methods.d.ts +18 -0
  28. package/dist/tempo/client/Methods.d.ts.map +1 -1
  29. package/dist/tempo/internal/account.d.ts +5 -11
  30. package/dist/tempo/internal/account.d.ts.map +1 -1
  31. package/dist/tempo/internal/charge.d.ts +20 -0
  32. package/dist/tempo/internal/charge.d.ts.map +1 -0
  33. package/dist/tempo/internal/charge.js +23 -0
  34. package/dist/tempo/internal/charge.js.map +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +15 -3
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/internal/proof.d.ts +23 -0
  39. package/dist/tempo/internal/proof.d.ts.map +1 -0
  40. package/dist/tempo/internal/proof.js +17 -0
  41. package/dist/tempo/internal/proof.js.map +1 -0
  42. package/dist/tempo/server/Charge.d.ts +20 -2
  43. package/dist/tempo/server/Charge.d.ts.map +1 -1
  44. package/dist/tempo/server/Charge.js +180 -103
  45. package/dist/tempo/server/Charge.js.map +1 -1
  46. package/dist/tempo/server/Methods.d.ts +20 -2
  47. package/dist/tempo/server/Methods.d.ts.map +1 -1
  48. package/dist/tempo/server/Methods.js +4 -1
  49. package/dist/tempo/server/Methods.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts +9 -4
  51. package/dist/tempo/server/Session.d.ts.map +1 -1
  52. package/dist/tempo/server/Session.js +18 -3
  53. package/dist/tempo/server/Session.js.map +1 -1
  54. package/dist/tempo/session/Chain.d.ts +18 -2
  55. package/dist/tempo/session/Chain.d.ts.map +1 -1
  56. package/dist/tempo/session/Chain.js +18 -14
  57. package/dist/tempo/session/Chain.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/Expires.ts +25 -0
  60. package/src/cli/cli.test.ts +230 -1
  61. package/src/internal/env.test.ts +12 -12
  62. package/src/internal/env.ts +2 -6
  63. package/src/internal/types.ts +25 -0
  64. package/src/middlewares/elysia.test.ts +127 -4
  65. package/src/middlewares/express.test.ts +120 -54
  66. package/src/middlewares/hono.test.ts +73 -34
  67. package/src/middlewares/nextjs.test.ts +159 -36
  68. package/src/server/Mppx.test.ts +373 -0
  69. package/src/server/Mppx.ts +64 -10
  70. package/src/stripe/server/Charge.ts +3 -7
  71. package/src/tempo/Methods.test.ts +105 -0
  72. package/src/tempo/Methods.ts +54 -17
  73. package/src/tempo/client/Charge.ts +67 -11
  74. package/src/tempo/internal/account.ts +7 -14
  75. package/src/tempo/internal/charge.test.ts +66 -0
  76. package/src/tempo/internal/charge.ts +43 -0
  77. package/src/tempo/internal/fee-payer.test.ts +33 -14
  78. package/src/tempo/internal/fee-payer.ts +21 -6
  79. package/src/tempo/internal/proof.test.ts +36 -0
  80. package/src/tempo/internal/proof.ts +19 -0
  81. package/src/tempo/server/Charge.test.ts +593 -1
  82. package/src/tempo/server/Charge.ts +233 -126
  83. package/src/tempo/server/Methods.ts +4 -1
  84. package/src/tempo/server/Session.test.ts +1152 -54
  85. package/src/tempo/server/Session.ts +26 -17
  86. package/src/tempo/server/internal/transport.test.ts +32 -0
  87. package/src/tempo/session/Chain.test.ts +60 -5
  88. package/src/tempo/session/Chain.ts +30 -14
  89. 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
- describe('charge', () => {
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
- beforeAll(async () => {
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 handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
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 = Mppx.create({
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
  })
@@ -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
+ })