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.
Files changed (168) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/client/Mppx.d.ts +12 -1
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +127 -10
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/internal/Fetch.d.ts +69 -1
  23. package/dist/client/internal/Fetch.d.ts.map +1 -1
  24. package/dist/client/internal/Fetch.js +250 -20
  25. package/dist/client/internal/Fetch.js.map +1 -1
  26. package/dist/middlewares/elysia.d.ts.map +1 -1
  27. package/dist/middlewares/elysia.js +14 -0
  28. package/dist/middlewares/elysia.js.map +1 -1
  29. package/dist/middlewares/express.d.ts.map +1 -1
  30. package/dist/middlewares/express.js +1 -2
  31. package/dist/middlewares/express.js.map +1 -1
  32. package/dist/middlewares/hono.d.ts.map +1 -1
  33. package/dist/middlewares/hono.js +14 -0
  34. package/dist/middlewares/hono.js.map +1 -1
  35. package/dist/middlewares/internal/mppx.d.ts +1 -1
  36. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  37. package/dist/middlewares/internal/mppx.js +2 -1
  38. package/dist/middlewares/internal/mppx.js.map +1 -1
  39. package/dist/middlewares/nextjs.d.ts.map +1 -1
  40. package/dist/middlewares/nextjs.js +14 -0
  41. package/dist/middlewares/nextjs.js.map +1 -1
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +2 -2
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts.map +1 -1
  46. package/dist/proxy/Service.js +1 -1
  47. package/dist/proxy/Service.js.map +1 -1
  48. package/dist/server/Mppx.d.ts +96 -5
  49. package/dist/server/Mppx.d.ts.map +1 -1
  50. package/dist/server/Mppx.js +739 -115
  51. package/dist/server/Mppx.js.map +1 -1
  52. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  53. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.js +1 -1
  55. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  56. package/dist/tempo/Methods.d.ts +96 -0
  57. package/dist/tempo/Methods.d.ts.map +1 -1
  58. package/dist/tempo/Methods.js +97 -0
  59. package/dist/tempo/Methods.js.map +1 -1
  60. package/dist/tempo/client/Methods.d.ts +3 -0
  61. package/dist/tempo/client/Methods.d.ts.map +1 -1
  62. package/dist/tempo/client/Methods.js +3 -0
  63. package/dist/tempo/client/Methods.js.map +1 -1
  64. package/dist/tempo/client/Subscription.d.ts +114 -0
  65. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  66. package/dist/tempo/client/Subscription.js +100 -0
  67. package/dist/tempo/client/Subscription.js.map +1 -0
  68. package/dist/tempo/client/index.d.ts +1 -0
  69. package/dist/tempo/client/index.d.ts.map +1 -1
  70. package/dist/tempo/client/index.js +1 -0
  71. package/dist/tempo/client/index.js.map +1 -1
  72. package/dist/tempo/index.d.ts +1 -0
  73. package/dist/tempo/index.d.ts.map +1 -1
  74. package/dist/tempo/index.js +1 -0
  75. package/dist/tempo/index.js.map +1 -1
  76. package/dist/tempo/server/Methods.d.ts +5 -0
  77. package/dist/tempo/server/Methods.d.ts.map +1 -1
  78. package/dist/tempo/server/Methods.js +5 -0
  79. package/dist/tempo/server/Methods.js.map +1 -1
  80. package/dist/tempo/server/Subscription.d.ts +221 -0
  81. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  82. package/dist/tempo/server/Subscription.js +637 -0
  83. package/dist/tempo/server/Subscription.js.map +1 -0
  84. package/dist/tempo/server/index.d.ts +1 -0
  85. package/dist/tempo/server/index.d.ts.map +1 -1
  86. package/dist/tempo/server/index.js +1 -0
  87. package/dist/tempo/server/index.js.map +1 -1
  88. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  89. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  90. package/dist/tempo/server/internal/html.gen.js +1 -1
  91. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  92. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  93. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  94. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  95. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  96. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  97. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  98. package/dist/tempo/subscription/Receipt.js +16 -0
  99. package/dist/tempo/subscription/Receipt.js.map +1 -0
  100. package/dist/tempo/subscription/Store.d.ts +99 -0
  101. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  102. package/dist/tempo/subscription/Store.js +292 -0
  103. package/dist/tempo/subscription/Store.js.map +1 -0
  104. package/dist/tempo/subscription/Types.d.ts +65 -0
  105. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  106. package/dist/tempo/subscription/Types.js +2 -0
  107. package/dist/tempo/subscription/Types.js.map +1 -0
  108. package/dist/tempo/subscription/index.d.ts +6 -0
  109. package/dist/tempo/subscription/index.d.ts.map +1 -0
  110. package/dist/tempo/subscription/index.js +4 -0
  111. package/dist/tempo/subscription/index.js.map +1 -0
  112. package/dist/zod.d.ts +7 -0
  113. package/dist/zod.d.ts.map +1 -1
  114. package/dist/zod.js +18 -0
  115. package/dist/zod.js.map +1 -1
  116. package/package.json +3 -3
  117. package/src/Challenge.test.ts +13 -0
  118. package/src/Challenge.ts +3 -3
  119. package/src/Method.ts +46 -1
  120. package/src/Receipt.ts +2 -0
  121. package/src/client/Methods.ts +1 -0
  122. package/src/client/Mppx.test-d.ts +55 -0
  123. package/src/client/Mppx.test.ts +181 -0
  124. package/src/client/Mppx.ts +248 -16
  125. package/src/client/internal/Fetch.test-d.ts +31 -0
  126. package/src/client/internal/Fetch.test.ts +261 -0
  127. package/src/client/internal/Fetch.ts +467 -24
  128. package/src/middlewares/elysia.test.ts +31 -1
  129. package/src/middlewares/elysia.ts +13 -0
  130. package/src/middlewares/express.ts +1 -5
  131. package/src/middlewares/hono.test.ts +30 -1
  132. package/src/middlewares/hono.ts +13 -0
  133. package/src/middlewares/internal/mppx.ts +5 -6
  134. package/src/middlewares/nextjs.test.ts +28 -1
  135. package/src/middlewares/nextjs.ts +13 -0
  136. package/src/proxy/Proxy.test.ts +69 -0
  137. package/src/proxy/Proxy.ts +2 -5
  138. package/src/proxy/Service.test.ts +34 -0
  139. package/src/proxy/Service.ts +7 -0
  140. package/src/server/Mppx.authorize.test.ts +210 -0
  141. package/src/server/Mppx.test-d.ts +73 -1
  142. package/src/server/Mppx.test.ts +965 -3
  143. package/src/server/Mppx.ts +1138 -140
  144. package/src/stripe/server/internal/html/package.json +1 -1
  145. package/src/stripe/server/internal/html.gen.ts +1 -1
  146. package/src/tempo/Methods.test.ts +131 -0
  147. package/src/tempo/Methods.ts +136 -0
  148. package/src/tempo/Subscription.integration.test.ts +591 -0
  149. package/src/tempo/client/Methods.ts +3 -0
  150. package/src/tempo/client/Subscription.test.ts +131 -0
  151. package/src/tempo/client/Subscription.ts +155 -0
  152. package/src/tempo/client/index.ts +1 -0
  153. package/src/tempo/index.ts +1 -0
  154. package/src/tempo/server/Methods.ts +5 -0
  155. package/src/tempo/server/Subscription.test.ts +1410 -0
  156. package/src/tempo/server/Subscription.ts +1014 -0
  157. package/src/tempo/server/index.ts +1 -0
  158. package/src/tempo/server/internal/html/package.json +1 -1
  159. package/src/tempo/server/internal/html.gen.ts +1 -1
  160. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  161. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  162. package/src/tempo/subscription/Receipt.ts +28 -0
  163. package/src/tempo/subscription/Store.test.ts +554 -0
  164. package/src/tempo/subscription/Store.ts +431 -0
  165. package/src/tempo/subscription/Types.ts +68 -0
  166. package/src/tempo/subscription/index.ts +23 -0
  167. package/src/zod.test.ts +23 -1
  168. package/src/zod.ts +24 -0
@@ -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).toISOString(),
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('withReceipt() requires a response argument')
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 () => {