mppx 0.6.0 → 0.6.2

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 (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Method.d.ts +2 -0
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/middlewares/hono.d.ts.map +1 -1
  6. package/dist/middlewares/hono.js +5 -1
  7. package/dist/middlewares/hono.js.map +1 -1
  8. package/dist/proxy/Proxy.d.ts.map +1 -1
  9. package/dist/proxy/Proxy.js +19 -1
  10. package/dist/proxy/Proxy.js.map +1 -1
  11. package/dist/proxy/internal/Route.d.ts +5 -0
  12. package/dist/proxy/internal/Route.d.ts.map +1 -1
  13. package/dist/proxy/internal/Route.js +2 -1
  14. package/dist/proxy/internal/Route.js.map +1 -1
  15. package/dist/server/Mppx.d.ts +13 -1
  16. package/dist/server/Mppx.d.ts.map +1 -1
  17. package/dist/server/Mppx.js +107 -36
  18. package/dist/server/Mppx.js.map +1 -1
  19. package/dist/server/internal/scope.d.ts +19 -0
  20. package/dist/server/internal/scope.d.ts.map +1 -0
  21. package/dist/server/internal/scope.js +33 -0
  22. package/dist/server/internal/scope.js.map +1 -0
  23. package/dist/tempo/server/internal/transport.js +2 -3
  24. package/dist/tempo/server/internal/transport.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/Method.ts +2 -0
  27. package/src/middlewares/hono.test.ts +95 -1
  28. package/src/middlewares/hono.ts +6 -1
  29. package/src/proxy/Proxy.test.ts +116 -0
  30. package/src/proxy/Proxy.ts +27 -1
  31. package/src/proxy/internal/Route.ts +2 -1
  32. package/src/server/Mppx.test-d.ts +18 -0
  33. package/src/server/Mppx.test.ts +283 -0
  34. package/src/server/Mppx.ts +153 -45
  35. package/src/server/internal/scope.ts +43 -0
  36. package/src/tempo/server/internal/transport.test.ts +84 -0
  37. package/src/tempo/server/internal/transport.ts +3 -3
@@ -13,6 +13,8 @@ import * as Http from '~test/Http.js'
13
13
  import { deployEscrow } from '~test/tempo/session.js'
14
14
  import { accounts, asset, client } from '~test/tempo/viem.js'
15
15
 
16
+ import type { SessionReceipt } from '../tempo/session/Types.js'
17
+
16
18
  const realm = 'api.example.com'
17
19
  const secretKey = 'test-secret-key'
18
20
 
@@ -2106,6 +2108,103 @@ describe('cross-route credential replay via scope binding flaw', () => {
2106
2108
  expect(result.status).toBe(402)
2107
2109
  })
2108
2110
 
2111
+ test('rejects same-economics credential replayed across sibling routes with different scope', async () => {
2112
+ const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
2113
+
2114
+ const routeA = handler.charge({
2115
+ amount: '0.01',
2116
+ currency: '0x0000000000000000000000000000000000000001',
2117
+ decimals: 6,
2118
+ expires: new Date(Date.now() + 60_000).toISOString(),
2119
+ recipient: '0x0000000000000000000000000000000000000002',
2120
+ scope: 'GET /a',
2121
+ })
2122
+ const routeB = handler.charge({
2123
+ amount: '0.01',
2124
+ currency: '0x0000000000000000000000000000000000000001',
2125
+ decimals: 6,
2126
+ expires: new Date(Date.now() + 60_000).toISOString(),
2127
+ recipient: '0x0000000000000000000000000000000000000002',
2128
+ scope: 'GET /b',
2129
+ })
2130
+
2131
+ const routeAChallengeResult = await routeA(new Request('https://example.com/a'))
2132
+ expect(routeAChallengeResult.status).toBe(402)
2133
+ if (routeAChallengeResult.status !== 402) throw new Error()
2134
+
2135
+ const routeAChallenge = Challenge.fromResponse(routeAChallengeResult.challenge)
2136
+ expect(routeAChallenge.opaque).toEqual({ _mppx_scope: 'GET /a' })
2137
+
2138
+ const credential = Credential.from({
2139
+ challenge: routeAChallenge,
2140
+ payload: { token: 'valid' },
2141
+ })
2142
+
2143
+ const result = await routeB(
2144
+ new Request('https://example.com/b', {
2145
+ headers: { Authorization: Credential.serialize(credential) },
2146
+ }),
2147
+ )
2148
+
2149
+ expect(result.status).toBe(402)
2150
+ })
2151
+
2152
+ test('rejects request-billed credential replayed at token-billed route', async () => {
2153
+ const sessionMethod = Method.from({
2154
+ name: 'mock',
2155
+ intent: 'session',
2156
+ schema: {
2157
+ credential: { payload: z.object({ token: z.string() }) },
2158
+ request: z.object({
2159
+ amount: z.string(),
2160
+ currency: z.string(),
2161
+ recipient: z.string(),
2162
+ unitType: z.string(),
2163
+ }),
2164
+ },
2165
+ })
2166
+
2167
+ const sessionServerMethod = Method.toServer(sessionMethod, {
2168
+ async verify() {
2169
+ return mockReceipt()
2170
+ },
2171
+ })
2172
+
2173
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
2174
+
2175
+ const requestRoute = handler.session({
2176
+ amount: '1',
2177
+ currency: '0x0000000000000000000000000000000000000001',
2178
+ expires: new Date(Date.now() + 60_000).toISOString(),
2179
+ recipient: '0x0000000000000000000000000000000000000002',
2180
+ unitType: 'request',
2181
+ })
2182
+ const tokenRoute = handler.session({
2183
+ amount: '1',
2184
+ currency: '0x0000000000000000000000000000000000000001',
2185
+ expires: new Date(Date.now() + 60_000).toISOString(),
2186
+ recipient: '0x0000000000000000000000000000000000000002',
2187
+ unitType: 'token',
2188
+ })
2189
+
2190
+ const first = await requestRoute(new Request('https://example.com/request'))
2191
+ expect(first.status).toBe(402)
2192
+ if (first.status !== 402) throw new Error()
2193
+
2194
+ const credential = Credential.from({
2195
+ challenge: Challenge.fromResponse(first.challenge),
2196
+ payload: { token: 'valid' },
2197
+ })
2198
+
2199
+ const result = await tokenRoute(
2200
+ new Request('https://example.com/token', {
2201
+ headers: { Authorization: Credential.serialize(credential) },
2202
+ }),
2203
+ )
2204
+
2205
+ expect(result.status).toBe(402)
2206
+ })
2207
+
2109
2208
  test('rejects credential with mismatched method field', async () => {
2110
2209
  const otherMethod = Method.from({
2111
2210
  name: 'other',
@@ -3033,6 +3132,37 @@ describe('challenge', () => {
3033
3132
  expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' })
3034
3133
  })
3035
3134
 
3135
+ test('challenge binds scope via reserved opaque metadata', async () => {
3136
+ const mppx = Mppx.create({
3137
+ methods: [alphaChargeServer],
3138
+ realm,
3139
+ secretKey,
3140
+ })
3141
+
3142
+ const challenge = await mppx.challenge.alpha.charge({
3143
+ ...challengeOpts,
3144
+ scope: 'GET /premium',
3145
+ })
3146
+
3147
+ expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /premium' })
3148
+ })
3149
+
3150
+ test('scope throws when it conflicts with reserved meta scope', async () => {
3151
+ const mppx = Mppx.create({
3152
+ methods: [alphaChargeServer],
3153
+ realm,
3154
+ secretKey,
3155
+ })
3156
+
3157
+ await expect(
3158
+ mppx.challenge.alpha.charge({
3159
+ ...challengeOpts,
3160
+ meta: { _mppx_scope: 'GET /other' },
3161
+ scope: 'GET /premium',
3162
+ }),
3163
+ ).rejects.toThrow('Conflicting scope values')
3164
+ })
3165
+
3036
3166
  test('challenge applies schema transforms', async () => {
3037
3167
  // Method with a z.transform that converts decimals
3038
3168
  const transformMethod = Method.from({
@@ -3271,6 +3401,70 @@ describe('verifyCredential', () => {
3271
3401
  expect(receipt.method).toBe('alpha')
3272
3402
  })
3273
3403
 
3404
+ test('verifies a credential when the expected scope matches', async () => {
3405
+ const mppx = Mppx.create({
3406
+ methods: [alphaChargeServer],
3407
+ realm,
3408
+ secretKey,
3409
+ })
3410
+
3411
+ const challenge = await mppx.challenge.alpha.charge({
3412
+ ...challengeOpts,
3413
+ scope: 'GET /premium',
3414
+ })
3415
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3416
+
3417
+ const receipt = await mppx.verifyCredential(credential, { scope: 'GET /premium' })
3418
+
3419
+ expect(receipt.status).toBe('success')
3420
+ expect(receipt.method).toBe('alpha')
3421
+ })
3422
+
3423
+ test('rejects a credential when the expected scope mismatches', async () => {
3424
+ const mppx = Mppx.create({
3425
+ methods: [alphaChargeServer],
3426
+ realm,
3427
+ secretKey,
3428
+ })
3429
+
3430
+ const challenge = await mppx.challenge.alpha.charge({
3431
+ ...challengeOpts,
3432
+ scope: 'GET /premium',
3433
+ })
3434
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3435
+
3436
+ await expect(mppx.verifyCredential(credential, { scope: 'GET /other' })).rejects.toThrow(
3437
+ "credential scope does not match this route's requirements",
3438
+ )
3439
+ })
3440
+
3441
+ test('verifies route requirements using the echoed challenge realm when host was auto-detected', async () => {
3442
+ const mppx = Mppx.create({
3443
+ methods: [alphaChargeServer],
3444
+ secretKey,
3445
+ })
3446
+ const request = {
3447
+ amount: '1000',
3448
+ currency: '0x0000000000000000000000000000000000000001',
3449
+ decimals: 6,
3450
+ recipient: '0x0000000000000000000000000000000000000002',
3451
+ }
3452
+
3453
+ const firstResult = await mppx.charge(request)(new Request('https://api.example.com/premium'))
3454
+ expect(firstResult.status).toBe(402)
3455
+ if (firstResult.status !== 402) throw new Error()
3456
+
3457
+ const challenge = Challenge.fromResponse(firstResult.challenge)
3458
+ expect(challenge.realm).toBe('api.example.com')
3459
+
3460
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3461
+
3462
+ const receipt = await mppx.verifyCredential(credential, { request })
3463
+
3464
+ expect(receipt.status).toBe('success')
3465
+ expect(receipt.method).toBe('alpha')
3466
+ })
3467
+
3274
3468
  test('verifies a credential for session intent', async () => {
3275
3469
  verifyArgs = undefined
3276
3470
  const mppx = Mppx.create({
@@ -3311,6 +3505,28 @@ describe('verifyCredential', () => {
3311
3505
  expect(receipt.method).toBe('beta')
3312
3506
  })
3313
3507
 
3508
+ test('rejects credential when verified against different route economics', async () => {
3509
+ const mppx = Mppx.create({
3510
+ methods: [alphaChargeServer],
3511
+ realm,
3512
+ secretKey,
3513
+ })
3514
+
3515
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3516
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3517
+
3518
+ await expect(
3519
+ mppx.verifyCredential(credential, {
3520
+ request: {
3521
+ amount: '100000',
3522
+ currency: '0x0000000000000000000000000000000000000001',
3523
+ decimals: 6,
3524
+ recipient: '0x0000000000000000000000000000000000000002',
3525
+ },
3526
+ }),
3527
+ ).rejects.toThrow()
3528
+ })
3529
+
3314
3530
  test('rejects credential with wrong HMAC (not issued by this server)', async () => {
3315
3531
  const mppx = Mppx.create({
3316
3532
  methods: [alphaChargeServer],
@@ -3698,6 +3914,73 @@ describe('verifyCredential', () => {
3698
3914
  httpServer.close()
3699
3915
  })
3700
3916
 
3917
+ test('verifyCredential charges repeated session voucher content requests when capturedRequest is provided', async () => {
3918
+ const escrowContract = await deployEscrow()
3919
+ const server = Mppx.create({
3920
+ methods: [
3921
+ tempo.session({
3922
+ store: Store.memory(),
3923
+ getClient: () => client,
3924
+ account: accounts[0],
3925
+ currency: asset,
3926
+ escrowContract,
3927
+ chainId: client.chain!.id,
3928
+ }),
3929
+ ],
3930
+ realm,
3931
+ secretKey,
3932
+ })
3933
+ const route = server.session({ amount: '1', unitType: 'request' })
3934
+ const clientMppx = Mppx_client.create({
3935
+ polyfill: false,
3936
+ methods: [
3937
+ tempo_session_client({
3938
+ account: accounts[1],
3939
+ deposit: '10',
3940
+ getClient: () => client,
3941
+ }),
3942
+ ],
3943
+ })
3944
+
3945
+ const openChallengeResponse = await route(new Request('https://example.com/session'))
3946
+ expect(openChallengeResponse.status).toBe(402)
3947
+ if (openChallengeResponse.status !== 402) throw new Error()
3948
+
3949
+ const serializedOpenCredential = await clientMppx.createCredential(
3950
+ openChallengeResponse.challenge,
3951
+ )
3952
+ await server.verifyCredential(serializedOpenCredential)
3953
+
3954
+ const voucherChallengeResponse = await route(new Request('https://example.com/session'))
3955
+ expect(voucherChallengeResponse.status).toBe(402)
3956
+ if (voucherChallengeResponse.status !== 402) throw new Error()
3957
+
3958
+ const serializedVoucherCredential = await clientMppx.createCredential(
3959
+ voucherChallengeResponse.challenge,
3960
+ )
3961
+ const contentRequest = {
3962
+ headers: new Headers(),
3963
+ hasBody: false,
3964
+ method: 'GET',
3965
+ url: new URL('https://example.com/session'),
3966
+ } as const
3967
+ const routeRequest = { amount: '1', unitType: 'request' } as const
3968
+
3969
+ const firstReceipt = (await server.verifyCredential(serializedVoucherCredential, {
3970
+ capturedRequest: contentRequest,
3971
+ request: routeRequest,
3972
+ })) as SessionReceipt
3973
+ const secondReceipt = (await server.verifyCredential(serializedVoucherCredential, {
3974
+ capturedRequest: contentRequest,
3975
+ request: routeRequest,
3976
+ })) as SessionReceipt
3977
+
3978
+ expect(BigInt(firstReceipt.spent)).toBeGreaterThan(0n)
3979
+ expect(firstReceipt.units).toBe(1)
3980
+ expect(BigInt(secondReceipt.spent)).toBeGreaterThan(BigInt(firstReceipt.spent))
3981
+ expect(secondReceipt.units).toBe(2)
3982
+ })
3983
+
3701
3984
  test('verifies a sponsored tempo credential created by the real client', async () => {
3702
3985
  const server = Mppx.create({
3703
3986
  methods: [
@@ -13,12 +13,23 @@ import type * as Receipt from '../Receipt.js'
13
13
  import type * as z from '../zod.js'
14
14
  import * as Html from './internal/html/config.js'
15
15
  import { serviceWorker } from './internal/html/serviceWorker.gen.js'
16
+ import * as Scope from './internal/scope.js'
16
17
  import * as NodeListener from './NodeListener.js'
17
18
  import * as Request from './Request.js'
18
19
  import * as Transport from './Transport.js'
19
20
 
20
21
  export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[]
21
22
 
23
+ /** Options for standalone credential verification. */
24
+ export type VerifyCredentialOptions = {
25
+ capturedRequest?: Method.CapturedRequest | undefined
26
+ meta?: Record<string, string> | undefined
27
+ realm?: string | undefined
28
+ request?: Record<string, unknown> | undefined
29
+ /** Optional expected route/resource scope bound via challenge `opaque`. */
30
+ scope?: string | undefined
31
+ }
32
+
22
33
  /**
23
34
  * Payment handler.
24
35
  */
@@ -92,9 +103,13 @@ export type Mppx<
92
103
  * ```ts
93
104
  * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
94
105
  * const receipt = await mppx.verifyCredential(credential)
106
+ * const receipt = await mppx.verifyCredential(credential, { request: { amount: '1000' } })
95
107
  * ```
96
108
  */
97
- verifyCredential(credential: string | Credential.Credential): Promise<Receipt.Receipt>
109
+ verifyCredential(
110
+ credential: string | Credential.Credential,
111
+ options?: VerifyCredentialOptions | undefined,
112
+ ): Promise<Receipt.Receipt>
98
113
  }
99
114
 
100
115
  /** Extracts the transport override from a method, if any. */
@@ -256,6 +271,7 @@ export function create<
256
271
  // verifyCredential: single-call end-to-end verification
257
272
  async function verifyCredentialFn(
258
273
  input: string | Credential.Credential,
274
+ options?: VerifyCredentialOptions,
259
275
  ): Promise<Receipt.Receipt> {
260
276
  const credential = typeof input === 'string' ? Credential.deserialize(input) : input
261
277
 
@@ -283,11 +299,59 @@ export function create<
283
299
  // Validate payload against method schema
284
300
  mi.schema.credential.payload.parse(credential.payload)
285
301
 
286
- // The challenge already contains the request params (HMAC-bound),
287
- // so we use them directly — no need for the caller to re-supply.
288
- const request = credential.challenge.request as z.input<typeof mi.schema.request>
302
+ const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
303
+
304
+ if (options?.scope !== undefined && Scope.read(credential.challenge.opaque) !== options.scope) {
305
+ throw new Errors.InvalidChallengeError({
306
+ id: credential.challenge.id,
307
+ reason: "credential scope does not match this route's requirements",
308
+ })
309
+ }
310
+
311
+ const shouldValidateRoute =
312
+ options?.capturedRequest !== undefined ||
313
+ options?.meta !== undefined ||
314
+ options?.realm !== undefined ||
315
+ options?.request !== undefined
316
+ const expectedRealm =
317
+ options?.realm ??
318
+ realm ??
319
+ (options?.capturedRequest === undefined ? credential.challenge.realm : undefined)
320
+
321
+ const request = shouldValidateRoute
322
+ ? await resolveRouteChallenge({
323
+ capturedRequest: options?.capturedRequest,
324
+ credential,
325
+ defaults: mi.defaults,
326
+ expires: credential.challenge.expires,
327
+ meta: expectedMeta,
328
+ method: mi,
329
+ realm: expectedRealm,
330
+ request: mi.request as never,
331
+ routeRequest: options?.request ?? {},
332
+ secretKey: secretKey!,
333
+ }).then((resolved) => {
334
+ const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge)
335
+ if (mismatch)
336
+ throw new Errors.InvalidChallengeError({
337
+ id: credential.challenge.id,
338
+ reason: `credential ${mismatch} does not match this route's requirements`,
339
+ })
340
+
341
+ return resolved.request as z.input<typeof mi.schema.request>
342
+ })
343
+ : (credential.challenge.request as z.input<typeof mi.schema.request>)
344
+
345
+ const envelope = options?.capturedRequest
346
+ ? ({
347
+ capturedRequest: options.capturedRequest,
348
+ challenge: credential.challenge,
349
+ credential,
350
+ request,
351
+ } as Method.VerifiedChallengeEnvelope)
352
+ : undefined
289
353
 
290
- return mi.verify({ credential, request } as never)
354
+ return mi.verify({ credential, envelope, request } as never)
291
355
  }
292
356
 
293
357
  function composeFn(
@@ -352,14 +416,18 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
352
416
  const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
353
417
 
354
418
  return (options) => {
355
- const { description, meta, ...rest } = options
356
- const merged = { ...defaults, ...rest }
419
+ const { description, meta, scope, ...rest } = options
420
+ const staticMeta = Scope.merge({ meta, scope })
357
421
 
358
422
  return Object.assign(
359
423
  async (input: Transport.InputOf): Promise<MethodFn.Response> => {
360
424
  const expires =
361
425
  'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
362
426
  const capturedRequest = await captureRequest(transport, input)
427
+ const effectiveMeta =
428
+ scope === undefined && input instanceof globalThis.Request
429
+ ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
430
+ : staticMeta
363
431
 
364
432
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
365
433
  const [credential, credentialError] = (() => {
@@ -372,26 +440,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
372
440
  return [null, e as Error] as const
373
441
  }
374
442
  })()
375
-
376
- // Transform request if method provides a `request` function.
377
- const request = (
378
- parameters.request
379
- ? await parameters.request({ capturedRequest, credential, request: merged } as never)
380
- : merged
381
- ) as never
382
-
383
- // Resolve realm: explicit > env var > request Host header.
384
- const effectiveRealm = realm ?? resolveRealmFromCapturedRequest(capturedRequest)
385
-
386
- // Recompute challenge from options. The HMAC-bound ID means we don't need to
387
- // store challenges server-side—if the client echoes back a credential with
388
- // a matching ID, we know it was issued by us with these exact parameters.
389
- const challenge = Challenge.fromMethod(method, {
443
+ const { challenge, request } = await resolveRouteChallenge({
444
+ capturedRequest,
445
+ credential,
446
+ defaults,
390
447
  description,
391
448
  expires,
392
- meta,
393
- realm: effectiveRealm,
394
- request,
449
+ meta: effectiveMeta,
450
+ method,
451
+ realm,
452
+ request: parameters.request,
453
+ routeRequest: rest,
395
454
  secretKey,
396
455
  })
397
456
 
@@ -507,6 +566,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
507
566
  capturedRequest,
508
567
  challenge: credential.challenge,
509
568
  credential,
569
+ request,
510
570
  })
511
571
 
512
572
  // User-provided verification (e.g., check signature, submit tx, verify payment).
@@ -565,9 +625,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
565
625
  ...method,
566
626
  ...defaults,
567
627
  ...options,
628
+ ...(staticMeta !== undefined ? { meta: staticMeta } : {}),
568
629
  name: method.name,
569
630
  intent: method.intent,
570
- _canonicalRequest: PaymentRequest.fromMethod(method, merged),
631
+ _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
571
632
  },
572
633
  },
573
634
  )
@@ -589,35 +650,28 @@ function createChallengeFn(parameters: {
589
650
  const { defaults, method, realm, secretKey } = parameters
590
651
 
591
652
  return async (options) => {
592
- const { description, meta, ...rest } = options as {
653
+ const { description, meta, scope, ...rest } = options as {
593
654
  description?: string
594
655
  expires?: string
595
656
  meta?: Record<string, string>
657
+ scope?: string
596
658
  [key: string]: unknown
597
659
  }
598
- const merged = { ...defaults, ...rest }
660
+ const effectiveMeta = Scope.merge({ meta, scope })
599
661
  const expires =
600
662
  'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
601
663
 
602
- // Transform request if method provides a `request` function.
603
- const request = (
604
- parameters.request
605
- ? await (parameters.request as (opts: { request: unknown }) => unknown)({
606
- request: merged,
607
- })
608
- : merged
609
- ) as never
610
-
611
- const effectiveRealm = realm ?? defaultRealm
612
-
613
- return Challenge.fromMethod(method, {
664
+ return resolveRouteChallenge({
665
+ defaults,
614
666
  description,
615
667
  expires,
616
- meta,
617
- realm: effectiveRealm,
618
- request,
668
+ meta: effectiveMeta,
669
+ method,
670
+ realm,
671
+ request: parameters.request,
672
+ routeRequest: rest,
619
673
  secretKey,
620
- })
674
+ }).then((resolved) => resolved.challenge)
621
675
  }
622
676
  }
623
677
 
@@ -676,6 +730,55 @@ function resolveRealmFromCapturedRequest(capturedRequest: Method.CapturedRequest
676
730
  return defaultRealm
677
731
  }
678
732
 
733
+ async function resolveRouteChallenge(parameters: {
734
+ capturedRequest?: Method.CapturedRequest | undefined
735
+ credential?: Credential.Credential | null | undefined
736
+ defaults?: Record<string, unknown> | undefined
737
+ description?: string | undefined
738
+ expires?: string | undefined
739
+ meta?: Record<string, string> | undefined
740
+ method: Method.Method
741
+ realm?: string | undefined
742
+ request?: Method.RequestFn<Method.Method> | undefined
743
+ routeRequest: Record<string, unknown>
744
+ secretKey: string
745
+ }): Promise<{
746
+ challenge: Challenge.Challenge
747
+ request: Record<string, unknown>
748
+ }> {
749
+ // Resolve the route's canonical request exactly as the handler path does:
750
+ const request = await (async () => {
751
+ // start from defaults + route options, then let the method request hook
752
+ const merged = { ...parameters.defaults, ...parameters.routeRequest }
753
+ // normalize or enrich it using the captured request and credential.
754
+ return parameters.request
755
+ ? ((await parameters.request({
756
+ capturedRequest: parameters.capturedRequest,
757
+ credential: parameters.credential,
758
+ request: merged,
759
+ } as never)) as Record<string, unknown>)
760
+ : merged
761
+ })()
762
+
763
+ const effectiveRealm =
764
+ parameters.realm ??
765
+ (parameters.capturedRequest
766
+ ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
767
+ : defaultRealm)
768
+
769
+ return {
770
+ challenge: Challenge.fromMethod(parameters.method, {
771
+ description: parameters.description,
772
+ expires: parameters.expires,
773
+ meta: parameters.meta,
774
+ realm: effectiveRealm,
775
+ request: request as never,
776
+ secretKey: parameters.secretKey,
777
+ }),
778
+ request,
779
+ }
780
+ }
781
+
679
782
  /**
680
783
  * Captures the transport request into a frozen snapshot at the start of the
681
784
  * verification flow. This snapshot is threaded through request() → verify() →
@@ -715,7 +818,7 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest {
715
818
  }
716
819
 
717
820
  const coreBindingFields = ['amount', 'currency', 'recipient'] as const
718
- const methodBindingFields = ['chainId', 'memo', 'splits'] as const
821
+ const methodBindingFields = ['chainId', 'memo', 'splits', 'unitType'] as const
719
822
  const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields] as const
720
823
 
721
824
  type CoreBindingField = (typeof coreBindingFields)[number]
@@ -785,6 +888,7 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
785
888
  const memo = normalizeHex(methodDetails.memo)
786
889
  const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient)
787
890
  const splits = normalizeComparable(methodDetails.splits)
891
+ const unitType = normalizeScalar(request.unitType ?? methodDetails.unitType)
788
892
 
789
893
  return {
790
894
  coreBinding: {
@@ -796,6 +900,7 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
796
900
  ...(chainId !== undefined ? { chainId } : {}),
797
901
  ...(memo !== undefined ? { memo } : {}),
798
902
  ...(splits !== undefined ? { splits } : {}),
903
+ ...(unitType !== undefined ? { unitType } : {}),
799
904
  },
800
905
  }
801
906
  }
@@ -870,6 +975,8 @@ declare namespace MethodFn {
870
975
  expires?: string | undefined
871
976
  /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
872
977
  meta?: Record<string, string> | undefined
978
+ /** Optional route/resource scope bound via reserved challenge metadata. */
979
+ scope?: string | undefined
873
980
  } & Method.WithDefaults<z.input<method['schema']['request']>, defaults>
874
981
 
875
982
  export type Response<transport extends Transport.AnyTransport = Transport.Http> =
@@ -890,6 +997,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
890
997
  intent: string
891
998
  html: Html.Options | undefined
892
999
  meta?: Record<string, string> | undefined
1000
+ scope?: string | undefined
893
1001
  _canonicalRequest: Record<string, unknown>
894
1002
  }
895
1003
  }
@@ -0,0 +1,43 @@
1
+ const requestScopes = new WeakMap<Request, string>()
2
+
3
+ /** Reserved `meta` key used for mppx-managed route/resource scope binding. */
4
+ export const reservedMetaKey = '_mppx_scope'
5
+
6
+ /** Attaches a trusted adapter-derived scope to a Request for this process only. */
7
+ export function attach(request: Request, scope: string): Request {
8
+ requestScopes.set(request, scope)
9
+ return request
10
+ }
11
+
12
+ /** Reads a previously attached trusted adapter-derived scope from a Request. */
13
+ export function get(request: Request): string | undefined {
14
+ return requestScopes.get(request)
15
+ }
16
+
17
+ /** Returns the reserved mppx scope value from challenge metadata, if present. */
18
+ export function read(meta: Record<string, string> | undefined): string | undefined {
19
+ return meta?.[reservedMetaKey]
20
+ }
21
+
22
+ /**
23
+ * Merges the public `scope` option into challenge metadata.
24
+ *
25
+ * Throws when both `scope` and `meta._mppx_scope` are provided with different
26
+ * values so callers have a single authoritative way to bind route scope.
27
+ */
28
+ export function merge(parameters: {
29
+ meta?: Record<string, string> | undefined
30
+ scope?: string | undefined
31
+ }): Record<string, string> | undefined {
32
+ const { meta, scope } = parameters
33
+ const metaScope = read(meta)
34
+
35
+ if (scope !== undefined && metaScope !== undefined && metaScope !== scope) {
36
+ throw new Error(
37
+ `Conflicting scope values: \`scope\` (${scope}) does not match \`meta.${reservedMetaKey}\` (${metaScope}).`,
38
+ )
39
+ }
40
+
41
+ if (scope === undefined || metaScope === scope) return meta
42
+ return { ...meta, [reservedMetaKey]: scope }
43
+ }