mppx 0.4.7 → 0.4.9
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 +15 -3
- package/README.md +13 -13
- package/dist/BodyDigest.d.ts.map +1 -1
- package/dist/BodyDigest.js.map +1 -1
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js.map +1 -1
- package/dist/Errors.js +64 -67
- package/dist/Errors.js.map +1 -1
- package/dist/PaymentRequest.d.ts.map +1 -1
- package/dist/PaymentRequest.js.map +1 -1
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js.map +1 -1
- package/dist/Store.d.ts +14 -4
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +17 -0
- package/dist/Store.js.map +1 -1
- package/dist/cli/account.d.ts.map +1 -1
- package/dist/cli/account.js +40 -5
- package/dist/cli/account.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +24 -8
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +11 -23
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +1 -1
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +1 -1
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +5 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +5 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +3 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +2 -2
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +4 -2
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +47 -11
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Request.d.ts.map +1 -1
- package/dist/server/Request.js.map +1 -1
- package/dist/stripe/Methods.d.ts.map +1 -1
- package/dist/stripe/Methods.js.map +1 -1
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/client/ChannelOps.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Session.d.ts.map +1 -1
- package/dist/tempo/client/Session.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +1 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/address.d.ts +3 -0
- package/dist/tempo/internal/address.d.ts.map +1 -0
- package/dist/tempo/internal/address.js +4 -0
- package/dist/tempo/internal/address.js.map +1 -0
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +4 -4
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +12 -4
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +110 -51
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +31 -23
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +41 -1
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +51 -10
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +4 -2
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Receipt.d.ts.map +1 -1
- package/dist/tempo/session/Receipt.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js +3 -2
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js.map +1 -1
- package/package.json +2 -2
- package/src/BodyDigest.ts +1 -0
- package/src/Challenge.test-d.ts +1 -0
- package/src/Challenge.ts +1 -0
- package/src/Credential.ts +1 -0
- package/src/Errors.test.ts +27 -39
- package/src/Expires.test.ts +1 -0
- package/src/PaymentRequest.ts +1 -0
- package/src/Receipt.ts +1 -0
- package/src/Store.test-d.ts +59 -0
- package/src/Store.test.ts +56 -6
- package/src/Store.ts +31 -4
- package/src/cli/account.ts +65 -30
- package/src/cli/cli.test.ts +127 -1
- package/src/cli/cli.ts +23 -8
- package/src/cli/config.test.ts +1 -0
- package/src/cli/internal.ts +1 -0
- package/src/cli/plugins/stripe.ts +1 -0
- package/src/cli/plugins/tempo.ts +21 -24
- package/src/cli/utils.ts +1 -0
- package/src/client/Mppx.test-d.ts +1 -0
- package/src/client/internal/Fetch.browser.test.ts +1 -0
- package/src/client/internal/Fetch.test-d.ts +1 -0
- package/src/client/internal/Fetch.test.ts +1 -0
- package/src/client/internal/Fetch.ts +1 -1
- package/src/internal/constantTimeEqual.test.ts +1 -0
- package/src/internal/types.ts +1 -3
- package/src/mcp-sdk/client/McpClient.test-d.ts +1 -0
- package/src/mcp-sdk/client/McpClient.test.ts +1 -0
- package/src/mcp-sdk/client/McpClient.ts +2 -0
- package/src/mcp-sdk/server/Transport.test.ts +1 -0
- package/src/mcp-sdk/server/Transport.ts +1 -0
- package/src/middlewares/elysia.test.ts +90 -0
- package/src/middlewares/elysia.ts +5 -1
- package/src/middlewares/express.test.ts +62 -2
- package/src/middlewares/express.ts +6 -2
- package/src/middlewares/hono.ts +1 -0
- package/src/middlewares/internal/mppx.test.ts +1 -0
- package/src/middlewares/nextjs.test.ts +1 -0
- package/src/proxy/Proxy.test.ts +57 -0
- package/src/proxy/Proxy.ts +8 -1
- package/src/proxy/Service.test.ts +1 -0
- package/src/proxy/Service.ts +8 -2
- package/src/proxy/internal/Headers.test.ts +1 -0
- package/src/proxy/internal/Route.test.ts +57 -0
- package/src/proxy/internal/Route.ts +3 -1
- package/src/proxy/services/openai.test.ts +1 -0
- package/src/server/Mppx.test.ts +438 -0
- package/src/server/Mppx.ts +51 -13
- package/src/server/Request.test.ts +1 -0
- package/src/server/Request.ts +1 -0
- package/src/server/Response.test.ts +1 -0
- package/src/server/Transport.test.ts +1 -0
- package/src/stripe/Methods.ts +1 -0
- package/src/stripe/client/Charge.test.ts +1 -0
- package/src/stripe/server/Charge.test.ts +1 -0
- package/src/tempo/Attribution.test.ts +1 -0
- package/src/tempo/Methods.ts +1 -0
- package/src/tempo/client/ChannelOps.test.ts +1 -0
- package/src/tempo/client/ChannelOps.ts +1 -0
- package/src/tempo/client/Charge.ts +1 -0
- package/src/tempo/client/Session.test.ts +1 -0
- package/src/tempo/client/Session.ts +1 -0
- package/src/tempo/client/SessionManager.test.ts +28 -0
- package/src/tempo/client/SessionManager.ts +2 -1
- package/src/tempo/internal/address.ts +6 -0
- package/src/tempo/internal/auto-swap.test.ts +1 -0
- package/src/tempo/internal/auto-swap.ts +4 -3
- package/src/tempo/internal/defaults.test.ts +1 -0
- package/src/tempo/internal/fee-payer.test.ts +1 -0
- package/src/tempo/internal/fee-payer.ts +19 -4
- package/src/tempo/server/Charge.test.ts +1081 -31
- package/src/tempo/server/Charge.ts +159 -63
- package/src/tempo/server/Session.test.ts +896 -107
- package/src/tempo/server/Session.ts +41 -23
- package/src/tempo/server/Sse.test.ts +2 -0
- package/src/tempo/server/internal/transport.test.ts +30 -0
- package/src/tempo/server/internal/transport.ts +41 -2
- package/src/tempo/session/Chain.test.ts +145 -0
- package/src/tempo/session/Chain.ts +59 -10
- package/src/tempo/session/Channel.test.ts +1 -0
- package/src/tempo/session/ChannelStore.test.ts +11 -0
- package/src/tempo/session/ChannelStore.ts +7 -3
- package/src/tempo/session/Receipt.test.ts +1 -0
- package/src/tempo/session/Receipt.ts +1 -0
- package/src/tempo/session/Sse.test.ts +2 -0
- package/src/tempo/session/Sse.ts +1 -0
- package/src/tempo/session/Voucher.test.ts +1 -0
- package/src/tempo/session/Voucher.ts +4 -2
- package/src/viem/Account.test.ts +1 -0
- package/src/viem/Client.test.ts +1 -0
- package/src/viem/Client.ts +1 -0
package/src/proxy/Service.ts
CHANGED
|
@@ -267,8 +267,14 @@ function resolvePayment(endpoint: Endpoint): Record<string, unknown> | null {
|
|
|
267
267
|
if (endpoint === true) return null
|
|
268
268
|
const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay
|
|
269
269
|
if (!('_internal' in handler)) return {}
|
|
270
|
-
const {
|
|
271
|
-
|
|
270
|
+
const {
|
|
271
|
+
name,
|
|
272
|
+
intent,
|
|
273
|
+
defaults: _,
|
|
274
|
+
schema: _s,
|
|
275
|
+
_canonicalRequest,
|
|
276
|
+
...rest
|
|
277
|
+
} = handler._internal as Record<string, unknown>
|
|
272
278
|
const amount = (() => {
|
|
273
279
|
if (typeof rest.amount === 'string' && typeof rest.decimals === 'number')
|
|
274
280
|
return String(Value.from(rest.amount, rest.decimals))
|
|
@@ -141,3 +141,60 @@ describe('match', () => {
|
|
|
141
141
|
expect(Route.match(routes, 'GET', '/v1/chat/completions')).toBeNull()
|
|
142
142
|
})
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
describe('matchPath', () => {
|
|
146
|
+
const paidOnly = (v: unknown) => v !== true
|
|
147
|
+
|
|
148
|
+
test('behavior: matches route by path without filter', () => {
|
|
149
|
+
const routes = { 'GET /v1/models': true }
|
|
150
|
+
expect(Route.matchPath(routes, '/v1/models')).toMatchObject({
|
|
151
|
+
key: 'GET /v1/models',
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('behavior: matches paid endpoint by path', () => {
|
|
156
|
+
const routes = { 'GET /v1/generate': { pay: () => {} } }
|
|
157
|
+
expect(Route.matchPath(routes, '/v1/generate', paidOnly)).toMatchObject({
|
|
158
|
+
key: 'GET /v1/generate',
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('behavior: skips free passthrough routes with filter', () => {
|
|
163
|
+
const routes = {
|
|
164
|
+
'GET /v1/models': true,
|
|
165
|
+
'POST /v1/generate': { pay: () => {} },
|
|
166
|
+
}
|
|
167
|
+
expect(Route.matchPath(routes, '/v1/models', paidOnly)).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('behavior: matches paid route even with different method', () => {
|
|
171
|
+
const routes = {
|
|
172
|
+
'GET /v1/stream': { pay: () => {} },
|
|
173
|
+
}
|
|
174
|
+
expect(Route.matchPath(routes, '/v1/stream', paidOnly)).toMatchObject({
|
|
175
|
+
key: 'GET /v1/stream',
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('behavior: skips free and matches next paid route', () => {
|
|
180
|
+
const routes = {
|
|
181
|
+
'GET /v1/*': true,
|
|
182
|
+
'POST /v1/*': { pay: () => {} },
|
|
183
|
+
}
|
|
184
|
+
const result = Route.matchPath(routes, '/v1/cachedContents', paidOnly)
|
|
185
|
+
expect(result).toMatchObject({ key: 'POST /v1/*' })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('error: returns null when all routes are free', () => {
|
|
189
|
+
const routes = {
|
|
190
|
+
'GET /v1/models': true,
|
|
191
|
+
'GET /v1/status': true,
|
|
192
|
+
}
|
|
193
|
+
expect(Route.matchPath(routes, '/v1/models', paidOnly)).toBeNull()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('error: returns null when no path match', () => {
|
|
197
|
+
const routes = { 'POST /v1/generate': { pay: () => {} } }
|
|
198
|
+
expect(Route.matchPath(routes, '/v2/unknown', paidOnly)).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -36,12 +36,14 @@ export function match(
|
|
|
36
36
|
return null
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Finds the first route matching
|
|
39
|
+
/** Finds the first route matching the path, ignoring the HTTP method. Optional `filter` predicate can exclude routes. */
|
|
40
40
|
export function matchPath(
|
|
41
41
|
routes: Record<string, unknown>,
|
|
42
42
|
path: string,
|
|
43
|
+
filter?: (value: unknown) => boolean,
|
|
43
44
|
): { key: string; value: unknown } | null {
|
|
44
45
|
for (const [key, value] of Object.entries(routes)) {
|
|
46
|
+
if (filter && !filter(value)) continue
|
|
45
47
|
const { pattern } = parseRouteKey(key)
|
|
46
48
|
const urlPattern = new URLPattern({ pathname: pattern })
|
|
47
49
|
if (urlPattern.test({ pathname: path })) return { key, value }
|
|
@@ -4,6 +4,7 @@ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
|
4
4
|
import { afterEach, describe, expect, test } from 'vitest'
|
|
5
5
|
import * as Http from '~test/Http.js'
|
|
6
6
|
import { accounts, asset, client } from '~test/tempo/viem.js'
|
|
7
|
+
|
|
7
8
|
import * as ApiProxy from '../Proxy.js'
|
|
8
9
|
import { openai } from './openai.js'
|
|
9
10
|
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -180,6 +180,198 @@ describe('request handler', () => {
|
|
|
180
180
|
expect(body.detail).toContain('does not match')
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
+
test('topUp credential bypasses cross-route amount validation', async () => {
|
|
184
|
+
// Use a session method whose schema defines action: 'topUp'
|
|
185
|
+
const sessionMethod = Method.from({
|
|
186
|
+
name: 'mock',
|
|
187
|
+
intent: 'session',
|
|
188
|
+
schema: {
|
|
189
|
+
credential: {
|
|
190
|
+
payload: z.discriminatedUnion('action', [
|
|
191
|
+
z.object({ action: z.literal('open'), token: z.string() }),
|
|
192
|
+
z.object({ action: z.literal('topUp'), token: z.string() }),
|
|
193
|
+
]),
|
|
194
|
+
},
|
|
195
|
+
request: z.object({
|
|
196
|
+
amount: z.string(),
|
|
197
|
+
currency: z.string(),
|
|
198
|
+
recipient: z.string(),
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
const sessionServerMethod = Method.toServer(sessionMethod, {
|
|
203
|
+
async verify() {
|
|
204
|
+
return {
|
|
205
|
+
status: 'settled',
|
|
206
|
+
method: 'mock',
|
|
207
|
+
timestamp: new Date().toISOString(),
|
|
208
|
+
reference: 'ref',
|
|
209
|
+
} as any
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
|
|
213
|
+
|
|
214
|
+
// Get a challenge from the "cheap" route (simulates HEAD-obtained challenge)
|
|
215
|
+
const cheapHandle = handler['mock/session']({
|
|
216
|
+
amount: '1',
|
|
217
|
+
currency: asset,
|
|
218
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
219
|
+
recipient: accounts[0].address,
|
|
220
|
+
})
|
|
221
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
222
|
+
expect(cheapResult.status).toBe(402)
|
|
223
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
224
|
+
|
|
225
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
226
|
+
|
|
227
|
+
// Build a topUp credential from the cheap challenge (echoed from HEAD)
|
|
228
|
+
const credential = Credential.from({
|
|
229
|
+
challenge: cheapChallenge,
|
|
230
|
+
payload: { action: 'topUp', token: 'valid' },
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Present it at the "expensive" route — topUp should bypass amount check
|
|
234
|
+
const expensiveHandle = handler['mock/session']({
|
|
235
|
+
amount: '1000000',
|
|
236
|
+
currency: asset,
|
|
237
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
238
|
+
recipient: accounts[0].address,
|
|
239
|
+
})
|
|
240
|
+
const result = await expensiveHandle(
|
|
241
|
+
new Request('https://example.com/expensive', {
|
|
242
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
// Should NOT get 402 for amount mismatch — topUp bypasses the check.
|
|
247
|
+
// It will fail at a later stage (payload validation), but not with
|
|
248
|
+
// "does not match this route's requirements".
|
|
249
|
+
if (result.status === 402) {
|
|
250
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
251
|
+
expect(body.detail).not.toContain('does not match')
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test('voucher credential bypasses cross-route amount validation', async () => {
|
|
256
|
+
const sessionMethod = Method.from({
|
|
257
|
+
name: 'mock',
|
|
258
|
+
intent: 'session',
|
|
259
|
+
schema: {
|
|
260
|
+
credential: {
|
|
261
|
+
payload: z.discriminatedUnion('action', [
|
|
262
|
+
z.object({ action: z.literal('open'), token: z.string() }),
|
|
263
|
+
z.object({
|
|
264
|
+
action: z.literal('voucher'),
|
|
265
|
+
cumulativeAmount: z.string(),
|
|
266
|
+
signature: z.string(),
|
|
267
|
+
}),
|
|
268
|
+
]),
|
|
269
|
+
},
|
|
270
|
+
request: z.object({
|
|
271
|
+
amount: z.string(),
|
|
272
|
+
currency: z.string(),
|
|
273
|
+
recipient: z.string(),
|
|
274
|
+
}),
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
const sessionServerMethod = Method.toServer(sessionMethod, {
|
|
278
|
+
async verify() {
|
|
279
|
+
return {
|
|
280
|
+
status: 'settled',
|
|
281
|
+
method: 'mock',
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
reference: 'ref',
|
|
284
|
+
} as any
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
|
|
288
|
+
|
|
289
|
+
// Get a challenge from the "cheap" route (simulates initial SSE request)
|
|
290
|
+
const cheapHandle = handler['mock/session']({
|
|
291
|
+
amount: '1',
|
|
292
|
+
currency: asset,
|
|
293
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
294
|
+
recipient: accounts[0].address,
|
|
295
|
+
})
|
|
296
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/chat'))
|
|
297
|
+
expect(cheapResult.status).toBe(402)
|
|
298
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
299
|
+
|
|
300
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
301
|
+
|
|
302
|
+
// Build a voucher credential echoing the original challenge — mid-stream
|
|
303
|
+
// the server may re-price (dynamic pricing), so the route's amount differs
|
|
304
|
+
const credential = Credential.from({
|
|
305
|
+
challenge: cheapChallenge,
|
|
306
|
+
payload: { action: 'voucher', cumulativeAmount: '500', signature: '0xabc' },
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Present it at the same route but with a higher price — voucher should
|
|
310
|
+
// bypass the cross-route amount check just like topUp does
|
|
311
|
+
const expensiveHandle = handler['mock/session']({
|
|
312
|
+
amount: '1000000',
|
|
313
|
+
currency: asset,
|
|
314
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
315
|
+
recipient: accounts[0].address,
|
|
316
|
+
})
|
|
317
|
+
const result = await expensiveHandle(
|
|
318
|
+
new Request('https://example.com/chat', {
|
|
319
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
320
|
+
}),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Should NOT get 402 for amount mismatch — voucher bypasses the check.
|
|
324
|
+
if (result.status === 402) {
|
|
325
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
326
|
+
expect(body.detail).not.toContain('does not match')
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('rejects charge credential with injected action: topUp (cross-route bypass attempt)', async () => {
|
|
331
|
+
const handler = Mppx.create({ methods: [method], realm, secretKey })
|
|
332
|
+
|
|
333
|
+
// Get a challenge from the "cheap" route
|
|
334
|
+
const cheapHandle = handler.charge({
|
|
335
|
+
amount: '1',
|
|
336
|
+
currency: asset,
|
|
337
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
338
|
+
recipient: accounts[0].address,
|
|
339
|
+
})
|
|
340
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
341
|
+
expect(cheapResult.status).toBe(402)
|
|
342
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
343
|
+
|
|
344
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
345
|
+
|
|
346
|
+
// Malicious client injects action: 'topUp' into a regular charge credential
|
|
347
|
+
// to try to bypass the cross-route amount check
|
|
348
|
+
const credential = Credential.from({
|
|
349
|
+
challenge: cheapChallenge,
|
|
350
|
+
payload: { action: 'topUp', signature: '0x123', type: 'transaction' },
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Present it at the "expensive" route — should still be rejected
|
|
354
|
+
const expensiveHandle = handler.charge({
|
|
355
|
+
amount: '1000000',
|
|
356
|
+
currency: asset,
|
|
357
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
358
|
+
recipient: accounts[0].address,
|
|
359
|
+
})
|
|
360
|
+
const result = await expensiveHandle(
|
|
361
|
+
new Request('https://example.com/expensive', {
|
|
362
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
363
|
+
}),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// Injecting action: 'topUp' on a charge credential must not bypass
|
|
367
|
+
// the cross-route amount check. The credential should be rejected
|
|
368
|
+
// with "does not match" just like a normal charge credential would be.
|
|
369
|
+
expect(result.status).toBe(402)
|
|
370
|
+
if (result.status !== 402) throw new Error()
|
|
371
|
+
const body = (await result.challenge.json()) as { detail: string }
|
|
372
|
+
expect(body.detail).toContain('does not match')
|
|
373
|
+
})
|
|
374
|
+
|
|
183
375
|
test('returns 402 when credential challenge is expired', async () => {
|
|
184
376
|
const pastExpires = new Date(Date.now() - 60_000).toISOString()
|
|
185
377
|
|
|
@@ -1130,6 +1322,252 @@ describe('nested accessors', () => {
|
|
|
1130
1322
|
})
|
|
1131
1323
|
})
|
|
1132
1324
|
|
|
1325
|
+
describe('cross-route credential replay via scope binding flaw', () => {
|
|
1326
|
+
// Method whose schema transform moves `amount`, `currency`, and `recipient`
|
|
1327
|
+
// into `methodDetails`, removing them from the top-level request. This mirrors
|
|
1328
|
+
// real-world methods (e.g. Tempo) that restructure fields via z.transform.
|
|
1329
|
+
const transformingMethod = Method.from({
|
|
1330
|
+
name: 'mock',
|
|
1331
|
+
intent: 'charge',
|
|
1332
|
+
schema: {
|
|
1333
|
+
credential: {
|
|
1334
|
+
payload: z.object({ token: z.string() }),
|
|
1335
|
+
},
|
|
1336
|
+
request: z.pipe(
|
|
1337
|
+
z.object({
|
|
1338
|
+
amount: z.string(),
|
|
1339
|
+
currency: z.string(),
|
|
1340
|
+
decimals: z.number(),
|
|
1341
|
+
recipient: z.string(),
|
|
1342
|
+
}),
|
|
1343
|
+
z.transform(({ amount, currency, decimals, recipient }) => ({
|
|
1344
|
+
methodDetails: { amount: String(Number(amount) * 10 ** decimals), currency, recipient },
|
|
1345
|
+
})),
|
|
1346
|
+
),
|
|
1347
|
+
},
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
function mockReceipt() {
|
|
1351
|
+
return {
|
|
1352
|
+
method: 'mock',
|
|
1353
|
+
reference: 'tx-ref',
|
|
1354
|
+
status: 'success' as const,
|
|
1355
|
+
timestamp: new Date().toISOString(),
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const serverMethod = Method.toServer(transformingMethod, {
|
|
1360
|
+
async verify() {
|
|
1361
|
+
return mockReceipt()
|
|
1362
|
+
},
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
test('rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
|
|
1366
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
1367
|
+
|
|
1368
|
+
// Get a challenge from the "cheap" route ($0.01)
|
|
1369
|
+
const cheapHandle = handler.charge({
|
|
1370
|
+
amount: '0.01',
|
|
1371
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1372
|
+
decimals: 6,
|
|
1373
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1374
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1375
|
+
})
|
|
1376
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
1377
|
+
expect(cheapResult.status).toBe(402)
|
|
1378
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
1379
|
+
|
|
1380
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
1381
|
+
|
|
1382
|
+
// Build a credential from the cheap challenge
|
|
1383
|
+
const credential = Credential.from({
|
|
1384
|
+
challenge: cheapChallenge,
|
|
1385
|
+
payload: { token: 'valid' },
|
|
1386
|
+
})
|
|
1387
|
+
|
|
1388
|
+
// Present the cheap credential at the "expensive" route ($100)
|
|
1389
|
+
const expensiveHandle = handler.charge({
|
|
1390
|
+
amount: '100',
|
|
1391
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1392
|
+
decimals: 6,
|
|
1393
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1394
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1395
|
+
})
|
|
1396
|
+
const result = await expensiveHandle(
|
|
1397
|
+
new Request('https://example.com/expensive', {
|
|
1398
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1399
|
+
}),
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
// Should be 402 (credential was for $0.01, not $100)
|
|
1403
|
+
expect(result.status).toBe(402)
|
|
1404
|
+
})
|
|
1405
|
+
|
|
1406
|
+
test('rejects credential with mismatched method field', async () => {
|
|
1407
|
+
const otherMethod = Method.from({
|
|
1408
|
+
name: 'other',
|
|
1409
|
+
intent: 'charge',
|
|
1410
|
+
schema: {
|
|
1411
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1412
|
+
request: z.object({
|
|
1413
|
+
amount: z.string(),
|
|
1414
|
+
currency: z.string(),
|
|
1415
|
+
decimals: z.number(),
|
|
1416
|
+
recipient: z.string(),
|
|
1417
|
+
}),
|
|
1418
|
+
},
|
|
1419
|
+
})
|
|
1420
|
+
|
|
1421
|
+
const otherServerMethod = Method.toServer(otherMethod, {
|
|
1422
|
+
async verify() {
|
|
1423
|
+
return mockReceipt()
|
|
1424
|
+
},
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
const handler = Mppx.create({ methods: [serverMethod, otherServerMethod], realm, secretKey })
|
|
1428
|
+
|
|
1429
|
+
// Get challenge from mock/charge
|
|
1430
|
+
const mockHandle = handler['mock/charge']({
|
|
1431
|
+
amount: '1',
|
|
1432
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1433
|
+
decimals: 6,
|
|
1434
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1435
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1436
|
+
})
|
|
1437
|
+
const mockResult = await mockHandle(new Request('https://example.com/mock'))
|
|
1438
|
+
expect(mockResult.status).toBe(402)
|
|
1439
|
+
if (mockResult.status !== 402) throw new Error()
|
|
1440
|
+
|
|
1441
|
+
const mockChallenge = Challenge.fromResponse(mockResult.challenge)
|
|
1442
|
+
const credential = Credential.from({
|
|
1443
|
+
challenge: mockChallenge,
|
|
1444
|
+
payload: { token: 'valid' },
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
// Present mock/charge credential at other/charge route
|
|
1448
|
+
const otherHandle = handler['other/charge']({
|
|
1449
|
+
amount: '1',
|
|
1450
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1451
|
+
decimals: 6,
|
|
1452
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1453
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1454
|
+
})
|
|
1455
|
+
const result = await otherHandle(
|
|
1456
|
+
new Request('https://example.com/other', {
|
|
1457
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1458
|
+
}),
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
// Should reject (credential was for method "mock", not "other")
|
|
1462
|
+
expect(result.status).toBe(402)
|
|
1463
|
+
})
|
|
1464
|
+
|
|
1465
|
+
test('rejects credential with mismatched intent field', async () => {
|
|
1466
|
+
const sessionMethod = Method.from({
|
|
1467
|
+
name: 'mock',
|
|
1468
|
+
intent: 'session',
|
|
1469
|
+
schema: {
|
|
1470
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1471
|
+
request: z.object({
|
|
1472
|
+
amount: z.string(),
|
|
1473
|
+
currency: z.string(),
|
|
1474
|
+
decimals: z.number(),
|
|
1475
|
+
recipient: z.string(),
|
|
1476
|
+
}),
|
|
1477
|
+
},
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
const sessionServerMethod = Method.toServer(sessionMethod, {
|
|
1481
|
+
async verify() {
|
|
1482
|
+
return mockReceipt()
|
|
1483
|
+
},
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
const handler = Mppx.create({ methods: [serverMethod, sessionServerMethod], realm, secretKey })
|
|
1487
|
+
|
|
1488
|
+
// Get challenge from mock/charge
|
|
1489
|
+
const chargeHandle = handler['mock/charge']({
|
|
1490
|
+
amount: '1',
|
|
1491
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1492
|
+
decimals: 6,
|
|
1493
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1494
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1495
|
+
})
|
|
1496
|
+
const chargeResult = await chargeHandle(new Request('https://example.com/charge'))
|
|
1497
|
+
expect(chargeResult.status).toBe(402)
|
|
1498
|
+
if (chargeResult.status !== 402) throw new Error()
|
|
1499
|
+
|
|
1500
|
+
const chargeChallenge = Challenge.fromResponse(chargeResult.challenge)
|
|
1501
|
+
const credential = Credential.from({
|
|
1502
|
+
challenge: chargeChallenge,
|
|
1503
|
+
payload: { token: 'valid' },
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
// Present charge credential at session route
|
|
1507
|
+
const sessionHandle = handler['mock/session']({
|
|
1508
|
+
amount: '1',
|
|
1509
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1510
|
+
decimals: 6,
|
|
1511
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1512
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1513
|
+
})
|
|
1514
|
+
const result = await sessionHandle(
|
|
1515
|
+
new Request('https://example.com/session', {
|
|
1516
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1517
|
+
}),
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
// Should reject (credential was for intent "charge", not "session")
|
|
1521
|
+
expect(result.status).toBe(402)
|
|
1522
|
+
})
|
|
1523
|
+
|
|
1524
|
+
test('compose: rejects cheap credential replayed at expensive route when schema transform moves scope fields', async () => {
|
|
1525
|
+
const handler = Mppx.create({ methods: [serverMethod], realm, secretKey })
|
|
1526
|
+
|
|
1527
|
+
const cheapHandle = handler.charge({
|
|
1528
|
+
amount: '0.01',
|
|
1529
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1530
|
+
decimals: 6,
|
|
1531
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1532
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1533
|
+
})
|
|
1534
|
+
const expensiveHandle = handler.charge({
|
|
1535
|
+
amount: '100',
|
|
1536
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1537
|
+
decimals: 6,
|
|
1538
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1539
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
const composed = Mppx.compose(cheapHandle, expensiveHandle)
|
|
1543
|
+
|
|
1544
|
+
// Get challenge (pick the cheap one)
|
|
1545
|
+
const firstResult = await composed(new Request('https://example.com/resource'))
|
|
1546
|
+
expect(firstResult.status).toBe(402)
|
|
1547
|
+
if (firstResult.status !== 402) throw new Error()
|
|
1548
|
+
|
|
1549
|
+
const challenges = Challenge.fromResponseList(firstResult.challenge)
|
|
1550
|
+
const cheapChallenge = challenges[0]!
|
|
1551
|
+
|
|
1552
|
+
const credential = Credential.from({
|
|
1553
|
+
challenge: cheapChallenge,
|
|
1554
|
+
payload: { token: 'valid' },
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1557
|
+
// The composed handler should NOT route the cheap credential to the expensive handler
|
|
1558
|
+
const result = await composed(
|
|
1559
|
+
new Request('https://example.com/resource', {
|
|
1560
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1561
|
+
}),
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
// If scope binding works, the credential should route to the cheap handler only.
|
|
1565
|
+
// It should NOT match the expensive handler's canonical request.
|
|
1566
|
+
// The result should be 200 (matched to cheap), not routed to expensive.
|
|
1567
|
+
expect(result.status).toBe(200)
|
|
1568
|
+
})
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1133
1571
|
describe('withReceipt', () => {
|
|
1134
1572
|
const mockCharge = Method.from({
|
|
1135
1573
|
name: 'mock',
|
package/src/server/Mppx.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
|
+
|
|
2
3
|
import * as Challenge from '../Challenge.js'
|
|
3
4
|
import * as Credential from '../Credential.js'
|
|
4
5
|
import * as Errors from '../Errors.js'
|
|
@@ -329,20 +330,22 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
// Verify the credential's challenge matches this route's configured
|
|
332
|
-
// request. Prevents cross-route scope
|
|
333
|
-
// issued for a cheap route
|
|
333
|
+
// method, intent, realm, and request. Prevents cross-route scope
|
|
334
|
+
// confusion where a credential issued for a cheap route (or different
|
|
335
|
+
// method/intent) is presented at an expensive route.
|
|
334
336
|
// Note: we compare specific payment parameters rather than the full
|
|
335
337
|
// request because the `request` hook may produce credential-dependent
|
|
336
338
|
// output (e.g. `feePayer` differs between 402 and credential calls).
|
|
339
|
+
//
|
|
340
|
+
// Skip this check for topUp and voucher actions: the route's
|
|
341
|
+
// `request` hook may produce a different amount because these
|
|
342
|
+
// requests carry no application body (e.g. no model field for
|
|
343
|
+
// dynamic pricing). The credential echoes a challenge obtained
|
|
344
|
+
// from the original request which had the correct amount; the
|
|
345
|
+
// on-chain voucher signature is the real validation.
|
|
337
346
|
{
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
341
|
-
if (
|
|
342
|
-
routeReq[field] !== undefined &&
|
|
343
|
-
echoedReq[field] !== undefined &&
|
|
344
|
-
String(routeReq[field]) !== String(echoedReq[field])
|
|
345
|
-
) {
|
|
347
|
+
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
348
|
+
if (credential.challenge[field] !== challenge[field]) {
|
|
346
349
|
const response = await transport.respondChallenge({
|
|
347
350
|
challenge,
|
|
348
351
|
input,
|
|
@@ -354,6 +357,39 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
354
357
|
return { challenge: response, status: 402 }
|
|
355
358
|
}
|
|
356
359
|
}
|
|
360
|
+
|
|
361
|
+
// Use safeParse (not raw payload) so only methods whose schema
|
|
362
|
+
// defines `action` can trigger the skip. Without this, a client
|
|
363
|
+
// could inject `action: 'topUp'` on a charge credential to bypass
|
|
364
|
+
// the amount check. Zod strips unknown keys, so charge payloads
|
|
365
|
+
// (which don't define `action`) will have it removed.
|
|
366
|
+
const parsed = method.schema.credential.payload.safeParse(credential.payload)
|
|
367
|
+
const action = parsed.success
|
|
368
|
+
? (parsed.data as Record<string, unknown>)?.action
|
|
369
|
+
: undefined
|
|
370
|
+
if (action !== 'topUp' && action !== 'voucher') {
|
|
371
|
+
const routeReq = challenge.request as Record<string, unknown>
|
|
372
|
+
const echoedReq = credential.challenge.request as Record<string, unknown>
|
|
373
|
+
const routeDetails = (routeReq.methodDetails ?? {}) as Record<string, unknown>
|
|
374
|
+
const echoedDetails = (echoedReq.methodDetails ?? {}) as Record<string, unknown>
|
|
375
|
+
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
376
|
+
const routeVal = routeReq[field] ?? routeDetails[field]
|
|
377
|
+
if (
|
|
378
|
+
routeVal !== undefined &&
|
|
379
|
+
String(routeVal) !== String(echoedReq[field] ?? echoedDetails[field])
|
|
380
|
+
) {
|
|
381
|
+
const response = await transport.respondChallenge({
|
|
382
|
+
challenge,
|
|
383
|
+
input,
|
|
384
|
+
error: new Errors.InvalidChallengeError({
|
|
385
|
+
id: credential.challenge.id,
|
|
386
|
+
reason: `credential ${field} does not match this route's requirements`,
|
|
387
|
+
}),
|
|
388
|
+
})
|
|
389
|
+
return { challenge: response, status: 402 }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
357
393
|
}
|
|
358
394
|
|
|
359
395
|
// Reject expired credentials
|
|
@@ -578,22 +614,24 @@ export function compose(
|
|
|
578
614
|
if (credential) {
|
|
579
615
|
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
580
616
|
const credReq = credential.challenge.request as Record<string, unknown>
|
|
617
|
+
const credDetails = (credReq.methodDetails ?? {}) as Record<string, unknown>
|
|
581
618
|
|
|
582
619
|
// Filter by name+intent, then narrow by comparing stable request fields
|
|
583
620
|
// from the echoed challenge against each handler's canonical request.
|
|
584
621
|
// Uses the schema-parsed canonical form (not raw options) so that
|
|
585
622
|
// transformed fields (e.g. amount with decimals) match correctly.
|
|
623
|
+
// Also checks inside methodDetails for fields moved there by transforms.
|
|
586
624
|
const candidates = handlers.filter((h) => {
|
|
587
625
|
const meta = (h as ConfiguredHandler)._internal
|
|
588
626
|
if (!meta || meta.name !== credMethod || meta.intent !== credIntent) return false
|
|
589
627
|
const canonical = meta._canonicalRequest
|
|
590
628
|
if (!canonical) return true
|
|
629
|
+
const canonicalDetails = (canonical.methodDetails ?? {}) as Record<string, unknown>
|
|
591
630
|
for (const field of ['amount', 'currency', 'recipient', 'chainId'] as const) {
|
|
592
|
-
const canonicalVal = canonical[field]
|
|
631
|
+
const canonicalVal = canonical[field] ?? canonicalDetails[field]
|
|
593
632
|
if (
|
|
594
633
|
canonicalVal !== undefined &&
|
|
595
|
-
credReq[field]
|
|
596
|
-
String(canonicalVal) !== String(credReq[field])
|
|
634
|
+
String(canonicalVal) !== String(credReq[field] ?? credDetails[field])
|
|
597
635
|
)
|
|
598
636
|
return false
|
|
599
637
|
}
|