mppx 0.8.13 → 0.8.14

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/Errors.d.ts +10 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +11 -2
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/Method.d.ts +105 -3
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +102 -2
  9. package/dist/Method.js.map +1 -1
  10. package/dist/client/internal/Fetch.d.ts.map +1 -1
  11. package/dist/client/internal/Fetch.js +12 -2
  12. package/dist/client/internal/Fetch.js.map +1 -1
  13. package/dist/client/internal/MethodChallenge.d.ts +17 -0
  14. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  15. package/dist/client/internal/MethodChallenge.js +11 -0
  16. package/dist/client/internal/MethodChallenge.js.map +1 -0
  17. package/dist/server/Mppx.d.ts +23 -4
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +91 -82
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/tempo/client/Methods.d.ts +1 -0
  22. package/dist/tempo/client/Methods.d.ts.map +1 -1
  23. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  24. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  25. package/dist/tempo/internal/fee-payer.js +7 -2
  26. package/dist/tempo/internal/fee-payer.js.map +1 -1
  27. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  28. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  29. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  30. package/dist/tempo/server/Charge.d.ts +38 -77
  31. package/dist/tempo/server/Charge.d.ts.map +1 -1
  32. package/dist/tempo/server/Charge.js +289 -209
  33. package/dist/tempo/server/Charge.js.map +1 -1
  34. package/dist/tempo/server/Methods.d.ts +12 -1
  35. package/dist/tempo/server/Methods.d.ts.map +1 -1
  36. package/dist/tempo/server/Methods.js +4 -0
  37. package/dist/tempo/server/Methods.js.map +1 -1
  38. package/dist/tempo/server/Relay.d.ts +48 -0
  39. package/dist/tempo/server/Relay.d.ts.map +1 -0
  40. package/dist/tempo/server/Relay.js +177 -0
  41. package/dist/tempo/server/Relay.js.map +1 -0
  42. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  43. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  44. package/dist/tempo/server/SponsorBudget.js +144 -0
  45. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  46. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/html.gen.js +1 -1
  49. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  50. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  51. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  52. package/dist/tempo/session/client/ChannelOps.js +9 -4
  53. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  54. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  55. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  56. package/dist/tempo/session/client/CredentialState.js +37 -19
  57. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  58. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  59. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  60. package/dist/tempo/session/client/Runtime.js +17 -0
  61. package/dist/tempo/session/client/Runtime.js.map +1 -1
  62. package/dist/tempo/session/client/Session.d.ts +15 -5
  63. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  64. package/dist/tempo/session/client/Session.js +93 -36
  65. package/dist/tempo/session/client/Session.js.map +1 -1
  66. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  67. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  68. package/dist/tempo/session/client/SessionManager.js +54 -26
  69. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  70. package/dist/tempo/session/client/Transports.d.ts +7 -5
  71. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  72. package/dist/tempo/session/client/Transports.js +48 -24
  73. package/dist/tempo/session/client/Transports.js.map +1 -1
  74. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  75. package/dist/tempo/session/precompile/Chain.js +96 -8
  76. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  77. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  78. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  79. package/dist/tempo/session/precompile/Channel.js +4 -0
  80. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  81. package/dist/tempo/session/server/RequestState.js +3 -1
  82. package/dist/tempo/session/server/RequestState.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/Errors.test.ts +23 -0
  85. package/src/Errors.ts +21 -2
  86. package/src/Method.test.ts +102 -1
  87. package/src/Method.ts +241 -5
  88. package/src/client/Mppx.test-d.ts +3 -3
  89. package/src/client/internal/Fetch.ts +16 -7
  90. package/src/client/internal/MethodChallenge.ts +30 -0
  91. package/src/server/Mppx.test-d.ts +2 -0
  92. package/src/server/Mppx.test.ts +205 -0
  93. package/src/server/Mppx.ts +167 -103
  94. package/src/tempo/PublicExports.test-d.ts +20 -0
  95. package/src/tempo/internal/fee-payer.ts +7 -3
  96. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  97. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  98. package/src/tempo/server/Charge.test.ts +337 -63
  99. package/src/tempo/server/Charge.ts +435 -267
  100. package/src/tempo/server/Methods.ts +11 -0
  101. package/src/tempo/server/Relay.test.ts +523 -0
  102. package/src/tempo/server/Relay.ts +288 -0
  103. package/src/tempo/server/SponsorBudget.test.ts +125 -0
  104. package/src/tempo/server/SponsorBudget.ts +213 -0
  105. package/src/tempo/server/internal/html.gen.ts +1 -1
  106. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  107. package/src/tempo/session/client/ChannelOps.ts +12 -4
  108. package/src/tempo/session/client/CredentialState.ts +61 -20
  109. package/src/tempo/session/client/Runtime.test.ts +12 -0
  110. package/src/tempo/session/client/Runtime.ts +22 -0
  111. package/src/tempo/session/client/Session.test.ts +235 -2
  112. package/src/tempo/session/client/Session.ts +130 -41
  113. package/src/tempo/session/client/SessionManager.test.ts +102 -4
  114. package/src/tempo/session/client/SessionManager.ts +71 -27
  115. package/src/tempo/session/client/Transports.test.ts +32 -1
  116. package/src/tempo/session/client/Transports.ts +79 -35
  117. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  118. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  119. package/src/tempo/session/precompile/Chain.ts +102 -8
  120. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  121. package/src/tempo/session/precompile/Channel.ts +5 -0
  122. package/src/tempo/session/server/RequestState.test.ts +8 -1
  123. package/src/tempo/session/server/RequestState.ts +2 -2
