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.
- package/CHANGELOG.md +12 -0
- package/dist/Method.d.ts +2 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +5 -1
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +19 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +5 -0
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +2 -1
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts +13 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +107 -36
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/internal/scope.d.ts +19 -0
- package/dist/server/internal/scope.d.ts.map +1 -0
- package/dist/server/internal/scope.js +33 -0
- package/dist/server/internal/scope.js.map +1 -0
- package/dist/tempo/server/internal/transport.js +2 -3
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/Method.ts +2 -0
- package/src/middlewares/hono.test.ts +95 -1
- package/src/middlewares/hono.ts +6 -1
- package/src/proxy/Proxy.test.ts +116 -0
- package/src/proxy/Proxy.ts +27 -1
- package/src/proxy/internal/Route.ts +2 -1
- package/src/server/Mppx.test-d.ts +18 -0
- package/src/server/Mppx.test.ts +283 -0
- package/src/server/Mppx.ts +153 -45
- package/src/server/internal/scope.ts +43 -0
- package/src/tempo/server/internal/transport.test.ts +84 -0
- package/src/tempo/server/internal/transport.ts +3 -3
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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: [
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
+
}
|