mppx 0.3.14 → 0.3.16

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 (60) hide show
  1. package/README.md +1 -0
  2. package/dist/Challenge.d.ts +38 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +62 -0
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/bin.d.ts +3 -0
  7. package/dist/bin.d.ts.map +1 -0
  8. package/dist/bin.js +4 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/cli.d.ts +26 -2
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +1478 -915
  13. package/dist/cli.js.map +1 -1
  14. package/dist/client/Mppx.d.ts +2 -0
  15. package/dist/client/Mppx.d.ts.map +1 -1
  16. package/dist/client/Mppx.js +2 -0
  17. package/dist/client/Mppx.js.map +1 -1
  18. package/dist/client/internal/Fetch.d.ts.map +1 -1
  19. package/dist/client/internal/Fetch.js +16 -4
  20. package/dist/client/internal/Fetch.js.map +1 -1
  21. package/dist/middlewares/internal/mppx.d.ts +6 -1
  22. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  23. package/dist/middlewares/internal/mppx.js +4 -0
  24. package/dist/middlewares/internal/mppx.js.map +1 -1
  25. package/dist/server/Mppx.d.ts +79 -1
  26. package/dist/server/Mppx.d.ts.map +1 -1
  27. package/dist/server/Mppx.js +135 -7
  28. package/dist/server/Mppx.js.map +1 -1
  29. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js +1 -0
  31. package/dist/tempo/client/ChannelOps.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts.map +1 -1
  33. package/dist/tempo/server/Charge.js +4 -4
  34. package/dist/tempo/server/Charge.js.map +1 -1
  35. package/dist/tempo/session/Chain.d.ts.map +1 -1
  36. package/dist/tempo/session/Chain.js +9 -6
  37. package/dist/tempo/session/Chain.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/Challenge.ts +72 -0
  40. package/src/bin.ts +4 -0
  41. package/src/cli.test.ts +180 -252
  42. package/src/cli.ts +1085 -485
  43. package/src/client/Mppx.test-d.ts +9 -0
  44. package/src/client/Mppx.test.ts +78 -0
  45. package/src/client/Mppx.ts +5 -0
  46. package/src/client/internal/Fetch.test.ts +1 -1
  47. package/src/client/internal/Fetch.ts +18 -6
  48. package/src/middlewares/internal/mppx.test.ts +152 -0
  49. package/src/middlewares/internal/mppx.ts +22 -3
  50. package/src/server/Mppx.test-d.ts +94 -299
  51. package/src/server/Mppx.test.ts +650 -0
  52. package/src/server/Mppx.ts +213 -9
  53. package/src/tempo/client/ChannelOps.ts +1 -0
  54. package/src/tempo/server/Charge.ts +4 -3
  55. package/src/tempo/session/Chain.ts +8 -5
  56. package/dist/tempo/internal/simulate.d.ts +0 -21
  57. package/dist/tempo/internal/simulate.d.ts.map +0 -1
  58. package/dist/tempo/internal/simulate.js +0 -31
  59. package/dist/tempo/internal/simulate.js.map +0 -1
  60. package/src/tempo/internal/simulate.ts +0 -49
@@ -28,6 +28,15 @@ describe('Mppx', () => {
28
28
  expectTypeOf(mppx.createCredential).toBeFunction()
29
29
  expectTypeOf(mppx.createCredential).returns.toMatchTypeOf<Promise<string>>()
30
30
  })
31
+
32
+ test('has rawFetch with standard fetch signature', () => {
33
+ const method = charge({
34
+ account: {} as Account,
35
+ })
36
+ const mppx = Mppx.create({ methods: [method] })
37
+
38
+ expectTypeOf(mppx.rawFetch).toEqualTypeOf<typeof globalThis.fetch>()
39
+ })
31
40
  })
32
41
 
