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.
- package/README.md +1 -0
- package/dist/Challenge.d.ts +38 -0
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +62 -0
- package/dist/Challenge.js.map +1 -1
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +26 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1478 -915
- package/dist/cli.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +2 -0
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +16 -4
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +6 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +4 -0
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +79 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +135 -7
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/client/ChannelOps.js +1 -0
- package/dist/tempo/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +4 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +9 -6
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +4 -4
- package/src/Challenge.ts +72 -0
- package/src/bin.ts +4 -0
- package/src/cli.test.ts +180 -252
- package/src/cli.ts +1085 -485
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +78 -0
- package/src/client/Mppx.ts +5 -0
- package/src/client/internal/Fetch.test.ts +1 -1
- package/src/client/internal/Fetch.ts +18 -6
- package/src/middlewares/internal/mppx.test.ts +152 -0
- package/src/middlewares/internal/mppx.ts +22 -3
- package/src/server/Mppx.test-d.ts +94 -299
- package/src/server/Mppx.test.ts +650 -0
- package/src/server/Mppx.ts +213 -9
- package/src/tempo/client/ChannelOps.ts +1 -0
- package/src/tempo/server/Charge.ts +4 -3
- package/src/tempo/session/Chain.ts +8 -5
- package/dist/tempo/internal/simulate.d.ts +0 -21
- package/dist/tempo/internal/simulate.d.ts.map +0 -1
- package/dist/tempo/internal/simulate.js +0 -31
- package/dist/tempo/internal/simulate.js.map +0 -1
- 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', () => {
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/client/Mppx.ts
CHANGED
|
@@ -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) {
|
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
:
|
|
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
|
}
|