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.
- package/CHANGELOG.md +13 -0
- package/dist/client/Mppx.d.ts +12 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +127 -10
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +69 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +250 -20
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +2 -1
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +82 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +557 -83
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts.map +1 -1
- package/dist/tempo/client/Subscription.js +4 -3
- package/dist/tempo/client/Subscription.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/client/Mppx.test-d.ts +55 -0
- package/src/client/Mppx.test.ts +181 -0
- package/src/client/Mppx.ts +248 -16
- package/src/client/internal/Fetch.test-d.ts +31 -0
- package/src/client/internal/Fetch.test.ts +261 -0
- package/src/client/internal/Fetch.ts +467 -24
- package/src/middlewares/internal/mppx.ts +5 -6
- package/src/proxy/Proxy.test.ts +69 -0
- package/src/server/Mppx.test-d.ts +50 -0
- package/src/server/Mppx.test.ts +893 -1
- package/src/server/Mppx.ts +862 -97
- package/src/tempo/client/Subscription.test.ts +51 -0
- package/src/tempo/client/Subscription.ts +4 -3
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,
|
|
1
|
+
{"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,8yteAA8yte,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { Account } from 'viem'
|
|
2
2
|
import { describe, expectTypeOf, test } from 'vp/test'
|
|
3
3
|
|
|
4
|
+
import * as Challenge from '../Challenge.js'
|
|
5
|
+
import type * as Mcp from '../Mcp.js'
|
|
4
6
|
import * as Method from '../Method.js'
|
|
5
7
|
import { charge } from '../tempo/client/Charge.js'
|
|
6
8
|
import { tempo } from '../tempo/client/Methods.js'
|
|
7
9
|
import type * as AutoSwap from '../tempo/internal/auto-swap.js'
|
|
8
10
|
import * as Methods from '../tempo/Methods.js'
|
|
9
11
|
import * as z from '../zod.js'
|
|
12
|
+
import * as Fetch from './internal/Fetch.js'
|
|
10
13
|
import * as Mppx from './Mppx.js'
|
|
14
|
+
import * as Transport from './Transport.js'
|
|
11
15
|
|
|
12
16
|
describe('Mppx', () => {
|
|
13
17
|
test('has methods array', () => {
|
|
@@ -63,6 +67,57 @@ describe('create.Config', () => {
|
|
|
63
67
|
|
|
64
68
|
expectTypeOf(mppx.fetch).toBeFunction()
|
|
65
69
|
})
|
|
70
|
+
|
|
71
|
+
test('client events expose typed payloads', () => {
|
|
72
|
+
const method = charge()
|
|
73
|
+
const mppx = Mppx.create({
|
|
74
|
+
methods: [method],
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const unsubscribe = mppx.on('payment.response', (payload) => {
|
|
78
|
+
expectTypeOf(payload.response).toEqualTypeOf<Response>()
|
|
79
|
+
})
|
|
80
|
+
expectTypeOf(unsubscribe).toEqualTypeOf<Fetch.Unsubscribe>()
|
|
81
|
+
|
|
82
|
+
mppx.on('*', (event) => {
|
|
83
|
+
if (event.name === 'credential.created')
|
|
84
|
+
expectTypeOf(event.payload.credential).toEqualTypeOf<string>()
|
|
85
|
+
if (event.name === 'payment.response')
|
|
86
|
+
expectTypeOf(event.payload.response).toEqualTypeOf<Response>()
|
|
87
|
+
})
|
|
88
|
+
mppx.onChallengeReceived((payload) => {
|
|
89
|
+
expectTypeOf(payload.challenge.id).toEqualTypeOf<string>()
|
|
90
|
+
expectTypeOf(payload.challenges).toEqualTypeOf<readonly Challenge.Challenge[]>()
|
|
91
|
+
expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
|
|
92
|
+
expectTypeOf(payload.createCredential({ account: {} as Account })).toEqualTypeOf<
|
|
93
|
+
Promise<string>
|
|
94
|
+
>()
|
|
95
|
+
return payload.createCredential({ account: {} as Account })
|
|
96
|
+
})
|
|
97
|
+
mppx.onCredentialCreated((payload) => {
|
|
98
|
+
expectTypeOf(payload.credential).toEqualTypeOf<string>()
|
|
99
|
+
expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
|
|
100
|
+
})
|
|
101
|
+
mppx.onPaymentFailed((payload) => {
|
|
102
|
+
expectTypeOf(payload.error).toEqualTypeOf<unknown>()
|
|
103
|
+
expectTypeOf(payload.challenge).toEqualTypeOf<Challenge.Challenge | undefined>()
|
|
104
|
+
})
|
|
105
|
+
mppx.onPaymentResponse((payload) => {
|
|
106
|
+
expectTypeOf(payload.response).toEqualTypeOf<Response>()
|
|
107
|
+
expectTypeOf(payload.credential).toEqualTypeOf<string>()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('client events use transport response types', () => {
|
|
112
|
+
const mppx = Mppx.create({
|
|
113
|
+
methods: [tempo({ account: {} as Account })],
|
|
114
|
+
transport: Transport.mcp(),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
mppx.onChallengeReceived((payload) => {
|
|
118
|
+
expectTypeOf(payload.response).toMatchTypeOf<Response | Mcp.Response>()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
66
121
|
})
|
|
67
122
|
|
|
68
123
|
describe('Method.toClient', () => {
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -108,11 +108,143 @@ describe('createCredential', () => {
|
|
|
108
108
|
expect(parsed.challenge.method).toBe('tempo')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test('behavior: createCredential emits client events and supports runtime handlers', async () => {
|
|
112
|
+
const events: string[] = []
|
|
113
|
+
const createCredential = vi.fn(async ({ challenge }) =>
|
|
114
|
+
Credential.serialize({
|
|
115
|
+
challenge,
|
|
116
|
+
payload: { signature: '0xsignature', type: 'transaction' },
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
const method = Method.toClient(Methods.charge, { createCredential })
|
|
120
|
+
const mppx = Mppx.create({
|
|
121
|
+
polyfill: false,
|
|
122
|
+
methods: [method],
|
|
123
|
+
})
|
|
124
|
+
mppx.onCredentialCreated((payload) => {
|
|
125
|
+
events.push(`credential:${payload.credential.startsWith('Payment ')}`)
|
|
126
|
+
})
|
|
127
|
+
const offCredential = mppx.onCredentialCreated(() => {
|
|
128
|
+
events.push('removed')
|
|
129
|
+
})
|
|
130
|
+
const offChallenge = mppx.onChallengeReceived((payload) => {
|
|
131
|
+
events.push(`runtime:${payload.method.intent}`)
|
|
132
|
+
return payload.createCredential()
|
|
133
|
+
})
|
|
134
|
+
mppx.on('*', (event) => {
|
|
135
|
+
events.push(`*:${event.name}`)
|
|
136
|
+
})
|
|
137
|
+
offCredential()
|
|
138
|
+
|
|
139
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
140
|
+
realm,
|
|
141
|
+
secretKey,
|
|
142
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
143
|
+
request: {
|
|
144
|
+
amount: '1000',
|
|
145
|
+
currency: '0x1234567890123456789012345678901234567890',
|
|
146
|
+
decimals: 6,
|
|
147
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
const response = new Response(null, {
|
|
151
|
+
status: 402,
|
|
152
|
+
headers: {
|
|
153
|
+
'WWW-Authenticate': Challenge.serialize(challenge),
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const credential = await mppx.createCredential(response)
|
|
158
|
+
offChallenge()
|
|
159
|
+
|
|
160
|
+
expect(credential).toMatch(/^Payment /)
|
|
161
|
+
expect(createCredential).toHaveBeenCalledTimes(1)
|
|
162
|
+
expect(events).toEqual([
|
|
163
|
+
'runtime:charge',
|
|
164
|
+
'*:challenge.received',
|
|
165
|
+
'credential:true',
|
|
166
|
+
'*:credential.created',
|
|
167
|
+
])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('behavior: createCredential memoizes event helper calls', async () => {
|
|
171
|
+
const createCredential = vi.fn(async ({ challenge }) =>
|
|
172
|
+
Credential.serialize({
|
|
173
|
+
challenge,
|
|
174
|
+
payload: { signature: '0xsignature', type: 'transaction' },
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
const method = Method.toClient(Methods.charge, { createCredential })
|
|
178
|
+
const mppx = Mppx.create({
|
|
179
|
+
polyfill: false,
|
|
180
|
+
methods: [method],
|
|
181
|
+
})
|
|
182
|
+
mppx.on('*', async (event) => {
|
|
183
|
+
if (event.name === 'challenge.received') await event.payload.createCredential()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
187
|
+
realm,
|
|
188
|
+
secretKey,
|
|
189
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
190
|
+
request: {
|
|
191
|
+
amount: '1000',
|
|
192
|
+
currency: '0x1234567890123456789012345678901234567890',
|
|
193
|
+
decimals: 6,
|
|
194
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
const response = new Response(null, {
|
|
198
|
+
status: 402,
|
|
199
|
+
headers: {
|
|
200
|
+
'WWW-Authenticate': Challenge.serialize(challenge),
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
await mppx.createCredential(response)
|
|
205
|
+
|
|
206
|
+
expect(createCredential).toHaveBeenCalledTimes(1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('behavior: createCredential validates event credentials', async () => {
|
|
210
|
+
const mppx = Mppx.create({
|
|
211
|
+
polyfill: false,
|
|
212
|
+
methods: [tempo({ account: accounts[1], getClient: () => client })],
|
|
213
|
+
})
|
|
214
|
+
mppx.onChallengeReceived(() => 'Payment invalid\r\nX-Injected: true')
|
|
215
|
+
|
|
216
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
217
|
+
realm,
|
|
218
|
+
secretKey,
|
|
219
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
220
|
+
request: {
|
|
221
|
+
amount: '1000',
|
|
222
|
+
currency: '0x1234567890123456789012345678901234567890',
|
|
223
|
+
decimals: 6,
|
|
224
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
const response = new Response(null, {
|
|
228
|
+
status: 402,
|
|
229
|
+
headers: {
|
|
230
|
+
'WWW-Authenticate': Challenge.serialize(challenge),
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
await expect(mppx.createCredential(response)).rejects.toThrow('illegal newline')
|
|
235
|
+
})
|
|
236
|
+
|
|
111
237
|
test('behavior: throws when method not found', async () => {
|
|
238
|
+
const events: string[] = []
|
|
112
239
|
const mppx = Mppx.create({
|
|
113
240
|
polyfill: false,
|
|
114
241
|
methods: [tempo({ account: accounts[1], getClient: () => client })],
|
|
115
242
|
})
|
|
243
|
+
mppx.onPaymentFailed((payload) => {
|
|
244
|
+
events.push(
|
|
245
|
+
`failed:${payload.challenge === undefined}:${payload.challenges?.length}:${payload.error instanceof Error}`,
|
|
246
|
+
)
|
|
247
|
+
})
|
|
116
248
|
|
|
117
249
|
const challenge = Challenge.from({
|
|
118
250
|
id: 'test-id',
|
|
@@ -132,6 +264,7 @@ describe('createCredential', () => {
|
|
|
132
264
|
await expect(mppx.createCredential(response)).rejects.toThrow(
|
|
133
265
|
'No method found for challenges: unknown.charge. Available: tempo.charge, tempo.session',
|
|
134
266
|
)
|
|
267
|
+
expect(events).toEqual(['failed:true:1:true'])
|
|
135
268
|
})
|
|
136
269
|
|
|
137
270
|
test('behavior: rejects expired challenges before creating credential', async () => {
|
|
@@ -451,6 +584,54 @@ describe('createCredential', () => {
|
|
|
451
584
|
expect((parsed.payload as { type: string }).type).toBe('transaction')
|
|
452
585
|
expect(parsed.challenge.method).toBe('tempo')
|
|
453
586
|
})
|
|
587
|
+
|
|
588
|
+
test('behavior: mcp transport event responses are not cast to DOM Response', async () => {
|
|
589
|
+
const method = Method.toClient(Methods.charge, {
|
|
590
|
+
async createCredential({ challenge }) {
|
|
591
|
+
return Credential.serialize({
|
|
592
|
+
challenge,
|
|
593
|
+
payload: { signature: '0xsignature', type: 'transaction' },
|
|
594
|
+
})
|
|
595
|
+
},
|
|
596
|
+
})
|
|
597
|
+
const mppx = Mppx.create({
|
|
598
|
+
polyfill: false,
|
|
599
|
+
methods: [method],
|
|
600
|
+
transport: Transport.mcp(),
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
604
|
+
realm,
|
|
605
|
+
secretKey,
|
|
606
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
607
|
+
request: {
|
|
608
|
+
amount: '1000',
|
|
609
|
+
currency: '0x1234567890123456789012345678901234567890',
|
|
610
|
+
decimals: 6,
|
|
611
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
const mcpResponse: Mcp.Response = {
|
|
615
|
+
jsonrpc: '2.0',
|
|
616
|
+
id: 1,
|
|
617
|
+
error: {
|
|
618
|
+
code: Mcp.paymentRequiredCode,
|
|
619
|
+
message: 'Payment Required',
|
|
620
|
+
data: {
|
|
621
|
+
httpStatus: 402,
|
|
622
|
+
challenges: [challenge],
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
const seen: unknown[] = []
|
|
627
|
+
mppx.onChallengeReceived((event) => {
|
|
628
|
+
seen.push(event.response)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
await mppx.createCredential(mcpResponse)
|
|
632
|
+
|
|
633
|
+
expect(seen).toEqual([mcpResponse])
|
|
634
|
+
})
|
|
454
635
|
})
|
|
455
636
|
|
|
456
637
|
const server = Mppx_server.create({
|
package/src/client/Mppx.ts
CHANGED
|
@@ -7,6 +7,9 @@ import * as Fetch from './internal/Fetch.js'
|
|
|
7
7
|
import * as Transport from './Transport.js'
|
|
8
8
|
|
|
9
9
|
export type Methods = readonly (Method.AnyClient | readonly Method.AnyClient[])[]
|
|
10
|
+
type EventResponseOf<transport extends Transport.Transport> =
|
|
11
|
+
| Response
|
|
12
|
+
| Transport.ResponseOf<transport>
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Client-side payment handler.
|
|
@@ -29,6 +32,43 @@ export type Mppx<
|
|
|
29
32
|
context?: AnyContextFor<FlattenMethods<methods>> | undefined,
|
|
30
33
|
options?: createCredential.Options | undefined,
|
|
31
34
|
) => Promise<string>
|
|
35
|
+
/** Register a client event handler by canonical event name. */
|
|
36
|
+
on<name extends Fetch.ClientEventName<FlattenMethods<methods>, EventResponseOf<transport>>>(
|
|
37
|
+
name: name,
|
|
38
|
+
handler: Fetch.ClientEventHandler<FlattenMethods<methods>, name, EventResponseOf<transport>>,
|
|
39
|
+
): Fetch.Unsubscribe
|
|
40
|
+
/** Register a handler for received payment challenges. */
|
|
41
|
+
onChallengeReceived(
|
|
42
|
+
handler: Fetch.ClientEventHandler<
|
|
43
|
+
FlattenMethods<methods>,
|
|
44
|
+
'challenge.received',
|
|
45
|
+
EventResponseOf<transport>
|
|
46
|
+
>,
|
|
47
|
+
): Fetch.Unsubscribe
|
|
48
|
+
/** Register a handler for created credentials. */
|
|
49
|
+
onCredentialCreated(
|
|
50
|
+
handler: Fetch.ClientEventHandler<
|
|
51
|
+
FlattenMethods<methods>,
|
|
52
|
+
'credential.created',
|
|
53
|
+
EventResponseOf<transport>
|
|
54
|
+
>,
|
|
55
|
+
): Fetch.Unsubscribe
|
|
56
|
+
/** Register a handler for failed automatic payment handling. */
|
|
57
|
+
onPaymentFailed(
|
|
58
|
+
handler: Fetch.ClientEventHandler<
|
|
59
|
+
FlattenMethods<methods>,
|
|
60
|
+
'payment.failed',
|
|
61
|
+
EventResponseOf<transport>
|
|
62
|
+
>,
|
|
63
|
+
): Fetch.Unsubscribe
|
|
64
|
+
/** Register a handler for payment retry responses. */
|
|
65
|
+
onPaymentResponse(
|
|
66
|
+
handler: Fetch.ClientEventHandler<
|
|
67
|
+
FlattenMethods<methods>,
|
|
68
|
+
'payment.response',
|
|
69
|
+
EventResponseOf<transport>
|
|
70
|
+
>,
|
|
71
|
+
): Fetch.Unsubscribe
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
/**
|
|
@@ -71,6 +111,7 @@ export function create<
|
|
|
71
111
|
const rawFetch = config.fetch ?? globalThis.fetch
|
|
72
112
|
const methods = config.methods.flat() as unknown as FlattenMethods<methods>
|
|
73
113
|
const acceptPayment = AcceptPayment.resolve(methods, config.paymentPreferences)
|
|
114
|
+
const events = Fetch.createEventDispatcher<FlattenMethods<methods>, EventResponseOf<transport>>()
|
|
74
115
|
|
|
75
116
|
const resolvedOnChallenge = onChallenge as Fetch.from.Config<
|
|
76
117
|
FlattenMethods<methods>
|
|
@@ -79,17 +120,64 @@ export function create<
|
|
|
79
120
|
acceptPayment,
|
|
80
121
|
acceptPaymentPolicy,
|
|
81
122
|
...(config.fetch && { fetch: config.fetch }),
|
|
123
|
+
eventDispatcher: events,
|
|
82
124
|
...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
|
|
83
125
|
methods,
|
|
84
126
|
} satisfies Fetch.from.Config<FlattenMethods<methods>>
|
|
85
127
|
const fetch = Fetch.from<FlattenMethods<methods>>(config_fetch)
|
|
86
128
|
|
|
87
129
|
if (polyfill) Fetch.polyfill(config_fetch)
|
|
130
|
+
|
|
131
|
+
function onChallengeReceived(
|
|
132
|
+
handler: Fetch.ClientEventHandler<
|
|
133
|
+
FlattenMethods<methods>,
|
|
134
|
+
'challenge.received',
|
|
135
|
+
EventResponseOf<transport>
|
|
136
|
+
>,
|
|
137
|
+
) {
|
|
138
|
+
return events.on('challenge.received', handler)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onCredentialCreated(
|
|
142
|
+
handler: Fetch.ClientEventHandler<
|
|
143
|
+
FlattenMethods<methods>,
|
|
144
|
+
'credential.created',
|
|
145
|
+
EventResponseOf<transport>
|
|
146
|
+
>,
|
|
147
|
+
) {
|
|
148
|
+
return events.on('credential.created', handler)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onPaymentFailed(
|
|
152
|
+
handler: Fetch.ClientEventHandler<
|
|
153
|
+
FlattenMethods<methods>,
|
|
154
|
+
'payment.failed',
|
|
155
|
+
EventResponseOf<transport>
|
|
156
|
+
>,
|
|
157
|
+
) {
|
|
158
|
+
return events.on('payment.failed', handler)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onPaymentResponse(
|
|
162
|
+
handler: Fetch.ClientEventHandler<
|
|
163
|
+
FlattenMethods<methods>,
|
|
164
|
+
'payment.response',
|
|
165
|
+
EventResponseOf<transport>
|
|
166
|
+
>,
|
|
167
|
+
) {
|
|
168
|
+
return events.on('payment.response', handler)
|
|
169
|
+
}
|
|
170
|
+
|
|
88
171
|
return {
|
|
89
172
|
fetch,
|
|
90
173
|
rawFetch,
|
|
91
174
|
methods,
|
|
92
175
|
transport,
|
|
176
|
+
on: events.on,
|
|
177
|
+
onChallengeReceived,
|
|
178
|
+
onCredentialCreated,
|
|
179
|
+
onPaymentFailed,
|
|
180
|
+
onPaymentResponse,
|
|
93
181
|
async createCredential(
|
|
94
182
|
response: Transport.ResponseOf<transport>,
|
|
95
183
|
context?: unknown,
|
|
@@ -100,23 +188,59 @@ export function create<
|
|
|
100
188
|
: [transport.getChallenge(response as never)]
|
|
101
189
|
const preferences = resolveChallengePreferences(acceptPayment.entries, options?.acceptPayment)
|
|
102
190
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
191
|
+
let challenge: Challenge.Challenge | undefined
|
|
192
|
+
let mi: FlattenMethods<methods>[number] | undefined
|
|
193
|
+
try {
|
|
194
|
+
const selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
|
|
195
|
+
if (!selected)
|
|
196
|
+
throw new Error(
|
|
197
|
+
`No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
|
|
198
|
+
)
|
|
108
199
|
|
|
109
|
-
|
|
110
|
-
|
|
200
|
+
const selectedChallenge = selected.challenge
|
|
201
|
+
challenge = selectedChallenge
|
|
202
|
+
mi = selected.method as FlattenMethods<methods>[number]
|
|
203
|
+
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
111
204
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
205
|
+
const createCredential = memoizeCreateCredential(
|
|
206
|
+
(overrideContext?: AnyContextFor<FlattenMethods<methods>>) =>
|
|
207
|
+
createCredentialForMethod(selectedChallenge, mi!, overrideContext ?? context),
|
|
208
|
+
)
|
|
209
|
+
const eventCredential = await events.emit(
|
|
210
|
+
'challenge.received',
|
|
211
|
+
createChallengeReceivedPayload({
|
|
212
|
+
challenge: selectedChallenge,
|
|
213
|
+
challenges,
|
|
214
|
+
createCredential,
|
|
215
|
+
method: mi,
|
|
216
|
+
response,
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
const credential = eventCredential ?? (await createCredential())
|
|
220
|
+
Fetch.validateCredentialHeaderValue(credential)
|
|
221
|
+
await events.emit(
|
|
222
|
+
'credential.created',
|
|
223
|
+
createCredentialCreatedPayload({
|
|
224
|
+
challenge: selectedChallenge,
|
|
225
|
+
credential,
|
|
226
|
+
method: mi,
|
|
227
|
+
response,
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
return credential
|
|
231
|
+
} catch (error) {
|
|
232
|
+
await events.emit(
|
|
233
|
+
'payment.failed',
|
|
234
|
+
createPaymentFailedPayload({
|
|
235
|
+
challenge,
|
|
236
|
+
challenges,
|
|
237
|
+
error,
|
|
238
|
+
method: mi,
|
|
239
|
+
response,
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
throw error
|
|
243
|
+
}
|
|
120
244
|
},
|
|
121
245
|
}
|
|
122
246
|
}
|
|
@@ -155,7 +279,7 @@ export declare namespace create {
|
|
|
155
279
|
acceptPaymentPolicy?: Fetch.from.Config['acceptPaymentPolicy'] | undefined
|
|
156
280
|
/** Custom fetch function to wrap. Defaults to `globalThis.fetch`. */
|
|
157
281
|
fetch?: typeof globalThis.fetch
|
|
158
|
-
/** Called when a 402 challenge is received
|
|
282
|
+
/** Called when a 402 challenge is received and no event handler supplies a credential. */
|
|
159
283
|
onChallenge?:
|
|
160
284
|
| ((
|
|
161
285
|
challenge: Challenge.Challenge,
|
|
@@ -187,6 +311,103 @@ type AnyContextFor<methods extends readonly Method.AnyClient[]> = {
|
|
|
187
311
|
: undefined
|
|
188
312
|
}[number]
|
|
189
313
|
|
|
314
|
+
function memoizeCreateCredential<methods extends readonly Method.AnyClient[]>(
|
|
315
|
+
createCredential: (context?: AnyContextFor<methods>) => Promise<string>,
|
|
316
|
+
) {
|
|
317
|
+
let promise: Promise<string> | undefined
|
|
318
|
+
return (context?: AnyContextFor<methods>) => {
|
|
319
|
+
promise ??= createCredential(context)
|
|
320
|
+
return promise
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function createChallengeReceivedPayload<
|
|
325
|
+
methods extends readonly Method.AnyClient[],
|
|
326
|
+
response,
|
|
327
|
+
>(parameters: {
|
|
328
|
+
challenge: Challenge.Challenge
|
|
329
|
+
challenges: readonly Challenge.Challenge[]
|
|
330
|
+
createCredential: (context?: AnyContextFor<methods>) => Promise<string>
|
|
331
|
+
method: methods[number]
|
|
332
|
+
response: response
|
|
333
|
+
}): Fetch.ChallengeReceivedPayload<methods, response> {
|
|
334
|
+
return Object.freeze({
|
|
335
|
+
challenge: snapshotValue(parameters.challenge),
|
|
336
|
+
challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)),
|
|
337
|
+
createCredential: parameters.createCredential,
|
|
338
|
+
method: snapshotMethod(parameters.method),
|
|
339
|
+
response: snapshotResponse(parameters.response),
|
|
340
|
+
}) as never
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function createCredentialCreatedPayload<
|
|
344
|
+
methods extends readonly Method.AnyClient[],
|
|
345
|
+
response,
|
|
346
|
+
>(parameters: {
|
|
347
|
+
challenge: Challenge.Challenge
|
|
348
|
+
credential: string
|
|
349
|
+
method: methods[number]
|
|
350
|
+
response: response
|
|
351
|
+
}): Fetch.CredentialCreatedPayload<methods, response> {
|
|
352
|
+
return Object.freeze({
|
|
353
|
+
challenge: snapshotValue(parameters.challenge),
|
|
354
|
+
credential: parameters.credential,
|
|
355
|
+
method: snapshotMethod(parameters.method),
|
|
356
|
+
response: snapshotResponse(parameters.response),
|
|
357
|
+
}) as never
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function createPaymentFailedPayload<
|
|
361
|
+
methods extends readonly Method.AnyClient[],
|
|
362
|
+
response,
|
|
363
|
+
>(parameters: {
|
|
364
|
+
challenge?: Challenge.Challenge | undefined
|
|
365
|
+
challenges?: readonly Challenge.Challenge[] | undefined
|
|
366
|
+
error: unknown
|
|
367
|
+
method?: methods[number] | undefined
|
|
368
|
+
response: response
|
|
369
|
+
}): Fetch.PaymentFailedPayload<methods, response> {
|
|
370
|
+
return Object.freeze({
|
|
371
|
+
...(parameters.challenge ? { challenge: snapshotValue(parameters.challenge) } : {}),
|
|
372
|
+
...(parameters.challenges
|
|
373
|
+
? { challenges: parameters.challenges.map((challenge) => snapshotValue(challenge)) }
|
|
374
|
+
: {}),
|
|
375
|
+
error: parameters.error,
|
|
376
|
+
...(parameters.method ? { method: snapshotMethod(parameters.method) } : {}),
|
|
377
|
+
response: snapshotResponse(parameters.response),
|
|
378
|
+
}) as never
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function snapshotMethod<method extends Method.AnyClient>(method: method): method {
|
|
382
|
+
return freezeSnapshot(Object.assign({}, method)) as method
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function snapshotResponse<response>(response: response): response {
|
|
386
|
+
if (response instanceof Response) return response.clone() as response
|
|
387
|
+
return snapshotValue(response)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function snapshotValue<value>(value: value): value {
|
|
391
|
+
try {
|
|
392
|
+
return deepFreeze(structuredClone(value))
|
|
393
|
+
} catch {
|
|
394
|
+
return freezeSnapshot(value)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function deepFreeze<value>(value: value): value {
|
|
399
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
400
|
+
Object.freeze(value)
|
|
401
|
+
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child)
|
|
402
|
+
return value
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function freezeSnapshot<value>(value: value): value {
|
|
406
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
407
|
+
Object.freeze(value)
|
|
408
|
+
return value
|
|
409
|
+
}
|
|
410
|
+
|
|
190
411
|
/**
|
|
191
412
|
* Flattens a methods config tuple, preserving positional types.
|
|
192
413
|
* @internal
|
|
@@ -209,3 +430,14 @@ function resolveChallengePreferences(
|
|
|
209
430
|
if (!override) return fallback
|
|
210
431
|
return typeof override === 'string' ? AcceptPayment.parse(override) : override
|
|
211
432
|
}
|
|
433
|
+
|
|
434
|
+
async function createCredentialForMethod(
|
|
435
|
+
challenge: Challenge.Challenge,
|
|
436
|
+
mi: Method.AnyClient,
|
|
437
|
+
context: unknown,
|
|
438
|
+
): Promise<string> {
|
|
439
|
+
const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
|
|
440
|
+
return mi.createCredential(
|
|
441
|
+
parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never),
|
|
442
|
+
)
|
|
443
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Account } from 'viem'
|
|
2
2
|
import { describe, expectTypeOf, test } from 'vp/test'
|
|
3
3
|
|
|
4
|
+
import * as Challenge from '../../Challenge.js'
|
|
4
5
|
import { charge } from '../../tempo/client/Charge.js'
|
|
5
6
|
import * as Fetch from './Fetch.js'
|
|
6
7
|
|
|
@@ -44,6 +45,36 @@ describe('Fetch.from', () => {
|
|
|
44
45
|
body: JSON.stringify({ foo: 'bar' }),
|
|
45
46
|
})
|
|
46
47
|
})
|
|
48
|
+
|
|
49
|
+
test('behavior: events infer payload types from methods', () => {
|
|
50
|
+
const method = charge()
|
|
51
|
+
const dispatcher = Fetch.createEventDispatcher<[typeof method]>()
|
|
52
|
+
dispatcher.on('*', (event) => {
|
|
53
|
+
if (event.name === 'challenge.received')
|
|
54
|
+
expectTypeOf(event.payload.challenge).toEqualTypeOf<Challenge.Challenge>()
|
|
55
|
+
})
|
|
56
|
+
dispatcher.on('challenge.received', (payload) => {
|
|
57
|
+
expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
|
|
58
|
+
return payload.createCredential({ account: {} as Account })
|
|
59
|
+
})
|
|
60
|
+
dispatcher.on('credential.created', (payload) => {
|
|
61
|
+
expectTypeOf(payload.method.intent).toEqualTypeOf<'charge'>()
|
|
62
|
+
expectTypeOf(payload.credential).toEqualTypeOf<string>()
|
|
63
|
+
})
|
|
64
|
+
dispatcher.on('payment.failed', (payload) => {
|
|
65
|
+
expectTypeOf(payload.error).toEqualTypeOf<unknown>()
|
|
66
|
+
})
|
|
67
|
+
dispatcher.on('payment.response', (payload) => {
|
|
68
|
+
expectTypeOf(payload.response).toEqualTypeOf<Response>()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const fetch = Fetch.from({
|
|
72
|
+
eventDispatcher: dispatcher,
|
|
73
|
+
methods: [method],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expectTypeOf(fetch).toBeFunction()
|
|
77
|
+
})
|
|
47
78
|
})
|
|
48
79
|
|
|
49
80
|
describe('Fetch.from.RequestInit', () => {
|