33
42
  describe('create.Config', () => {
@@ -28,6 +28,7 @@ describe('Mppx.create', () => {
28
28
  expect(mppx.transport.name).toBe('http')
29
29
  expect(typeof mppx.createCredential).toBe('function')
30
30
  expect(typeof mppx.fetch).toBe('function')
31
+ expect(typeof mppx.rawFetch).toBe('function')
31
32
  })
32
33
 
33
34
  test('behavior: with mcp transport', () => {
@@ -471,3 +472,80 @@ describe('restore', () => {
471
472
  expect(globalThis.fetch).toBe(originalFetch)
472
473
  })
473
474
  })
475
+
476
+ describe('rawFetch', () => {
477
+ test('default: returns the original fetch when polyfill is enabled', () => {
478
+ const originalFetch = globalThis.fetch
479
+
480
+ const mppx = Mppx.create({
481
+ methods: [
482
+ tempo({
483
+ account: accounts[1],
484
+ getClient: () => client,
485
+ }),
486
+ ],
487
+ })
488
+
489
+ expect(globalThis.fetch).not.toBe(originalFetch)
490
+ expect(mppx.rawFetch).toBe(originalFetch)
491
+ })
492
+
493
+ test('behavior: returns the original fetch when polyfill is disabled', () => {
494
+ const originalFetch = globalThis.fetch
495
+
496
+ const mppx = Mppx.create({
497
+ polyfill: false,
498
+ methods: [
499
+ tempo({
500
+ account: accounts[1],
501
+ getClient: () => client,
502
+ }),
503
+ ],
504
+ })
505
+
506
+ expect(mppx.rawFetch).toBe(originalFetch)
507
+ })
508
+
509
+ test('behavior: returns custom fetch when provided', () => {
510
+ const customFetch = async () => new Response('custom')
511
+
512
+ const mppx = Mppx.create({
513
+ polyfill: false,
514
+ fetch: customFetch as typeof globalThis.fetch,
515
+ methods: [
516
+ tempo({
517
+ account: accounts[1],
518
+ getClient: () => client,
519
+ }),
520
+ ],
521
+ })
522
+
523
+ expect(mppx.rawFetch).toBe(customFetch)
524
+ })
525
+
526
+ test('behavior: rawFetch does not intercept 402 responses', async () => {
527
+ const mppx = Mppx.create({
528
+ methods: [
529
+ tempo({
530
+ account: accounts[1],
531
+ getClient: () => client,
532
+ }),
533
+ ],
534
+ })
535
+
536
+ const httpServer = await Http.createServer(async (req, res) => {
537
+ const result = await Mppx_server.toNodeListener(
538
+ server.charge({
539
+ amount: '1',
540
+ }),
541
+ )(req, res)
542
+ if (result.status === 402) return
543
+ res.end('OK')
544
+ })
545
+
546
+ const response = await mppx.rawFetch(httpServer.url)
547
+ expect(response.status).toBe(402)
548
+
549
+ httpServer.close()
550
+ })
551
+ })
@@ -15,6 +15,8 @@ export type Mppx<
15
15
  > = {
16
16
  /** Payment-aware fetch function that automatically handles 402 responses. */
17
17
  fetch: Fetch.from.Fetch<FlattenMethods<methods>>
18
+ /** The original, unwrapped fetch function (pre-polyfill). Useful when you need to make requests that should not be intercepted (e.g. 402 probes for websocket auth). */
19
+ rawFetch: typeof globalThis.fetch
18
20
  /** Methods to configure. */
19
21
  methods: FlattenMethods<methods>
20
22
  /** The transport used. */
@@ -56,6 +58,8 @@ export function create<
56
58
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
57
59
  const { onChallenge, polyfill = true, transport = Transport.http() as transport } = config
58
60
 
61
+ const rawFetch = config.fetch ?? globalThis.fetch
62
+
59
63
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
60
64
 
61
65
  const resolvedOnChallenge = onChallenge as Fetch.from.Config<
@@ -71,6 +75,7 @@ export function create<
71
75
  if (polyfill) Fetch.polyfill(config_fetch)
72
76
  return {
73
77
  fetch,
78
+ rawFetch,
74
79
  methods,
75
80
  transport,
76
81
  async createCredential(response: Transport.ResponseOf<transport>, context?: unknown) {
@@ -530,7 +530,7 @@ describe('Fetch.from: 402 retry path', () => {
530
530
  })
531
531
 
532
532
  await expect(fetch('https://example.com/api')).rejects.toThrow(
533
- 'No method found for "stripe.charge"',
533
+ 'No method found for challenges: stripe.charge',
534
534
  )
535
535
  })
536
536
 
@@ -52,18 +52,30 @@ export function from<const methods extends readonly Method.AnyClient[]>(
52
52
  const context = (init as Record<string, unknown> | undefined)?.context
53
53
  const { context: _, ...fetchInit } = (init ?? {}) as Record<string, unknown>
54
54
 
55
- const challenge = Challenge.fromResponse(response)
56
-
57
- const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent)
58
- if (!mi)
55
+ // Parse all challenges from the response (supports merged WWW-Authenticate headers).
56
+ // Match in client preference order: iterate the client's methods array and pick the
57
+ // first method that has a matching challenge, so the client controls priority.
58
+ const challenges = Challenge.fromResponseList(response)
59
+
60
+ let challenge: Challenge.Challenge | undefined
61
+ let mi: (typeof methods)[number] | undefined
62
+ for (const m of methods) {
63
+ const match = challenges.find((c) => c.method === m.name && c.intent === m.intent)
64
+ if (match) {
65
+ challenge = match
66
+ mi = m
67
+ break
68
+ }
69
+ }
70
+ if (!challenge || !mi)
59
71
  throw new Error(
60
- `No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
72
+ `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
61
73
  )
62
74
 
63
75
  const onChallengeCredential = onChallenge
64
76
  ? await onChallenge(challenge, {
65
77
  createCredential: async (overrideContext?: AnyContextFor<methods>) =>
66
- resolveCredential(challenge, mi, overrideContext ?? context),
78
+ resolveCredential(challenge, mi!, overrideContext ?? context),
67
79
  })
68
80
  : undefined
69
81
  const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
@@ -0,0 +1,152 @@
1
+ import { Challenge, Credential, Method, z } from 'mppx'
2
+ import { Mppx } from 'mppx/server'
3
+ import { describe, expect, test } from 'vitest'
4
+ import { wrap } from './mppx.js'
5
+
6
+ const realm = 'api.example.com'
7
+ const secretKey = 'test-secret-key'
8
+
9
+ const mockChargeA = Method.from({
10
+ name: 'alpha',
11
+ intent: 'charge',
12
+ schema: {
13
+ credential: {
14
+ payload: z.object({ token: z.string() }),
15
+ },
16
+ request: z.object({
17
+ amount: z.string(),
18
+ currency: z.string(),
19
+ decimals: z.number(),
20
+ recipient: z.string(),
21
+ }),
22
+ },
23
+ })
24
+
25
+ const mockChargeB = Method.from({
26
+ name: 'beta',
27
+ intent: 'charge',
28
+ schema: {
29
+ credential: {
30
+ payload: z.object({ token: z.string() }),
31
+ },
32
+ request: z.object({
33
+ amount: z.string(),
34
+ currency: z.string(),
35
+ decimals: z.number(),
36
+ recipient: z.string(),
37
+ }),
38
+ },
39
+ })
40
+
41
+ function mockReceipt(name: string) {
42
+ return {
43
+ method: name,
44
+ reference: `tx-${name}`,
45
+ status: 'success' as const,
46
+ timestamp: new Date().toISOString(),
47
+ }
48
+ }
49
+
50
+ const alphaMethod = Method.toServer(mockChargeA, {
51
+ async verify() {
52
+ return mockReceipt('alpha')
53
+ },
54
+ })
55
+
56
+ const betaMethod = Method.toServer(mockChargeB, {
57
+ async verify() {
58
+ return mockReceipt('beta')
59
+ },
60
+ })
61
+
62
+ const challengeOpts = {
63
+ amount: '1000',
64
+ currency: '0x0000000000000000000000000000000000000001',
65
+ decimals: 6,
66
+ expires: new Date(Date.now() + 60_000).toISOString(),
67
+ recipient: '0x0000000000000000000000000000000000000002',
68
+ }
69
+
70
+ describe('wrap: nested handlers', () => {
71
+ test('wrapped.alpha.charge produces a wrapped handler', () => {
72
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey }) as any
73
+
74
+ const wrapped = wrap(mppx, (methodFn, options) => {
75
+ return { type: 'wrapped' as const, handler: methodFn(options) }
76
+ })
77
+
78
+ const result = wrapped.alpha.charge(challengeOpts)
79
+ expect(result.type).toBe('wrapped')
80
+ expect(typeof result.handler).toBe('function')
81
+ })
82
+
83
+ test('wrapped.beta.charge produces a wrapped handler', () => {
84
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey }) as any
85
+
86
+ const wrapped = wrap(mppx, (methodFn, options) => {
87
+ return { type: 'wrapped' as const, handler: methodFn(options) }
88
+ })
89
+
90
+ const result = wrapped.beta.charge(challengeOpts)
91
+ expect(result.type).toBe('wrapped')
92
+ })
93
+
94
+ test('nested wrapped handler works end-to-end (402 then 200)', async () => {
95
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) as any
96
+
97
+ const wrapped = wrap(mppx, (methodFn, options) => methodFn(options))
98
+
99
+ const handle = wrapped.alpha.charge(challengeOpts)
100
+
101
+ const firstResult = await handle(new Request('https://example.com/resource'))
102
+ expect(firstResult.status).toBe(402)
103
+ if (firstResult.status !== 402) throw new Error()
104
+
105
+ const challenge = Challenge.fromResponse(firstResult.challenge)
106
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
107
+
108
+ const result = await handle(
109
+ new Request('https://example.com/resource', {
110
+ headers: { Authorization: Credential.serialize(credential) },
111
+ }),
112
+ )
113
+ expect(result.status).toBe(200)
114
+ })
115
+
116
+ test('slash key and nested key produce equivalent wrapped handlers', () => {
117
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) as any
118
+
119
+ const wrapped = wrap(mppx, (methodFn, options) => {
120
+ return { methodFn, options }
121
+ })
122
+
123
+ const nestedResult = wrapped.alpha.charge(challengeOpts) as {
124
+ methodFn: unknown
125
+ options: unknown
126
+ }
127
+ const slashResult = wrapped['alpha/charge'](challengeOpts) as {
128
+ methodFn: unknown
129
+ options: unknown
130
+ }
131
+
132
+ expect(nestedResult.methodFn).toBe(slashResult.methodFn)
133
+ expect(nestedResult.options).toEqual(slashResult.options)
134
+ })
135
+
136
+ test('compose is passed through unwrapped', () => {
137
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) as any
138
+
139
+ const wrapped = wrap(mppx, (_methodFn, _options) => 'wrapped')
140
+
141
+ expect(wrapped.compose).toBe(mppx.compose)
142
+ })
143
+
144
+ test('realm and transport are passed through', () => {
145
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) as any
146
+
147
+ const wrapped = wrap(mppx, (_methodFn, _options) => 'wrapped')
148
+
149
+ expect(wrapped.realm).toBe(realm)
150
+ expect(wrapped.transport).toBe(mppx.transport)
151
+ })
152
+ })
@@ -4,10 +4,24 @@ import type * as Mppx from '../../server/Mppx.js'
4
4
  export type AnyMethodFn = Mppx.AnyMethodFn
