mppx 0.6.22 → 0.6.24
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 +13 -0
- package/dist/Credential.d.ts.map +1 -1
- package/dist/Credential.js +1 -1
- package/dist/Credential.js.map +1 -1
- package/dist/client/Mppx.d.ts +6 -2
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +8 -2
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +4 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +9 -3
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts +16 -0
- package/dist/internal/AcceptPayment.d.ts.map +1 -1
- package/dist/internal/AcceptPayment.js +31 -8
- package/dist/internal/AcceptPayment.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +6 -5
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +12 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +8 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +7 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -1
- package/dist/tempo/server/Subscription.js +47 -6
- package/dist/tempo/server/Subscription.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/Credential.test.ts +23 -0
- package/src/Credential.ts +2 -1
- package/src/client/Mppx.test-d.ts +13 -0
- package/src/client/Mppx.test.ts +52 -0
- package/src/client/Mppx.ts +22 -4
- package/src/client/internal/Fetch.test-d.ts +11 -0
- package/src/client/internal/Fetch.test.ts +117 -1
- package/src/client/internal/Fetch.ts +24 -2
- package/src/internal/AcceptPayment.test.ts +26 -0
- package/src/internal/AcceptPayment.ts +55 -10
- package/src/server/Mppx.test.ts +24 -0
- package/src/server/Mppx.ts +6 -5
- package/src/tempo/client/SessionManager.test.ts +84 -3
- package/src/tempo/client/SessionManager.ts +35 -5
- package/src/tempo/server/Subscription.ts +62 -3
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,4mueAA4mue,CAAA"}
|
package/package.json
CHANGED
package/src/Credential.test.ts
CHANGED
|
@@ -198,6 +198,29 @@ describe('deserialize', () => {
|
|
|
198
198
|
expect(credential.challenge.request).toEqual({ amount: '1000' })
|
|
199
199
|
})
|
|
200
200
|
|
|
201
|
+
test('security: drops injected meta when opaque is a string', () => {
|
|
202
|
+
const encoded = Base64.fromString(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
challenge: {
|
|
205
|
+
id: 'opaque123',
|
|
206
|
+
intent: 'charge',
|
|
207
|
+
meta: { _mppx_scope: 'GET /admin' },
|
|
208
|
+
method: 'tempo',
|
|
209
|
+
opaque: 'eyJfbXBweF9zY29wZSI6IkdFVCAvcHVibGljIn0',
|
|
210
|
+
realm: 'api.example.com',
|
|
211
|
+
request: 'eyJhbW91bnQiOiIxMDAwIn0',
|
|
212
|
+
},
|
|
213
|
+
payload: { signature: '0x1234' },
|
|
214
|
+
}),
|
|
215
|
+
{ pad: false, url: true },
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const credential = Credential.deserialize(`Payment ${encoded}`)
|
|
219
|
+
|
|
220
|
+
expect(credential.challenge.opaque).toBe('eyJfbXBweF9zY29wZSI6IkdFVCAvcHVibGljIn0')
|
|
221
|
+
expect(credential.challenge.meta).toBeUndefined()
|
|
222
|
+
})
|
|
223
|
+
|
|
201
224
|
test('behavior: preserves non-json opaque string credentials', () => {
|
|
202
225
|
const encoded = Base64.fromString(
|
|
203
226
|
JSON.stringify({
|
package/src/Credential.ts
CHANGED
|
@@ -64,13 +64,14 @@ export function deserialize<payload = unknown>(value: string): Credential<payloa
|
|
|
64
64
|
const json = Base64.toString(prefixMatch[1])
|
|
65
65
|
const parsed = JSON.parse(json) as {
|
|
66
66
|
challenge: Omit<Challenge.Challenge, 'meta' | 'opaque' | 'request'> & {
|
|
67
|
+
meta?: unknown
|
|
67
68
|
opaque?: unknown
|
|
68
69
|
request: string
|
|
69
70
|
}
|
|
70
71
|
payload: payload
|
|
71
72
|
source?: string
|
|
72
73
|
}
|
|
73
|
-
const { opaque: challengeOpaque, request, ...challengeFields } = parsed.challenge
|
|
74
|
+
const { opaque: challengeOpaque, request, meta: _meta, ...challengeFields } = parsed.challenge
|
|
74
75
|
const { meta, opaque } = normalizeCredentialOpaque(challengeOpaque)
|
|
75
76
|
const challenge = Challenge.Schema.parse({
|
|
76
77
|
...challengeFields,
|
|
@@ -68,6 +68,19 @@ describe('create.Config', () => {
|
|
|
68
68
|
expectTypeOf(mppx.fetch).toBeFunction()
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
test('orderChallenges receives supported challenge candidates', () => {
|
|
72
|
+
const mppx = Mppx.create({
|
|
73
|
+
methods: [tempo({ account: {} as Account })],
|
|
74
|
+
orderChallenges: (candidates) => {
|
|
75
|
+
expectTypeOf(candidates[0]?.challenge.method).toEqualTypeOf<'tempo' | undefined>()
|
|
76
|
+
expectTypeOf(candidates[0]?.method.name).toEqualTypeOf<'tempo' | undefined>()
|
|
77
|
+
return candidates
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expectTypeOf(mppx.fetch).toBeFunction()
|
|
82
|
+
})
|
|
83
|
+
|
|
71
84
|
test('client events expose typed payloads', () => {
|
|
72
85
|
const method = charge()
|
|
73
86
|
const mppx = Mppx.create({
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -485,6 +485,58 @@ describe('createCredential', () => {
|
|
|
485
485
|
expect(parsed.challenge.method).toBe('stripe')
|
|
486
486
|
})
|
|
487
487
|
|
|
488
|
+
test('behavior: createCredential accepts request-local challenge ordering', async () => {
|
|
489
|
+
const testMethod = Method.toClient(
|
|
490
|
+
Method.from({
|
|
491
|
+
name: 'test',
|
|
492
|
+
intent: 'test',
|
|
493
|
+
schema: Methods.charge.schema,
|
|
494
|
+
}),
|
|
495
|
+
{
|
|
496
|
+
async createCredential({ challenge }) {
|
|
497
|
+
return Credential.serialize({
|
|
498
|
+
challenge,
|
|
499
|
+
payload: { signature: `0x${challenge.id}`, type: 'transaction' },
|
|
500
|
+
})
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
const mppx = Mppx.create({
|
|
506
|
+
polyfill: false,
|
|
507
|
+
methods: [testMethod],
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const first = Challenge.from({
|
|
511
|
+
id: '1111',
|
|
512
|
+
realm,
|
|
513
|
+
method: 'test',
|
|
514
|
+
intent: 'test',
|
|
515
|
+
request: { currency: 'pathusd' },
|
|
516
|
+
})
|
|
517
|
+
const second = Challenge.from({
|
|
518
|
+
id: '2222',
|
|
519
|
+
realm,
|
|
520
|
+
method: 'test',
|
|
521
|
+
intent: 'test',
|
|
522
|
+
request: { currency: 'usdc' },
|
|
523
|
+
})
|
|
524
|
+
const response = new Response(null, {
|
|
525
|
+
status: 402,
|
|
526
|
+
headers: {
|
|
527
|
+
'WWW-Authenticate': `${Challenge.serialize(first)}, ${Challenge.serialize(second)}`,
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
const credential = await mppx.createCredential(response, undefined, {
|
|
532
|
+
orderChallenges: (candidates) =>
|
|
533
|
+
candidates.filter(({ challenge }) => challenge.request.currency === 'usdc'),
|
|
534
|
+
})
|
|
535
|
+
const parsed = Credential.deserialize(credential)
|
|
536
|
+
|
|
537
|
+
expect(parsed.challenge.id).toBe('2222')
|
|
538
|
+
})
|
|
539
|
+
|
|
488
540
|
test('behavior: passes context to createCredential', async () => {
|
|
489
541
|
const mppx = Mppx.create({
|
|
490
542
|
polyfill: false,
|
package/src/client/Mppx.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type Mppx<
|
|
|
30
30
|
createCredential: (
|
|
31
31
|
response: Transport.ResponseOf<transport>,
|
|
32
32
|
context?: AnyContextFor<FlattenMethods<methods>> | undefined,
|
|
33
|
-
options?: createCredential.Options | undefined,
|
|
33
|
+
options?: createCredential.Options<FlattenMethods<methods>> | undefined,
|
|
34
34
|
) => Promise<string>
|
|
35
35
|
/** Register a client event handler by canonical event name. */
|
|
36
36
|
on<name extends Fetch.ClientEventName<FlattenMethods<methods>, EventResponseOf<transport>>>(
|
|
@@ -101,6 +101,7 @@ export function create<
|
|
|
101
101
|
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
|
|
102
102
|
const {
|
|
103
103
|
onChallenge,
|
|
104
|
+
orderChallenges,
|
|
104
105
|
polyfill = true,
|
|
105
106
|
acceptPaymentPolicy = polyfill && typeof globalThis.location !== 'undefined'
|
|
106
107
|
? 'same-origin'
|
|
@@ -122,6 +123,7 @@ export function create<
|
|
|
122
123
|
...(config.fetch && { fetch: config.fetch }),
|
|
123
124
|
eventDispatcher: events,
|
|
124
125
|
...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
|
|
126
|
+
...(orderChallenges && { orderChallenges }),
|
|
125
127
|
methods,
|
|
126
128
|
} satisfies Fetch.from.Config<FlattenMethods<methods>>
|
|
127
129
|
const fetch = Fetch.from<FlattenMethods<methods>>(config_fetch)
|
|
@@ -181,7 +183,7 @@ export function create<
|
|
|
181
183
|
async createCredential(
|
|
182
184
|
response: Transport.ResponseOf<transport>,
|
|
183
185
|
context?: unknown,
|
|
184
|
-
options?: createCredential.Options
|
|
186
|
+
options?: createCredential.Options<FlattenMethods<methods>>,
|
|
185
187
|
) {
|
|
186
188
|
const challenges = transport.getChallenges
|
|
187
189
|
? transport.getChallenges(response as never)
|
|
@@ -191,7 +193,12 @@ export function create<
|
|
|
191
193
|
let challenge: Challenge.Challenge | undefined
|
|
192
194
|
let mi: FlattenMethods<methods>[number] | undefined
|
|
193
195
|
try {
|
|
194
|
-
const
|
|
196
|
+
const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences)
|
|
197
|
+
const orderedCandidates = await resolveChallengeOrder(
|
|
198
|
+
candidates,
|
|
199
|
+
options?.orderChallenges ?? orderChallenges,
|
|
200
|
+
)
|
|
201
|
+
const selected = orderedCandidates[0]
|
|
195
202
|
if (!selected)
|
|
196
203
|
throw new Error(
|
|
197
204
|
`No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
|
|
@@ -246,9 +253,11 @@ export function create<
|
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
export declare namespace createCredential {
|
|
249
|
-
type Options = {
|
|
256
|
+
type Options<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = {
|
|
250
257
|
/** Request-local Accept-Payment override for manual rawFetch + createCredential flows. */
|
|
251
258
|
acceptPayment?: string | readonly AcceptPayment.Entry[] | undefined
|
|
259
|
+
/** Request-local challenge filtering and sorting. */
|
|
260
|
+
orderChallenges?: AcceptPayment.OrderChallenges<methods> | undefined
|
|
252
261
|
}
|
|
253
262
|
}
|
|
254
263
|
|
|
@@ -288,6 +297,8 @@ export declare namespace create {
|
|
|
288
297
|
},
|
|
289
298
|
) => Promise<string | undefined>)
|
|
290
299
|
| undefined
|
|
300
|
+
/** Filters and sorts supported challenges before credential creation. */
|
|
301
|
+
orderChallenges?: AcceptPayment.OrderChallenges<FlattenMethods<methods>> | undefined
|
|
291
302
|
/** Client-declared supported payment methods, keyed by typed `method/intent` strings. */
|
|
292
303
|
paymentPreferences?: AcceptPayment.Config<FlattenMethods<methods>> | undefined
|
|
293
304
|
/** Array of methods to use. Accepts individual clients or tuples (e.g. from `tempo()`). */
|
|
@@ -431,6 +442,13 @@ function resolveChallengePreferences(
|
|
|
431
442
|
return typeof override === 'string' ? AcceptPayment.parse(override) : override
|
|
432
443
|
}
|
|
433
444
|
|
|
445
|
+
async function resolveChallengeOrder<methods extends readonly Method.AnyClient[]>(
|
|
446
|
+
candidates: readonly AcceptPayment.ChallengeCandidate<methods[number]>[],
|
|
447
|
+
orderChallenges: AcceptPayment.OrderChallenges<methods> | undefined,
|
|
448
|
+
): Promise<readonly AcceptPayment.ChallengeCandidate<methods[number]>[]> {
|
|
449
|
+
return orderChallenges ? orderChallenges(candidates) : candidates
|
|
450
|
+
}
|
|
451
|
+
|
|
434
452
|
async function createCredentialForMethod(
|
|
435
453
|
challenge: Challenge.Challenge,
|
|
436
454
|
mi: Method.AnyClient,
|
|
@@ -46,6 +46,17 @@ describe('Fetch.from', () => {
|
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
+
test('behavior: accepts challenge ordering hook', () => {
|
|
50
|
+
const fetch = Fetch.from({
|
|
51
|
+
methods: [charge()],
|
|
52
|
+
orderChallenges: (candidates) => candidates,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expectTypeOf(fetch).toBeCallableWith('https://example.com', {
|
|
56
|
+
orderChallenges: (candidates) => candidates,
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
49
60
|
test('behavior: events infer payload types from methods', () => {
|
|
50
61
|
const method = charge()
|
|
51
62
|
const dispatcher = Fetch.createEventDispatcher<[typeof method]>()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Errors, Receipt } from 'mppx'
|
|
1
|
+
import { Challenge, Errors, Receipt } from 'mppx'
|
|
2
2
|
import { tempo } from 'mppx/client'
|
|
3
3
|
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
|
|
4
4
|
import { createClient, defineChain } from 'viem'
|
|
@@ -1097,6 +1097,122 @@ describe('Fetch.from: 402 retry path', () => {
|
|
|
1097
1097
|
expect(response.status).toBe(200)
|
|
1098
1098
|
})
|
|
1099
1099
|
|
|
1100
|
+
test('orderChallenges filters and sorts supported challenges before signing', async () => {
|
|
1101
|
+
let callCount = 0
|
|
1102
|
+
const pathUsd = Challenge.from({
|
|
1103
|
+
id: 'pathusd',
|
|
1104
|
+
realm: 'test',
|
|
1105
|
+
method: 'test',
|
|
1106
|
+
intent: 'test',
|
|
1107
|
+
request: { chainId: 11155111, currency: 'pathusd' },
|
|
1108
|
+
})
|
|
1109
|
+
const usdc = Challenge.from({
|
|
1110
|
+
id: 'usdc',
|
|
1111
|
+
realm: 'test',
|
|
1112
|
+
method: 'test',
|
|
1113
|
+
intent: 'test',
|
|
1114
|
+
request: { chainId: 8453, currency: 'usdc' },
|
|
1115
|
+
})
|
|
1116
|
+
const createCredential = vi.fn(
|
|
1117
|
+
async ({ challenge }: { challenge: Challenge.Challenge }) => `credential-${challenge.id}`,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
1121
|
+
callCount++
|
|
1122
|
+
if (callCount === 1) {
|
|
1123
|
+
return new Response(null, {
|
|
1124
|
+
status: 402,
|
|
1125
|
+
headers: {
|
|
1126
|
+
'WWW-Authenticate': `${Challenge.serialize(pathUsd)}, ${Challenge.serialize(usdc)}`,
|
|
1127
|
+
},
|
|
1128
|
+
})
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
expect(new Headers(init?.headers).get('Authorization')).toBe('credential-usdc')
|
|
1132
|
+
return new Response('OK', { status: 200 })
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const fetch = Fetch.from({
|
|
1136
|
+
fetch: mockFetch,
|
|
1137
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
1138
|
+
orderChallenges: (candidates) =>
|
|
1139
|
+
candidates.filter(({ challenge }) => challenge.request.currency === 'usdc'),
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
const response = await fetch('https://example.com/api')
|
|
1143
|
+
|
|
1144
|
+
expect(response.status).toBe(200)
|
|
1145
|
+
expect(createCredential).toHaveBeenCalledOnce()
|
|
1146
|
+
expect(createCredential.mock.calls[0]?.[0].challenge.id).toBe('usdc')
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
test('request-local orderChallenges overrides configured ordering', async () => {
|
|
1150
|
+
let callCount = 0
|
|
1151
|
+
const first = Challenge.from({
|
|
1152
|
+
id: 'first',
|
|
1153
|
+
realm: 'test',
|
|
1154
|
+
method: 'test',
|
|
1155
|
+
intent: 'test',
|
|
1156
|
+
request: { currency: 'first' },
|
|
1157
|
+
})
|
|
1158
|
+
const second = Challenge.from({
|
|
1159
|
+
id: 'second',
|
|
1160
|
+
realm: 'test',
|
|
1161
|
+
method: 'test',
|
|
1162
|
+
intent: 'test',
|
|
1163
|
+
request: { currency: 'second' },
|
|
1164
|
+
})
|
|
1165
|
+
const createCredential = vi.fn(
|
|
1166
|
+
async ({ challenge }: { challenge: Challenge.Challenge }) => `credential-${challenge.id}`,
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
1170
|
+
callCount++
|
|
1171
|
+
if (callCount === 1) {
|
|
1172
|
+
return new Response(null, {
|
|
1173
|
+
status: 402,
|
|
1174
|
+
headers: {
|
|
1175
|
+
'WWW-Authenticate': `${Challenge.serialize(first)}, ${Challenge.serialize(second)}`,
|
|
1176
|
+
},
|
|
1177
|
+
})
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
expect(new Headers(init?.headers).get('Authorization')).toBe('credential-second')
|
|
1181
|
+
return new Response('OK', { status: 200 })
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const fetch = Fetch.from({
|
|
1185
|
+
fetch: mockFetch,
|
|
1186
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
1187
|
+
orderChallenges: (candidates) =>
|
|
1188
|
+
candidates.filter(({ challenge }) => challenge.id === 'first'),
|
|
1189
|
+
})
|
|
1190
|
+
|
|
1191
|
+
const response = await fetch('https://example.com/api', {
|
|
1192
|
+
orderChallenges: (candidates) =>
|
|
1193
|
+
candidates.filter(({ challenge }) => challenge.id === 'second'),
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
expect(response.status).toBe(200)
|
|
1197
|
+
expect(createCredential.mock.calls[0]?.[0].challenge.id).toBe('second')
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
test('throws when orderChallenges rejects every supported challenge', async () => {
|
|
1201
|
+
const mockFetch: typeof globalThis.fetch = async () => make402()
|
|
1202
|
+
const createCredential = vi.fn(async () => 'credential')
|
|
1203
|
+
|
|
1204
|
+
const fetch = Fetch.from({
|
|
1205
|
+
fetch: mockFetch,
|
|
1206
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
1207
|
+
orderChallenges: () => [],
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
await expect(fetch('https://example.com/api')).rejects.toThrow(
|
|
1211
|
+
'No method found for challenges: test.test',
|
|
1212
|
+
)
|
|
1213
|
+
expect(createCredential).not.toHaveBeenCalled()
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1100
1216
|
test('falls back to configured preferences when explicit Accept-Payment is invalid', async () => {
|
|
1101
1217
|
let callCount = 0
|
|
1102
1218
|
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
@@ -161,6 +161,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
161
161
|
fetch = globalThis.fetch,
|
|
162
162
|
methods,
|
|
163
163
|
onChallenge,
|
|
164
|
+
orderChallenges,
|
|
164
165
|
} = config
|
|
165
166
|
const events = config.eventDispatcher ?? createEventDispatcher()
|
|
166
167
|
const resolvedAcceptPayment = acceptPayment ?? AcceptPayment.resolve(methods)
|
|
@@ -186,7 +187,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
186
187
|
|
|
187
188
|
// Only extract context for payment handling after confirming 402.
|
|
188
189
|
const context = (init as Record<string, unknown> | undefined)?.context
|
|
189
|
-
const {
|
|
190
|
+
const {
|
|
191
|
+
context: _,
|
|
192
|
+
orderChallenges: requestOrderChallenges,
|
|
193
|
+
...fetchInit
|
|
194
|
+
} = (initialRequest.init ?? {}) as Record<string, unknown>
|
|
190
195
|
|
|
191
196
|
let challenge: Challenge.Challenge | undefined
|
|
192
197
|
let challenges: readonly Challenge.Challenge[] | undefined
|
|
@@ -196,11 +201,17 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
196
201
|
// Parse all challenges from the response (supports merged WWW-Authenticate headers).
|
|
197
202
|
challenges = Challenge.fromResponseList(response)
|
|
198
203
|
|
|
199
|
-
const
|
|
204
|
+
const candidates = AcceptPayment.selectChallengeCandidates(
|
|
200
205
|
challenges,
|
|
201
206
|
methods,
|
|
202
207
|
paymentPreferences.entries,
|
|
203
208
|
)
|
|
209
|
+
const orderedCandidates = await resolveChallengeOrder(
|
|
210
|
+
candidates,
|
|
211
|
+
(requestOrderChallenges as AcceptPayment.OrderChallenges<methods> | undefined) ??
|
|
212
|
+
orderChallenges,
|
|
213
|
+
)
|
|
214
|
+
const selected = orderedCandidates[0]
|
|
204
215
|
if (!selected)
|
|
205
216
|
throw new Error(
|
|
206
217
|
`No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
|
|
@@ -322,6 +333,8 @@ export declare namespace from {
|
|
|
322
333
|
},
|
|
323
334
|
) => Promise<string | undefined>)
|
|
324
335
|
| undefined
|
|
336
|
+
/** Filters and sorts supported challenges before credential creation. */
|
|
337
|
+
orderChallenges?: AcceptPayment.OrderChallenges<methods> | undefined
|
|
325
338
|
}
|
|
326
339
|
|
|
327
340
|
type Fetch<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[]> = (
|
|
@@ -333,6 +346,8 @@ export declare namespace from {
|
|
|
333
346
|
globalThis.RequestInit & {
|
|
334
347
|
/** Context to pass to the method intent's createCredential. */
|
|
335
348
|
context?: AnyContextFor<methods>
|
|
349
|
+
/** Request-local challenge filtering and sorting. */
|
|
350
|
+
orderChallenges?: AcceptPayment.OrderChallenges<methods> | undefined
|
|
336
351
|
}
|
|
337
352
|
}
|
|
338
353
|
|
|
@@ -783,6 +798,13 @@ function resolvePaymentPreferences<methods extends readonly Method.AnyClient[]>(
|
|
|
783
798
|
}
|
|
784
799
|
}
|
|
785
800
|
|
|
801
|
+
async function resolveChallengeOrder<methods extends readonly Method.AnyClient[]>(
|
|
802
|
+
candidates: readonly AcceptPayment.ChallengeCandidate<methods[number]>[],
|
|
803
|
+
orderChallenges: AcceptPayment.OrderChallenges<methods> | undefined,
|
|
804
|
+
): Promise<readonly AcceptPayment.ChallengeCandidate<methods[number]>[]> {
|
|
805
|
+
return orderChallenges ? orderChallenges(candidates) : candidates
|
|
806
|
+
}
|
|
807
|
+
|
|
786
808
|
/** @internal */
|
|
787
809
|
function shouldInjectForPolicy(
|
|
788
810
|
input: RequestInfo | URL,
|
|
@@ -152,6 +152,32 @@ describe('AcceptPayment', () => {
|
|
|
152
152
|
expect(selected?.method).toEqual({ name: 'tempo', intent: 'session' })
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
test('selectChallengeCandidates returns supported offers with methods and response indexes', () => {
|
|
156
|
+
const candidates = AcceptPayment.selectChallengeCandidates(
|
|
157
|
+
[
|
|
158
|
+
{ id: '1', intent: 'charge', method: 'unknown', realm: 'test', request: {} },
|
|
159
|
+
{ id: '2', intent: 'session', method: 'tempo', realm: 'test', request: {} },
|
|
160
|
+
{ id: '3', intent: 'charge', method: 'stripe', realm: 'test', request: {} },
|
|
161
|
+
],
|
|
162
|
+
[
|
|
163
|
+
{ name: 'tempo', intent: 'session' },
|
|
164
|
+
{ name: 'stripe', intent: 'charge' },
|
|
165
|
+
] as const,
|
|
166
|
+
AcceptPayment.parse('stripe/charge;q=0.5, tempo/session;q=0.9'),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(
|
|
170
|
+
candidates.map(({ challenge, index, method }) => ({
|
|
171
|
+
id: challenge.id,
|
|
172
|
+
index,
|
|
173
|
+
key: AcceptPayment.keyOf(method),
|
|
174
|
+
})),
|
|
175
|
+
).toEqual([
|
|
176
|
+
{ id: '2', index: 1, key: 'tempo/session' },
|
|
177
|
+
{ id: '3', index: 2, key: 'stripe/charge' },
|
|
178
|
+
])
|
|
179
|
+
})
|
|
180
|
+
|
|
155
181
|
test('selectChallenge honors a specific opt-out over a broader wildcard', () => {
|
|
156
182
|
const selected = AcceptPayment.selectChallenge(
|
|
157
183
|
[
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import type * as Challenge from '../Challenge.js'
|
|
2
|
+
import type { MaybePromise } from './types.js'
|
|
2
3
|
|
|
3
4
|
type MethodLike = {
|
|
4
5
|
intent: string
|
|
5
6
|
name: string
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/** Supported challenge paired with the configured client method that can sign it. */
|
|
10
|
+
export type ChallengeCandidate<method extends MethodLike = MethodLike> = method extends MethodLike
|
|
11
|
+
? {
|
|
12
|
+
challenge: Challenge.Challenge<Record<string, unknown>, method['intent'], method['name']>
|
|
13
|
+
index: number
|
|
14
|
+
method: method
|
|
15
|
+
}
|
|
16
|
+
: never
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hook for filtering and sorting supported challenges before credential creation.
|
|
20
|
+
*
|
|
21
|
+
* Return candidates in preference order. The SDK signs the first returned
|
|
22
|
+
* candidate. Return an empty array to reject all offered challenges.
|
|
23
|
+
*/
|
|
24
|
+
export type OrderChallenges<methods extends readonly MethodLike[]> = (
|
|
25
|
+
candidates: readonly ChallengeCandidate<methods[number]>[],
|
|
26
|
+
) => MaybePromise<readonly ChallengeCandidate<methods[number]>[]>
|
|
27
|
+
|
|
8
28
|
/** Typed `method/intent` key for a configured payment capability. */
|
|
9
29
|
export type Key<methods extends readonly MethodLike[]> = methods[number] extends infer mi
|
|
10
30
|
? mi extends { name: infer name extends string; intent: infer intent extends string }
|
|
@@ -144,23 +164,48 @@ export function selectChallenge<const methods extends readonly MethodLike[]>(
|
|
|
144
164
|
method: methods[number]
|
|
145
165
|
}
|
|
146
166
|
| undefined {
|
|
167
|
+
const candidate = selectChallengeCandidates(challenges, methods, preferences)[0]
|
|
168
|
+
if (!candidate) return undefined
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
challenge: candidate.challenge,
|
|
172
|
+
method: candidate.method,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Returns supported challenge candidates ordered by client payment preferences. */
|
|
177
|
+
export function selectChallengeCandidates<const methods extends readonly MethodLike[]>(
|
|
178
|
+
challenges: readonly Challenge.Challenge[],
|
|
179
|
+
methods: methods,
|
|
180
|
+
preferences: readonly Entry[],
|
|
181
|
+
): ChallengeCandidate<methods[number]>[] {
|
|
147
182
|
const methodByKey = new Map<string, methods[number]>()
|
|
148
183
|
for (const method of methods) {
|
|
149
184
|
const key = keyOf(method)
|
|
150
185
|
if (!methodByKey.has(key)) methodByKey.set(key, method)
|
|
151
186
|
}
|
|
152
187
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const challenge = ranked[0]
|
|
158
|
-
if (!challenge) return undefined
|
|
188
|
+
return challenges
|
|
189
|
+
.map((challenge, index) => {
|
|
190
|
+
const method = methodByKey.get(keyOf(challenge))
|
|
191
|
+
if (!method) return undefined
|
|
159
192
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
193
|
+
const match = bestMatch(challenge, preferences)
|
|
194
|
+
if (!match || match.q <= 0) return undefined
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
challenge,
|
|
198
|
+
index,
|
|
199
|
+
match,
|
|
200
|
+
method,
|
|
201
|
+
} as ChallengeCandidate<methods[number]> & { match: Match }
|
|
202
|
+
})
|
|
203
|
+
.filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate))
|
|
204
|
+
.sort((left, right) => right.match.q - left.match.q || left.index - right.index)
|
|
205
|
+
.map((candidate) => {
|
|
206
|
+
const { match: _match, ...rest } = candidate
|
|
207
|
+
return rest as unknown as ChallengeCandidate<methods[number]>
|
|
208
|
+
})
|
|
164
209
|
}
|
|
165
210
|
|
|
166
211
|
/** Returns the canonical `method/intent` key for a method or challenge-like value. */
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -4415,6 +4415,30 @@ describe('verifyCredential', () => {
|
|
|
4415
4415
|
)
|
|
4416
4416
|
})
|
|
4417
4417
|
|
|
4418
|
+
test('rejects direct credential objects with forged meta and valid opaque', async () => {
|
|
4419
|
+
const mppx = Mppx.create({
|
|
4420
|
+
methods: [alphaChargeServer],
|
|
4421
|
+
realm,
|
|
4422
|
+
secretKey,
|
|
4423
|
+
})
|
|
4424
|
+
|
|
4425
|
+
const challenge = await mppx.challenge.alpha.charge({
|
|
4426
|
+
...challengeOpts,
|
|
4427
|
+
scope: 'GET /public',
|
|
4428
|
+
})
|
|
4429
|
+
const credential = Credential.from({
|
|
4430
|
+
challenge: {
|
|
4431
|
+
...challenge,
|
|
4432
|
+
meta: { _mppx_scope: 'GET /admin' },
|
|
4433
|
+
},
|
|
4434
|
+
payload: { token: 'valid' },
|
|
4435
|
+
})
|
|
4436
|
+
|
|
4437
|
+
await expect(mppx.verifyCredential(credential, { scope: 'GET /admin' })).rejects.toThrow(
|
|
4438
|
+
"credential scope does not match this route's requirements",
|
|
4439
|
+
)
|
|
4440
|
+
})
|
|
4441
|
+
|
|
4418
4442
|
test('verifies route requirements using the echoed challenge realm when host was auto-detected', async () => {
|
|
4419
4443
|
const mppx = Mppx.create({
|
|
4420
4444
|
methods: [alphaChargeServer],
|
package/src/server/Mppx.ts
CHANGED
|
@@ -1919,13 +1919,14 @@ function hydrateCredentialMeta<payload>(
|
|
|
1919
1919
|
credential: Credential.Credential<payload>,
|
|
1920
1920
|
): Credential.Credential<payload> {
|
|
1921
1921
|
const { challenge } = credential
|
|
1922
|
-
if (challenge.
|
|
1922
|
+
if (challenge.opaque === undefined) return credential
|
|
1923
|
+
const hydratedChallenge = Challenge.Schema.parse({
|
|
1924
|
+
...challenge,
|
|
1925
|
+
meta: PaymentRequest.deserialize(challenge.opaque),
|
|
1926
|
+
})
|
|
1923
1927
|
return {
|
|
1924
1928
|
...credential,
|
|
1925
|
-
challenge:
|
|
1926
|
-
...challenge,
|
|
1927
|
-
meta: PaymentRequest.deserialize(challenge.opaque) as Record<string, string>,
|
|
1928
|
-
},
|
|
1929
|
+
challenge: hydratedChallenge,
|
|
1929
1930
|
}
|
|
1930
1931
|
}
|
|
1931
1932
|
|