mppx 0.6.19 → 0.6.21

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 (168) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/client/Mppx.d.ts +12 -1
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +127 -10
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/internal/Fetch.d.ts +69 -1
  23. package/dist/client/internal/Fetch.d.ts.map +1 -1
  24. package/dist/client/internal/Fetch.js +250 -20
  25. package/dist/client/internal/Fetch.js.map +1 -1
  26. package/dist/middlewares/elysia.d.ts.map +1 -1
  27. package/dist/middlewares/elysia.js +14 -0
  28. package/dist/middlewares/elysia.js.map +1 -1
  29. package/dist/middlewares/express.d.ts.map +1 -1
  30. package/dist/middlewares/express.js +1 -2
  31. package/dist/middlewares/express.js.map +1 -1
  32. package/dist/middlewares/hono.d.ts.map +1 -1
  33. package/dist/middlewares/hono.js +14 -0
  34. package/dist/middlewares/hono.js.map +1 -1
  35. package/dist/middlewares/internal/mppx.d.ts +1 -1
  36. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  37. package/dist/middlewares/internal/mppx.js +2 -1
  38. package/dist/middlewares/internal/mppx.js.map +1 -1
  39. package/dist/middlewares/nextjs.d.ts.map +1 -1
  40. package/dist/middlewares/nextjs.js +14 -0
  41. package/dist/middlewares/nextjs.js.map +1 -1
  42. package/dist/proxy/Proxy.d.ts.map +1 -1
  43. package/dist/proxy/Proxy.js +2 -2
  44. package/dist/proxy/Proxy.js.map +1 -1
  45. package/dist/proxy/Service.d.ts.map +1 -1
  46. package/dist/proxy/Service.js +1 -1
  47. package/dist/proxy/Service.js.map +1 -1
  48. package/dist/server/Mppx.d.ts +96 -5
  49. package/dist/server/Mppx.d.ts.map +1 -1
  50. package/dist/server/Mppx.js +739 -115
  51. package/dist/server/Mppx.js.map +1 -1
  52. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  53. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.js +1 -1
  55. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  56. package/dist/tempo/Methods.d.ts +96 -0
  57. package/dist/tempo/Methods.d.ts.map +1 -1
  58. package/dist/tempo/Methods.js +97 -0
  59. package/dist/tempo/Methods.js.map +1 -1
  60. package/dist/tempo/client/Methods.d.ts +3 -0
  61. package/dist/tempo/client/Methods.d.ts.map +1 -1
  62. package/dist/tempo/client/Methods.js +3 -0
  63. package/dist/tempo/client/Methods.js.map +1 -1
  64. package/dist/tempo/client/Subscription.d.ts +114 -0
  65. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  66. package/dist/tempo/client/Subscription.js +100 -0
  67. package/dist/tempo/client/Subscription.js.map +1 -0
  68. package/dist/tempo/client/index.d.ts +1 -0
  69. package/dist/tempo/client/index.d.ts.map +1 -1
  70. package/dist/tempo/client/index.js +1 -0
  71. package/dist/tempo/client/index.js.map +1 -1
  72. package/dist/tempo/index.d.ts +1 -0
  73. package/dist/tempo/index.d.ts.map +1 -1
  74. package/dist/tempo/index.js +1 -0
  75. package/dist/tempo/index.js.map +1 -1
  76. package/dist/tempo/server/Methods.d.ts +5 -0
  77. package/dist/tempo/server/Methods.d.ts.map +1 -1
  78. package/dist/tempo/server/Methods.js +5 -0
  79. package/dist/tempo/server/Methods.js.map +1 -1
  80. package/dist/tempo/server/Subscription.d.ts +221 -0
  81. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  82. package/dist/tempo/server/Subscription.js +637 -0
  83. package/dist/tempo/server/Subscription.js.map +1 -0
  84. package/dist/tempo/server/index.d.ts +1 -0
  85. package/dist/tempo/server/index.d.ts.map +1 -1
  86. package/dist/tempo/server/index.js +1 -0
  87. package/dist/tempo/server/index.js.map +1 -1
  88. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  89. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  90. package/dist/tempo/server/internal/html.gen.js +1 -1
  91. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  92. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  93. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  94. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  95. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  96. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  97. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  98. package/dist/tempo/subscription/Receipt.js +16 -0
  99. package/dist/tempo/subscription/Receipt.js.map +1 -0
  100. package/dist/tempo/subscription/Store.d.ts +99 -0
  101. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  102. package/dist/tempo/subscription/Store.js +292 -0
  103. package/dist/tempo/subscription/Store.js.map +1 -0
  104. package/dist/tempo/subscription/Types.d.ts +65 -0
  105. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  106. package/dist/tempo/subscription/Types.js +2 -0
  107. package/dist/tempo/subscription/Types.js.map +1 -0
  108. package/dist/tempo/subscription/index.d.ts +6 -0
  109. package/dist/tempo/subscription/index.d.ts.map +1 -0
  110. package/dist/tempo/subscription/index.js +4 -0
  111. package/dist/tempo/subscription/index.js.map +1 -0
  112. package/dist/zod.d.ts +7 -0
  113. package/dist/zod.d.ts.map +1 -1
  114. package/dist/zod.js +18 -0
  115. package/dist/zod.js.map +1 -1
  116. package/package.json +3 -3
  117. package/src/Challenge.test.ts +13 -0
  118. package/src/Challenge.ts +3 -3
  119. package/src/Method.ts +46 -1
  120. package/src/Receipt.ts +2 -0
  121. package/src/client/Methods.ts +1 -0
  122. package/src/client/Mppx.test-d.ts +55 -0
  123. package/src/client/Mppx.test.ts +181 -0
  124. package/src/client/Mppx.ts +248 -16
  125. package/src/client/internal/Fetch.test-d.ts +31 -0
  126. package/src/client/internal/Fetch.test.ts +261 -0
  127. package/src/client/internal/Fetch.ts +467 -24
  128. package/src/middlewares/elysia.test.ts +31 -1
  129. package/src/middlewares/elysia.ts +13 -0
  130. package/src/middlewares/express.ts +1 -5
  131. package/src/middlewares/hono.test.ts +30 -1
  132. package/src/middlewares/hono.ts +13 -0
  133. package/src/middlewares/internal/mppx.ts +5 -6
  134. package/src/middlewares/nextjs.test.ts +28 -1
  135. package/src/middlewares/nextjs.ts +13 -0
  136. package/src/proxy/Proxy.test.ts +69 -0
  137. package/src/proxy/Proxy.ts +2 -5
  138. package/src/proxy/Service.test.ts +34 -0
  139. package/src/proxy/Service.ts +7 -0
  140. package/src/server/Mppx.authorize.test.ts +210 -0
  141. package/src/server/Mppx.test-d.ts +73 -1
  142. package/src/server/Mppx.test.ts +965 -3
  143. package/src/server/Mppx.ts +1138 -140
  144. package/src/stripe/server/internal/html/package.json +1 -1
  145. package/src/stripe/server/internal/html.gen.ts +1 -1
  146. package/src/tempo/Methods.test.ts +131 -0
  147. package/src/tempo/Methods.ts +136 -0
  148. package/src/tempo/Subscription.integration.test.ts +591 -0
  149. package/src/tempo/client/Methods.ts +3 -0
  150. package/src/tempo/client/Subscription.test.ts +131 -0
  151. package/src/tempo/client/Subscription.ts +155 -0
  152. package/src/tempo/client/index.ts +1 -0
  153. package/src/tempo/index.ts +1 -0
  154. package/src/tempo/server/Methods.ts +5 -0
  155. package/src/tempo/server/Subscription.test.ts +1410 -0
  156. package/src/tempo/server/Subscription.ts +1014 -0
  157. package/src/tempo/server/index.ts +1 -0
  158. package/src/tempo/server/internal/html/package.json +1 -1
  159. package/src/tempo/server/internal/html.gen.ts +1 -1
  160. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  161. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  162. package/src/tempo/subscription/Receipt.ts +28 -0
  163. package/src/tempo/subscription/Store.test.ts +554 -0
  164. package/src/tempo/subscription/Store.ts +431 -0
  165. package/src/tempo/subscription/Types.ts +68 -0
  166. package/src/tempo/subscription/index.ts +23 -0
  167. package/src/zod.test.ts +23 -1
  168. package/src/zod.ts +24 -0
