mppx 0.8.13 → 0.8.15

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 (156) hide show
  1. package/CHANGELOG.md +37 -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/cli/validate/payment.d.ts.map +1 -1
  11. package/dist/cli/validate/payment.js +18 -4
  12. package/dist/cli/validate/payment.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +98 -5
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/client/internal/MethodChallenge.d.ts +19 -0
  17. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  18. package/dist/client/internal/MethodChallenge.js +15 -0
  19. package/dist/client/internal/MethodChallenge.js.map +1 -0
  20. package/dist/client/internal/MethodResponse.d.ts +15 -0
  21. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  22. package/dist/client/internal/MethodResponse.js +10 -0
  23. package/dist/client/internal/MethodResponse.js.map +1 -1
  24. package/dist/server/Mppx.d.ts +23 -4
  25. package/dist/server/Mppx.d.ts.map +1 -1
  26. package/dist/server/Mppx.js +91 -82
  27. package/dist/server/Mppx.js.map +1 -1
  28. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  29. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/stripe/server/internal/html.gen.js +1 -1
  31. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/client/Methods.d.ts +1 -0
  33. package/dist/tempo/client/Methods.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +7 -2
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  39. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  40. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  41. package/dist/tempo/server/Charge.d.ts +46 -77
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +289 -209
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/Methods.d.ts +12 -1
  46. package/dist/tempo/server/Methods.d.ts.map +1 -1
  47. package/dist/tempo/server/Methods.js +4 -0
  48. package/dist/tempo/server/Methods.js.map +1 -1
  49. package/dist/tempo/server/Relay.d.ts +57 -0
  50. package/dist/tempo/server/Relay.d.ts.map +1 -0
  51. package/dist/tempo/server/Relay.js +179 -0
  52. package/dist/tempo/server/Relay.js.map +1 -0
  53. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  54. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  55. package/dist/tempo/server/SponsorBudget.js +150 -0
  56. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  62. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/session/client/ChannelOps.js +9 -4
  64. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  66. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  67. package/dist/tempo/session/client/CredentialState.js +37 -19
  68. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  69. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  70. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  71. package/dist/tempo/session/client/Runtime.js +17 -0
  72. package/dist/tempo/session/client/Runtime.js.map +1 -1
  73. package/dist/tempo/session/client/Session.d.ts +15 -5
  74. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/session/client/Session.js +216 -45
  76. package/dist/tempo/session/client/Session.js.map +1 -1
  77. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  78. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  79. package/dist/tempo/session/client/SessionManager.js +114 -44
  80. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  81. package/dist/tempo/session/client/Transports.d.ts +16 -6
  82. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  83. package/dist/tempo/session/client/Transports.js +84 -37
  84. package/dist/tempo/session/client/Transports.js.map +1 -1
  85. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  86. package/dist/tempo/session/precompile/Chain.js +96 -8
  87. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  88. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  89. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  90. package/dist/tempo/session/precompile/Channel.js +4 -0
  91. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  92. package/dist/tempo/session/server/RequestState.js +3 -1
  93. package/dist/tempo/session/server/RequestState.js.map +1 -1
  94. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  95. package/dist/tempo/session/server/Ws.js +3 -0
  96. package/dist/tempo/session/server/Ws.js.map +1 -1
  97. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  98. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  99. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  100. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  101. package/dist/viem/Client.d.ts.map +1 -1
  102. package/dist/viem/Client.js +6 -1
  103. package/dist/viem/Client.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/Errors.test.ts +23 -0
  106. package/src/Errors.ts +21 -2
  107. package/src/Method.test.ts +102 -1
  108. package/src/Method.ts +241 -5
  109. package/src/cli/validate/payment.ts +18 -4
  110. package/src/cli/validate.test.ts +16 -0
  111. package/src/client/Mppx.test-d.ts +3 -3
  112. package/src/client/internal/Fetch.test.ts +77 -0
  113. package/src/client/internal/Fetch.ts +122 -11
  114. package/src/client/internal/MethodChallenge.ts +35 -0
  115. package/src/client/internal/MethodResponse.ts +28 -0
  116. package/src/server/Mppx.test-d.ts +2 -0
  117. package/src/server/Mppx.test.ts +205 -0
  118. package/src/server/Mppx.ts +167 -103
  119. package/src/stripe/server/internal/html.gen.ts +1 -1
  120. package/src/tempo/PublicExports.test-d.ts +20 -0
  121. package/src/tempo/internal/fee-payer.ts +7 -3
  122. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  123. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  124. package/src/tempo/server/Charge.test.ts +337 -63
  125. package/src/tempo/server/Charge.ts +443 -267
  126. package/src/tempo/server/Methods.ts +11 -0
  127. package/src/tempo/server/Relay.test.ts +677 -0
  128. package/src/tempo/server/Relay.ts +297 -0
  129. package/src/tempo/server/SponsorBudget.test.ts +154 -0
  130. package/src/tempo/server/SponsorBudget.ts +219 -0
  131. package/src/tempo/server/internal/html.gen.ts +1 -1
  132. package/src/tempo/session/README.md +7 -0
  133. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  134. package/src/tempo/session/client/ChannelOps.ts +12 -4
  135. package/src/tempo/session/client/CredentialState.ts +61 -20
  136. package/src/tempo/session/client/Runtime.test.ts +12 -0
  137. package/src/tempo/session/client/Runtime.ts +22 -0
  138. package/src/tempo/session/client/Session.test.ts +506 -7
  139. package/src/tempo/session/client/Session.ts +279 -54
  140. package/src/tempo/session/client/SessionManager.test.ts +318 -13
  141. package/src/tempo/session/client/SessionManager.ts +132 -42
  142. package/src/tempo/session/client/Transports.test.ts +54 -1
  143. package/src/tempo/session/client/Transports.ts +137 -57
  144. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  145. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  146. package/src/tempo/session/precompile/Chain.ts +102 -8
  147. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  148. package/src/tempo/session/precompile/Channel.ts +5 -0
  149. package/src/tempo/session/server/RequestState.test.ts +8 -1
  150. package/src/tempo/session/server/RequestState.ts +2 -2
  151. package/src/tempo/session/server/Session.test.ts +66 -4
  152. package/src/tempo/session/server/Ws.ts +3 -0
  153. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  154. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  155. package/src/viem/Client.test.ts +18 -1
  156. package/src/viem/Client.ts +7 -1