package/src/Method.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type * as Challenge from './Challenge.js'
2
- import type * as Credential from './Credential.js'
2
+ import * as Constants from './Constants.js'
3
+ import * as Credential from './Credential.js'
4
+ import * as Errors from './Errors.js'
5
+ import * as Expires from './Expires.js'
3
6
  import type { ExactPartial, LooseOmit, MaybePromise } from './internal/types.js'
4
7
  import type * as Receipt from './Receipt.js'
5
8
  import type * as Html from './server/internal/html/config.js'
@@ -117,12 +120,72 @@ export type VerifyContext<method extends Method> = {
117
120
  request: z.input<method['schema']['request']>
118
121
  }
119
122
 
123
+ /** Validation hook parameters for a single method. */
124
+ export type ValidateContext<method extends Method> = VerifyContext<method>
125
+
120
126
  /** Response hook parameters for a single method. */
121
127
  export type RespondContext<method extends Method> = VerifyContext<method> & {
122
128
  input: globalThis.Request
123
129
  receipt: Receipt.Receipt
124
130
  }
125
131
 
132
+ /**
133
+ * Non-mutating method-specific validation result.
134
+ *
135
+ * Returned by {@link ValidateFn} and exposed from `mppx.validateCredential()`.
136
+ * This confirms that a credential is currently acceptable to the payment
137
+ * method; it does not settle, reserve, or otherwise consume the payment.
138
+ */
139
+ export type Validation<method extends Method = Method, details = unknown> = Readonly<{
140
+ /**
141
+ * The challenge echoed by the credential and accepted by the method.
142
+ *
143
+ * Callers using `Mppx.validateCredential()` receive a challenge whose HMAC,
144
+ * expiry, route binding, and method identity have already been checked.
145
+ * Lower-level `Method.validateCredential()` callers must verify challenge
146
+ * issuance and route binding themselves.
147
+ */
148
+ challenge: Challenge.Challenge<
149
+ z.output<method['schema']['request']>,
150
+ method['intent'],
151
+ method['name']
152
+ >
153
+ /**
154
+ * The submitted credential with its method-specific payload parsed by the
155
+ * selected method schema. This is the exact credential validation examined.
156
+ */
157
+ credential: Credential.Credential<
158
+ z.output<method['schema']['credential']['payload']>,
159
+ Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
160
+ >
161
+ /**
162
+ * Method-defined, non-mutating validation data.
163
+ *
164
+ * Its shape is intentionally method-specific—for example, a recovered payer
165
+ * address or a simulation result. Consumers must not assume a particular
166
+ * shape or use it as a settlement receipt.
167
+ */
168
+ details: details
169
+ /** The validated payment intent, repeated for convenient dispatch. */
170
+ intent: method['intent']
171
+ /** The validated payment method, repeated for convenient dispatch. */
172
+ method: method['name']
173
+ /**
174
+ * The validated method request after the method's schema transforms.
175
+ *
176
+ * For `Mppx.validateCredential()` with route options, this is the
177
+ * authoritative route request after defaults and request-hook transforms.
178
+ */
179
+ request: z.output<method['schema']['request']>
180
+ /**
181
+ * Optional payer identity declared by the credential.
182
+ *
183
+ * This is an asserted identity, not independent proof of control; methods
184
+ * that rely on it must validate the relationship to the credential payload.
185
+ */
186
+ source?: string | undefined
187
+ }>
188
+
126
189
  /**
127
190
  * A server-side configured method with verification logic.
128
191
  */
