mppx 0.5.17 → 0.6.1

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 (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/Method.d.ts +2 -0
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +2 -0
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +4 -1
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/index.d.ts +1 -0
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +1 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts +4 -0
  14. package/dist/client/internal/Fetch.d.ts.map +1 -1
  15. package/dist/client/internal/Fetch.js +43 -5
  16. package/dist/client/internal/Fetch.js.map +1 -1
  17. package/dist/server/Mppx.d.ts +45 -1
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +139 -16
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts.map +1 -1
  22. package/dist/stripe/server/Charge.js +8 -1
  23. package/dist/stripe/server/Charge.js.map +1 -1
  24. package/dist/tempo/server/Charge.d.ts.map +1 -1
  25. package/dist/tempo/server/Charge.js +15 -4
  26. package/dist/tempo/server/Charge.js.map +1 -1
  27. package/dist/tempo/server/Session.d.ts +39 -38
  28. package/dist/tempo/server/Session.d.ts.map +1 -1
  29. package/dist/tempo/server/Session.js +14 -24
  30. package/dist/tempo/server/Session.js.map +1 -1
  31. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  32. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  33. package/dist/tempo/server/internal/html.gen.js +1 -1
  34. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  35. package/dist/tempo/server/internal/request-body.d.ts +8 -0
  36. package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
  37. package/dist/tempo/server/internal/request-body.js +27 -0
  38. package/dist/tempo/server/internal/request-body.js.map +1 -0
  39. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  40. package/dist/tempo/server/internal/transport.js +6 -17
  41. package/dist/tempo/server/internal/transport.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Method.ts +2 -0
  44. package/src/cli/cli.test.ts +15 -7
  45. package/src/client/Mppx.ts +11 -2
  46. package/src/client/index.ts +1 -0
  47. package/src/client/internal/Fetch.browser.test.ts +58 -0
  48. package/src/client/internal/Fetch.test.ts +173 -0
  49. package/src/client/internal/Fetch.ts +62 -3
  50. package/src/server/Mppx.test-d.ts +36 -0
  51. package/src/server/Mppx.test.ts +1073 -1
  52. package/src/server/Mppx.ts +241 -22
  53. package/src/server/Transport.test.ts +2 -1
  54. package/src/stripe/server/Charge.ts +7 -1
  55. package/src/tempo/server/Charge.ts +15 -4
  56. package/src/tempo/server/Session.test.ts +68 -0
  57. package/src/tempo/server/Session.ts +15 -35
  58. package/src/tempo/server/internal/html.gen.ts +1 -1
  59. package/src/tempo/server/internal/request-body.test.ts +142 -0
  60. package/src/tempo/server/internal/request-body.ts +37 -0
  61. package/src/tempo/server/internal/transport.test.ts +126 -2
  62. package/src/tempo/server/internal/transport.ts +7 -19
@@ -1,11 +1,20 @@
1
1
  import * as http from 'node:http'
2
2
 
3
3
  import { Challenge, Credential, Method, z } from 'mppx'
4
- import { Mppx, Transport, tempo } from 'mppx/server'
4
+ import {
5
+ Mppx as Mppx_client,
6
+ session as tempo_session_client,
7
+ tempo as tempo_client,
8
+ } from 'mppx/client'
9
+ import { Mppx, stripe, Store, Transport, tempo } from 'mppx/server'
10
+ import { getTransactionReceipt } from 'viem/actions'
5
11
  import { describe, expect, test } from 'vp/test'
6
12
  import * as Http from '~test/Http.js'
13
+ import { deployEscrow } from '~test/tempo/session.js'
7
14
  import { accounts, asset, client } from '~test/tempo/viem.js'
8
15
 
16
+ import type { SessionReceipt } from '../tempo/session/Types.js'
17
+
9
18
  const realm = 'api.example.com'
10
19
  const secretKey = 'test-secret-key'
11
20
 
@@ -2099,6 +2108,62 @@ describe('cross-route credential replay via scope binding flaw', () => {
2099
2108
  expect(result.status).toBe(402)
2100
2109
  })
2101
2110
 
2111
+ test('rejects request-billed credential replayed at token-billed route', async () => {
2112
+ const sessionMethod = Method.from({
2113
+ name: 'mock',
2114
+ intent: 'session',
2115
+ schema: {
2116
+ credential: { payload: z.object({ token: z.string() }) },
2117
+ request: z.object({
2118
+ amount: z.string(),
2119
+ currency: z.string(),
2120
+ recipient: z.string(),
2121
+ unitType: z.string(),
2122
+ }),
2123
+ },
2124
+ })
2125
+
2126
+ const sessionServerMethod = Method.toServer(sessionMethod, {
2127
+ async verify() {
2128
+ return mockReceipt()
2129
+ },
2130
+ })
2131
+
2132
+ const handler = Mppx.create({ methods: [sessionServerMethod], realm, secretKey })
2133
+
2134
+ const requestRoute = handler.session({
2135
+ amount: '1',
2136
+ currency: '0x0000000000000000000000000000000000000001',
2137
+ expires: new Date(Date.now() + 60_000).toISOString(),
2138
+ recipient: '0x0000000000000000000000000000000000000002',
2139
+ unitType: 'request',
2140
+ })
2141
+ const tokenRoute = handler.session({
2142
+ amount: '1',
2143
+ currency: '0x0000000000000000000000000000000000000001',
2144
+ expires: new Date(Date.now() + 60_000).toISOString(),
2145
+ recipient: '0x0000000000000000000000000000000000000002',
2146
+ unitType: 'token',
2147
+ })
2148
+
2149
+ const first = await requestRoute(new Request('https://example.com/request'))
2150
+ expect(first.status).toBe(402)
2151
+ if (first.status !== 402) throw new Error()
2152
+
2153
+ const credential = Credential.from({
2154
+ challenge: Challenge.fromResponse(first.challenge),
2155
+ payload: { token: 'valid' },
2156
+ })
2157
+
2158
+ const result = await tokenRoute(
2159
+ new Request('https://example.com/token', {
2160
+ headers: { Authorization: Credential.serialize(credential) },
2161
+ }),
2162
+ )
2163
+
2164
+ expect(result.status).toBe(402)
2165
+ })
2166
+
2102
2167
  test('rejects credential with mismatched method field', async () => {
2103
2168
  const otherMethod = Method.from({
2104
2169
  name: 'other',
@@ -2861,3 +2926,1010 @@ describe('realm auto-detection', () => {
2861
2926
  expect(body.detail).toContain('realm')
2862
2927
  })
2863
2928
  })
2929
+
2930
+ // ── mppx.challenge ──────────────────────────────────────────────────────
2931
+
2932
+ describe('challenge', () => {
2933
+ const mockCharge = Method.from({
2934
+ name: 'alpha',
2935
+ intent: 'charge',
2936
+ schema: {
2937
+ credential: { payload: z.object({ token: z.string() }) },
2938
+ request: z.object({
2939
+ amount: z.string(),
2940
+ currency: z.string(),
2941
+ decimals: z.number(),
2942
+ recipient: z.string(),
2943
+ }),
2944
+ },
2945
+ })
2946
+
2947
+ const mockSession = Method.from({
2948
+ name: 'alpha',
2949
+ intent: 'session',
2950
+ schema: {
2951
+ credential: {
2952
+ payload: z.discriminatedUnion('action', [
2953
+ z.object({ action: z.literal('open'), token: z.string() }),
2954
+ z.object({ action: z.literal('voucher'), amount: z.string() }),
2955
+ ]),
2956
+ },
2957
+ request: z.object({
2958
+ amount: z.string(),
2959
+ currency: z.string(),
2960
+ recipient: z.string(),
2961
+ unitType: z.string(),
2962
+ }),
2963
+ },
2964
+ })
2965
+
2966
+ const betaCharge = Method.from({
2967
+ name: 'beta',
2968
+ intent: 'charge',
2969
+ schema: {
2970
+ credential: { payload: z.object({ token: z.string() }) },
2971
+ request: z.object({
2972
+ amount: z.string(),
2973
+ currency: z.string(),
2974
+ decimals: z.number(),
2975
+ recipient: z.string(),
2976
+ }),
2977
+ },
2978
+ })
2979
+
2980
+ function mockReceipt(name: string) {
2981
+ return {
2982
+ method: name,
2983
+ reference: `tx-${name}`,
2984
+ status: 'success' as const,
2985
+ timestamp: new Date().toISOString(),
2986
+ }
2987
+ }
2988
+
2989
+ const alphaChargeServer = Method.toServer(mockCharge, {
2990
+ async verify() {
2991
+ return mockReceipt('alpha')
2992
+ },
2993
+ })
2994
+
2995
+ const alphaSessionServer = Method.toServer(mockSession, {
2996
+ async verify() {
2997
+ return mockReceipt('alpha-session')
2998
+ },
2999
+ })
3000
+
3001
+ const betaChargeServer = Method.toServer(betaCharge, {
3002
+ async verify() {
3003
+ return mockReceipt('beta')
3004
+ },
3005
+ })
3006
+
3007
+ const challengeOpts = {
3008
+ amount: '1000',
3009
+ currency: '0x0000000000000000000000000000000000000001',
3010
+ decimals: 6,
3011
+ expires: new Date(Date.now() + 60_000).toISOString(),
3012
+ recipient: '0x0000000000000000000000000000000000000002',
3013
+ }
3014
+
3015
+ test('mppx.challenge.alpha.charge returns a valid Challenge object', async () => {
3016
+ const mppx = Mppx.create({
3017
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
3018
+ realm,
3019
+ secretKey,
3020
+ })
3021
+
3022
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3023
+
3024
+ expect(challenge.method).toBe('alpha')
3025
+ expect(challenge.intent).toBe('charge')
3026
+ expect(challenge.realm).toBe(realm)
3027
+ expect(challenge.request.amount).toBe('1000')
3028
+ expect(challenge.request.currency).toBe('0x0000000000000000000000000000000000000001')
3029
+ expect(challenge.request.recipient).toBe('0x0000000000000000000000000000000000000002')
3030
+ expect(challenge.id).toBeDefined()
3031
+ })
3032
+
3033
+ test('mppx.challenge.alpha.session returns a valid Challenge object', async () => {
3034
+ const mppx = Mppx.create({
3035
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
3036
+ realm,
3037
+ secretKey,
3038
+ })
3039
+
3040
+ const challenge = await mppx.challenge.alpha.session({
3041
+ amount: '500',
3042
+ currency: '0x0000000000000000000000000000000000000001',
3043
+ recipient: '0x0000000000000000000000000000000000000002',
3044
+ unitType: 'token',
3045
+ })
3046
+
3047
+ expect(challenge.method).toBe('alpha')
3048
+ expect(challenge.intent).toBe('session')
3049
+ expect(challenge.realm).toBe(realm)
3050
+ expect(challenge.request.unitType).toBe('token')
3051
+ })
3052
+
3053
+ test('mppx.challenge.beta.charge returns challenge for a different method', async () => {
3054
+ const mppx = Mppx.create({
3055
+ methods: [alphaChargeServer, betaChargeServer],
3056
+ realm,
3057
+ secretKey,
3058
+ })
3059
+
3060
+ const challenge = await mppx.challenge.beta.charge(challengeOpts)
3061
+
3062
+ expect(challenge.method).toBe('beta')
3063
+ expect(challenge.intent).toBe('charge')
3064
+ })
3065
+
3066
+ test('challenge ID is HMAC-bound and verifiable', async () => {
3067
+ const mppx = Mppx.create({
3068
+ methods: [alphaChargeServer],
3069
+ realm,
3070
+ secretKey,
3071
+ })
3072
+
3073
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3074
+ expect(Challenge.verify(challenge, { secretKey })).toBe(true)
3075
+ })
3076
+
3077
+ test('challenge includes description and meta when provided', async () => {
3078
+ const mppx = Mppx.create({
3079
+ methods: [alphaChargeServer],
3080
+ realm,
3081
+ secretKey,
3082
+ })
3083
+
3084
+ const challenge = await mppx.challenge.alpha.charge({
3085
+ ...challengeOpts,
3086
+ description: 'Order #123',
3087
+ meta: { checkout_id: 'chk_abc' },
3088
+ })
3089
+
3090
+ expect(challenge.description).toBe('Order #123')
3091
+ expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' })
3092
+ })
3093
+
3094
+ test('challenge applies schema transforms', async () => {
3095
+ // Method with a z.transform that converts decimals
3096
+ const transformMethod = Method.from({
3097
+ name: 'transform',
3098
+ intent: 'charge',
3099
+ schema: {
3100
+ credential: { payload: z.object({ token: z.string() }) },
3101
+ request: z.pipe(
3102
+ z.object({
3103
+ amount: z.string(),
3104
+ currency: z.string(),
3105
+ decimals: z.number(),
3106
+ recipient: z.string(),
3107
+ }),
3108
+ z.transform(({ amount, currency, decimals, recipient }) => ({
3109
+ amount: String(Number(amount) * 10 ** decimals),
3110
+ currency,
3111
+ recipient,
3112
+ })),
3113
+ ),
3114
+ },
3115
+ })
3116
+
3117
+ const serverMethod = Method.toServer(transformMethod, {
3118
+ async verify() {
3119
+ return mockReceipt('transform')
3120
+ },
3121
+ })
3122
+
3123
+ const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey })
3124
+
3125
+ const challenge = await mppx.challenge.transform.charge({
3126
+ amount: '25.92',
3127
+ currency: '0x0000000000000000000000000000000000000001',
3128
+ decimals: 6,
3129
+ recipient: '0x0000000000000000000000000000000000000002',
3130
+ })
3131
+
3132
+ // Schema transform should apply: 25.92 * 10^6 = 25920000
3133
+ expect(challenge.request.amount).toBe('25920000')
3134
+ })
3135
+
3136
+ test('challenge awaits async request hooks before creating the challenge', async () => {
3137
+ const asyncMethod = Method.from({
3138
+ name: 'async',
3139
+ intent: 'charge',
3140
+ schema: {
3141
+ credential: { payload: z.object({ token: z.string() }) },
3142
+ request: z.pipe(
3143
+ z.object({
3144
+ amount: z.string(),
3145
+ chainId: z.optional(z.number()),
3146
+ currency: z.string(),
3147
+ decimals: z.number(),
3148
+ recipient: z.string(),
3149
+ }),
3150
+ z.transform(({ amount, chainId, currency, decimals, recipient }) => ({
3151
+ amount: String(Number(amount) * 10 ** decimals),
3152
+ currency,
3153
+ methodDetails: { chainId },
3154
+ recipient,
3155
+ })),
3156
+ ),
3157
+ },
3158
+ })
3159
+
3160
+ const asyncServer = Method.toServer(asyncMethod, {
3161
+ async request({ request }) {
3162
+ await Promise.resolve()
3163
+ return { ...request, chainId: 42431 }
3164
+ },
3165
+ async verify() {
3166
+ return mockReceipt('async')
3167
+ },
3168
+ })
3169
+
3170
+ const mppx = Mppx.create({ methods: [asyncServer], realm, secretKey })
3171
+
3172
+ const challenge = await mppx.challenge.async.charge({
3173
+ amount: '25.92',
3174
+ currency: '0x0000000000000000000000000000000000000001',
3175
+ decimals: 6,
3176
+ recipient: '0x0000000000000000000000000000000000000002',
3177
+ })
3178
+
3179
+ expect(challenge.request.amount).toBe('25920000')
3180
+ expect(challenge.request.methodDetails).toEqual({ chainId: 42431 })
3181
+ })
3182
+
3183
+ test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => {
3184
+ const mppx = Mppx.create({
3185
+ methods: [alphaChargeServer],
3186
+ realm,
3187
+ secretKey,
3188
+ })
3189
+
3190
+ // Generate challenge via the new API
3191
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3192
+
3193
+ // Build a credential from it
3194
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3195
+
3196
+ // Present it to the 402 handler
3197
+ const result = await mppx.charge(challengeOpts)(
3198
+ new Request('https://example.com/resource', {
3199
+ headers: { Authorization: Credential.serialize(credential) },
3200
+ }),
3201
+ )
3202
+
3203
+ expect(result.status).toBe(200)
3204
+ })
3205
+ })
3206
+
3207
+ // ── mppx.verifyCredential ───────────────────────────────────────────────
3208
+
3209
+ describe('verifyCredential', () => {
3210
+ const mockCharge = Method.from({
3211
+ name: 'alpha',
3212
+ intent: 'charge',
3213
+ schema: {
3214
+ credential: { payload: z.object({ token: z.string() }) },
3215
+ request: z.object({
3216
+ amount: z.string(),
3217
+ currency: z.string(),
3218
+ decimals: z.number(),
3219
+ recipient: z.string(),
3220
+ }),
3221
+ },
3222
+ })
3223
+
3224
+ const mockSession = Method.from({
3225
+ name: 'alpha',
3226
+ intent: 'session',
3227
+ schema: {
3228
+ credential: {
3229
+ payload: z.discriminatedUnion('action', [
3230
+ z.object({ action: z.literal('open'), token: z.string() }),
3231
+ z.object({ action: z.literal('voucher'), amount: z.string() }),
3232
+ ]),
3233
+ },
3234
+ request: z.object({
3235
+ amount: z.string(),
3236
+ currency: z.string(),
3237
+ recipient: z.string(),
3238
+ unitType: z.string(),
3239
+ }),
3240
+ },
3241
+ })
3242
+
3243
+ const betaCharge = Method.from({
3244
+ name: 'beta',
3245
+ intent: 'charge',
3246
+ schema: {
3247
+ credential: { payload: z.object({ token: z.string() }) },
3248
+ request: z.object({
3249
+ amount: z.string(),
3250
+ currency: z.string(),
3251
+ decimals: z.number(),
3252
+ recipient: z.string(),
3253
+ }),
3254
+ },
3255
+ })
3256
+
3257
+ function mockReceipt(name: string) {
3258
+ return {
3259
+ method: name,
3260
+ reference: `tx-${name}`,
3261
+ status: 'success' as const,
3262
+ timestamp: new Date().toISOString(),
3263
+ }
3264
+ }
3265
+
3266
+ let verifyArgs: Record<string, unknown> | undefined
3267
+
3268
+ const alphaChargeServer = Method.toServer(mockCharge, {
3269
+ async verify({ credential, request }) {
3270
+ verifyArgs = { credential, request }
3271
+ return mockReceipt('alpha')
3272
+ },
3273
+ })
3274
+
3275
+ const alphaSessionServer = Method.toServer(mockSession, {
3276
+ async verify({ credential, request }) {
3277
+ verifyArgs = { credential, request }
3278
+ return mockReceipt('alpha-session')
3279
+ },
3280
+ })
3281
+
3282
+ const betaChargeServer = Method.toServer(betaCharge, {
3283
+ async verify() {
3284
+ return mockReceipt('beta')
3285
+ },
3286
+ })
3287
+
3288
+ const challengeOpts = {
3289
+ amount: '1000',
3290
+ currency: '0x0000000000000000000000000000000000000001',
3291
+ decimals: 6,
3292
+ expires: new Date(Date.now() + 60_000).toISOString(),
3293
+ recipient: '0x0000000000000000000000000000000000000002',
3294
+ }
3295
+
3296
+ test('verifies a serialized credential string (charge)', async () => {
3297
+ verifyArgs = undefined
3298
+ const mppx = Mppx.create({
3299
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
3300
+ realm,
3301
+ secretKey,
3302
+ })
3303
+
3304
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3305
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3306
+ const serialized = Credential.serialize(credential)
3307
+
3308
+ const receipt = await mppx.verifyCredential(serialized)
3309
+
3310
+ expect(receipt.status).toBe('success')
3311
+ expect(receipt.method).toBe('alpha')
3312
+ expect(verifyArgs).toBeDefined()
3313
+ })
3314
+
3315
+ test('verifies a parsed Credential object (charge)', async () => {
3316
+ verifyArgs = undefined
3317
+ const mppx = Mppx.create({
3318
+ methods: [alphaChargeServer],
3319
+ realm,
3320
+ secretKey,
3321
+ })
3322
+
3323
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3324
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3325
+
3326
+ const receipt = await mppx.verifyCredential(credential)
3327
+
3328
+ expect(receipt.status).toBe('success')
3329
+ expect(receipt.method).toBe('alpha')
3330
+ })
3331
+
3332
+ test('verifies a credential for session intent', async () => {
3333
+ verifyArgs = undefined
3334
+ const mppx = Mppx.create({
3335
+ methods: [alphaChargeServer, alphaSessionServer],
3336
+ realm,
3337
+ secretKey,
3338
+ })
3339
+
3340
+ const challenge = await mppx.challenge.alpha.session({
3341
+ amount: '500',
3342
+ currency: '0x0000000000000000000000000000000000000001',
3343
+ recipient: '0x0000000000000000000000000000000000000002',
3344
+ unitType: 'token',
3345
+ })
3346
+ const credential = Credential.from({
3347
+ challenge,
3348
+ payload: { action: 'open', token: 'valid' },
3349
+ })
3350
+
3351
+ const receipt = await mppx.verifyCredential(credential)
3352
+
3353
+ expect(receipt.status).toBe('success')
3354
+ expect(receipt.method).toBe('alpha-session')
3355
+ })
3356
+
3357
+ test('dispatches to correct method when multiple methods are registered', async () => {
3358
+ const mppx = Mppx.create({
3359
+ methods: [alphaChargeServer, betaChargeServer],
3360
+ realm,
3361
+ secretKey,
3362
+ })
3363
+
3364
+ const challenge = await mppx.challenge.beta.charge(challengeOpts)
3365
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3366
+
3367
+ const receipt = await mppx.verifyCredential(credential)
3368
+
3369
+ expect(receipt.method).toBe('beta')
3370
+ })
3371
+
3372
+ test('rejects credential when verified against different route economics', async () => {
3373
+ const mppx = Mppx.create({
3374
+ methods: [alphaChargeServer],
3375
+ realm,
3376
+ secretKey,
3377
+ })
3378
+
3379
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3380
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3381
+
3382
+ await expect(
3383
+ mppx.verifyCredential(credential, {
3384
+ request: {
3385
+ amount: '100000',
3386
+ currency: '0x0000000000000000000000000000000000000001',
3387
+ decimals: 6,
3388
+ recipient: '0x0000000000000000000000000000000000000002',
3389
+ },
3390
+ }),
3391
+ ).rejects.toThrow()
3392
+ })
3393
+
3394
+ test('rejects credential with wrong HMAC (not issued by this server)', async () => {
3395
+ const mppx = Mppx.create({
3396
+ methods: [alphaChargeServer],
3397
+ realm,
3398
+ secretKey,
3399
+ })
3400
+
3401
+ const wrongChallenge = Challenge.from({
3402
+ id: 'tampered-id',
3403
+ intent: 'charge',
3404
+ method: 'alpha',
3405
+ realm,
3406
+ request: {
3407
+ amount: '1000',
3408
+ currency: '0x0000000000000000000000000000000000000001',
3409
+ decimals: 6,
3410
+ recipient: '0x0000000000000000000000000000000000000002',
3411
+ },
3412
+ })
3413
+ const credential = Credential.from({
3414
+ challenge: wrongChallenge,
3415
+ payload: { token: 'valid' },
3416
+ })
3417
+
3418
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow(
3419
+ 'challenge was not issued by this server',
3420
+ )
3421
+ })
3422
+
3423
+ test('rejects credential with expired challenge', async () => {
3424
+ const mppx = Mppx.create({
3425
+ methods: [alphaChargeServer],
3426
+ realm,
3427
+ secretKey,
3428
+ })
3429
+
3430
+ const challenge = await mppx.challenge.alpha.charge({
3431
+ ...challengeOpts,
3432
+ expires: new Date(Date.now() - 1000).toISOString(), // already expired
3433
+ })
3434
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3435
+
3436
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow()
3437
+ })
3438
+
3439
+ test('rejects credential with invalid payload schema', async () => {
3440
+ const mppx = Mppx.create({
3441
+ methods: [alphaChargeServer],
3442
+ realm,
3443
+ secretKey,
3444
+ })
3445
+
3446
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3447
+ const credential = Credential.from({
3448
+ challenge,
3449
+ payload: { wrong_field: 123 }, // doesn't match z.object({ token: z.string() })
3450
+ })
3451
+
3452
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow()
3453
+ })
3454
+
3455
+ test('rejects credential for unregistered method/intent', async () => {
3456
+ const mppx = Mppx.create({
3457
+ methods: [alphaChargeServer],
3458
+ realm,
3459
+ secretKey,
3460
+ })
3461
+
3462
+ // Forge a challenge for an unregistered method using the same secret
3463
+ const challenge = Challenge.from({
3464
+ secretKey,
3465
+ intent: 'charge',
3466
+ method: 'unknown',
3467
+ realm,
3468
+ expires: new Date(Date.now() + 60_000).toISOString(),
3469
+ request: {
3470
+ amount: '1000',
3471
+ currency: '0x0000000000000000000000000000000000000001',
3472
+ },
3473
+ })
3474
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3475
+
3476
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow(
3477
+ 'no registered method for unknown/charge',
3478
+ )
3479
+ })
3480
+
3481
+ test('rejects malformed credential string', async () => {
3482
+ const mppx = Mppx.create({
3483
+ methods: [alphaChargeServer],
3484
+ realm,
3485
+ secretKey,
3486
+ })
3487
+
3488
+ await expect(mppx.verifyCredential('not-valid-base64')).rejects.toThrow()
3489
+ })
3490
+
3491
+ test('challenge + verifyCredential round-trip with schema transforms', async () => {
3492
+ const transformMethod = Method.from({
3493
+ name: 'transform',
3494
+ intent: 'charge',
3495
+ schema: {
3496
+ credential: { payload: z.object({ token: z.string() }) },
3497
+ request: z.pipe(
3498
+ z.object({
3499
+ amount: z.string(),
3500
+ currency: z.string(),
3501
+ decimals: z.number(),
3502
+ recipient: z.string(),
3503
+ }),
3504
+ z.transform(({ amount, currency, decimals, recipient }) => ({
3505
+ amount: String(Number(amount) * 10 ** decimals),
3506
+ currency,
3507
+ recipient,
3508
+ })),
3509
+ ),
3510
+ },
3511
+ })
3512
+
3513
+ const serverMethod = Method.toServer(transformMethod, {
3514
+ async verify() {
3515
+ return mockReceipt('transform')
3516
+ },
3517
+ })
3518
+
3519
+ const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey })
3520
+
3521
+ // Generate challenge with human-readable amount
3522
+ const challenge = await mppx.challenge.transform.charge({
3523
+ amount: '25.92',
3524
+ currency: '0x0000000000000000000000000000000000000001',
3525
+ decimals: 6,
3526
+ recipient: '0x0000000000000000000000000000000000000002',
3527
+ })
3528
+
3529
+ // Verify the transform was applied
3530
+ expect(challenge.request.amount).toBe('25920000')
3531
+
3532
+ // Build credential and verify end-to-end
3533
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3534
+ const receipt = await mppx.verifyCredential(credential)
3535
+
3536
+ expect(receipt.status).toBe('success')
3537
+ expect(receipt.method).toBe('transform')
3538
+ })
3539
+
3540
+ test('verifies a credential for a transformed built-in method', async () => {
3541
+ const stripeClient = {
3542
+ paymentIntents: {
3543
+ create: async (input: { amount: number; currency: string }) => {
3544
+ expect(input.amount).toBe(2592)
3545
+ expect(input.currency).toBe('usd')
3546
+
3547
+ return {
3548
+ id: 'pi_123',
3549
+ lastResponse: { headers: {} },
3550
+ status: 'succeeded',
3551
+ }
3552
+ },
3553
+ },
3554
+ }
3555
+
3556
+ const mppx = Mppx.create({
3557
+ methods: [
3558
+ stripe.charge({
3559
+ client: stripeClient as never,
3560
+ currency: 'usd',
3561
+ decimals: 2,
3562
+ networkId: 'internal',
3563
+ paymentMethodTypes: ['card'],
3564
+ }),
3565
+ ],
3566
+ realm,
3567
+ secretKey,
3568
+ })
3569
+
3570
+ const challenge = await mppx.challenge.stripe.charge({
3571
+ amount: '25.92',
3572
+ })
3573
+ const credential = Credential.from({
3574
+ challenge,
3575
+ payload: { spt: 'spt_test' },
3576
+ })
3577
+
3578
+ const receipt = await mppx.verifyCredential(credential)
3579
+
3580
+ expect(receipt.status).toBe('success')
3581
+ expect(receipt.method).toBe('stripe')
3582
+ })
3583
+
3584
+ test('verifies a serialized credential for a transformed built-in method', async () => {
3585
+ const stripeClient = {
3586
+ paymentIntents: {
3587
+ create: async (input: { amount: number; currency: string }) => {
3588
+ expect(input.amount).toBe(2592)
3589
+ expect(input.currency).toBe('usd')
3590
+
3591
+ return {
3592
+ id: 'pi_456',
3593
+ lastResponse: { headers: {} },
3594
+ status: 'succeeded',
3595
+ }
3596
+ },
3597
+ },
3598
+ }
3599
+
3600
+ const mppx = Mppx.create({
3601
+ methods: [
3602
+ stripe.charge({
3603
+ client: stripeClient as never,
3604
+ currency: 'usd',
3605
+ decimals: 2,
3606
+ networkId: 'internal',
3607
+ paymentMethodTypes: ['card'],
3608
+ }),
3609
+ ],
3610
+ realm,
3611
+ secretKey,
3612
+ })
3613
+
3614
+ const challenge = await mppx.challenge.stripe.charge({ amount: '25.92' })
3615
+ const credential = Credential.from({
3616
+ challenge,
3617
+ payload: { spt: 'spt_serialized' },
3618
+ })
3619
+
3620
+ const receipt = await mppx.verifyCredential(Credential.serialize(credential))
3621
+
3622
+ expect(receipt.status).toBe('success')
3623
+ expect(receipt.method).toBe('stripe')
3624
+ })
3625
+
3626
+ test('verifies a zero-amount proof credential created from a real 402 response', async () => {
3627
+ const server = Mppx.create({
3628
+ methods: [
3629
+ tempo.charge({
3630
+ account: accounts[0],
3631
+ currency: asset,
3632
+ getClient: () => client,
3633
+ }),
3634
+ ],
3635
+ realm,
3636
+ secretKey,
3637
+ })
3638
+ const clientMppx = Mppx_client.create({
3639
+ polyfill: false,
3640
+ methods: [
3641
+ tempo_client.charge({
3642
+ account: accounts[1],
3643
+ getClient: () => client,
3644
+ }),
3645
+ ],
3646
+ })
3647
+
3648
+ const httpServer = await Http.createServer(async (req, res) => {
3649
+ const result = await Mppx.toNodeListener(server.charge({ amount: '0' }))(req, res)
3650
+ if (result.status === 402) return
3651
+ res.end('OK')
3652
+ })
3653
+
3654
+ const response = await fetch(httpServer.url)
3655
+ expect(response.status).toBe(402)
3656
+
3657
+ const serializedCredential = await clientMppx.createCredential(response)
3658
+ const proofCredential = Credential.deserialize(serializedCredential)
3659
+ expect(proofCredential.payload).toMatchObject({ type: 'proof' })
3660
+
3661
+ const receipt = await server.verifyCredential(serializedCredential)
3662
+
3663
+ expect(receipt.status).toBe('success')
3664
+ expect(receipt.method).toBe('tempo')
3665
+
3666
+ httpServer.close()
3667
+ })
3668
+
3669
+ test('verifies a sponsored tempo credential created from a real 402 response', async () => {
3670
+ const server = Mppx.create({
3671
+ methods: [
3672
+ tempo.charge({
3673
+ account: accounts[0],
3674
+ currency: asset,
3675
+ feePayer: true,
3676
+ getClient: () => client,
3677
+ }),
3678
+ ],
3679
+ realm,
3680
+ secretKey,
3681
+ })
3682
+ const clientMppx = Mppx_client.create({
3683
+ polyfill: false,
3684
+ methods: [
3685
+ tempo_client.charge({
3686
+ account: accounts[1],
3687
+ getClient: () => client,
3688
+ }),
3689
+ ],
3690
+ })
3691
+
3692
+ const httpServer = await Http.createServer(async (req, res) => {
3693
+ const result = await Mppx.toNodeListener(server.charge({ amount: '1' }))(req, res)
3694
+ if (result.status === 402) return
3695
+ res.end('OK')
3696
+ })
3697
+
3698
+ const response = await fetch(httpServer.url)
3699
+ expect(response.status).toBe(402)
3700
+
3701
+ const serializedCredential = await clientMppx.createCredential(response, { mode: 'pull' })
3702
+ const transactionCredential = Credential.deserialize(serializedCredential)
3703
+ expect(transactionCredential.payload).toMatchObject({ type: 'transaction' })
3704
+
3705
+ const receipt = await server.verifyCredential(serializedCredential)
3706
+
3707
+ expect(receipt.status).toBe('success')
3708
+ expect(receipt.method).toBe('tempo')
3709
+
3710
+ const txReceipt = await getTransactionReceipt(client, {
3711
+ hash: receipt.reference as `0x${string}`,
3712
+ })
3713
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3714
+
3715
+ httpServer.close()
3716
+ })
3717
+
3718
+ test('verifies real session open and voucher credentials created from 402 responses', async () => {
3719
+ const escrowContract = await deployEscrow()
3720
+ const server = Mppx.create({
3721
+ methods: [
3722
+ tempo.session({
3723
+ store: Store.memory(),
3724
+ getClient: () => client,
3725
+ account: accounts[0],
3726
+ currency: asset,
3727
+ escrowContract,
3728
+ chainId: client.chain!.id,
3729
+ }),
3730
+ ],
3731
+ realm,
3732
+ secretKey,
3733
+ })
3734
+ const clientMppx = Mppx_client.create({
3735
+ polyfill: false,
3736
+ methods: [
3737
+ tempo_session_client({
3738
+ account: accounts[1],
3739
+ deposit: '10',
3740
+ getClient: () => client,
3741
+ }),
3742
+ ],
3743
+ })
3744
+
3745
+ const httpServer = await Http.createServer(async (req, res) => {
3746
+ const result = await Mppx.toNodeListener(
3747
+ server.session({ amount: '1', unitType: 'request' }),
3748
+ )(req, res)
3749
+ if (result.status === 402) return
3750
+ res.end('OK')
3751
+ })
3752
+
3753
+ const openChallengeResponse = await fetch(httpServer.url)
3754
+ expect(openChallengeResponse.status).toBe(402)
3755
+
3756
+ const serializedOpenCredential = await clientMppx.createCredential(openChallengeResponse)
3757
+ const openCredential = Credential.deserialize(serializedOpenCredential)
3758
+ expect(openCredential.payload).toMatchObject({ action: 'open' })
3759
+
3760
+ const openReceipt = await server.verifyCredential(serializedOpenCredential)
3761
+
3762
+ expect(openReceipt.status).toBe('success')
3763
+ expect(openReceipt.method).toBe('tempo')
3764
+
3765
+ const voucherChallengeResponse = await fetch(httpServer.url)
3766
+ expect(voucherChallengeResponse.status).toBe(402)
3767
+
3768
+ const serializedVoucherCredential = await clientMppx.createCredential(voucherChallengeResponse)
3769
+ const voucherCredential = Credential.deserialize(serializedVoucherCredential)
3770
+ expect(voucherCredential.payload).toMatchObject({ action: 'voucher' })
3771
+
3772
+ const voucherReceipt = await server.verifyCredential(serializedVoucherCredential)
3773
+
3774
+ expect(voucherReceipt.status).toBe('success')
3775
+ expect(voucherReceipt.method).toBe('tempo')
3776
+ expect(voucherReceipt.reference).toBe(openReceipt.reference)
3777
+
3778
+ httpServer.close()
3779
+ })
3780
+
3781
+ test('verifyCredential charges repeated session voucher content requests when capturedRequest is provided', async () => {
3782
+ const escrowContract = await deployEscrow()
3783
+ const server = Mppx.create({
3784
+ methods: [
3785
+ tempo.session({
3786
+ store: Store.memory(),
3787
+ getClient: () => client,
3788
+ account: accounts[0],
3789
+ currency: asset,
3790
+ escrowContract,
3791
+ chainId: client.chain!.id,
3792
+ }),
3793
+ ],
3794
+ realm,
3795
+ secretKey,
3796
+ })
3797
+ const route = server.session({ amount: '1', unitType: 'request' })
3798
+ const clientMppx = Mppx_client.create({
3799
+ polyfill: false,
3800
+ methods: [
3801
+ tempo_session_client({
3802
+ account: accounts[1],
3803
+ deposit: '10',
3804
+ getClient: () => client,
3805
+ }),
3806
+ ],
3807
+ })
3808
+
3809
+ const openChallengeResponse = await route(new Request('https://example.com/session'))
3810
+ expect(openChallengeResponse.status).toBe(402)
3811
+ if (openChallengeResponse.status !== 402) throw new Error()
3812
+
3813
+ const serializedOpenCredential = await clientMppx.createCredential(
3814
+ openChallengeResponse.challenge,
3815
+ )
3816
+ await server.verifyCredential(serializedOpenCredential)
3817
+
3818
+ const voucherChallengeResponse = await route(new Request('https://example.com/session'))
3819
+ expect(voucherChallengeResponse.status).toBe(402)
3820
+ if (voucherChallengeResponse.status !== 402) throw new Error()
3821
+
3822
+ const serializedVoucherCredential = await clientMppx.createCredential(
3823
+ voucherChallengeResponse.challenge,
3824
+ )
3825
+ const contentRequest = {
3826
+ headers: new Headers(),
3827
+ hasBody: false,
3828
+ method: 'GET',
3829
+ url: new URL('https://example.com/session'),
3830
+ } as const
3831
+ const routeRequest = { amount: '1', unitType: 'request' } as const
3832
+
3833
+ const firstReceipt = (await server.verifyCredential(serializedVoucherCredential, {
3834
+ capturedRequest: contentRequest,
3835
+ request: routeRequest,
3836
+ })) as SessionReceipt
3837
+ const secondReceipt = (await server.verifyCredential(serializedVoucherCredential, {
3838
+ capturedRequest: contentRequest,
3839
+ request: routeRequest,
3840
+ })) as SessionReceipt
3841
+
3842
+ expect(BigInt(firstReceipt.spent)).toBeGreaterThan(0n)
3843
+ expect(firstReceipt.units).toBe(1)
3844
+ expect(BigInt(secondReceipt.spent)).toBeGreaterThan(BigInt(firstReceipt.spent))
3845
+ expect(secondReceipt.units).toBe(2)
3846
+ })
3847
+
3848
+ test('verifies a sponsored tempo credential created by the real client', async () => {
3849
+ const server = Mppx.create({
3850
+ methods: [
3851
+ tempo.charge({
3852
+ account: accounts[0],
3853
+ currency: asset,
3854
+ feePayer: true,
3855
+ getClient: () => client,
3856
+ }),
3857
+ ],
3858
+ realm,
3859
+ secretKey,
3860
+ })
3861
+
3862
+ const challenge = await server.challenge.tempo.charge({ amount: '1' })
3863
+ const clientMethod = tempo_client.charge({
3864
+ account: accounts[1],
3865
+ getClient: () => client,
3866
+ })
3867
+ const credential = await clientMethod.createCredential({
3868
+ challenge: challenge as Parameters<typeof clientMethod.createCredential>[0]['challenge'],
3869
+ context: { mode: 'pull' },
3870
+ })
3871
+
3872
+ const receipt = await server.verifyCredential(credential)
3873
+
3874
+ expect(receipt.status).toBe('success')
3875
+ expect(receipt.method).toBe('tempo')
3876
+
3877
+ const txReceipt = await getTransactionReceipt(client, {
3878
+ hash: receipt.reference as `0x${string}`,
3879
+ })
3880
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3881
+ })
3882
+
3883
+ test('verifies a sponsored tempo credential object created by the real client', async () => {
3884
+ const server = Mppx.create({
3885
+ methods: [
3886
+ tempo.charge({
3887
+ account: accounts[0],
3888
+ currency: asset,
3889
+ feePayer: true,
3890
+ getClient: () => client,
3891
+ }),
3892
+ ],
3893
+ realm,
3894
+ secretKey,
3895
+ })
3896
+
3897
+ const challenge = await server.challenge.tempo.charge({ amount: '1' })
3898
+ const clientMethod = tempo_client.charge({
3899
+ account: accounts[1],
3900
+ getClient: () => client,
3901
+ })
3902
+ const serializedCredential = await clientMethod.createCredential({
3903
+ challenge: challenge as Parameters<typeof clientMethod.createCredential>[0]['challenge'],
3904
+ context: { mode: 'pull' },
3905
+ })
3906
+
3907
+ const receipt = await server.verifyCredential(Credential.deserialize(serializedCredential))
3908
+
3909
+ expect(receipt.status).toBe('success')
3910
+ expect(receipt.method).toBe('tempo')
3911
+
3912
+ const txReceipt = await getTransactionReceipt(client, {
3913
+ hash: receipt.reference as `0x${string}`,
3914
+ })
3915
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3916
+ })
3917
+
3918
+ test('challenge + verifyCredential round-trip with serialized string', async () => {
3919
+ const mppx = Mppx.create({
3920
+ methods: [alphaChargeServer, alphaSessionServer],
3921
+ realm,
3922
+ secretKey,
3923
+ })
3924
+
3925
+ // Generate, serialize, verify — the full UCP flow
3926
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3927
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3928
+ const serialized = Credential.serialize(credential)
3929
+
3930
+ // Simulate receiving the credential string from a UCP instrument
3931
+ const receipt = await mppx.verifyCredential(serialized)
3932
+
3933
+ expect(receipt.status).toBe('success')
3934
+ })
3935
+ })