5
5
  export type AnyServer = Method.AnyServer
6
6
 
7
- export type Wrap<mppx, handler> = {
8
- [key in keyof mppx]: mppx[key] extends (options: infer options) => any
7
+ /** Recursively wraps nested handler objects one level deep. */
8
+ type WrapNested<obj, handler> = {
9
+ [key in keyof obj]: obj[key] extends (options: infer options) => any
9
10
  ? (o: options) => handler
10
- : mppx[key]
11
+ : obj[key]
12
+ }
13
+
14
+ export type Wrap<mppx, handler> = {
15
+ // `compose` is passed through unwrapped because it's a multi-method
16
+ // combinator (takes `[method, options]` tuples), not a per-method handler.
17
+ // `methods`, `realm`, `transport` are data properties — not handlers.
18
+ [key in keyof mppx]: key extends 'compose' | 'methods' | 'realm' | 'transport'
19
+ ? mppx[key]
20
+ : mppx[key] extends (options: infer options) => any
21
+ ? (o: options) => handler
22
+ : mppx[key] extends Record<string, (options: any) => any>
23
+ ? WrapNested<mppx[key], handler>
24
+ : mppx[key]
11
25
  }
12
26
 
13
27
  /**
@@ -28,6 +42,11 @@ export function wrap<mppx extends Mppx.Mppx<any, any>, handler>(
28
42
  result[key] = (options: any) => wrapper(methodFn, options)
29
43
  // Also set shorthand intent key if Mppx registered it (no collision)
30
44
  if ((mppx as any)[mi.intent]) result[mi.intent] = (options: any) => wrapper(methodFn, options)
45
+ // Build nested handlers: wrapped.tempo.charge(...)
46
+ if (!result[mi.name] || typeof result[mi.name] !== 'object')
47
+ result[mi.name] = {} as Record<string, unknown>
48
+ ;(result[mi.name] as Record<string, unknown>)[mi.intent] = (options: any) =>
49
+ wrapper(methodFn, options)
31
50
  }
32
51
  return result as never
33
52
  }