@@ -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({
@@ -9,7 +9,7 @@ import * as Expires from '../Expires.js'
9
9
  import * as AcceptPayment from '../internal/AcceptPayment.js'
10
10
  import * as Env from '../internal/env.js'
11
11
  import type { MaybePromise } from '../internal/types.js'
12
- import type * as Method from '../Method.js'
12
+ import * as Method from '../Method.js'
13
13
  import * as PaymentRequest from '../PaymentRequest.js'
14
14
  import type * as Receipt from '../Receipt.js'
15
15
  import * as x402_Header from '../x402/Header.js'
@@ -248,14 +248,18 @@ export type Mppx<
248
248
  /**
249
249
  * Verify a credential string or object end-to-end: deserialize,
250
250
  * HMAC-check, match to a registered method, validate payload schema,
251
- * check expiry, and call the method's verify function.
251
+ * check expiry, and call the method's broadcast or legacy verify function.
252
252
  *
253
- * Method verification can settle payments and persist state. For example,
253
+ * Method verification can broadcast payments and persist state. For example,
254
254
  * subscription credentials may activate or renew a subscription. Failed
255
255
  * standalone verification emits `payment.failed` once a credential challenge
256
256
  * can be parsed; strings that cannot be deserialized have no challenge
257
257
  * context to report.
258
258
  *
259
+ * @deprecated Use `broadcastCredential()` instead. This legacy name maps
260
+ * to the same mutating operation and does not provide validation-only
261
+ * semantics.
262
+ *
259
263
  * @example
260
264
  * ```ts
261
265
  * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
@@ -267,6 +271,27 @@ export type Mppx<
267
271
  credential: string | Credential.Credential,
268
272
  options?: VerifyCredentialOptions | undefined,
269
273
  ): Promise<Receipt.Receipt>
274
+ /**
275
+ * Validate a credential without consuming or broadcasting it.
276
+ *
277
+ * This is an advisory pre-check. Methods that support it must not write
278
+ * replay state, sign fee-payer transactions, broadcast, or otherwise
279
+ * mutate payment state. Use `broadcastCredential()` when accepting payment.
280
+ */
281
+ validateCredential(
282
+ credential: string | Credential.Credential,
283
+ options?: VerifyCredentialOptions | undefined,
284
+ ): Promise<Method.Validation>
285
+ /**
286
+ * Re-validates and broadcasts a credential, returning a receipt.
287
+ *
288
+ * `verifyCredential()` is retained as a backwards-compatible alias for
289
+ * this mutating path.
290
+ */
291
+ broadcastCredential(
292
+ credential: string | Credential.Credential,
293
+ options?: VerifyCredentialOptions | undefined,
294
+ ): Promise<Receipt.Receipt>
270
295
  }
271
296
 
272
297
  /** Extracts the transport override from a method, if any. */
@@ -292,7 +317,9 @@ const reservedMppxKeyValues = [
292
317
  'onPaymentFailed',
293
318
  'onPaymentSuccess',
294
319
  'realm',
320
+ 'broadcastCredential',
295
321
  'transport',
322
+ 'validateCredential',
296
323
  'verifyCredential',
297
324
  ] as const
298
325
 
@@ -457,9 +484,11 @@ export function create<
457
484
  preflight: mi.preflight as never,
458
485
  request: mi.request as never,
459
486
  respond: mi.respond as never,
487
+ broadcast: mi.broadcast as never,
460
488
  secretKey,
461
489
  stableBinding: mi.stableBinding as never,
462
490
  transport: (mi.transport ?? transport) as never,
491
+ validate: mi.validate as never,
463
492
  verify: mi.verify as never,
464
493
  })
