mppx 0.6.20 → 0.6.22

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/client/Mppx.d.ts +12 -1
  3. package/dist/client/Mppx.d.ts.map +1 -1
  4. package/dist/client/Mppx.js +127 -10
  5. package/dist/client/Mppx.js.map +1 -1
  6. package/dist/client/internal/Fetch.d.ts +69 -1
  7. package/dist/client/internal/Fetch.d.ts.map +1 -1
  8. package/dist/client/internal/Fetch.js +250 -20
  9. package/dist/client/internal/Fetch.js.map +1 -1
  10. package/dist/middlewares/internal/mppx.d.ts +1 -1
  11. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  12. package/dist/middlewares/internal/mppx.js +2 -1
  13. package/dist/middlewares/internal/mppx.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +82 -3
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +557 -83
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/tempo/client/Subscription.d.ts.map +1 -1
  19. package/dist/tempo/client/Subscription.js +4 -3
  20. package/dist/tempo/client/Subscription.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/client/Mppx.test-d.ts +55 -0
  27. package/src/client/Mppx.test.ts +181 -0
  28. package/src/client/Mppx.ts +248 -16
  29. package/src/client/internal/Fetch.test-d.ts +31 -0
  30. package/src/client/internal/Fetch.test.ts +261 -0
  31. package/src/client/internal/Fetch.ts +467 -24
  32. package/src/middlewares/internal/mppx.ts +5 -6
  33. package/src/proxy/Proxy.test.ts +69 -0
  34. package/src/server/Mppx.test-d.ts +50 -0
  35. package/src/server/Mppx.test.ts +893 -1
  36. package/src/server/Mppx.ts +862 -97
  37. package/src/tempo/client/Subscription.test.ts +51 -0
  38. package/src/tempo/client/Subscription.ts +4 -3
  39. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import * as http from 'node:http'
2
2
 
3
- import { Challenge, Credential, Errors, 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',
@@ -3679,11 +4566,15 @@ describe('verifyCredential', () => {
3679
4566
  })
3680
4567
 
3681
4568
  test('rejects credential for unregistered method/intent', async () => {
4569
+ const events: string[] = []
3682
4570
  const mppx = Mppx.create({
3683
4571
  methods: [alphaChargeServer],
3684
4572
  realm,
3685
4573
  secretKey,
3686
4574
  })
4575
+ mppx.onPaymentFailed((context) => {
4576
+ events.push(`${context.method.name}/${context.method.intent}:${context.error.name}`)
4577
+ })
3687
4578
 
3688
4579
  // Forge a challenge for an unregistered method using the same secret
3689
4580
  const challenge = Challenge.from({
@@ -3702,6 +4593,7 @@ describe('verifyCredential', () => {
3702
4593
  await expect(mppx.verifyCredential(credential)).rejects.toThrow(
3703
4594
  'no registered method for unknown/charge',
3704
4595
  )
4596
+ expect(events).toEqual(['unknown/charge:InvalidChallengeError'])
3705
4597
  })
3706
4598
 
3707
4599
  test('rejects malformed credential string', async () => {