@@ -141,8 +204,15 @@ export type Server<
141
204
  preflight?: PreflightFn<method> | undefined
142
205
  request?: RequestFn<method> | undefined
143
206
  respond?: RespondFn<method> | undefined
207
+ broadcast?: BroadcastFn<method> | undefined
144
208
  stableBinding?: StableBindingFn<method> | undefined
145
209
  transport?: transportOverride | undefined
210
+ validate?: ValidateFn<method> | undefined
211
+ /**
212
+ * @deprecated Use `validate` for the non-mutating pre-check and `broadcast`
213
+ * for settlement. `verify` combines both operations and may consume payment
214
+ * state, so it cannot support a safe pre-check endpoint.
215
+ */
146
216
  verify: VerifyFn<method>
147
217
  }
148
218
  export type AnyServer = Server<any, any, any, any, any>
@@ -221,11 +291,148 @@ export type StableBindingFn<method extends Method> = (
221
291
  request: z.output<method['schema']['request']>,
222
292
  ) => Record<string, unknown>
223
293
 
224
- /** Verification function for a single method. */
294
+ /**
295
+ * Legacy combined validation and settlement function for a single method.
296
+ *
297
+ * @deprecated Implement `validate` and `broadcast` instead. `validate` must
298
+ * be non-mutating; `broadcast` performs the terminal payment operation. This
299
+ * hook remains only for existing methods that cannot yet split those phases.
300
+ */
225
301
  export type VerifyFn<method extends Method> = (
226
302
  parameters: VerifyContext<method>,
227
303
  ) => Promise<Receipt.Receipt>
228
304
 
