mppx 0.5.11 → 0.5.13

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/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -127,6 +127,50 @@ describe('request handler', () => {
127
127
  expect(body.detail).not.toContain('rpc.example.com')
128
128
  })
129
129
 
130
+ test('returns 402 when challenge ID mismatch', async () => {
131
+ const wrongChallenge = Challenge.from({
132
+ id: 'wrong-id',
133
+ intent: 'charge',
134
+ method: 'tempo',
135
+ realm,
136
+ request: { amount: '1000', currency: asset, recipient: accounts[0].address },
137
+ })
138
+ const credential = Credential.from({
139
+ challenge: wrongChallenge,
140
+ payload: { signature: '0x123', type: 'transaction' },
141
+ })
142
+
143
+ const request = new Request('https://example.com/resource', {
144
+ headers: { Authorization: Credential.serialize(credential) },
145
+ })
146
+
147
+ const result = await Mppx.create({ methods: [method], realm, secretKey }).charge({
148
+ amount: '1000',
149
+ currency: asset,
150
+ expires: new Date(Date.now() + 60_000).toISOString(),
151
+ recipient: accounts[0].address,
152
+ })(request)
153
+
154
+ expect(result.status).toBe(402)
155
+ if (result.status !== 402) throw new Error()
156
+
157
+ const body = (await result.challenge.json()) as object
158
+ expect({
159
+ ...body,
160
+ challengeId: '[challengeId]',
161
+ instance: '[instance]',
162
+ }).toMatchInlineSnapshot(`
163
+ {
164
+ "challengeId": "[challengeId]",
165
+ "detail": "Challenge "wrong-id" is invalid: challenge was not issued by this server.",
166
+ "instance": "[instance]",
167
+ "status": 402,
168
+ "title": "Invalid Challenge",
169
+ "type": "https://paymentauth.org/problems/invalid-challenge",
170
+ }
171
+ `)
172
+ })
173
+
130
174
  test('captures each transport request once and threads the verified envelope additively', async () => {
131
175
  const requestMethod = Method.from({
132
176
  name: 'mock',
@@ -232,50 +276,6 @@ describe('request handler', () => {
232
276
  expect(receiptEnvelope?.challenge.id).toBe(credential.challenge.id)
233
277
  })
234
278
 
235
- test('returns 402 when challenge ID mismatch', async () => {
236
- const wrongChallenge = Challenge.from({
237
- id: 'wrong-id',
238
- intent: 'charge',
239
- method: 'tempo',
240
- realm,
241
- request: { amount: '1000', currency: asset, recipient: accounts[0].address },
242
- })
243
- const credential = Credential.from({
244
- challenge: wrongChallenge,
245
- payload: { signature: '0x123', type: 'transaction' },
246
- })
247
-
248
- const request = new Request('https://example.com/resource', {
249
- headers: { Authorization: Credential.serialize(credential) },
250
- })
251
-
252
- const result = await Mppx.create({ methods: [method], realm, secretKey }).charge({
253
- amount: '1000',
254
- currency: asset,
255
- expires: new Date(Date.now() + 60_000).toISOString(),
256
- recipient: accounts[0].address,
257
- })(request)
258
-
259
- expect(result.status).toBe(402)
260
- if (result.status !== 402) throw new Error()
261
-
262
- const body = (await result.challenge.json()) as object
263
- expect({
264
- ...body,
265
- challengeId: '[challengeId]',
266
- instance: '[instance]',
267
- }).toMatchInlineSnapshot(`
268
- {
269
- "challengeId": "[challengeId]",
270
- "detail": "Challenge "wrong-id" is invalid: challenge was not issued by this server.",
271
- "instance": "[instance]",
272
- "status": 402,
273
- "title": "Invalid Challenge",
274
- "type": "https://paymentauth.org/problems/invalid-challenge",
275
- }
276
- `)
277
- })
278
-
279
279
  test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => {
280
280
  const handler = Mppx.create({ methods: [method], realm, secretKey })
281
281
 
@@ -982,6 +982,103 @@ describe('compose', () => {
982
982
  expect(wwwAuth).toContain('method="beta"')
983
983
  })
984
984
 
985
+ test('filters compose challenges using Accept-Payment', async () => {
986
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
987
+
988
+ const result = await mppx.compose(
989
+ [alphaMethod, challengeOpts],
990
+ [betaMethod, challengeOpts],
991
+ )(
992
+ new Request('https://example.com/resource', {
993
+ headers: { 'Accept-Payment': 'beta/charge' },
994
+ }),
995
+ )
996
+
997
+ expect(result.status).toBe(402)
998
+ if (result.status !== 402) throw new Error()
999
+
1000
+ const challenges = Challenge.fromResponseList(result.challenge)
1001
+ expect(challenges).toHaveLength(1)
1002
+ expect(challenges[0]?.method).toBe('beta')
1003
+ })
1004
+
1005
+ test('orders compose challenges by Accept-Payment q-value', async () => {
1006
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1007
+
1008
+ const result = await mppx.compose(
1009
+ [alphaMethod, challengeOpts],
1010
+ [betaMethod, challengeOpts],
1011
+ )(
1012
+ new Request('https://example.com/resource', {
1013
+ headers: { 'Accept-Payment': 'beta/charge;q=0.9, alpha/charge;q=0.3' },
1014
+ }),
1015
+ )
1016
+
1017
+ expect(result.status).toBe(402)
1018
+ if (result.status !== 402) throw new Error()
1019
+
1020
+ const challenges = Challenge.fromResponseList(result.challenge)
1021
+ expect(challenges.map((challenge) => challenge.method)).toEqual(['beta', 'alpha'])
1022
+ })
1023
+
1024
+ test('applies a specific Accept-Payment opt-out before broader wildcards', async () => {
1025
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1026
+
1027
+ const result = await mppx.compose(
1028
+ [alphaMethod, challengeOpts],
1029
+ [betaMethod, challengeOpts],
1030
+ )(
1031
+ new Request('https://example.com/resource', {
1032
+ headers: { 'Accept-Payment': 'alpha/*;q=1, alpha/charge;q=0, beta/*;q=0.5' },
1033
+ }),
1034
+ )
1035
+
1036
+ expect(result.status).toBe(402)
1037
+ if (result.status !== 402) throw new Error()
1038
+
1039
+ const challenges = Challenge.fromResponseList(result.challenge)
1040
+ expect(challenges).toHaveLength(1)
1041
+ expect(challenges[0]?.method).toBe('beta')
1042
+ })
1043
+
1044
+ test('falls back to all compose challenges when Accept-Payment has no matches', async () => {
1045
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1046
+
1047
+ const result = await mppx.compose(
1048
+ [alphaMethod, challengeOpts],
1049
+ [betaMethod, challengeOpts],
1050
+ )(
1051
+ new Request('https://example.com/resource', {
1052
+ headers: { 'Accept-Payment': 'gamma/charge' },
1053
+ }),
1054
+ )
1055
+
1056
+ expect(result.status).toBe(402)
1057
+ if (result.status !== 402) throw new Error()
1058
+
1059
+ const challenges = Challenge.fromResponseList(result.challenge)
1060
+ expect(challenges.map((challenge) => challenge.method)).toEqual(['alpha', 'beta'])
1061
+ })
1062
+
1063
+ test('falls back to all compose challenges when Accept-Payment is invalid', async () => {
1064
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1065
+
1066
+ const result = await mppx.compose(
1067
+ [alphaMethod, challengeOpts],
1068
+ [betaMethod, challengeOpts],
1069
+ )(
1070
+ new Request('https://example.com/resource', {
1071
+ headers: { 'Accept-Payment': 'not a valid header' },
1072
+ }),
1073
+ )
1074
+
1075
+ expect(result.status).toBe(402)
1076
+ if (result.status !== 402) throw new Error()
1077
+
1078
+ const challenges = Challenge.fromResponseList(result.challenge)
1079
+ expect(challenges.map((challenge) => challenge.method)).toEqual(['alpha', 'beta'])
1080
+ })
1081
+
985
1082
  test('dispatches to matching handler when credential matches alpha', async () => {
986
1083
  const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
987
1084
 
@@ -5,8 +5,9 @@ import * as Challenge from '../Challenge.js'
5
5
  import * as Credential from '../Credential.js'
6
6
  import * as Errors from '../Errors.js'
7
7
  import * as Expires from '../Expires.js'
8
+ import * as AcceptPayment from '../internal/AcceptPayment.js'
8
9
  import * as Env from '../internal/env.js'
9
- import * as Method from '../Method.js'
10
+ import type * as Method from '../Method.js'
10
11
  import * as PaymentRequest from '../PaymentRequest.js'
11
12
  import type * as Receipt from '../Receipt.js'
12
13
  import type * as z from '../zod.js'
@@ -447,22 +448,22 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
447
448
  withReceipt<response>(response?: response) {
448
449
  if (managementResponse) {
449
450
  return transport.respondReceipt({
451
+ challengeId: credential.challenge.id,
450
452
  credential,
451
453
  envelope,
452
454
  input,
453
455
  receipt: receiptData,
454
456
  response: managementResponse as never,
455
- challengeId: credential.challenge.id,
456
457
  }) as response
457
458
  }
458
459
  if (!response) throw new Error('withReceipt() requires a response argument')
459
460
  return transport.respondReceipt({
461
+ challengeId: credential.challenge.id,
460
462
  credential,
461
463
  envelope,
462
464
  input,
463
465
  receipt: receiptData,
464
466
  response: response as never,
465
- challengeId: credential.challenge.id,
466
467
  }) as response
467
468
  },
468
469
  }
@@ -883,38 +884,58 @@ export function compose(
883
884
  // No credential — call all handlers and merge 402 challenges.
884
885
  const results = await Promise.all(handlers.map((h) => h(input)))
885
886
 
886
- // Merge WWW-Authenticate headers from all 402 responses.
887
- const mergedHeaders = new Headers()
888
- mergedHeaders.set('Cache-Control', 'no-store')
889
-
890
- for (const result of results) {
891
- if (result.status !== 402) continue
892
- const response = result.challenge as Response
893
- const wwwAuth = response.headers.get('WWW-Authenticate')
894
- if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth)
895
- }
896
-
897
- // Collect html-enabled handlers and their challenges
898
- const htmlEntries = (() => {
887
+ const challengeEntries = (() => {
899
888
  const entries: {
900
889
  handler: ConfiguredHandler
901
890
  challenge: Challenge.Challenge
891
+ result: Extract<MethodFn.Response<Transport.Http>, { status: 402 }>
902
892
  }[] = []
893
+
903
894
  for (let i = 0; i < handlers.length; i++) {
904
- const meta = (handlers[i] as ConfiguredHandler)._internal
905
- if (!meta?.html) continue
906
895
  const result = results[i]
907
896
  if (result?.status !== 402) continue
908
- const wwwAuth = result.challenge.headers.get('WWW-Authenticate')
897
+
898
+ const response = result.challenge as Response
899
+ const wwwAuth = response.headers.get('WWW-Authenticate')
909
900
  if (!wwwAuth) continue
901
+
910
902
  entries.push({
911
903
  handler: handlers[i] as ConfiguredHandler,
912
904
  challenge: Challenge.deserialize(wwwAuth),
905
+ result,
913
906
  })
914
907
  }
915
- return entries
908
+
909
+ const acceptPayment = input.headers.get('Accept-Payment')
910
+ if (!acceptPayment) return entries
911
+
912
+ try {
913
+ const ranked = AcceptPayment.rank(
914
+ entries.map((entry) => entry.challenge),
915
+ AcceptPayment.parse(acceptPayment),
916
+ )
917
+ if (ranked.length === 0) return entries
918
+
919
+ const entriesById = new Map(entries.map((entry) => [entry.challenge.id, entry] as const))
920
+ return ranked.map((challenge) => entriesById.get(challenge.id)!)
921
+ } catch {
922
+ return entries
923
+ }
916
924
  })()
917
925
 
926
+ // Merge WWW-Authenticate headers from all 402 responses.
927
+ const mergedHeaders = new Headers()
928
+ mergedHeaders.set('Cache-Control', 'no-store')
929
+
930
+ for (const entry of challengeEntries) {
931
+ const response = entry.result.challenge as Response
932
+ const wwwAuth = response.headers.get('WWW-Authenticate')
933
+ if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth)
934
+ }
935
+
936
+ // Collect html-enabled handlers and their challenges
937
+ const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html)
938
+
918
939
  const wantsHtml = input.headers.get('Accept')?.includes('text/html')
919
940
  if (wantsHtml && htmlEntries.length > 0) {
920
941
  const { theme, text } = Html.resolveOptions(
@@ -962,10 +983,9 @@ export function compose(
962
983
 
963
984
  // Non-HTML fallback: use first handler's body
964
985
  let body: string | null = null
965
- for (const result of results) {
966
- if (result.status !== 402) continue
986
+ for (const entry of challengeEntries) {
967
987
  if (!body) {
968
- const response = result.challenge as Response
988
+ const response = entry.result.challenge as Response
969
989
  const contentType = response.headers.get('Content-Type')
970
990
  if (contentType) mergedHeaders.set('Content-Type', contentType)
971
991
  body = await response.text()
@@ -269,6 +269,26 @@ describe('http', () => {
269
269
  expect(body).toContain('Gotta Pay')
270
270
  })
271
271
 
272
+ test('uses paymentRequired as the title when title is omitted', async () => {
273
+ const transport = Transport.http()
274
+ const request = new Request('https://example.com', {
275
+ headers: { Accept: 'text/html' },
276
+ })
277
+
278
+ const response = await transport.respondChallenge({
279
+ challenge,
280
+ input: request,
281
+ html: {
282
+ ...htmlOptions,
283
+ text: { paymentRequired: 'Gotta Pay' },
284
+ },
285
+ })
286
+
287
+ const body = await response.text()
288
+ expect(body).toContain('<title>Gotta Pay</title>')
289
+ expect(body).toContain('<span>Gotta Pay</span>')
290
+ })
291
+
272
292
  test('applies custom theme logo', async () => {
273
293
  const transport = Transport.http()
274
294
  const request = new Request('https://example.com', {
@@ -172,7 +172,15 @@ export function resolveOptions(options: Options): {
172
172
  },
173
173
  (options.theme as never) ?? {},
174
174
  )
175
- const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {}))
175
+ const textOverrides = (options.text as Text | undefined) ?? undefined
176
+ const mergedText = mergeDefined(defaultText, (textOverrides as never) ?? {})
177
+ const text = sanitizeRecord({
178
+ ...mergedText,
179
+ title:
180
+ typeof textOverrides?.title === 'string' && textOverrides.title.length > 0
181
+ ? mergedText.title
182
+ : mergedText.paymentRequired,
183
+ })
176
184
  return { theme, text }
177
185
  }
178
186
 
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Stripe API version with `.preview` suffix.
3
+ *
4
+ * Required for `shared_payment_granted_token` (SPTs are in private preview).
5
+ * Bump this when upgrading to a newer Stripe API version.
6
+ */
7
+ export const stripePreviewVersion = '2026-02-25.preview'
@@ -5,6 +5,7 @@ import type { LooseOmit, OneOf } from '../../internal/types.js'
5
5
  import * as Method from '../../Method.js'
6
6
  import type * as Html from '../../server/internal/html/config.ts'
7
7
  import type * as z from '../../zod.js'
8
+ import { stripePreviewVersion } from '../internal/constants.js'
8
9
  import type {
9
10
  StripeClient,
10
11
  CreatePaymentMethodFromElements,
@@ -202,13 +203,16 @@ async function createWithClient(parameters: {
202
203
  // `shared_payment_granted_token` is not yet in the Stripe SDK types (SPTs are in private preview).
203
204
  shared_payment_granted_token: spt,
204
205
  } as any,
205
- { idempotencyKey: `mppx_${challenge.id}_${spt}` },
206
+ { idempotencyKey: `mppx_${challenge.id}_${spt}`, apiVersion: stripePreviewVersion },
206
207
  )
207
208
  // https://docs.stripe.com/error-low-level#idempotency
208
209
  const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true'
209
210
  return { id: result.id, status: result.status, replayed }
210
- } catch {
211
- throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
211
+ } catch (error) {
212
+ const detail = error instanceof Error ? error.message : String(error)
213
+ throw new VerificationFailedError({
214
+ reason: `Stripe PaymentIntent failed: ${detail}`,
215
+ })
212
216
  }
213
217
  }
214
218
 
@@ -240,11 +244,25 @@ async function createWithSecretKey(parameters: {
240
244
  Authorization: `Basic ${btoa(`${secretKey}:`)}`,
241
245
  'Content-Type': 'application/x-www-form-urlencoded',
242
246
  'Idempotency-Key': `mppx_${challenge.id}_${spt}`,
247
+ 'Stripe-Version': stripePreviewVersion,
243
248
  },
244
249
  body,
245
250
  })
246
251
 
247
- if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
252
+ if (!response.ok) {
253
+ const body = await response.text().catch(() => '')
254
+ const detail = (() => {
255
+ try {
256
+ const parsed = JSON.parse(body) as { error?: { message?: string } }
257
+ return parsed.error?.message ?? body
258
+ } catch {
259
+ return body
260
+ }
261
+ })()
262
+ throw new VerificationFailedError({
263
+ reason: `Stripe PaymentIntent failed: ${detail}`,
264
+ })
265
+ }
248
266
  // https://docs.stripe.com/error-low-level#idempotency
249
267
  const replayed = response.headers.get('idempotent-replayed') === 'true'
250
268
  const result = (await response.json()) as { id: string; status: string }
@@ -39,6 +39,31 @@ describe('charge', () => {
39
39
  expect(result.success).toBe(true)
40
40
  })
41
41
 
42
+ test('schema: validates request with supportedModes', () => {
43
+ const result = Methods.charge.schema.request.safeParse({
44
+ amount: '1',
45
+ currency: '0x20c0000000000000000000000000000000000001',
46
+ decimals: 6,
47
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
48
+ supportedModes: ['pull'],
49
+ })
50
+ expect(result.success).toBe(true)
51
+ if (!result.success) return
52
+
53
+ expect(result.data.methodDetails?.supportedModes).toEqual(['pull'])
54
+ })
55
+
56
+ test('schema: rejects empty supportedModes', () => {
57
+ const result = Methods.charge.schema.request.safeParse({
58
+ amount: '1',
59
+ currency: '0x20c0000000000000000000000000000000000001',
60
+ decimals: 6,
61
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
62
+ supportedModes: [],
63
+ })
64
+ expect(result.success).toBe(false)
65
+ })
66
+
42
67
  test('schema: validates request with memo', () => {
43
68
  const result = Methods.charge.schema.request.safeParse({
44
69
  amount: '1',
@@ -4,6 +4,9 @@ import { parseUnits } from 'viem'
4
4
  import * as Method from '../Method.js'
5
5
  import * as z from '../zod.js'
6
6
 
7
+ export const chargeModes = ['push', 'pull'] as const
8
+ export type ChargeMode = (typeof chargeModes)[number]
9
+
7
10
  const split = z.object({
8
11
  amount: z.amount(),
9
12
  memo: z.optional(z.hash()),
@@ -47,6 +50,7 @@ export const charge = Method.from({
47
50
  memo: z.optional(z.hash()),
48
51
  recipient: z.optional(z.string()),
49
52
  splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(10))),
53
+ supportedModes: z.optional(z.array(z.enum(chargeModes)).check(z.minLength(1))),
50
54
  })
51
55
  .check(
52
56
  z.refine(({ amount, decimals, splits }) => {
@@ -64,28 +68,32 @@ export const charge = Method.from({
64
68
  )
65
69
  }, 'Invalid splits'),
66
70
  ),
67
- z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
68
- ...rest,
69
- amount: parseUnits(amount, decimals).toString(),
70
- ...(chainId !== undefined ||
71
- feePayer !== undefined ||
72
- memo !== undefined ||
73
- splits !== undefined
74
- ? {
75
- methodDetails: {
76
- ...(chainId !== undefined && { chainId }),
77
- ...(feePayer !== undefined && { feePayer }),
78
- ...(memo !== undefined && { memo }),
79
- ...(splits !== undefined && {
80
- splits: splits.map((split) => ({
81
- ...split,
82
- amount: parseUnits(split.amount, decimals).toString(),
83
- })),
84
- }),
85
- },
86
- }
87
- : {}),
88
- })),
71
+ z.transform(
72
+ ({ amount, chainId, decimals, feePayer, memo, splits, supportedModes, ...rest }) => ({
73
+ ...rest,
74
+ amount: parseUnits(amount, decimals).toString(),
75
+ ...(chainId !== undefined ||
76
+ feePayer !== undefined ||
77
+ memo !== undefined ||
78
+ splits !== undefined ||
79
+ supportedModes !== undefined
80
+ ? {
81
+ methodDetails: {
82
+ ...(chainId !== undefined && { chainId }),
83
+ ...(feePayer !== undefined && { feePayer }),
84
+ ...(memo !== undefined && { memo }),
85
+ ...(splits !== undefined && {
86
+ splits: splits.map((split) => ({
87
+ ...split,
88
+ amount: parseUnits(split.amount, decimals).toString(),
89
+ })),
90
+ }),
91
+ ...(supportedModes !== undefined && { supportedModes }),
92
+ },
93
+ }
94
+ : {}),
95
+ }),
96
+ ),
89
97
  ),
90
98
  },
91
99
  })
@@ -0,0 +1,13 @@
1
+ import { expectTypeOf, test } from 'vp/test'
2
+
3
+ import { Proof } from './index.js'
4
+
5
+ test('Proof exports public proof source helpers', () => {
6
+ expectTypeOf(Proof.proofSource).toEqualTypeOf<
7
+ (parameters: { address: string; chainId: number }) => string
8
+ >()
9
+
10
+ expectTypeOf(Proof.parseProofSource).toEqualTypeOf<
11
+ (source: string) => { address: `0x${string}`; chainId: number } | null
12
+ >()
13
+ })
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as tempo from './index.js'
4
+
5
+ describe('tempo.Proof', () => {
6
+ test('proofSource constructs a did:pkh:eip155 source', () => {
7
+ expect(
8
+ tempo.Proof.proofSource({
9
+ address: '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12',
10
+ chainId: 42431,
11
+ }),
12
+ ).toBe('did:pkh:eip155:42431:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12')
13
+ })
14
+
15
+ test('parseProofSource parses a valid did:pkh:eip155 source', () => {
16
+ expect(
17
+ tempo.Proof.parseProofSource(
18
+ 'did:pkh:eip155:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
19
+ ),
20
+ ).toEqual({
21
+ address: '0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
22
+ chainId: 42431,
23
+ })
24
+ })
25
+
26
+ test('parseProofSource rejects invalid source values', () => {
27
+ expect(
28
+ tempo.Proof.parseProofSource('did:pkh:eip155:01:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141'),
29
+ ).toBe(null)
30
+ })
31
+ })
@@ -0,0 +1,13 @@
1
+ import type { Address } from 'viem'
2
+
3
+ import * as Proof_internal from './internal/proof.js'
4
+
5
+ /** Constructs the canonical `did:pkh:eip155` source DID for Tempo proof credentials. */
6
+ export function proofSource(parameters: { address: string; chainId: number }): string {
7
+ return Proof_internal.proofSource(parameters)
8
+ }
9
+
10
+ /** Parses a Tempo proof credential source DID into its chain ID and wallet address. */
11
+ export function parseProofSource(source: string): { address: Address; chainId: number } | null {
12
+ return Proof_internal.parseProofSource(source)
13
+ }
@@ -47,7 +47,7 @@ export function charge(parameters: charge.Parameters = {}) {
47
47
  context: z.object({
48
48
  account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
49
49
  autoSwap: z.optional(z.custom<charge.AutoSwap>()),
50
- mode: z.optional(z.enum(['push', 'pull'])),
50
+ mode: z.optional(z.enum(Methods.chargeModes)),
51
51
  }),
52
52
 
53
53
  async createCredential({ challenge, context }) {
@@ -74,11 +74,7 @@ export function charge(parameters: charge.Parameters = {}) {
74
74
  })
75
75
  }
76
76
 
77
- const mode =
78
- context?.mode ?? parameters.mode ?? (account.type === 'json-rpc' ? 'push' : 'pull')
79
-
80
77
  const currency = request.currency as Address
81
-
82
78
  if (parameters.expectedRecipients) {
83
79
  const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase()))
84
80
  const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined
@@ -89,6 +85,21 @@ export function charge(parameters: charge.Parameters = {}) {
89
85
  }
90
86
  }
91
87
  }
88
+ const supportedModes = (methodDetails?.supportedModes as
89
+ | readonly Methods.ChargeMode[]
90
+ | undefined) ?? ['pull', 'push']
91
+ const mode = (() => {
92
+ const explicitMode = context?.mode ?? parameters.mode
93
+ if (explicitMode) {
94
+ if (!supportedModes.includes(explicitMode))
95
+ throw new Error(`Challenge does not support ${explicitMode} mode.`)
96
+ return explicitMode
97
+ }
98
+
99
+ const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull'
100
+ if (supportedModes.includes(preferredMode)) return preferredMode
101
+ return supportedModes[0]!
102
+ })()
92
103
 
93
104
  const memo = methodDetails?.memo
94
105
  ? (methodDetails.memo as Hex.Hex)
@@ -193,9 +204,12 @@ export declare namespace charge {
193
204
  * - `'push'`: Client broadcasts the transaction and sends the tx hash to the server.
194
205
  * - `'pull'`: Client signs the transaction and sends the serialized tx to the server for broadcast.
195
206
  *
207
+ * If the server advertises `supportedModes`, this setting must be one of
208
+ * the supported values for the challenge.
209
+ *
196
210
  * @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts.
197
211
  */
198
- mode?: 'push' | 'pull' | undefined
212
+ mode?: Methods.ChargeMode | undefined
199
213
  } & Account.getResolver.Parameters &
200
214
  Client.getResolver.Parameters
201
215
  }