@@ -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
- const selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
104
- if (!selected)
105
- throw new Error(
106
- `No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
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
- const { challenge, method: mi } = selected
110
- if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
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
- const parsedContext =
113
- mi.context && context !== undefined ? mi.context.parse(context) : undefined
114
-
115
- return mi.createCredential(
116
- parsedContext !== undefined
117
- ? { challenge, context: parsedContext }
118
- : ({ challenge } as never),
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, before credential creation. */
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', () => {
@@ -669,6 +669,267 @@ describe('Fetch.from: 402 retry path', () => {
669
669
  expect(headers.Authorization).toBe('credential')
670
670
  })
671
671
 
672
+ test('emits client events and allows challenge handler to provide credential', async () => {
673
+ const events: string[] = []
674
+ const createCredential = vi.fn(async () => 'method-credential')
675
+ let callCount = 0
676
+ const calls: { init: RequestInit | undefined }[] = []
677
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
678
+ calls.push({ init })
679
+ callCount++
680
+ if (callCount === 1) return make402()
681
+ return new Response('OK', { status: 200 })
682
+ }
683
+
684
+ const method = { ...noopMethod, createCredential }
685
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
686
+ eventDispatcher.on('*', (event) => {
687
+ events.push(`*:${event.name}`)
688
+ })
689
+ eventDispatcher.on('challenge.received', async (payload) => {
690
+ events.push(`challenge:${payload.challenge.id}`)
691
+ return 'event-credential'
692
+ })
693
+ eventDispatcher.on('credential.created', (payload) => {
694
+ events.push(`credential:${payload.credential}`)
695
+ throw new Error('observer failed')
696
+ })
697
+ eventDispatcher.on('payment.response', (payload) => {
698
+ events.push(`response:${payload.response.status}`)
699
+ throw new Error('observer failed')
700
+ })
701
+
702
+ const fetch = Fetch.from({
703
+ eventDispatcher,
704
+ fetch: mockFetch,
705
+ methods: [method],
706
+ })
707
+
708
+ const response = await fetch('https://example.com/api')
709
+
710
+ expect(response.status).toBe(200)
711
+ expect(createCredential).not.toHaveBeenCalled()
712
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
713
+ expect(retryHeaders.get('Authorization')).toBe('event-credential')
714
+ expect(events).toEqual([
715
+ 'challenge:abc',
716
+ '*:challenge.received',
717
+ 'credential:event-credential',
718
+ '*:credential.created',
719
+ 'response:200',
720
+ '*:payment.response',
721
+ ])
722
+ })
723
+
724
+ test('uses the first challenge event credential', async () => {
725
+ const events: string[] = []
726
+ const createCredential = vi.fn(async () => 'method-credential')
727
+ const method = { ...noopMethod, createCredential }
728
+ let callCount = 0
729
+ const calls: { init: RequestInit | undefined }[] = []
730
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
731
+ calls.push({ init })
732
+ callCount++
733
+ if (callCount === 1) return make402()
734
+ return new Response('OK', { status: 200 })
735
+ }
736
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
737
+ eventDispatcher.on('challenge.received', () => {
738
+ events.push('first')
739
+ return 'first-credential'
740
+ })
741
+ eventDispatcher.on('challenge.received', () => {
742
+ events.push('second')
743
+ return 'second-credential'
744
+ })
745
+
746
+ const fetch = Fetch.from({
747
+ eventDispatcher,
748
+ fetch: mockFetch,
749
+ methods: [method],
750
+ })
751
+
752
+ await fetch('https://example.com/api')
753
+
754
+ expect(createCredential).not.toHaveBeenCalled()
755
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
756
+ expect(retryHeaders.get('Authorization')).toBe('first-credential')
757
+ expect(events).toEqual(['first'])
758
+ })
759
+
760
+ test('does not emit payment.response for non-ok retry responses', async () => {
761
+ const events: string[] = []
762
+ const createCredential = vi.fn(async () => 'method-credential')
763
+ const method = { ...noopMethod, createCredential }
764
+ let callCount = 0
765
+ const mockFetch: typeof globalThis.fetch = async () => {
766
+ callCount++
767
+ if (callCount === 1) return make402()
768
+ return new Response('Internal Server Error', { status: 500 })
769
+ }
770
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
771
+ eventDispatcher.on('payment.response', (payload) => {
772
+ events.push(`response:${payload.response.status}`)
773
+ })
774
+
775
+ const fetch = Fetch.from({
776
+ eventDispatcher,
777
+ fetch: mockFetch,
778
+ methods: [method],
779
+ })
780
+
781
+ const response = await fetch('https://example.com/api')
782
+
783
+ expect(response.status).toBe(500)
784
+ expect(events).toEqual([])
785
+ })
786
+
787
+ test('ignores empty challenge event credentials and continues handlers', async () => {
788
+ const createCredential = vi.fn(async () => 'method-credential')
789
+ const method = { ...noopMethod, createCredential }
790
+ let callCount = 0
791
+ const calls: { init: RequestInit | undefined }[] = []
792
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
793
+ calls.push({ init })
794
+ callCount++
795
+ if (callCount === 1) return make402()
796
+ return new Response('OK', { status: 200 })
797
+ }
798
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
799
+ eventDispatcher.on('challenge.received', () => '')
800
+ eventDispatcher.on('challenge.received', () => 'second-credential')
801
+
802
+ const fetch = Fetch.from({
803
+ eventDispatcher,
804
+ fetch: mockFetch,
805
+ methods: [method],
806
+ })
807
+
808
+ await fetch('https://example.com/api')
809
+
810
+ expect(createCredential).not.toHaveBeenCalled()
811
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
812
+ expect(retryHeaders.get('Authorization')).toBe('second-credential')
813
+ })
814
+
815
+ test('memoizes createCredential across wildcard observers and fallback', async () => {
816
+ const createCredential = vi.fn(async () => 'method-credential')
817
+ const method = { ...noopMethod, createCredential }
818
+ let callCount = 0
819
+ const mockFetch: typeof globalThis.fetch = async () => {
820
+ callCount++
821
+ if (callCount === 1) return make402()
822
+ return new Response('OK', { status: 200 })
823
+ }
824
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
825
+ eventDispatcher.on('*', async (event) => {
826
+ if (event.name === 'challenge.received') await event.payload.createCredential()
827
+ })
828
+
829
+ const fetch = Fetch.from({
830
+ eventDispatcher,
831
+ fetch: mockFetch,
832
+ methods: [method],
833
+ })
834
+
835
+ await fetch('https://example.com/api')
836
+
837
+ expect(createCredential).toHaveBeenCalledTimes(1)
838
+ })
839
+
840
+ test('does not expose live challenges or init headers to observers', async () => {
841
+ const createCredential = vi.fn(async ({ challenge }) => `amount:${challenge.request.amount}`)
842
+ const method = { ...noopMethod, createCredential }
843
+ let callCount = 0
844
+ const calls: { init: RequestInit | undefined }[] = []
845
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
846
+ calls.push({ init })
847
+ callCount++
848
+ if (callCount === 1) return make402()
849
+ return new Response('OK', { status: 200 })
850
+ }
851
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
852
+ eventDispatcher.on('*', (event) => {
853
+ if (event.name !== 'challenge.received') return
854
+ try {
855
+ ;(event.payload.challenge.request as { amount: string }).amount = '999'
856
+ } catch {}
857
+ const headers = new Headers(event.payload.init?.headers)
858
+ headers.set('Authorization', 'attacker')
859
+ if (event.payload.init) event.payload.init.headers = headers
860
+ })
861
+
862
+ const fetch = Fetch.from({
863
+ eventDispatcher,
864
+ fetch: mockFetch,
865
+ methods: [method],
866
+ })
867
+
868
+ await fetch('https://example.com/api', {
869
+ headers: { 'X-Test': '1' },
870
+ })
871
+
872
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
873
+ expect(retryHeaders.get('Authorization')).toBe('amount:1')
874
+ })
875
+
876
+ test('continues dispatching observer listeners after one throws', async () => {
877
+ const events: string[] = []
878
+ const method = { ...noopMethod, createCredential: vi.fn(async () => 'credential') }
879
+ let callCount = 0
880
+ const mockFetch: typeof globalThis.fetch = async () => {
881
+ callCount++
882
+ if (callCount === 1) return make402()
883
+ return new Response('OK', { status: 200 })
884
+ }
885
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
886
+ eventDispatcher.on('credential.created', () => {
887
+ events.push('first')
888
+ throw new Error('observer failed')
889
+ })
890
+ eventDispatcher.on('credential.created', () => {
891
+ events.push('second')
892
+ })
893
+
894
+ const fetch = Fetch.from({
895
+ eventDispatcher,
896
+ fetch: mockFetch,
897
+ methods: [method],
898
+ })
899
+
900
+ await fetch('https://example.com/api')
901
+
902
+ expect(events).toEqual(['first', 'second'])
903
+ })
904
+
905
+ test('emits payment.failed when automatic payment handling rejects', async () => {
906
+ const events: string[] = []
907
+ const createCredential = vi.fn(async () => 'credential')
908
+ const mockFetch = vi.fn(async () =>
909
+ make402({ expires: new Date(Date.now() - 60_000).toISOString() }),
910
+ )
911
+ const method = { ...noopMethod, createCredential }
912
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
913
+ eventDispatcher.on('*', (event) => {
914
+ events.push(`*:${event.name}`)
915
+ })
916
+ eventDispatcher.on('payment.failed', (payload) => {
917
+ events.push(
918
+ `failed:${payload.error instanceof Errors.PaymentExpiredError}:${payload.challenge?.id}`,
919
+ )
920
+ throw new Error('observer failed')
921
+ })
922
+ const fetch = Fetch.from({
923
+ eventDispatcher,
924
+ fetch: mockFetch as typeof globalThis.fetch,
925
+ methods: [method],
926
+ })
927
+
928
+ await expect(fetch('https://example.com/api')).rejects.toThrow(Errors.PaymentExpiredError)
929
+ expect(createCredential).not.toHaveBeenCalled()
930
+ expect(events).toEqual(['failed:true:abc', '*:payment.failed'])
931
+ })
932
+
672
933
  test('preserves existing headers on retry', async () => {
673
934
  let callCount = 0
674
935
  const calls: { init: RequestInit | undefined }[] = []