305
+ /**
306
+ * Non-mutating validation function for a single method.
307
+ *
308
+ * @returns A {@link Validation} record describing the credential that was
309
+ * accepted. It must not settle, reserve, or otherwise consume payment state.
310
+ */
311
+ export type ValidateFn<method extends Method> = (
312
+ parameters: ValidateContext<method>,
313
+ ) => Promise<Validation<method>>
314
+
315
+ /** Terminal payment function for a single method. */
316
+ export type BroadcastFn<method extends Method> = (
317
+ parameters: VerifyContext<method>,
318
+ ) => Promise<Receipt.Receipt>
319
+
320
+ /**
321
+ * Validates a credential against one of the configured methods.
322
+ *
323
+ * This checks credential structure, challenge expiry, and method-specific
324
+ * validation. It does not prove that the challenge was issued by a particular
325
+ * server; hosts that issue challenges must verify that binding separately.
326
+ */
327
+ export async function validateCredential<const methods extends readonly AnyServer[]>(
328
+ methods: methods,
329
+ input: string | Credential.Credential,
330
+ ): Promise<Validation<methods[number]>> {
331
+ const prepared = prepareCredential(methods, input)
332
+ if (!prepared.method.validate)
333
+ throw new Errors.VerificationFailedError({
334
+ details: { intent: prepared.method.intent, method: prepared.method.name },
335
+ reason: `${prepared.method.name}/${prepared.method.intent} does not support non-mutating credential validation`,
336
+ })
337
+ return prepared.method.validate({
338
+ credential: prepared.credential,
339
+ request: prepared.request,
340
+ } as never) as Promise<Validation<methods[number]>>
341
+ }
342
+
343
+ /**
344
+ * Re-validates and performs the terminal payment operation for a credential.
345
+ *
346
+ * This does not prove that the challenge was issued by a particular server;
347
+ * hosts that issue challenges must verify that binding separately.
348
+ */
349
+ export async function broadcastCredential<const methods extends readonly AnyServer[]>(
350
+ methods: methods,
351
+ input: string | Credential.Credential,
352
+ ): Promise<Receipt.Receipt> {
353
+ const prepared = prepareCredential(methods, input)
354
+ const { method } = prepared
355
+
356
+ if (method.broadcast && method.validate)
357
+ await method.validate({ credential: prepared.credential, request: prepared.request } as never)
358
+
359
+ const broadcast = method.broadcast ?? method.verify
360
+ return broadcast({ credential: prepared.credential, request: prepared.request } as never)
361
+ }
362
+
363
+ /**
364
+ * Parses a submitted credential into the inputs required for method execution.
365
+ *
366
+ * Dispatch is based on the challenge method and intent. When more than one
367
+ * server method handles the same wire identity, session protocol details select
368
+ * the appropriate implementation. The helper then asserts challenge expiry and
369
+ * parses the method-specific credential payload before returning the selected
370
+ * method and the unmodified challenge request.
371
+ *
372
+ * This intentionally does not verify that the challenge was issued by a
373
+ * particular host, authorize the caller or requested resource, validate the
374
+ * method request, or invoke method lifecycle hooks. Hosts that issue challenges
375
+ * must verify their challenge binding before accepting the credential.
376
+ */
377
+ function prepareCredential(
378
+ methods: readonly AnyServer[],
379
+ input: string | Credential.Credential,
380
+ ): {
381
+ credential: Credential.Credential
382
+ method: AnyServer
383
+ request: Record<string, unknown>
384
+ } {
385
+ const credential = typeof input === 'string' ? Credential.deserialize(input) : input
386
+ const candidates = methods.filter(
387
+ (method) =>
388
+ method.name === credential.challenge.method && method.intent === credential.challenge.intent,
389
+ )
390
+ const method = selectServerMethod(candidates, credential.challenge)
391
+ if (!method)
392
+ throw new Errors.InvalidChallengeError({
393
+ id: credential.challenge.id,
394
+ reason: `no registered method for ${credential.challenge.method}/${credential.challenge.intent}`,
395
+ })
396
+
397
+ Expires.assert(credential.challenge.expires, credential.challenge.id)
398
+
399
+ let payload: unknown
400
+ try {
401
+ payload = method.schema.credential.payload.parse(credential.payload)
402
+ } catch (error) {
403
+ throw new Errors.InvalidPayloadError(error instanceof Error ? { reason: error.message } : {})
404
+ }
405
+
406
+ return {
407
+ credential: { ...credential, payload },
408
+ method,
409
+ request: credential.challenge.request,
410
+ }
411
+ }
412
+
413
+ /** @internal */
414
+ export function selectServerMethod(
415
+ methods: readonly AnyServer[],
416
+ challenge: Challenge.Challenge,
417
+ ): AnyServer | undefined {
418
+ if (methods.length <= 1) return methods[0]
419
+ if (
420
+ challenge.method !== Constants.Methods.tempo ||
421
+ challenge.intent !== Constants.Intents.session
422
+ )
423
+ return methods[0]
424
+
425
+ const sessionProtocol = Constants.getMethodDetail(
426
+ challenge.request.methodDetails,
427
+ Constants.MethodDetailKeys.sessionProtocol,
428
+ )
429
+ if (sessionProtocol === undefined || sessionProtocol === Constants.SessionProtocols.v1)
430
+ return methods.find((method) => method.alias === 'sessionLegacy') ?? methods[0]
431
+ if (sessionProtocol === Constants.SessionProtocols.v2)
432
+ return methods.find((method) => method.alias === undefined) ?? methods[0]
433
+ return undefined
434
+ }
435
+
229
436
  /**
230
437
  * Optional respond function for a server-side method.
231
438
  *
@@ -336,10 +543,22 @@ export function toServer<
336
543
  preflight,
337
544
  request,
338
545
  respond,
546
+ broadcast,
339
547
  stableBinding,
340
548
  transport,
549
+ validate,
341
550
  verify,
342
551
  } = options
552
+ const effectiveVerify =
553
+ verify ??
554
+ (async (parameters: VerifyContext<method>) => {
555
+ if (validate) await validate(parameters)
556
+ if (!broadcast)
557
+ throw new Errors.VerificationFailedError({
558
+ reason: `${method.name}/${method.intent} does not support credential broadcast`,
559
+ })
560
+ return broadcast(parameters)
561
+ })
343
562
  return {
344
563
  ...method,
345
564
  alias,
@@ -350,9 +569,11 @@ export function toServer<
350
569
  preflight,
351
570
  request,
352
571
  respond,
572
+ broadcast,
353
573
  stableBinding,
354
574
  transport,
355
- verify,
575
+ validate,
576
+ verify: effectiveVerify,
356
577
  } as Server<method, defaults, transportOverride, extensions, toServer.Alias<options>>
357
578
  }
358
579
 
@@ -376,6 +597,21 @@ export declare namespace toServer {
376
597
  respond?: RespondFn<method> | undefined
377
598
  stableBinding?: StableBindingFn<method> | undefined
378
599
  transport?: transportOverride | Transport.AnyTransport | undefined
379
- verify: VerifyFn<method>
380
- }
600
+ } & (
601
+ | {
602
+ broadcast: BroadcastFn<method>
603
+ validate?: ValidateFn<method> | undefined
604
+ verify?: undefined
605
+ }
606
+ | {
607
+ broadcast?: undefined
608
+ validate?: undefined
609
+ /**
610
+ * @deprecated Use `validate` and `broadcast` for new methods. This
611
+ * combined hook may mutate payment state and cannot power a safe
612
+ * validation-only endpoint.
613
+ */
614
+ verify: VerifyFn<method>
615
+ }
616
+ )
381
617
  }