465
494
  const wireKey = `${mi.name}/${mi.intent}`
@@ -496,125 +525,118 @@ export function create<
496
525
  })
497
526
  }
498
527
 
499
- // verifyCredential: single-call end-to-end verification
500
- async function verifyCredentialFn(
528
+ async function prepareStandaloneCredential(
501
529
  input: string | Credential.Credential,
502
- options?: VerifyCredentialOptions,
503
- ): Promise<Receipt.Receipt> {
530
+ options: VerifyCredentialOptions | undefined,
531
+ parameters: { emitFailures?: boolean | undefined; requireValidate?: boolean | undefined } = {},
532
+ ) {
504
533
  const credential = hydrateCredentialMeta(
505
534
  typeof input === 'string' ? Credential.deserialize(input) : input,
506
535
  )
507
536
 
508
- // Find matching method by name + intent, then use method-details markers
509
- // when multiple handlers intentionally share the same wire identity.
537
+ const emitFailures = parameters.emitFailures === true
510
538
  const { method: credMethod, intent: credIntent } = credential.challenge
511
539
  const methodCandidates = (methods as readonly Method.AnyServer[]).filter(
512
540
  (m) => m.name === credMethod && m.intent === credIntent,
513
541
  )
514
- const mi = selectVerificationMethod(methodCandidates, credential.challenge)
542
+ const mi = Method.selectServerMethod(methodCandidates, credential.challenge)
515
543
  const eventMethod =
516
544
  mi ?? ({ intent: credIntent, name: credMethod } satisfies ServerMethodDescriptor)
517
545
 
518
- const emitStandalonePaymentFailed = async (parameters: {
546
+ const emitStandalonePaymentFailed = async (failure: {
519
547
  challenge: Challenge.Challenge
520
548
  credential: Credential.Credential | null
521
549
  error: Errors.PaymentError
522
550
  request: Record<string, unknown>
523
551
  submittedChallenge?: Challenge.Challenge | undefined
524
552
  }) => {
553
+ if (!emitFailures) return
525
554
  await serverEvents.emit(
526
555
  'payment.failed',
527
556
  createPaymentFailedContext({
528
557
  capturedRequest: options?.capturedRequest,
529
- challenge: parameters.challenge,
530
- credential: parameters.credential,
531
- error: parameters.error,
558
+ challenge: failure.challenge,
559
+ credential: failure.credential,
560
+ error: failure.error,
532
561
  method: eventMethod,
533
- request: parameters.request,
534
- submittedChallenge: parameters.submittedChallenge,
562
+ request: failure.request,
563
+ submittedChallenge: failure.submittedChallenge,
535
564
  }) as never,
536
565
  )
537
566
  }
538
567
 
539
- if (!mi) {
540
- const error = new Errors.InvalidChallengeError({
541
- id: credential.challenge.id,
542
- reason: `no registered method for ${credMethod}/${credIntent}`,
543
- })
568
+ const fail = async (
569
+ error: Errors.PaymentError,
570
+ failure: {
571
+ credential?: Credential.Credential | null | undefined
572
+ request?: Record<string, unknown> | undefined
573
+ submittedChallenge?: Challenge.Challenge | undefined
574
+ } = {},
575
+ thrown: unknown = error,
576
+ ): Promise<never> => {
544
577
  await emitStandalonePaymentFailed({
545
578
  challenge: credential.challenge,
546
- credential,
579
+ credential: failure.credential ?? credential,
547
580
  error,
548
- request: credential.challenge.request as Record<string, unknown>,
549
- submittedChallenge: credential.challenge,
581
+ request: failure.request ?? (credential.challenge.request as Record<string, unknown>),
582
+ submittedChallenge: failure.submittedChallenge ?? credential.challenge,
550
583
  })
551
- throw error
584
+ throw thrown
585
+ }
586
+
587
+ if (!mi) {
588
+ await fail(
589
+ new Errors.InvalidChallengeError({
590
+ id: credential.challenge.id,
591
+ reason: `no registered method for ${credMethod}/${credIntent}`,
592
+ }),
593
+ )
552
594
  }
553
595
 
554
- // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
596
+ if (parameters.requireValidate && !mi.validate)
597
+ await fail(
598
+ new Errors.VerificationFailedError({
599
+ details: { intent: credIntent, method: credMethod },
600
+ reason: `${credMethod}/${credIntent} does not support non-mutating credential validation`,
601
+ }),
602
+ )
603
+
555
604
  if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) {
556
- const error = new Errors.InvalidChallengeError({
557
- id: credential.challenge.id,
558
- reason: 'challenge was not issued by this server',
559
- })
560
- await emitStandalonePaymentFailed({
561
- challenge: credential.challenge,
562
- credential,
563
- error,
564
- request: credential.challenge.request as Record<string, unknown>,
565
- submittedChallenge: credential.challenge,
566
- })
567
- throw error
605
+ await fail(
606
+ new Errors.InvalidChallengeError({
607
+ id: credential.challenge.id,
608
+ reason: 'challenge was not issued by this server',
609
+ }),
610
+ )
568
611
  }
569
612
 
570
- // Expiry check
571
613
  try {
572
614
  Expires.assert(credential.challenge.expires, credential.challenge.id)
573
615
  } catch (e) {
574
- if (e instanceof Errors.PaymentError)
575
- await emitStandalonePaymentFailed({
576
- challenge: credential.challenge,
577
- credential,
578
- error: e,
579
- request: credential.challenge.request as Record<string, unknown>,
580
- submittedChallenge: credential.challenge,
581
- })
616
+ if (e instanceof Errors.PaymentError) await fail(e)
582
617
  throw e
583
618
  }
584
619
 
585
- // Validate payload against method schema
586
- let parsedCredential: Credential.Credential
620
+ let parsedCredential!: Credential.Credential
587
621
  try {
588
622
  parsedCredential = withParsedCredentialPayload(
589
623
  credential,
590
624
  mi.schema.credential.payload.parse(credential.payload),
591
625
  )
592
626
  } catch (e) {
593
- await emitStandalonePaymentFailed({
594
- challenge: credential.challenge,
595
- credential,
596
- error: new Errors.InvalidPayloadError(),
597
- request: credential.challenge.request as Record<string, unknown>,
598
- submittedChallenge: credential.challenge,
599
- })
600
- throw e
627
+ await fail(new Errors.InvalidPayloadError(), {}, e)
601
628
  }
602
629
 
603
630
  const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
604
631
 
605
632
  if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
606
- const error = new Errors.InvalidChallengeError({
607
- id: credential.challenge.id,
608
- reason: "credential scope does not match this route's requirements",
609
- })
610
- await emitStandalonePaymentFailed({
611
- challenge: credential.challenge,
612
- credential: parsedCredential,
613
- error,
614
- request: credential.challenge.request as Record<string, unknown>,
615
- submittedChallenge: credential.challenge,
616
- })
617
- throw error
633
+ await fail(
634
+ new Errors.InvalidChallengeError({
635
+ id: credential.challenge.id,
636
+ reason: "credential scope does not match this route's requirements",
637
+ }),
638
+ { credential: parsedCredential },
639
+ )
618
640
  }
619
641
 
620
642
  const shouldValidateRoute =
@@ -660,12 +682,9 @@ export function create<
660
682
  : (credential.challenge.request as z.input<typeof mi.schema.request>)
661
683
  } catch (e) {
662
684
  if (e instanceof Errors.PaymentError)
663
- await emitStandalonePaymentFailed({
664
- challenge: credential.challenge,
685
+ await fail(e, {
665
686
  credential: parsedCredential,
666
- error: e,
667
687
  request: credential.challenge.request as Record<string, unknown>,
668
- submittedChallenge: credential.challenge,
669
688
  })
670
689
  throw e
671
690
  }
@@ -679,17 +698,72 @@ export function create<
679
698
  } as Method.VerifiedChallengeEnvelope)
680
699
  : undefined
681
700
 
701
+ return {
702
+ credential,
703
+ envelope,
704
+ eventMethod,
705
+ method: mi,
706
+ parsedCredential,
707
+ parsedRequest,
708
+ request,
709
+ }
710
+ }
711
+
712
+ async function validateCredentialFn(
713
+ input: string | Credential.Credential,
714
+ options?: VerifyCredentialOptions,
715
+ ): Promise<Method.Validation> {
716
+ const prepared = await prepareStandaloneCredential(input, options, { requireValidate: true })
717
+ return prepared.method.validate!({
718
+ credential: prepared.parsedCredential,
719
+ envelope: prepared.envelope,
720
+ request: prepared.request,
721
+ } as never)
722
+ }
723
+
724
+ // broadcastCredential: single-call end-to-end validation and broadcast
725
+ async function broadcastCredentialFn(
726
+ input: string | Credential.Credential,
727
+ options?: VerifyCredentialOptions,
728
+ ): Promise<Receipt.Receipt> {
729
+ const prepared = await prepareStandaloneCredential(input, options, { emitFailures: true })
730
+ const { method: mi, parsedCredential, parsedRequest, request, envelope } = prepared
731
+
732
+ const emitStandalonePaymentFailed = async (parameters: {
733
+ challenge: Challenge.Challenge
734
+ credential: Credential.Credential | null
735
+ error: Errors.PaymentError
736
+ request: Record<string, unknown>
737
+ submittedChallenge?: Challenge.Challenge | undefined
738
+ }) => {
739
+ await serverEvents.emit(
740
+ 'payment.failed',
741
+ createPaymentFailedContext({
742
+ capturedRequest: options?.capturedRequest,
743
+ challenge: parameters.challenge,
744
+ credential: parameters.credential,
745
+ error: parameters.error,
746
+ method: prepared.eventMethod,
747
+ request: parameters.request,
748
+ submittedChallenge: parameters.submittedChallenge,
749
+ }) as never,
750
+ )
751
+ }
752
+
682
753
  let receipt: Receipt.Receipt
683
754
  try {
684
- receipt = await mi.verify({ credential: parsedCredential, envelope, request } as never)
755
+ if (mi.broadcast && mi.validate)
756
+ await mi.validate({ credential: parsedCredential, envelope, request } as never)
757
+ const broadcast = mi.broadcast ?? mi.verify
758
+ receipt = await broadcast({ credential: parsedCredential, envelope, request } as never)
685
759
  } catch (e) {
686
760
  const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
687
761
  await emitStandalonePaymentFailed({
688
- challenge: credential.challenge,
762
+ challenge: prepared.credential.challenge,
689
763
  credential: parsedCredential,
690
764
  error,
691
765
  request: parsedRequest,
692
- submittedChallenge: credential.challenge,
766
+ submittedChallenge: prepared.credential.challenge,
693
767
  })
694
768
  throw e
695
769
  }
@@ -698,7 +772,7 @@ export function create<
698
772
  'payment.success',
699
773
  createPaymentSuccessContext({
700
774
  capturedRequest: options?.capturedRequest,
701
- challenge: credential.challenge,
775
+ challenge: prepared.credential.challenge,
702
776
  credential: parsedCredential,
703
777
  envelope,
704
778
  method: mi,
@@ -710,6 +784,8 @@ export function create<
710
784
  return receipt
711
785
  }
712
786
 
787
+ const verifyCredentialFn = broadcastCredentialFn
788
+
713
789
  function composeFn(
714
790
  ...entries: readonly [
715
791
  Method.AnyServer | AnyMethodFnWithMethod | string,
@@ -760,7 +836,9 @@ export function create<
760
836
  onPaymentFailed,
761
837
  onPaymentSuccess,
762
838
  realm: realm as string | undefined,
839
+ broadcastCredential: broadcastCredentialFn,
763
840
  transport,
841
+ validateCredential: validateCredentialFn,
764
842
  verifyCredential: verifyCredentialFn,
765
843
  ...handlers,
766
844
  } as never
@@ -809,8 +887,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
809
887
  realm,
810
888
  respond,
811
889
  secretKey,
890
+ broadcast,
812
891
  stableBinding,
813
892
  transport,
893
+ validate,
814
894
  verify,
815
895
  } = parameters
816
896
 
@@ -1256,7 +1336,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
1256
1336
  // If verification fails, re-issue the challenge so the client can retry.
1257
1337
  let receiptData: Receipt.Receipt
1258
1338
  try {
1259
- receiptData = await verify({ credential: parsedCredential, envelope, request } as never)
1339
+ if (broadcast && validate)
1340
+ await validate({ credential: parsedCredential, envelope, request } as never)
1341
+ const broadcastCredential = broadcast ?? verify
1342
+ receiptData = await broadcastCredential({
1343
+ credential: parsedCredential,
1344
+ envelope,
1345
+ request,
1346
+ } as never)
1260
1347
  } catch (e) {
1261
1348
  if (!(e instanceof Errors.PaymentError))
1262
1349
  console.error('mppx: internal verification error', e)
@@ -1375,9 +1462,11 @@ declare namespace createMethodFn {
1375
1462
  realm: string | undefined
1376
1463
  request?: Method.RequestFn<method>
1377
1464
  respond?: Method.RespondFn<method>
1465
+ broadcast?: Method.BroadcastFn<method>
1378
1466
  secretKey: string
1379
1467
  stableBinding?: Method.StableBindingFn<method>
1380
1468
  transport: transport
1469
+ validate?: Method.ValidateFn<method>
1381
1470
  verify: Method.VerifyFn<method>
1382
1471
  }
1383
1472
 
@@ -1843,31 +1932,6 @@ type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
1843
1932
  type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField
1844
1933
  type StableBinding = Record<string, unknown>
1845
1934
 
1846
- function selectVerificationMethod(
1847
- methods: readonly Method.AnyServer[],
1848
- challenge: Challenge.Challenge,
1849
- ): Method.AnyServer | undefined {
1850
- if (methods.length <= 1) return methods[0]
1851
- if (
1852
- challenge.method !== Constants.Methods.tempo ||
1853
- challenge.intent !== Constants.Intents.session
1854
- )
1855
- return methods[0]
1856
-
1857
- const sessionProtocolMarker = Constants.getMethodDetail(
1858
- challenge.request.methodDetails,
1859
- Constants.MethodDetailKeys.sessionProtocol,
1860
- )
1861
- if (
1862
- sessionProtocolMarker === undefined ||
1863
- sessionProtocolMarker === Constants.SessionProtocols.v1
1864
- )
1865
- return methods.find((method) => method.alias === 'sessionLegacy') ?? methods[0]
1866
- if (sessionProtocolMarker === Constants.SessionProtocols.v2)
1867
- return methods.find((method) => method.alias === undefined) ?? methods[0]
1868
- return undefined
1869
- }
1870
-
1871
1935
  function getChallengeBindingMismatch(
1872
1936
  expectedChallenge: Challenge.Challenge,
1873
1937
  actualChallenge: Challenge.Challenge,