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.
- package/CHANGELOG.md +16 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +41 -16
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/config.d.ts +6 -4
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/internal.d.ts +8 -0
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js +33 -3
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/plugin.d.ts +2 -0
- package/dist/cli/plugins/plugin.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +3 -0
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -0
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts +10 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +17 -5
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts +2 -0
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -0
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +3 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +65 -19
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts +72 -0
- package/dist/internal/AcceptPayment.d.ts.map +1 -0
- package/dist/internal/AcceptPayment.js +185 -0
- package/dist/internal/AcceptPayment.js.map +1 -0
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +8 -4
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +33 -24
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +8 -1
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/stripe/internal/constants.d.ts +8 -0
- package/dist/stripe/internal/constants.d.ts.map +1 -0
- package/dist/stripe/internal/constants.js +8 -0
- package/dist/stripe/internal/constants.js.map +1 -0
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +23 -5
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +8 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +6 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/Proof.d.ts +12 -0
- package/dist/tempo/Proof.d.ts.map +1 -0
- package/dist/tempo/Proof.js +10 -0
- package/dist/tempo/Proof.js.map +1 -0
- package/dist/tempo/client/Charge.d.ts +11 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +14 -2
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +6 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +29 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +69 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +6 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/cli.test.ts +278 -0
- package/src/cli/cli.ts +47 -16
- package/src/cli/config.ts +10 -4
- package/src/cli/internal.ts +59 -3
- package/src/cli/plugins/plugin.ts +3 -0
- package/src/cli/plugins/stripe.ts +3 -0
- package/src/cli/plugins/tempo.ts +3 -0
- package/src/client/Mppx.test-d.ts +33 -0
- package/src/client/Mppx.test.ts +130 -1
- package/src/client/Mppx.ts +35 -5
- package/src/client/Transport.test.ts +88 -55
- package/src/client/Transport.ts +13 -0
- package/src/client/internal/Fetch.browser.test.ts +16 -13
- package/src/client/internal/Fetch.test.ts +307 -10
- package/src/client/internal/Fetch.ts +85 -19
- package/src/internal/AcceptPayment.test.ts +211 -0
- package/src/internal/AcceptPayment.ts +304 -0
- package/src/mcp-sdk/client/McpClient.ts +11 -5
- package/src/server/Mppx.test.ts +141 -44
- package/src/server/Mppx.ts +43 -23
- package/src/server/Transport.test.ts +20 -0
- package/src/server/internal/html/config.ts +9 -1
- package/src/stripe/internal/constants.ts +7 -0
- package/src/stripe/server/Charge.ts +22 -4
- package/src/tempo/Methods.test.ts +25 -0
- package/src/tempo/Methods.ts +30 -22
- package/src/tempo/Proof.test-d.ts +13 -0
- package/src/tempo/Proof.test.ts +31 -0
- package/src/tempo/Proof.ts +13 -0
- package/src/tempo/client/Charge.ts +20 -6
- package/src/tempo/client/SessionManager.test.ts +4 -7
- package/src/tempo/index.ts +1 -0
- package/src/tempo/internal/fee-payer.test.ts +75 -1
- package/src/tempo/internal/fee-payer.ts +41 -3
- package/src/tempo/server/Charge.test.ts +309 -1
- package/src/tempo/server/Charge.ts +99 -1
- package/src/tempo/server/internal/html/main.ts +2 -2
- package/src/tempo/server/internal/html.gen.ts +1 -1
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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
|
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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)
|
|
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',
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -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(
|
|
68
|
-
...rest
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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?:
|
|
212
|
+
mode?: Methods.ChargeMode | undefined
|
|
199
213
|
} & Account.getResolver.Parameters &
|
|
200
214
|
Client.getResolver.Parameters
|
|
201
215
|
}
|