@@ -262,9 +262,9 @@ describe('fetch context', () => {
262
262
  type FetchInit = NonNullable<Parameters<typeof _mppx.fetch>[1]>
263
263
  type Context = NonNullable<FetchInit['context']>
264
264
 
265
- // Context is a union of charge and session contexts.
266
- // `account` exists on both; `autoSwap` only on charge.
265
+ // Context is a union of charge and session contexts; both support auto-swap.
267
266
  expectTypeOf<Context>().toHaveProperty('account')
268
- expectTypeOf<Extract<Context, { autoSwap?: unknown }>>().toHaveProperty('autoSwap')
267
+ expectTypeOf<Context>().toHaveProperty('autoSwap')
268
+ expectTypeOf<Context['autoSwap']>().toEqualTypeOf<AutoSwap.resolve.Value | undefined>()
269
269
  })
270
270
  })
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
6
6
  import type * as Method from '../../Method.js'
7
7
  import type * as z from '../../zod.js'
8
8
  import * as Transport from '../Transport.js'
9
+ import * as MethodChallenge from './MethodChallenge.js'
9
10
  import * as MethodResponse from './MethodResponse.js'
10
11
 
11
12
  // We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
@@ -241,9 +242,22 @@ export function from<const methods extends readonly Method.AnyClient[]>(
241
242
  mi = selected.method
242
243
  if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
243
244
 
245
+ const paymentInput = resolvePaymentRetryInput(
246
+ response,
247
+ initialRequest.input,
248
+ initialRequest.input,
249
+ )
244
250
  const createCredential = memoizeCreateCredential(
245
- (overrideContext?: AnyContextFor<methods>) =>
246
- resolveCredential(selectedChallenge, selected.method, overrideContext ?? context),
251
+ async (overrideContext?: AnyContextFor<methods>) => {
252
+ const credentialContext = overrideContext ?? context
253
+ await MethodChallenge.handle(selected.method, {
254
+ challenge: selectedChallenge,
255
+ context: credentialContext,
256
+ fetch: baseFetch,
257
+ input: paymentInput,
258
+ })
259
+ return resolveCredential(selectedChallenge, selected.method, credentialContext)
260
+ },
247
261
  )
248
262
  const eventCredential = await events.emit(
249
263
  'challenge.received',
@@ -278,11 +292,6 @@ export function from<const methods extends readonly Method.AnyClient[]>(
278
292
  }),
279
293
  )
280
294
 
