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.
Files changed (48) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/Credential.d.ts.map +1 -1
  3. package/dist/Credential.js +1 -1
  4. package/dist/Credential.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +6 -2
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +8 -2
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/internal/Fetch.d.ts +4 -0
  10. package/dist/client/internal/Fetch.d.ts.map +1 -1
  11. package/dist/client/internal/Fetch.js +9 -3
  12. package/dist/client/internal/Fetch.js.map +1 -1
  13. package/dist/internal/AcceptPayment.d.ts +16 -0
  14. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  15. package/dist/internal/AcceptPayment.js +31 -8
  16. package/dist/internal/AcceptPayment.js.map +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +6 -5
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/tempo/client/SessionManager.d.ts +12 -2
  21. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  22. package/dist/tempo/client/SessionManager.js +8 -1
  23. package/dist/tempo/client/SessionManager.js.map +1 -1
  24. package/dist/tempo/server/Subscription.d.ts +7 -0
  25. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  26. package/dist/tempo/server/Subscription.js +47 -6
  27. package/dist/tempo/server/Subscription.js.map +1 -1
  28. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  29. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/tempo/server/internal/html.gen.js +1 -1
  31. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/Credential.test.ts +23 -0
  34. package/src/Credential.ts +2 -1
  35. package/src/client/Mppx.test-d.ts +13 -0
  36. package/src/client/Mppx.test.ts +52 -0
  37. package/src/client/Mppx.ts +22 -4
  38. package/src/client/internal/Fetch.test-d.ts +11 -0
  39. package/src/client/internal/Fetch.test.ts +117 -1
  40. package/src/client/internal/Fetch.ts +24 -2
  41. package/src/internal/AcceptPayment.test.ts +26 -0
  42. package/src/internal/AcceptPayment.ts +55 -10
  43. package/src/server/Mppx.test.ts +24 -0
  44. package/src/server/Mppx.ts +6 -5
  45. package/src/tempo/client/SessionManager.test.ts +84 -3
  46. package/src/tempo/client/SessionManager.ts +35 -5
  47. package/src/tempo/server/Subscription.ts +62 -3
  48. 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,8yteAA8yte,CAAA"}
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.6.22",
4
+ "version": "0.6.24",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -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({
@@ -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,
@@ -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 selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
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 { context: _, ...fetchInit } = (initialRequest.init ?? {}) as Record<string, unknown>
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 selected = AcceptPayment.selectChallenge(
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
- const ranked = rank(
154
- challenges.filter((challenge) => methodByKey.has(keyOf(challenge))),
155
- preferences,
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
- return {
161
- challenge,
162
- method: methodByKey.get(keyOf(challenge))!,
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. */
@@ -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],
@@ -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.meta !== undefined || challenge.opaque === undefined) return credential
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