mppx 0.6.19 → 0.6.21
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 +14 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- package/dist/client/Mppx.d.ts +12 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +127 -10
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +69 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +250 -20
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +2 -1
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +96 -5
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +739 -115
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.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/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.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/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- package/src/client/Mppx.test-d.ts +55 -0
- package/src/client/Mppx.test.ts +181 -0
- package/src/client/Mppx.ts +248 -16
- package/src/client/internal/Fetch.test-d.ts +31 -0
- package/src/client/internal/Fetch.test.ts +261 -0
- package/src/client/internal/Fetch.ts +467 -24
- package/src/middlewares/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/internal/mppx.ts +5 -6
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.test.ts +69 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +73 -1
- package/src/server/Mppx.test.ts +965 -3
- package/src/server/Mppx.ts +1138 -140
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- package/src/zod.ts +24 -0
package/src/server/Mppx.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http'
|
|
2
2
|
|
|
3
|
-
import { Challenge, Credential, Method, z } from 'mppx'
|
|
3
|
+
import { Challenge, Credential, Errors, Method, Receipt, z } from 'mppx'
|
|
4
4
|
import {
|
|
5
5
|
Mppx as Mppx_client,
|
|
6
6
|
session as tempo_session_client,
|
|
@@ -914,6 +914,893 @@ describe('receipt handling', () => {
|
|
|
914
914
|
})
|
|
915
915
|
})
|
|
916
916
|
|
|
917
|
+
describe('server events', () => {
|
|
918
|
+
const eventCharge = Method.from({
|
|
919
|
+
name: 'mock',
|
|
920
|
+
intent: 'charge',
|
|
921
|
+
schema: {
|
|
922
|
+
credential: {
|
|
923
|
+
payload: z.object({ token: z.string() }),
|
|
924
|
+
},
|
|
925
|
+
request: z.object({
|
|
926
|
+
amount: z.string(),
|
|
927
|
+
currency: z.string(),
|
|
928
|
+
decimals: z.number(),
|
|
929
|
+
recipient: z.string(),
|
|
930
|
+
}),
|
|
931
|
+
},
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
function options() {
|
|
935
|
+
return {
|
|
936
|
+
amount: '1000',
|
|
937
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
938
|
+
decimals: 6,
|
|
939
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
940
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function receipt(reference = 'tx-events') {
|
|
945
|
+
return {
|
|
946
|
+
method: 'mock',
|
|
947
|
+
reference,
|
|
948
|
+
status: 'success' as const,
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
test('emits challenge then success events for a successful request', async () => {
|
|
954
|
+
const events: string[] = []
|
|
955
|
+
const seen: Record<string, unknown> = {}
|
|
956
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
957
|
+
async verify() {
|
|
958
|
+
return receipt()
|
|
959
|
+
},
|
|
960
|
+
})
|
|
961
|
+
const handler = Mppx.create({
|
|
962
|
+
methods: [serverMethod],
|
|
963
|
+
realm,
|
|
964
|
+
secretKey,
|
|
965
|
+
})
|
|
966
|
+
handler.onChallengeCreated((context) => {
|
|
967
|
+
events.push(`challenge:${context.error?.name}`)
|
|
968
|
+
seen.challengeMethod = context.method.name
|
|
969
|
+
seen.challengeMethodKeys = Object.keys(context.method)
|
|
970
|
+
seen.challengePath = context.capturedRequest?.url.pathname
|
|
971
|
+
seen.challengeAmount = context.request.amount
|
|
972
|
+
seen.challengeCredential = context.credential
|
|
973
|
+
})
|
|
974
|
+
handler.onPaymentSuccess((context) => {
|
|
975
|
+
events.push(`payment:${context.receipt.reference}`)
|
|
976
|
+
seen.paymentMethod = context.method.name
|
|
977
|
+
seen.paymentChallenge = context.challenge.id
|
|
978
|
+
seen.paymentEnvelope = context.envelope?.challenge.id
|
|
979
|
+
seen.paymentToken = context.credential?.payload.token
|
|
980
|
+
seen.paymentAmount = context.request.amount
|
|
981
|
+
})
|
|
982
|
+
handler.onPaymentFailed(() => {
|
|
983
|
+
events.push('failed')
|
|
984
|
+
})
|
|
985
|
+
const handle = handler.charge(options())
|
|
986
|
+
|
|
987
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
988
|
+
expect(first.status).toBe(402)
|
|
989
|
+
if (first.status !== 402) throw new Error()
|
|
990
|
+
|
|
991
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
992
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
993
|
+
const paid = await handle(
|
|
994
|
+
new Request('https://example.com/resource', {
|
|
995
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
996
|
+
}),
|
|
997
|
+
)
|
|
998
|
+
expect(paid.status).toBe(200)
|
|
999
|
+
if (paid.status !== 200) throw new Error()
|
|
1000
|
+
|
|
1001
|
+
const response = paid.withReceipt(Response.json({ ok: true }))
|
|
1002
|
+
|
|
1003
|
+
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
|
|
1004
|
+
expect(events).toEqual(['challenge:PaymentRequiredError', 'payment:tx-events'])
|
|
1005
|
+
expect(seen).toMatchObject({
|
|
1006
|
+
challengeAmount: '1000',
|
|
1007
|
+
challengeCredential: null,
|
|
1008
|
+
challengeMethod: 'mock',
|
|
1009
|
+
challengeMethodKeys: ['intent', 'name'],
|
|
1010
|
+
challengePath: '/resource',
|
|
1011
|
+
paymentAmount: '1000',
|
|
1012
|
+
paymentChallenge: challenge.id,
|
|
1013
|
+
paymentEnvelope: challenge.id,
|
|
1014
|
+
paymentMethod: 'mock',
|
|
1015
|
+
paymentToken: 'valid',
|
|
1016
|
+
})
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
test('does not let server event errors alter payment control flow', async () => {
|
|
1020
|
+
const events: string[] = []
|
|
1021
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1022
|
+
async verify() {
|
|
1023
|
+
return receipt('tx-event-error')
|
|
1024
|
+
},
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
const handler = Mppx.create({
|
|
1028
|
+
methods: [serverMethod],
|
|
1029
|
+
realm,
|
|
1030
|
+
secretKey,
|
|
1031
|
+
})
|
|
1032
|
+
handler.on('*', async () => {
|
|
1033
|
+
events.push('*')
|
|
1034
|
+
throw new Error('catchall event failed')
|
|
1035
|
+
})
|
|
1036
|
+
handler.onChallengeCreated(() => {
|
|
1037
|
+
events.push('challenge.created')
|
|
1038
|
+
throw new Error('challenge event failed')
|
|
1039
|
+
})
|
|
1040
|
+
handler.onPaymentFailed(async () => {
|
|
1041
|
+
events.push('failed')
|
|
1042
|
+
throw new Error('failed event failed')
|
|
1043
|
+
})
|
|
1044
|
+
handler.onPaymentSuccess(async () => {
|
|
1045
|
+
events.push('success')
|
|
1046
|
+
throw new Error('success event failed')
|
|
1047
|
+
})
|
|
1048
|
+
const handle = handler.charge(options())
|
|
1049
|
+
|
|
1050
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1051
|
+
expect(first.status).toBe(402)
|
|
1052
|
+
if (first.status !== 402) throw new Error()
|
|
1053
|
+
|
|
1054
|
+
const invalid = await handle(
|
|
1055
|
+
new Request('https://example.com/resource', {
|
|
1056
|
+
headers: {
|
|
1057
|
+
Authorization: Credential.serialize(
|
|
1058
|
+
Credential.from({
|
|
1059
|
+
challenge: Challenge.from({
|
|
1060
|
+
id: 'wrong-id',
|
|
1061
|
+
intent: 'charge',
|
|
1062
|
+
method: 'mock',
|
|
1063
|
+
realm,
|
|
1064
|
+
request: {
|
|
1065
|
+
amount: '1000',
|
|
1066
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1067
|
+
decimals: 6,
|
|
1068
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1069
|
+
},
|
|
1070
|
+
}),
|
|
1071
|
+
payload: { token: 'valid' },
|
|
1072
|
+
}),
|
|
1073
|
+
),
|
|
1074
|
+
},
|
|
1075
|
+
}),
|
|
1076
|
+
)
|
|
1077
|
+
expect(invalid.status).toBe(402)
|
|
1078
|
+
|
|
1079
|
+
const paid = await handle(
|
|
1080
|
+
new Request('https://example.com/resource', {
|
|
1081
|
+
headers: {
|
|
1082
|
+
Authorization: Credential.serialize(
|
|
1083
|
+
Credential.from({
|
|
1084
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1085
|
+
payload: { token: 'valid' },
|
|
1086
|
+
}),
|
|
1087
|
+
),
|
|
1088
|
+
},
|
|
1089
|
+
}),
|
|
1090
|
+
)
|
|
1091
|
+
expect(paid.status).toBe(200)
|
|
1092
|
+
if (paid.status !== 200) throw new Error()
|
|
1093
|
+
expect(paid.withReceipt(Response.json({ ok: true })).status).toBe(200)
|
|
1094
|
+
|
|
1095
|
+
expect(events).toEqual([
|
|
1096
|
+
'challenge.created',
|
|
1097
|
+
'*',
|
|
1098
|
+
'failed',
|
|
1099
|
+
'*',
|
|
1100
|
+
'challenge.created',
|
|
1101
|
+
'*',
|
|
1102
|
+
'success',
|
|
1103
|
+
'*',
|
|
1104
|
+
])
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
test('continues dispatching later listeners after an earlier listener throws', async () => {
|
|
1108
|
+
const events: string[] = []
|
|
1109
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1110
|
+
async verify() {
|
|
1111
|
+
return receipt('tx-listener-isolation')
|
|
1112
|
+
},
|
|
1113
|
+
})
|
|
1114
|
+
const handler = Mppx.create({
|
|
1115
|
+
methods: [serverMethod],
|
|
1116
|
+
realm,
|
|
1117
|
+
secretKey,
|
|
1118
|
+
})
|
|
1119
|
+
handler.onPaymentSuccess(() => {
|
|
1120
|
+
events.push('first')
|
|
1121
|
+
throw new Error('listener failed')
|
|
1122
|
+
})
|
|
1123
|
+
handler.onPaymentSuccess(() => {
|
|
1124
|
+
events.push('second')
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
const handle = handler.charge(options())
|
|
1128
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1129
|
+
expect(first.status).toBe(402)
|
|
1130
|
+
if (first.status !== 402) throw new Error()
|
|
1131
|
+
|
|
1132
|
+
const paid = await handle(
|
|
1133
|
+
new Request('https://example.com/resource', {
|
|
1134
|
+
headers: {
|
|
1135
|
+
Authorization: Credential.serialize(
|
|
1136
|
+
Credential.from({
|
|
1137
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1138
|
+
payload: { token: 'valid' },
|
|
1139
|
+
}),
|
|
1140
|
+
),
|
|
1141
|
+
},
|
|
1142
|
+
}),
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
expect(paid.status).toBe(200)
|
|
1146
|
+
expect(events).toEqual(['first', 'second'])
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
test('supports canonical event names and generated registration methods', async () => {
|
|
1150
|
+
const events: string[] = []
|
|
1151
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1152
|
+
async verify() {
|
|
1153
|
+
return receipt('tx-registered-event')
|
|
1154
|
+
},
|
|
1155
|
+
})
|
|
1156
|
+
const handler = Mppx.create({
|
|
1157
|
+
methods: [serverMethod],
|
|
1158
|
+
realm,
|
|
1159
|
+
secretKey,
|
|
1160
|
+
})
|
|
1161
|
+
const offFailed = handler.on('payment.failed', (context) => {
|
|
1162
|
+
events.push(`failed:${context.error.name}`)
|
|
1163
|
+
})
|
|
1164
|
+
const offChallengeCreated = handler.on('challenge.created', (context) => {
|
|
1165
|
+
events.push(`runtime:${context.error?.name}`)
|
|
1166
|
+
})
|
|
1167
|
+
const offAll = handler.on('*', (event) => {
|
|
1168
|
+
events.push(`runtime:*:${event.name}`)
|
|
1169
|
+
})
|
|
1170
|
+
const offSuccess = handler.onPaymentSuccess((context) => {
|
|
1171
|
+
events.push(`success:${context.receipt.reference}`)
|
|
1172
|
+
})
|
|
1173
|
+
offFailed()
|
|
1174
|
+
|
|
1175
|
+
const handle = handler.charge(options())
|
|
1176
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1177
|
+
expect(first.status).toBe(402)
|
|
1178
|
+
if (first.status !== 402) throw new Error()
|
|
1179
|
+
offChallengeCreated()
|
|
1180
|
+
offAll()
|
|
1181
|
+
|
|
1182
|
+
const badCredential = Credential.from({
|
|
1183
|
+
challenge: Challenge.from({
|
|
1184
|
+
id: 'wrong-id',
|
|
1185
|
+
intent: 'charge',
|
|
1186
|
+
method: 'mock',
|
|
1187
|
+
realm,
|
|
1188
|
+
request: {
|
|
1189
|
+
amount: '1000',
|
|
1190
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1191
|
+
decimals: 6,
|
|
1192
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1193
|
+
},
|
|
1194
|
+
}),
|
|
1195
|
+
payload: { token: 'valid' },
|
|
1196
|
+
})
|
|
1197
|
+
const invalid = await handle(
|
|
1198
|
+
new Request('https://example.com/resource', {
|
|
1199
|
+
headers: { Authorization: Credential.serialize(badCredential) },
|
|
1200
|
+
}),
|
|
1201
|
+
)
|
|
1202
|
+
expect(invalid.status).toBe(402)
|
|
1203
|
+
|
|
1204
|
+
const paid = await handle(
|
|
1205
|
+
new Request('https://example.com/resource', {
|
|
1206
|
+
headers: {
|
|
1207
|
+
Authorization: Credential.serialize(
|
|
1208
|
+
Credential.from({
|
|
1209
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1210
|
+
payload: { token: 'valid' },
|
|
1211
|
+
}),
|
|
1212
|
+
),
|
|
1213
|
+
},
|
|
1214
|
+
}),
|
|
1215
|
+
)
|
|
1216
|
+
expect(paid.status).toBe(200)
|
|
1217
|
+
offSuccess()
|
|
1218
|
+
|
|
1219
|
+
expect(events).toEqual([
|
|
1220
|
+
'runtime:PaymentRequiredError',
|
|
1221
|
+
'runtime:*:challenge.created',
|
|
1222
|
+
'success:tx-registered-event',
|
|
1223
|
+
])
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
test('emits payment failure before reissuing challenge for invalid credentials', async () => {
|
|
1227
|
+
const events: string[] = []
|
|
1228
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1229
|
+
async verify() {
|
|
1230
|
+
return receipt()
|
|
1231
|
+
},
|
|
1232
|
+
})
|
|
1233
|
+
const handler = Mppx.create({
|
|
1234
|
+
methods: [serverMethod],
|
|
1235
|
+
realm,
|
|
1236
|
+
secretKey,
|
|
1237
|
+
})
|
|
1238
|
+
handler.onChallengeCreated((context) => {
|
|
1239
|
+
events.push(`challenge:${context.error?.name}`)
|
|
1240
|
+
})
|
|
1241
|
+
handler.onPaymentSuccess((context) => {
|
|
1242
|
+
events.push(`payment:${context.receipt.reference}`)
|
|
1243
|
+
})
|
|
1244
|
+
handler.onPaymentFailed((context) => {
|
|
1245
|
+
events.push(`failed:${context.error.name}:${context.credential?.challenge.id}`)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
const badChallenge = Challenge.from({
|
|
1249
|
+
id: 'wrong-id',
|
|
1250
|
+
intent: 'charge',
|
|
1251
|
+
method: 'mock',
|
|
1252
|
+
realm,
|
|
1253
|
+
request: {
|
|
1254
|
+
amount: '1000',
|
|
1255
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1256
|
+
decimals: 6,
|
|
1257
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1258
|
+
},
|
|
1259
|
+
})
|
|
1260
|
+
const credential = Credential.from({
|
|
1261
|
+
challenge: badChallenge,
|
|
1262
|
+
payload: { token: 'valid' },
|
|
1263
|
+
})
|
|
1264
|
+
|
|
1265
|
+
const result = await handler.charge(options())(
|
|
1266
|
+
new Request('https://example.com/resource', {
|
|
1267
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1268
|
+
}),
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
expect(result.status).toBe(402)
|
|
1272
|
+
expect(events).toEqual([
|
|
1273
|
+
'failed:InvalidChallengeError:wrong-id',
|
|
1274
|
+
'challenge:InvalidChallengeError',
|
|
1275
|
+
])
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
test('emits payment failure for malformed credentials with no parsed credential', async () => {
|
|
1279
|
+
const events: string[] = []
|
|
1280
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1281
|
+
async verify() {
|
|
1282
|
+
return receipt()
|
|
1283
|
+
},
|
|
1284
|
+
})
|
|
1285
|
+
const handler = Mppx.create({
|
|
1286
|
+
methods: [serverMethod],
|
|
1287
|
+
realm,
|
|
1288
|
+
secretKey,
|
|
1289
|
+
})
|
|
1290
|
+
handler.onChallengeCreated((context) => {
|
|
1291
|
+
events.push(`challenge:${context.error?.name}:${context.credential}`)
|
|
1292
|
+
})
|
|
1293
|
+
handler.onPaymentFailed((context) => {
|
|
1294
|
+
events.push(`failed:${context.error.name}:${context.credential}`)
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
const result = await handler.charge(options())(
|
|
1298
|
+
new Request('https://example.com/resource', {
|
|
1299
|
+
headers: { Authorization: 'Payment invalid' },
|
|
1300
|
+
}),
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
expect(result.status).toBe(402)
|
|
1304
|
+
expect(events).toEqual([
|
|
1305
|
+
'failed:MalformedCredentialError:null',
|
|
1306
|
+
'challenge:MalformedCredentialError:null',
|
|
1307
|
+
])
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
test('emits payment failure when method verification rejects', async () => {
|
|
1311
|
+
const events: string[] = []
|
|
1312
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1313
|
+
async verify() {
|
|
1314
|
+
throw new Errors.VerificationFailedError({ reason: 'declined' })
|
|
1315
|
+
},
|
|
1316
|
+
})
|
|
1317
|
+
const handler = Mppx.create({
|
|
1318
|
+
methods: [serverMethod],
|
|
1319
|
+
realm,
|
|
1320
|
+
secretKey,
|
|
1321
|
+
})
|
|
1322
|
+
handler.onChallengeCreated((context) => {
|
|
1323
|
+
events.push(`challenge:${context.error?.name}`)
|
|
1324
|
+
})
|
|
1325
|
+
handler.onPaymentSuccess(() => {
|
|
1326
|
+
events.push('payment')
|
|
1327
|
+
})
|
|
1328
|
+
handler.onPaymentFailed((context) => {
|
|
1329
|
+
events.push(`failed:${context.error.name}`)
|
|
1330
|
+
})
|
|
1331
|
+
const handle = handler.charge(options())
|
|
1332
|
+
|
|
1333
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1334
|
+
expect(first.status).toBe(402)
|
|
1335
|
+
if (first.status !== 402) throw new Error()
|
|
1336
|
+
|
|
1337
|
+
const credential = Credential.from({
|
|
1338
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1339
|
+
payload: { token: 'valid' },
|
|
1340
|
+
})
|
|
1341
|
+
const result = await handle(
|
|
1342
|
+
new Request('https://example.com/resource', {
|
|
1343
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1344
|
+
}),
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
expect(result.status).toBe(402)
|
|
1348
|
+
expect(events).toEqual([
|
|
1349
|
+
'challenge:PaymentRequiredError',
|
|
1350
|
+
'failed:VerificationFailedError',
|
|
1351
|
+
'challenge:VerificationFailedError',
|
|
1352
|
+
])
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
test('emits payment success when authorize grants access without a credential', async () => {
|
|
1356
|
+
const events: string[] = []
|
|
1357
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1358
|
+
async authorize() {
|
|
1359
|
+
return { receipt: receipt('tx-authorized') }
|
|
1360
|
+
},
|
|
1361
|
+
async verify() {
|
|
1362
|
+
return receipt()
|
|
1363
|
+
},
|
|
1364
|
+
})
|
|
1365
|
+
const handler = Mppx.create({
|
|
1366
|
+
methods: [serverMethod],
|
|
1367
|
+
realm,
|
|
1368
|
+
secretKey,
|
|
1369
|
+
})
|
|
1370
|
+
handler.onPaymentSuccess((context) => {
|
|
1371
|
+
events.push(`success:${context.receipt.reference}:${context.credential}:${context.envelope}`)
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
const result = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1375
|
+
|
|
1376
|
+
expect(result.status).toBe(200)
|
|
1377
|
+
expect(events).toEqual(['success:tx-authorized:undefined:undefined'])
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
test('emits payment failure when authorize rejects', async () => {
|
|
1381
|
+
const events: string[] = []
|
|
1382
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1383
|
+
async authorize() {
|
|
1384
|
+
throw new Errors.VerificationFailedError({ reason: 'not active' })
|
|
1385
|
+
},
|
|
1386
|
+
async verify() {
|
|
1387
|
+
return receipt()
|
|
1388
|
+
},
|
|
1389
|
+
})
|
|
1390
|
+
const handler = Mppx.create({
|
|
1391
|
+
methods: [serverMethod],
|
|
1392
|
+
realm,
|
|
1393
|
+
secretKey,
|
|
1394
|
+
})
|
|
1395
|
+
handler.onPaymentFailed((context) => {
|
|
1396
|
+
events.push(`failed:${context.error.name}:${context.credential}`)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
const result = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1400
|
+
|
|
1401
|
+
expect(result.status).toBe(402)
|
|
1402
|
+
expect(events).toEqual(['failed:VerificationFailedError:null'])
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
test('emits events from standalone verifyCredential', async () => {
|
|
1406
|
+
const events: string[] = []
|
|
1407
|
+
const capturedRequest = {
|
|
1408
|
+
headers: new Headers({ 'x-route': 'standalone' }),
|
|
1409
|
+
hasBody: false,
|
|
1410
|
+
method: 'POST',
|
|
1411
|
+
url: new URL('https://example.com/standalone'),
|
|
1412
|
+
}
|
|
1413
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1414
|
+
async verify() {
|
|
1415
|
+
return receipt('tx-standalone')
|
|
1416
|
+
},
|
|
1417
|
+
})
|
|
1418
|
+
const handler = Mppx.create({
|
|
1419
|
+
methods: [serverMethod],
|
|
1420
|
+
realm,
|
|
1421
|
+
secretKey,
|
|
1422
|
+
})
|
|
1423
|
+
handler.onPaymentSuccess((context) => {
|
|
1424
|
+
events.push(
|
|
1425
|
+
`success:${context.receipt.reference}:${context.input}:${context.capturedRequest?.url.pathname}`,
|
|
1426
|
+
)
|
|
1427
|
+
})
|
|
1428
|
+
|
|
1429
|
+
const first = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1430
|
+
expect(first.status).toBe(402)
|
|
1431
|
+
if (first.status !== 402) throw new Error()
|
|
1432
|
+
|
|
1433
|
+
const result = await handler.verifyCredential(
|
|
1434
|
+
Credential.from({
|
|
1435
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1436
|
+
payload: { token: 'valid' },
|
|
1437
|
+
}),
|
|
1438
|
+
{ capturedRequest, request: options() },
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
expect(result.reference).toBe('tx-standalone')
|
|
1442
|
+
expect(events).toEqual(['success:tx-standalone:undefined:/standalone'])
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1445
|
+
test('emits payment failure from standalone verifyCredential failures', async () => {
|
|
1446
|
+
const events: string[] = []
|
|
1447
|
+
const capturedRequest = {
|
|
1448
|
+
headers: new Headers({ 'x-route': 'standalone' }),
|
|
1449
|
+
hasBody: false,
|
|
1450
|
+
method: 'POST',
|
|
1451
|
+
url: new URL('https://example.com/standalone-failure'),
|
|
1452
|
+
}
|
|
1453
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1454
|
+
async verify() {
|
|
1455
|
+
throw new Errors.VerificationFailedError({ reason: 'declined' })
|
|
1456
|
+
},
|
|
1457
|
+
})
|
|
1458
|
+
const handler = Mppx.create({
|
|
1459
|
+
methods: [serverMethod],
|
|
1460
|
+
realm,
|
|
1461
|
+
secretKey,
|
|
1462
|
+
})
|
|
1463
|
+
handler.onPaymentFailed((context) => {
|
|
1464
|
+
events.push(
|
|
1465
|
+
`failed:${context.error.name}:${context.submittedChallenge?.id}:${context.capturedRequest?.url.pathname}`,
|
|
1466
|
+
)
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
const first = await handler.charge(options())(new Request('https://example.com/resource'))
|
|
1470
|
+
expect(first.status).toBe(402)
|
|
1471
|
+
if (first.status !== 402) throw new Error()
|
|
1472
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
1473
|
+
|
|
1474
|
+
await expect(
|
|
1475
|
+
handler.verifyCredential(
|
|
1476
|
+
Credential.from({
|
|
1477
|
+
challenge,
|
|
1478
|
+
payload: { token: 'valid' },
|
|
1479
|
+
}),
|
|
1480
|
+
{ capturedRequest, request: options() },
|
|
1481
|
+
),
|
|
1482
|
+
).rejects.toThrow(Errors.VerificationFailedError)
|
|
1483
|
+
expect(events).toEqual([`failed:VerificationFailedError:${challenge.id}:/standalone-failure`])
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
test('emits standalone payment failure for forged challenge HMAC failures', async () => {
|
|
1487
|
+
const events: string[] = []
|
|
1488
|
+
let failedRequestAmount: unknown
|
|
1489
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1490
|
+
async verify() {
|
|
1491
|
+
return receipt()
|
|
1492
|
+
},
|
|
1493
|
+
})
|
|
1494
|
+
const handler = Mppx.create({
|
|
1495
|
+
methods: [serverMethod],
|
|
1496
|
+
realm,
|
|
1497
|
+
secretKey,
|
|
1498
|
+
})
|
|
1499
|
+
handler.onPaymentFailed((context) => {
|
|
1500
|
+
events.push(`failed:${context.challenge.id}`)
|
|
1501
|
+
failedRequestAmount = context.request.amount
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
await expect(
|
|
1505
|
+
handler.verifyCredential(
|
|
1506
|
+
Credential.from({
|
|
1507
|
+
challenge: Challenge.from({
|
|
1508
|
+
id: 'forged-id',
|
|
1509
|
+
intent: 'charge',
|
|
1510
|
+
method: 'mock',
|
|
1511
|
+
realm,
|
|
1512
|
+
request: { amount: { $ne: '1000' }, currency: asset },
|
|
1513
|
+
}),
|
|
1514
|
+
payload: { token: 'valid' },
|
|
1515
|
+
}),
|
|
1516
|
+
),
|
|
1517
|
+
).rejects.toThrow(Errors.InvalidChallengeError)
|
|
1518
|
+
expect(events).toEqual(['failed:forged-id'])
|
|
1519
|
+
expect(failedRequestAmount).toEqual({ $ne: '1000' })
|
|
1520
|
+
})
|
|
1521
|
+
|
|
1522
|
+
test('emits payment failure when credential-bearing request hook rejects', async () => {
|
|
1523
|
+
const events: string[] = []
|
|
1524
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1525
|
+
request({ credential, request }) {
|
|
1526
|
+
if (credential) throw new Errors.VerificationFailedError({ reason: 'request rejected' })
|
|
1527
|
+
return request
|
|
1528
|
+
},
|
|
1529
|
+
async verify() {
|
|
1530
|
+
return receipt()
|
|
1531
|
+
},
|
|
1532
|
+
})
|
|
1533
|
+
const handler = Mppx.create({
|
|
1534
|
+
methods: [serverMethod],
|
|
1535
|
+
realm,
|
|
1536
|
+
secretKey,
|
|
1537
|
+
})
|
|
1538
|
+
handler.onPaymentFailed((context) => {
|
|
1539
|
+
events.push(`failed:${context.error.name}:${context.submittedChallenge?.id}`)
|
|
1540
|
+
})
|
|
1541
|
+
|
|
1542
|
+
const handle = handler.charge(options())
|
|
1543
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1544
|
+
expect(first.status).toBe(402)
|
|
1545
|
+
if (first.status !== 402) throw new Error()
|
|
1546
|
+
const challenge = Challenge.fromResponse(first.challenge)
|
|
1547
|
+
|
|
1548
|
+
const result = await handle(
|
|
1549
|
+
new Request('https://example.com/resource', {
|
|
1550
|
+
headers: {
|
|
1551
|
+
Authorization: Credential.serialize(
|
|
1552
|
+
Credential.from({
|
|
1553
|
+
challenge,
|
|
1554
|
+
payload: { token: 'valid' },
|
|
1555
|
+
}),
|
|
1556
|
+
),
|
|
1557
|
+
},
|
|
1558
|
+
}),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
expect(result.status).toBe(402)
|
|
1562
|
+
expect(events).toEqual([`failed:VerificationFailedError:${challenge.id}`])
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
test('does not emit challenge events for service worker requests', async () => {
|
|
1566
|
+
const events: string[] = []
|
|
1567
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1568
|
+
html: {
|
|
1569
|
+
config: {},
|
|
1570
|
+
content: '<script src="/bundle.js"></script>',
|
|
1571
|
+
formatAmount: (request: Record<string, unknown>) => `$${request.amount}`,
|
|
1572
|
+
text: undefined,
|
|
1573
|
+
theme: undefined,
|
|
1574
|
+
},
|
|
1575
|
+
async verify() {
|
|
1576
|
+
return receipt()
|
|
1577
|
+
},
|
|
1578
|
+
})
|
|
1579
|
+
const handler = Mppx.create({
|
|
1580
|
+
methods: [serverMethod],
|
|
1581
|
+
realm,
|
|
1582
|
+
secretKey,
|
|
1583
|
+
})
|
|
1584
|
+
handler.onChallengeCreated(() => {
|
|
1585
|
+
events.push('challenge')
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
const result = await handler.charge(options())(
|
|
1589
|
+
new Request('https://example.com/resource?__mppx_worker'),
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
expect(result.status).toBe(402)
|
|
1593
|
+
if (result.status !== 402) throw new Error()
|
|
1594
|
+
expect(result.challenge.status).toBe(200)
|
|
1595
|
+
expect(events).toEqual([])
|
|
1596
|
+
})
|
|
1597
|
+
|
|
1598
|
+
test('rejects reserved lifecycle helper collisions', () => {
|
|
1599
|
+
const collidingMethod = Method.toServer(
|
|
1600
|
+
Method.from({
|
|
1601
|
+
name: 'mock',
|
|
1602
|
+
intent: 'onPaymentSuccess',
|
|
1603
|
+
schema: {
|
|
1604
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1605
|
+
request: z.object({ amount: z.string() }),
|
|
1606
|
+
},
|
|
1607
|
+
}),
|
|
1608
|
+
{
|
|
1609
|
+
async verify() {
|
|
1610
|
+
return receipt()
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
expect(() =>
|
|
1616
|
+
Mppx.create({
|
|
1617
|
+
methods: [collidingMethod],
|
|
1618
|
+
realm,
|
|
1619
|
+
secretKey,
|
|
1620
|
+
}),
|
|
1621
|
+
).toThrow('reserved Mppx property')
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
test('rejects duplicated reserved intent collisions', () => {
|
|
1625
|
+
const createCollidingMethod = (name: string) =>
|
|
1626
|
+
Method.toServer(
|
|
1627
|
+
Method.from({
|
|
1628
|
+
name,
|
|
1629
|
+
intent: 'onPaymentSuccess',
|
|
1630
|
+
schema: {
|
|
1631
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
1632
|
+
request: z.object({ amount: z.string() }),
|
|
1633
|
+
},
|
|
1634
|
+
}),
|
|
1635
|
+
{
|
|
1636
|
+
async verify() {
|
|
1637
|
+
return receipt()
|
|
1638
|
+
},
|
|
1639
|
+
},
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
expect(() =>
|
|
1643
|
+
Mppx.create({
|
|
1644
|
+
methods: [createCollidingMethod('alpha'), createCollidingMethod('beta')],
|
|
1645
|
+
realm,
|
|
1646
|
+
secretKey,
|
|
1647
|
+
}),
|
|
1648
|
+
).toThrow('reserved Mppx property')
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
test('server event request inputs are bodyless snapshots', async () => {
|
|
1652
|
+
let eventInput: Request | undefined
|
|
1653
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1654
|
+
async verify() {
|
|
1655
|
+
return receipt()
|
|
1656
|
+
},
|
|
1657
|
+
})
|
|
1658
|
+
const handler = Mppx.create({
|
|
1659
|
+
methods: [serverMethod],
|
|
1660
|
+
realm,
|
|
1661
|
+
secretKey,
|
|
1662
|
+
})
|
|
1663
|
+
handler.onChallengeCreated((context) => {
|
|
1664
|
+
eventInput = context.input
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
const original = new Request('https://example.com/resource', {
|
|
1668
|
+
body: 'paid upload',
|
|
1669
|
+
method: 'POST',
|
|
1670
|
+
})
|
|
1671
|
+
const result = await handler.charge(options())(original)
|
|
1672
|
+
|
|
1673
|
+
expect(result.status).toBe(402)
|
|
1674
|
+
expect(eventInput).toBeInstanceOf(Request)
|
|
1675
|
+
expect(eventInput?.body).toBeNull()
|
|
1676
|
+
await expect(original.text()).resolves.toBe('paid upload')
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
test('payment success events expose transformed credential and request values', async () => {
|
|
1680
|
+
const transformed = Method.from({
|
|
1681
|
+
name: 'transformed',
|
|
1682
|
+
intent: 'charge',
|
|
1683
|
+
schema: {
|
|
1684
|
+
credential: {
|
|
1685
|
+
payload: z.pipe(
|
|
1686
|
+
z.object({ admin: z.string() }),
|
|
1687
|
+
z.transform(({ admin }) => ({ admin: admin === 'true' })),
|
|
1688
|
+
),
|
|
1689
|
+
},
|
|
1690
|
+
request: z.pipe(
|
|
1691
|
+
z.object({
|
|
1692
|
+
amount: z.string(),
|
|
1693
|
+
currency: z.string(),
|
|
1694
|
+
decimals: z.number(),
|
|
1695
|
+
recipient: z.string(),
|
|
1696
|
+
}),
|
|
1697
|
+
z.transform(({ amount, currency, decimals, recipient }) => ({
|
|
1698
|
+
amount: String(Number(amount) * 10 ** decimals),
|
|
1699
|
+
currency,
|
|
1700
|
+
recipient,
|
|
1701
|
+
})),
|
|
1702
|
+
),
|
|
1703
|
+
},
|
|
1704
|
+
})
|
|
1705
|
+
const seen: Record<string, unknown> = {}
|
|
1706
|
+
const serverMethod = Method.toServer(transformed, {
|
|
1707
|
+
async verify({ credential, envelope, request }) {
|
|
1708
|
+
seen.verifyAdmin = credential.payload.admin
|
|
1709
|
+
seen.verifyEnvelopeAmount = envelope?.request.amount
|
|
1710
|
+
seen.verifyRequestAmount = request.amount
|
|
1711
|
+
return receipt('tx-transformed')
|
|
1712
|
+
},
|
|
1713
|
+
})
|
|
1714
|
+
const handler = Mppx.create({
|
|
1715
|
+
methods: [serverMethod],
|
|
1716
|
+
realm,
|
|
1717
|
+
secretKey,
|
|
1718
|
+
})
|
|
1719
|
+
handler.onPaymentSuccess((context) => {
|
|
1720
|
+
seen.eventAdmin = context.credential?.payload.admin
|
|
1721
|
+
seen.eventEnvelopeAmount = context.envelope?.request.amount
|
|
1722
|
+
seen.eventRequestAmount = context.request.amount
|
|
1723
|
+
})
|
|
1724
|
+
const handle = handler.charge({
|
|
1725
|
+
amount: '25',
|
|
1726
|
+
currency: '0x0000000000000000000000000000000000000001',
|
|
1727
|
+
decimals: 2,
|
|
1728
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
1729
|
+
recipient: '0x0000000000000000000000000000000000000002',
|
|
1730
|
+
})
|
|
1731
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1732
|
+
expect(first.status).toBe(402)
|
|
1733
|
+
if (first.status !== 402) throw new Error()
|
|
1734
|
+
|
|
1735
|
+
const paid = await handle(
|
|
1736
|
+
new Request('https://example.com/resource', {
|
|
1737
|
+
headers: {
|
|
1738
|
+
Authorization: Credential.serialize(
|
|
1739
|
+
Credential.from({
|
|
1740
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1741
|
+
payload: { admin: 'false' },
|
|
1742
|
+
}),
|
|
1743
|
+
),
|
|
1744
|
+
},
|
|
1745
|
+
}),
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
expect(paid.status).toBe(200)
|
|
1749
|
+
expect(seen).toMatchObject({
|
|
1750
|
+
eventAdmin: false,
|
|
1751
|
+
eventEnvelopeAmount: '2500',
|
|
1752
|
+
eventRequestAmount: '2500',
|
|
1753
|
+
verifyAdmin: false,
|
|
1754
|
+
verifyEnvelopeAmount: '2500',
|
|
1755
|
+
verifyRequestAmount: '25',
|
|
1756
|
+
})
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
test('snapshots payment success payloads before listeners run', async () => {
|
|
1760
|
+
let mutationBlocked = false
|
|
1761
|
+
const serverMethod = Method.toServer(eventCharge, {
|
|
1762
|
+
async verify() {
|
|
1763
|
+
return receipt('tx-original')
|
|
1764
|
+
},
|
|
1765
|
+
})
|
|
1766
|
+
const handler = Mppx.create({
|
|
1767
|
+
methods: [serverMethod],
|
|
1768
|
+
realm,
|
|
1769
|
+
secretKey,
|
|
1770
|
+
})
|
|
1771
|
+
handler.onPaymentSuccess((context) => {
|
|
1772
|
+
try {
|
|
1773
|
+
;(context.receipt as { reference: string }).reference = 'tx-mutated'
|
|
1774
|
+
} catch {
|
|
1775
|
+
mutationBlocked = true
|
|
1776
|
+
}
|
|
1777
|
+
})
|
|
1778
|
+
const handle = handler.charge(options())
|
|
1779
|
+
const first = await handle(new Request('https://example.com/resource'))
|
|
1780
|
+
expect(first.status).toBe(402)
|
|
1781
|
+
if (first.status !== 402) throw new Error()
|
|
1782
|
+
|
|
1783
|
+
const paid = await handle(
|
|
1784
|
+
new Request('https://example.com/resource', {
|
|
1785
|
+
headers: {
|
|
1786
|
+
Authorization: Credential.serialize(
|
|
1787
|
+
Credential.from({
|
|
1788
|
+
challenge: Challenge.fromResponse(first.challenge),
|
|
1789
|
+
payload: { token: 'valid' },
|
|
1790
|
+
}),
|
|
1791
|
+
),
|
|
1792
|
+
},
|
|
1793
|
+
}),
|
|
1794
|
+
)
|
|
1795
|
+
expect(paid.status).toBe(200)
|
|
1796
|
+
if (paid.status !== 200) throw new Error()
|
|
1797
|
+
|
|
1798
|
+
const response = paid.withReceipt(Response.json({ ok: true }))
|
|
1799
|
+
expect(mutationBlocked).toBe(true)
|
|
1800
|
+
expect(Receipt.fromResponse(response).reference).toBe('tx-original')
|
|
1801
|
+
})
|
|
1802
|
+
})
|
|
1803
|
+
|
|
917
1804
|
describe('compose', () => {
|
|
918
1805
|
const mockChargeA = Method.from({
|
|
919
1806
|
name: 'alpha',
|
|
@@ -972,7 +1859,7 @@ describe('compose', () => {
|
|
|
972
1859
|
amount: '1000',
|
|
973
1860
|
currency: '0x0000000000000000000000000000000000000001',
|
|
974
1861
|
decimals: 6,
|
|
975
|
-
expires: new Date(Date.now() + 60_000)
|
|
1862
|
+
expires: new Date(Date.now() + 60_000),
|
|
976
1863
|
recipient: '0x0000000000000000000000000000000000000002',
|
|
977
1864
|
}
|
|
978
1865
|
|
|
@@ -1779,6 +2666,37 @@ describe('compose: pre-dispatch narrowing edge cases', () => {
|
|
|
1779
2666
|
expect(result.status).toBe(402)
|
|
1780
2667
|
})
|
|
1781
2668
|
|
|
2669
|
+
test('ignores compose candidates whose stable binding throws on forged credentials', async () => {
|
|
2670
|
+
const bindingMethod = Method.toServer(mockCharge, {
|
|
2671
|
+
stableBinding(request) {
|
|
2672
|
+
return { currency: request.currency.toLowerCase() }
|
|
2673
|
+
},
|
|
2674
|
+
async verify() {
|
|
2675
|
+
return mockReceipt()
|
|
2676
|
+
},
|
|
2677
|
+
})
|
|
2678
|
+
const mppx = Mppx.create({ methods: [bindingMethod], realm, secretKey })
|
|
2679
|
+
const handle = mppx.compose([bindingMethod, challengeOpts])
|
|
2680
|
+
const credential = Credential.from({
|
|
2681
|
+
challenge: {
|
|
2682
|
+
id: 'forged',
|
|
2683
|
+
intent: 'charge',
|
|
2684
|
+
method: 'alpha',
|
|
2685
|
+
realm,
|
|
2686
|
+
request: {},
|
|
2687
|
+
},
|
|
2688
|
+
payload: { token: 'valid' },
|
|
2689
|
+
})
|
|
2690
|
+
|
|
2691
|
+
const result = await handle(
|
|
2692
|
+
new Request('https://example.com/resource', {
|
|
2693
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
2694
|
+
}),
|
|
2695
|
+
)
|
|
2696
|
+
|
|
2697
|
+
expect(result.status).toBe(402)
|
|
2698
|
+
})
|
|
2699
|
+
|
|
1782
2700
|
test('single handler in compose() returns 402 and then 200', async () => {
|
|
1783
2701
|
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
|
|
1784
2702
|
const handle = mppx.compose([alphaMethod, challengeOpts])
|
|
@@ -2605,7 +3523,15 @@ describe('withReceipt', () => {
|
|
|
2605
3523
|
expect(result.status).toBe(200)
|
|
2606
3524
|
if (result.status !== 200) throw new Error()
|
|
2607
3525
|
|
|
2608
|
-
expect(() => result.withReceipt()).toThrow(
|
|
3526
|
+
expect(() => result.withReceipt()).toThrow(Mppx.MissingReceiptResponseError)
|
|
3527
|
+
})
|
|
3528
|
+
|
|
3529
|
+
test('recognizes missing response sentinel across module instances', () => {
|
|
3530
|
+
const error = new Error('withReceipt() requires a response argument')
|
|
3531
|
+
error.name = 'MissingReceiptResponseError'
|
|
3532
|
+
|
|
3533
|
+
expect(Mppx.isMissingReceiptResponseError(error)).toBe(true)
|
|
3534
|
+
expect(Mppx.isMissingReceiptResponseError(new Error(error.message))).toBe(false)
|
|
2609
3535
|
})
|
|
2610
3536
|
|
|
2611
3537
|
test('returns management response when respond hook returns Response', async () => {
|
|
@@ -3252,6 +4178,37 @@ describe('challenge', () => {
|
|
|
3252
4178
|
expect(challenge.request.methodDetails).toEqual({ chainId: 42431 })
|
|
3253
4179
|
})
|
|
3254
4180
|
|
|
4181
|
+
test('request hook payment errors are normalized to 402 responses', async () => {
|
|
4182
|
+
const errorMethod = Method.toServer(
|
|
4183
|
+
Method.from({
|
|
4184
|
+
name: 'error',
|
|
4185
|
+
intent: 'charge',
|
|
4186
|
+
schema: {
|
|
4187
|
+
credential: { payload: z.object({ token: z.string() }) },
|
|
4188
|
+
request: z.object({ amount: z.string() }),
|
|
4189
|
+
},
|
|
4190
|
+
}),
|
|
4191
|
+
{
|
|
4192
|
+
request() {
|
|
4193
|
+
throw new Errors.VerificationFailedError({ reason: 'request rejected' })
|
|
4194
|
+
},
|
|
4195
|
+
async verify() {
|
|
4196
|
+
return mockReceipt('error')
|
|
4197
|
+
},
|
|
4198
|
+
},
|
|
4199
|
+
)
|
|
4200
|
+
const mppx = Mppx.create({ methods: [errorMethod], realm, secretKey })
|
|
4201
|
+
|
|
4202
|
+
const result = await mppx.error.charge({ amount: '1' })(
|
|
4203
|
+
new Request('https://example.com/resource'),
|
|
4204
|
+
)
|
|
4205
|
+
|
|
4206
|
+
expect(result.status).toBe(402)
|
|
4207
|
+
if (result.status !== 402) throw new Error('expected challenge')
|
|
4208
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
4209
|
+
expect(body.detail).toBe('Payment verification failed: request rejected.')
|
|
4210
|
+
})
|
|
4211
|
+
|
|
3255
4212
|
test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => {
|
|
3256
4213
|
const mppx = Mppx.create({
|
|
3257
4214
|
methods: [alphaChargeServer],
|
|
@@ -3609,11 +4566,15 @@ describe('verifyCredential', () => {
|
|
|
3609
4566
|
})
|
|
3610
4567
|
|
|
3611
4568
|
test('rejects credential for unregistered method/intent', async () => {
|
|
4569
|
+
const events: string[] = []
|
|
3612
4570
|
const mppx = Mppx.create({
|
|
3613
4571
|
methods: [alphaChargeServer],
|
|
3614
4572
|
realm,
|
|
3615
4573
|
secretKey,
|
|
3616
4574
|
})
|
|
4575
|
+
mppx.onPaymentFailed((context) => {
|
|
4576
|
+
events.push(`${context.method.name}/${context.method.intent}:${context.error.name}`)
|
|
4577
|
+
})
|
|
3617
4578
|
|
|
3618
4579
|
// Forge a challenge for an unregistered method using the same secret
|
|
3619
4580
|
const challenge = Challenge.from({
|
|
@@ -3632,6 +4593,7 @@ describe('verifyCredential', () => {
|
|
|
3632
4593
|
await expect(mppx.verifyCredential(credential)).rejects.toThrow(
|
|
3633
4594
|
'no registered method for unknown/charge',
|
|
3634
4595
|
)
|
|
4596
|
+
expect(events).toEqual(['unknown/charge:InvalidChallengeError'])
|
|
3635
4597
|
})
|
|
3636
4598
|
|
|
3637
4599
|
test('rejects malformed credential string', async () => {
|