281
- const paymentInput = resolvePaymentRetryInput(
282
- response,
283
- initialRequest.input,
284
- initialRequest.input,
285
- )
286
295
  response = await baseFetch(
287
296
  paymentInput,
288
297
  transport.setCredential(
@@ -0,0 +1,30 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type { MaybePromise } from '../../internal/types.js'
3
+ import type * as Method from '../../Method.js'
4
+
5
+ const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+
7
+ /** Inputs available before a client method creates a challenge credential. */
8
+ export type HandlerParameters = {
9
+ challenge: Challenge.Challenge
10
+ context?: unknown
11
+ fetch: typeof globalThis.fetch
12
+ input: RequestInfo | URL
13
+ }
14
+
15
+ /** Internal client-method challenge hook. */
16
+ export type Handler = (parameters: HandlerParameters) => MaybePromise<void>
17
+
18
+ /** Registers an internal challenge hook without changing the public method shape. */
19
+ export function register<const method extends Method.AnyClient>(
20
+ method: method,
21
+ handler: Handler,
22
+ ): method {
23
+ handlers.set(method, handler)
24
+ return method
25
+ }
26
+
27
+ /** Runs method-specific work before creating a challenge credential. */
28
+ export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void> {
29
+ return Promise.resolve(handlers.get(method)?.(parameters))
30
+ }
@@ -223,6 +223,8 @@ describe('Mppx type tests', () => {
223
223
  const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
224
224
 
225
225
  expectTypeOf(mppx.verifyCredential).toBeFunction()
226
+ expectTypeOf(mppx.broadcastCredential).toBeFunction()
227
+ expectTypeOf(mppx.validateCredential).toBeFunction()
226
228
  })
227
229
 
228
230
  test('server events receive typed method context', () => {
@@ -4872,6 +4872,211 @@ describe('verifyCredential', () => {
4872
4872
  expect(verifyArgs).toBeDefined()
4873
4873
  })
4874
4874
 
4875
+ test('validateCredential uses pure method validation without broadcast', async () => {
4876
+ const calls: string[] = []
4877
+ const splitServer = Method.toServer(mockCharge, {
4878
+ async validate({ credential, request }) {
4879
+ calls.push('validate')
4880
+ return {
4881
+ challenge: credential.challenge,
4882
+ credential,
4883
+ details: { token: credential.payload.token },
4884
+ intent: 'charge',
4885
+ method: 'alpha',
4886
+ request,
4887
+ source: credential.source,
4888
+ }
4889
+ },
4890
+ async broadcast() {
4891
+ calls.push('broadcast')
4892
+ return mockReceipt('broadcast')
4893
+ },
4894
+ })
4895
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
4896
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
4897
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
4898
+
4899
+ const validation = await mppx.validateCredential(credential)
4900
+
4901
+ expect(validation.details).toEqual({ token: 'valid' })
4902
+ expect(calls).toEqual(['validate'])
4903
+ })
4904
+
4905
+ test('broadcastCredential revalidates and uses method broadcast', async () => {
4906
+ const calls: string[] = []
4907
+ const splitServer = Method.toServer(mockCharge, {
4908
+ async validate({ credential, request }) {
4909
+ calls.push('validate')
4910
+ return {
4911
+ challenge: credential.challenge,
4912
+ credential,
4913
+ details: {},
4914
+ intent: 'charge',
4915
+ method: 'alpha',
4916
+ request,
4917
+ source: credential.source,
4918
+ }
4919
+ },
4920
+ async broadcast() {
4921
+ calls.push('broadcast')
4922
+ return mockReceipt('broadcast')
4923
+ },
4924
+ })
4925
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
4926
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
4927
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
4928
+
4929
+ const receipt = await mppx.broadcastCredential(credential)
4930
+
4931
+ expect(receipt.method).toBe('broadcast')
4932
+ expect(calls).toEqual(['validate', 'broadcast'])
4933
+ })
4934
+
4935
+ test('verifyCredential remains a legacy alias for broadcast', async () => {
4936
+ const calls: string[] = []
4937
+ const splitServer = Method.toServer(mockCharge, {
4938
+ async validate({ credential, request }) {
4939
+ calls.push('validate')
4940
+ return {
4941
+ challenge: credential.challenge,
4942
+ credential,
4943
+ details: {},
4944
+ intent: 'charge',
4945
+ method: 'alpha',
4946
+ request,
4947
+ source: credential.source,
4948
+ }
4949
+ },
4950
+ async broadcast() {
4951
+ calls.push('broadcast')
4952
+ return mockReceipt('broadcast')
4953
+ },
4954
+ })
4955
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
4956
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
4957
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
4958
+
4959
+ const receipt = await mppx.verifyCredential(credential)
4960
+
4961
+ expect(receipt.method).toBe('broadcast')
4962
+ expect(calls).toEqual(['validate', 'broadcast'])
4963
+ })
4964
+
4965
+ test('validateCredential rejects legacy-only methods without emitting payment failure', async () => {
4966
+ const events: string[] = []
4967
+ const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey })
4968
+ mppx.onPaymentFailed((context) => {
4969
+ events.push(context.error.name)
4970
+ })
4971
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
4972
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
4973
+
4974
+ await expect(mppx.validateCredential(credential)).rejects.toThrow(
4975
+ 'does not support non-mutating credential validation',
4976
+ )
4977
+
4978
+ expect(events).toEqual([])
4979
+ })
4980
+
4981
+ test('validateCredential enforces supplied route requirements', async () => {
4982
+ const splitServer = Method.toServer(mockCharge, {
4983
+ async validate({ credential, request }) {
4984
+ return {
4985
+ challenge: credential.challenge,
4986
+ credential,
4987
+ details: { amount: request.amount },
4988
+ intent: 'charge',
4989
+ method: 'alpha',
4990
+ request,
4991
+ source: credential.source,
4992
+ }
4993
+ },
4994
+ async broadcast() {
4995
+ return mockReceipt('broadcast')
4996
+ },
4997
+ })
4998
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
4999
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
5000
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
5001
+
5002
+ const validation = await mppx.validateCredential(credential, { request: challengeOpts })
5003
+ expect(validation.details).toEqual({ amount: '1000' })
5004
+
5005
+ await expect(
5006
+ mppx.validateCredential(credential, {
5007
+ request: {
5008
+ ...challengeOpts,
5009
+ amount: '2000',
5010
+ },
5011
+ }),
5012
+ ).rejects.toThrow('credential amount does not match this route')
5013
+ })
5014
+
5015
+ test('broadcastCredential emits payment failure when split validation fails', async () => {
5016
+ const calls: string[] = []
5017
+ const events: string[] = []
5018
+ const splitServer = Method.toServer(mockCharge, {
5019
+ async validate() {
5020
+ calls.push('validate')
5021
+ throw new Errors.VerificationFailedError({ reason: 'risk denied' })
5022
+ },
5023
+ async broadcast() {
5024
+ calls.push('broadcast')
5025
+ return mockReceipt('broadcast')
5026
+ },
5027
+ })
5028
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
5029
+ mppx.onPaymentFailed((context) => {
5030
+ events.push(context.error.name)
5031
+ })
5032
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
5033
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
5034
+
5035
+ await expect(mppx.broadcastCredential(credential)).rejects.toThrow('risk denied')
5036
+
5037
+ expect(calls).toEqual(['validate'])
5038
+ expect(events).toEqual(['VerificationFailedError'])
5039
+ })
5040
+
5041
+ test('route handlers revalidate before broadcast for split methods', async () => {
5042
+ const calls: string[] = []
5043
+ const splitServer = Method.toServer(mockCharge, {
5044
+ async validate({ credential, request }) {
5045
+ calls.push('validate')
5046
+ return {
5047
+ challenge: credential.challenge,
5048
+ credential,
5049
+ details: {},
5050
+ intent: 'charge',
5051
+ method: 'alpha',
5052
+ request,
5053
+ source: credential.source,
5054
+ }
5055
+ },
5056
+ async broadcast() {
5057
+ calls.push('broadcast')
5058
+ return mockReceipt('broadcast')
5059
+ },
5060
+ })
5061
+ const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
5062
+ const firstResult = await mppx.charge(challengeOpts)(
5063
+ new Request('https://api.example.com/resource'),
5064
+ )
5065
+ expect(firstResult.status).toBe(402)
5066
+ if (firstResult.status !== 402) throw new Error()
5067
+
5068
+ const challenge = Challenge.fromResponse(firstResult.challenge)
5069
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
5070
+ const result = await mppx.charge(challengeOpts)(
5071
+ new Request('https://api.example.com/resource', {
5072
+ headers: { Authorization: Credential.serialize(credential) },
5073
+ }),
5074
+ )
5075
+
5076
+ expect(result.status).toBe(200)
5077
+ expect(calls).toEqual(['validate', 'broadcast'])
5078
+ })
5079
+
4875
5080
  test('verifies a parsed Credential object (charge)', async () => {
4876
5081
  verifyArgs = undefined
4877
5082
  const mppx = Mppx.create({