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.
- package/CHANGELOG.md +14 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- 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/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.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/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +96 -5
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +739 -115
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.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/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- 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/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/internal/mppx.ts +5 -6
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.test.ts +69 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +73 -1
- package/src/server/Mppx.test.ts +965 -3
- package/src/server/Mppx.ts +1138 -140
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- package/src/zod.ts +24 -0
package/src/server/Mppx.ts
CHANGED
|
@@ -7,10 +7,11 @@ import * as Errors from '../Errors.js'
|
|
|
7
7
|
import * as Expires from '../Expires.js'
|
|
8
8
|
import * as AcceptPayment from '../internal/AcceptPayment.js'
|
|
9
9
|
import * as Env from '../internal/env.js'
|
|
10
|
+
import type { MaybePromise } from '../internal/types.js'
|
|
10
11
|
import type * as Method from '../Method.js'
|
|
11
12
|
import * as PaymentRequest from '../PaymentRequest.js'
|
|
12
13
|
import type * as Receipt from '../Receipt.js'
|
|
13
|
-
import
|
|
14
|
+
import * as z from '../zod.js'
|
|
14
15
|
import * as Html from './internal/html/config.js'
|
|
15
16
|
import { serviceWorker } from './internal/html/serviceWorker.gen.js'
|
|
16
17
|
import * as Scope from './internal/scope.js'
|
|
@@ -20,6 +21,125 @@ import * as Transport from './Transport.js'
|
|
|
20
21
|
|
|
21
22
|
export type Methods = readonly (Method.AnyServer | readonly Method.AnyServer[])[]
|
|
22
23
|
|
|
24
|
+
export type ServerEventMap<
|
|
25
|
+
methods extends readonly Method.Method[] = readonly Method.Method[],
|
|
26
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
27
|
+
> = {
|
|
28
|
+
'challenge.created': ChallengeContext<methods[number], transport>
|
|
29
|
+
'payment.failed': PaymentFailedContext<methods[number], transport>
|
|
30
|
+
'payment.success': PaymentSuccessContext<methods[number], transport>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ServerEventName<
|
|
34
|
+
methods extends readonly Method.Method[] = readonly Method.Method[],
|
|
35
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
36
|
+
> = keyof ServerEventMap<methods, transport> | '*'
|
|
37
|
+
|
|
38
|
+
export type ServerEventEnvelope<
|
|
39
|
+
methods extends readonly Method.Method[] = readonly Method.Method[],
|
|
40
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
41
|
+
> = {
|
|
42
|
+
[name in keyof ServerEventMap<methods, transport>]: Readonly<{
|
|
43
|
+
name: name
|
|
44
|
+
payload: ServerEventMap<methods, transport>[name]
|
|
45
|
+
}>
|
|
46
|
+
}[keyof ServerEventMap<methods, transport>]
|
|
47
|
+
|
|
48
|
+
export type ServerEventPayload<
|
|
49
|
+
methods extends readonly Method.Method[] = readonly Method.Method[],
|
|
50
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
51
|
+
name extends ServerEventName<methods, transport> = ServerEventName<methods, transport>,
|
|
52
|
+
> = name extends '*'
|
|
53
|
+
? ServerEventEnvelope<methods, transport>
|
|
54
|
+
: name extends keyof ServerEventMap<methods, transport>
|
|
55
|
+
? ServerEventMap<methods, transport>[name]
|
|
56
|
+
: never
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Server event handler.
|
|
60
|
+
*
|
|
61
|
+
* Handlers are awaited inline and sequentially on the payment request path.
|
|
62
|
+
* Errors are swallowed so observers cannot change payment control flow, but
|
|
63
|
+
* slow handlers still delay the response.
|
|
64
|
+
*/
|
|
65
|
+
export type ServerEventHandler<
|
|
66
|
+
methods extends readonly Method.Method[] = readonly Method.Method[],
|
|
67
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
68
|
+
name extends ServerEventName<methods, transport> = ServerEventName<methods, transport>,
|
|
69
|
+
> = (context: ServerEventPayload<methods, transport, name>) => MaybePromise<void>
|
|
70
|
+
|
|
71
|
+
/** Removes a registered server event handler. */
|
|
72
|
+
export type Unsubscribe = () => void
|
|
73
|
+
|
|
74
|
+
/** Inert method descriptor exposed to server event handlers. */
|
|
75
|
+
export type ServerMethodDescriptor<
|
|
76
|
+
method extends Pick<Method.Method, 'intent' | 'name'> = Method.Method,
|
|
77
|
+
> = Readonly<{
|
|
78
|
+
intent: method['intent']
|
|
79
|
+
name: method['name']
|
|
80
|
+
}>
|
|
81
|
+
|
|
82
|
+
/** Context passed to `onChallengeCreated`. */
|
|
83
|
+
export type ChallengeContext<
|
|
84
|
+
method extends Method.Method = Method.Method,
|
|
85
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
86
|
+
> = Readonly<{
|
|
87
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
88
|
+
challenge: Challenge.Challenge
|
|
89
|
+
credential?: Credential.Credential | null | undefined
|
|
90
|
+
error?: Errors.PaymentError | undefined
|
|
91
|
+
input?: Transport.InputOf<transport> | undefined
|
|
92
|
+
method: ServerMethodDescriptor<method>
|
|
93
|
+
request: z.output<method['schema']['request']>
|
|
94
|
+
}>
|
|
95
|
+
|
|
96
|
+
/** Context passed to `onPaymentFailed`. */
|
|
97
|
+
export type PaymentFailedContext<
|
|
98
|
+
method extends Method.Method = Method.Method,
|
|
99
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
100
|
+
> = Readonly<{
|
|
101
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
102
|
+
challenge: Challenge.Challenge
|
|
103
|
+
credential: Credential.Credential | null
|
|
104
|
+
error: Errors.PaymentError
|
|
105
|
+
input?: Transport.InputOf<transport> | undefined
|
|
106
|
+
method: ServerMethodDescriptor<method>
|
|
107
|
+
request: z.output<method['schema']['request']>
|
|
108
|
+
retryChallenge?: Challenge.Challenge | undefined
|
|
109
|
+
submittedChallenge?: Challenge.Challenge | undefined
|
|
110
|
+
}>
|
|
111
|
+
|
|
112
|
+
/** Context passed to `onPaymentSuccess`. */
|
|
113
|
+
export type PaymentSuccessContext<
|
|
114
|
+
method extends Method.Method = Method.Method,
|
|
115
|
+
transport extends Transport.AnyTransport = Transport.AnyTransport,
|
|
116
|
+
> = Readonly<{
|
|
117
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
118
|
+
challenge: Challenge.Challenge<
|
|
119
|
+
z.output<method['schema']['request']>,
|
|
120
|
+
method['intent'],
|
|
121
|
+
method['name']
|
|
122
|
+
>
|
|
123
|
+
credential?:
|
|
124
|
+
| Credential.Credential<
|
|
125
|
+
z.output<method['schema']['credential']['payload']>,
|
|
126
|
+
Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
|
|
127
|
+
>
|
|
128
|
+
| undefined
|
|
129
|
+
envelope?:
|
|
130
|
+
| Method.VerifiedChallengeEnvelope<
|
|
131
|
+
z.output<method['schema']['request']>,
|
|
132
|
+
z.output<method['schema']['credential']['payload']>,
|
|
133
|
+
method['intent'],
|
|
134
|
+
method['name']
|
|
135
|
+
>
|
|
136
|
+
| undefined
|
|
137
|
+
input?: Transport.InputOf<transport> | undefined
|
|
138
|
+
method: ServerMethodDescriptor<method>
|
|
139
|
+
receipt: Receipt.Receipt
|
|
140
|
+
request: z.output<method['schema']['request']>
|
|
141
|
+
}>
|
|
142
|
+
|
|
23
143
|
/** Options for standalone credential verification. */
|
|
24
144
|
export type VerifyCredentialOptions = {
|
|
25
145
|
capturedRequest?: Method.CapturedRequest | undefined
|
|
@@ -43,6 +163,23 @@ export type Mppx<
|
|
|
43
163
|
realm: string
|
|
44
164
|
/** The transport used. */
|
|
45
165
|
transport: transport
|
|
166
|
+
/** Register a server event handler by canonical event name. */
|
|
167
|
+
on<name extends ServerEventName<FlattenMethods<methods>, transport>>(
|
|
168
|
+
name: name,
|
|
169
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, name>,
|
|
170
|
+
): Unsubscribe
|
|
171
|
+
/** Register a handler for issued payment challenges. */
|
|
172
|
+
onChallengeCreated(
|
|
173
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'challenge.created'>,
|
|
174
|
+
): Unsubscribe
|
|
175
|
+
/** Register a handler for failed submitted payment credentials. */
|
|
176
|
+
onPaymentFailed(
|
|
177
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.failed'>,
|
|
178
|
+
): Unsubscribe
|
|
179
|
+
/** Register a handler for successful verified payments. */
|
|
180
|
+
onPaymentSuccess(
|
|
181
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.success'>,
|
|
182
|
+
): Unsubscribe
|
|
46
183
|
} & (transport extends Transport.Http
|
|
47
184
|
? {
|
|
48
185
|
/**
|
|
@@ -53,6 +190,8 @@ export type Mppx<
|
|
|
53
190
|
* server methods passed to `Mppx.create()`, looked up by `name`+`intent`.
|
|
54
191
|
*
|
|
55
192
|
* Only available on HTTP transports.
|
|
193
|
+
* No-credential authorize hooks run in entry order; the first 200 response
|
|
194
|
+
* wins, and earlier hooks may have already run side effects.
|
|
56
195
|
*
|
|
57
196
|
* @example
|
|
58
197
|
* ```ts
|
|
@@ -105,6 +244,12 @@ export type Mppx<
|
|
|
105
244
|
* HMAC-check, match to a registered method, validate payload schema,
|
|
106
245
|
* check expiry, and call the method's verify function.
|
|
107
246
|
*
|
|
247
|
+
* Method verification can settle payments and persist state. For example,
|
|
248
|
+
* subscription credentials may activate or renew a subscription. Failed
|
|
249
|
+
* standalone verification emits `payment.failed` once a credential challenge
|
|
250
|
+
* can be parsed; strings that cannot be deserialized have no challenge
|
|
251
|
+
* context to report.
|
|
252
|
+
*
|
|
108
253
|
* @example
|
|
109
254
|
* ```ts
|
|
110
255
|
* const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
|
|
@@ -132,6 +277,25 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
|
|
|
132
277
|
? defaultTransport
|
|
133
278
|
: TransportOverrideOf<mi>
|
|
134
279
|
|
|
280
|
+
const reservedMppxKeyValues = [
|
|
281
|
+
'challenge',
|
|
282
|
+
'compose',
|
|
283
|
+
'methods',
|
|
284
|
+
'on',
|
|
285
|
+
'onChallengeCreated',
|
|
286
|
+
'onPaymentFailed',
|
|
287
|
+
'onPaymentSuccess',
|
|
288
|
+
'realm',
|
|
289
|
+
'transport',
|
|
290
|
+
'verifyCredential',
|
|
291
|
+
] as const
|
|
292
|
+
|
|
293
|
+
/** Public instance keys that payment method names and shorthand intents cannot shadow. */
|
|
294
|
+
export type ReservedKey = (typeof reservedMppxKeyValues)[number]
|
|
295
|
+
|
|
296
|
+
/** Public instance keys that payment method names and shorthand intents cannot shadow. */
|
|
297
|
+
export const reservedMppxKeys: ReadonlySet<ReservedKey> = new Set(reservedMppxKeyValues)
|
|
298
|
+
|
|
135
299
|
/** True when exactly one method has the given intent (no name collision). */
|
|
136
300
|
type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> =
|
|
137
301
|
Extract<methods[number], { intent: intent }> extends infer M
|
|
@@ -148,7 +312,9 @@ type UniqueIntentHandlers<
|
|
|
148
312
|
transport extends Transport.AnyTransport,
|
|
149
313
|
> = {
|
|
150
314
|
[method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
|
|
151
|
-
? method_name
|
|
315
|
+
? method_name extends ReservedKey
|
|
316
|
+
? never
|
|
317
|
+
: method_name
|
|
152
318
|
: never]: MethodFn<
|
|
153
319
|
Extract<methods[number], { intent: method_name }>,
|
|
154
320
|
EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
|
|
@@ -161,7 +327,7 @@ type NestedHandlers<
|
|
|
161
327
|
methods extends readonly Method.AnyServer[],
|
|
162
328
|
transport extends Transport.AnyTransport,
|
|
163
329
|
> = {
|
|
164
|
-
[name in methods[number]['name']]: {
|
|
330
|
+
[name in methods[number]['name'] as name extends ReservedKey ? never : name]: {
|
|
165
331
|
[mi in Extract<methods[number], { name: name }> as mi['intent']]: MethodFn<
|
|
166
332
|
mi,
|
|
167
333
|
EffectiveTransportOf<mi, transport>,
|
|
@@ -230,19 +396,27 @@ export function create<
|
|
|
230
396
|
}
|
|
231
397
|
|
|
232
398
|
const methods = config.methods.flat() as unknown as FlattenMethods<methods>
|
|
399
|
+
const serverEvents = createServerEventDispatcher<FlattenMethods<methods>, transport>()
|
|
233
400
|
|
|
234
401
|
const handlers: Record<string, unknown> = {}
|
|
235
402
|
const intentCount: Record<string, number> = {}
|
|
236
403
|
|
|
237
404
|
for (const mi of methods) {
|
|
238
405
|
intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
|
|
406
|
+
}
|
|
407
|
+
assertNoReservedMppxKeys(methods as readonly Method.AnyServer[])
|
|
408
|
+
|
|
409
|
+
for (const mi of methods) {
|
|
239
410
|
handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
|
|
411
|
+
authorize: mi.authorize as never,
|
|
240
412
|
defaults: mi.defaults,
|
|
241
413
|
method: mi,
|
|
242
414
|
realm,
|
|
415
|
+
events: serverEvents as never,
|
|
243
416
|
request: mi.request as never,
|
|
244
417
|
respond: mi.respond as never,
|
|
245
418
|
secretKey,
|
|
419
|
+
stableBinding: mi.stableBinding as never,
|
|
246
420
|
transport: (mi.transport ?? transport) as never,
|
|
247
421
|
verify: mi.verify as never,
|
|
248
422
|
})
|
|
@@ -283,37 +457,114 @@ export function create<
|
|
|
283
457
|
typeof input === 'string' ? Credential.deserialize(input) : input,
|
|
284
458
|
)
|
|
285
459
|
|
|
286
|
-
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
287
|
-
if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
|
|
288
|
-
throw new Errors.InvalidChallengeError({
|
|
289
|
-
id: credential.challenge.id,
|
|
290
|
-
reason: 'challenge was not issued by this server',
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
// Expiry check
|
|
294
|
-
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
295
|
-
|
|
296
460
|
// Find matching method by name + intent
|
|
297
461
|
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
298
462
|
const mi = (methods as readonly Method.AnyServer[]).find(
|
|
299
463
|
(m) => m.name === credMethod && m.intent === credIntent,
|
|
300
464
|
)
|
|
301
|
-
|
|
302
|
-
|
|
465
|
+
const eventMethod =
|
|
466
|
+
mi ?? ({ intent: credIntent, name: credMethod } satisfies ServerMethodDescriptor)
|
|
467
|
+
|
|
468
|
+
const emitStandalonePaymentFailed = async (parameters: {
|
|
469
|
+
challenge: Challenge.Challenge
|
|
470
|
+
credential: Credential.Credential | null
|
|
471
|
+
error: Errors.PaymentError
|
|
472
|
+
request: Record<string, unknown>
|
|
473
|
+
submittedChallenge?: Challenge.Challenge | undefined
|
|
474
|
+
}) => {
|
|
475
|
+
await serverEvents.emit(
|
|
476
|
+
'payment.failed',
|
|
477
|
+
createPaymentFailedContext({
|
|
478
|
+
capturedRequest: options?.capturedRequest,
|
|
479
|
+
challenge: parameters.challenge,
|
|
480
|
+
credential: parameters.credential,
|
|
481
|
+
error: parameters.error,
|
|
482
|
+
method: eventMethod,
|
|
483
|
+
request: parameters.request,
|
|
484
|
+
submittedChallenge: parameters.submittedChallenge,
|
|
485
|
+
}) as never,
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!mi) {
|
|
490
|
+
const error = new Errors.InvalidChallengeError({
|
|
303
491
|
id: credential.challenge.id,
|
|
304
492
|
reason: `no registered method for ${credMethod}/${credIntent}`,
|
|
305
493
|
})
|
|
494
|
+
await emitStandalonePaymentFailed({
|
|
495
|
+
challenge: credential.challenge,
|
|
496
|
+
credential,
|
|
497
|
+
error,
|
|
498
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
499
|
+
submittedChallenge: credential.challenge,
|
|
500
|
+
})
|
|
501
|
+
throw error
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
505
|
+
if (!Challenge.verify(credential.challenge, { secretKey: secretKey! })) {
|
|
506
|
+
const error = new Errors.InvalidChallengeError({
|
|
507
|
+
id: credential.challenge.id,
|
|
508
|
+
reason: 'challenge was not issued by this server',
|
|
509
|
+
})
|
|
510
|
+
await emitStandalonePaymentFailed({
|
|
511
|
+
challenge: credential.challenge,
|
|
512
|
+
credential,
|
|
513
|
+
error,
|
|
514
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
515
|
+
submittedChallenge: credential.challenge,
|
|
516
|
+
})
|
|
517
|
+
throw error
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Expiry check
|
|
521
|
+
try {
|
|
522
|
+
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
523
|
+
} catch (e) {
|
|
524
|
+
if (e instanceof Errors.PaymentError)
|
|
525
|
+
await emitStandalonePaymentFailed({
|
|
526
|
+
challenge: credential.challenge,
|
|
527
|
+
credential,
|
|
528
|
+
error: e,
|
|
529
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
530
|
+
submittedChallenge: credential.challenge,
|
|
531
|
+
})
|
|
532
|
+
throw e
|
|
533
|
+
}
|
|
306
534
|
|
|
307
535
|
// Validate payload against method schema
|
|
308
|
-
|
|
536
|
+
let parsedCredential: Credential.Credential
|
|
537
|
+
try {
|
|
538
|
+
parsedCredential = withParsedCredentialPayload(
|
|
539
|
+
credential,
|
|
540
|
+
mi.schema.credential.payload.parse(credential.payload),
|
|
541
|
+
)
|
|
542
|
+
} catch (e) {
|
|
543
|
+
await emitStandalonePaymentFailed({
|
|
544
|
+
challenge: credential.challenge,
|
|
545
|
+
credential,
|
|
546
|
+
error: new Errors.InvalidPayloadError(),
|
|
547
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
548
|
+
submittedChallenge: credential.challenge,
|
|
549
|
+
})
|
|
550
|
+
throw e
|
|
551
|
+
}
|
|
309
552
|
|
|
310
553
|
const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })
|
|
311
554
|
|
|
312
555
|
if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
|
|
313
|
-
|
|
556
|
+
const error = new Errors.InvalidChallengeError({
|
|
314
557
|
id: credential.challenge.id,
|
|
315
558
|
reason: "credential scope does not match this route's requirements",
|
|
316
559
|
})
|
|
560
|
+
await emitStandalonePaymentFailed({
|
|
561
|
+
challenge: credential.challenge,
|
|
562
|
+
credential: parsedCredential,
|
|
563
|
+
error,
|
|
564
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
565
|
+
submittedChallenge: credential.challenge,
|
|
566
|
+
})
|
|
567
|
+
throw error
|
|
317
568
|
}
|
|
318
569
|
|
|
319
570
|
const shouldValidateRoute =
|
|
@@ -326,40 +577,87 @@ export function create<
|
|
|
326
577
|
realm ??
|
|
327
578
|
(options?.capturedRequest === undefined ? credential.challenge.realm : undefined)
|
|
328
579
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
580
|
+
let parsedRequest = credential.challenge.request as Record<string, unknown>
|
|
581
|
+
let request: z.input<typeof mi.schema.request>
|
|
582
|
+
try {
|
|
583
|
+
request = shouldValidateRoute
|
|
584
|
+
? await resolveRouteChallenge({
|
|
585
|
+
capturedRequest: options?.capturedRequest,
|
|
586
|
+
credential: parsedCredential,
|
|
587
|
+
defaults: mi.defaults,
|
|
588
|
+
expires: credential.challenge.expires,
|
|
589
|
+
meta: expectedMeta,
|
|
590
|
+
method: mi,
|
|
591
|
+
realm: expectedRealm,
|
|
592
|
+
request: mi.request as never,
|
|
593
|
+
routeRequest: options?.request ?? {},
|
|
594
|
+
secretKey: secretKey!,
|
|
595
|
+
}).then((resolved) => {
|
|
596
|
+
const mismatch = getChallengeBindingMismatch(
|
|
597
|
+
resolved.challenge,
|
|
598
|
+
credential.challenge,
|
|
599
|
+
mi.stableBinding as never,
|
|
600
|
+
)
|
|
601
|
+
if (mismatch)
|
|
602
|
+
throw new Errors.InvalidChallengeError({
|
|
603
|
+
id: credential.challenge.id,
|
|
604
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
605
|
+
})
|
|
348
606
|
|
|
349
|
-
|
|
607
|
+
parsedRequest = resolved.parsedRequest
|
|
608
|
+
return resolved.request as z.input<typeof mi.schema.request>
|
|
609
|
+
})
|
|
610
|
+
: (credential.challenge.request as z.input<typeof mi.schema.request>)
|
|
611
|
+
} catch (e) {
|
|
612
|
+
if (e instanceof Errors.PaymentError)
|
|
613
|
+
await emitStandalonePaymentFailed({
|
|
614
|
+
challenge: credential.challenge,
|
|
615
|
+
credential: parsedCredential,
|
|
616
|
+
error: e,
|
|
617
|
+
request: credential.challenge.request as Record<string, unknown>,
|
|
618
|
+
submittedChallenge: credential.challenge,
|
|
350
619
|
})
|
|
351
|
-
|
|
620
|
+
throw e
|
|
621
|
+
}
|
|
352
622
|
|
|
353
623
|
const envelope = options?.capturedRequest
|
|
354
624
|
? ({
|
|
355
625
|
capturedRequest: options.capturedRequest,
|
|
356
626
|
challenge: credential.challenge,
|
|
357
|
-
credential,
|
|
358
|
-
request,
|
|
627
|
+
credential: parsedCredential,
|
|
628
|
+
request: parsedRequest,
|
|
359
629
|
} as Method.VerifiedChallengeEnvelope)
|
|
360
630
|
: undefined
|
|
361
631
|
|
|
362
|
-
|
|
632
|
+
let receipt: Receipt.Receipt
|
|
633
|
+
try {
|
|
634
|
+
receipt = await mi.verify({ credential: parsedCredential, envelope, request } as never)
|
|
635
|
+
} catch (e) {
|
|
636
|
+
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
|
|
637
|
+
await emitStandalonePaymentFailed({
|
|
638
|
+
challenge: credential.challenge,
|
|
639
|
+
credential: parsedCredential,
|
|
640
|
+
error,
|
|
641
|
+
request: parsedRequest,
|
|
642
|
+
submittedChallenge: credential.challenge,
|
|
643
|
+
})
|
|
644
|
+
throw e
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await serverEvents.emit(
|
|
648
|
+
'payment.success',
|
|
649
|
+
createPaymentSuccessContext({
|
|
650
|
+
capturedRequest: options?.capturedRequest,
|
|
651
|
+
challenge: credential.challenge,
|
|
652
|
+
credential: parsedCredential,
|
|
653
|
+
envelope,
|
|
654
|
+
method: mi,
|
|
655
|
+
receipt,
|
|
656
|
+
request: parsedRequest,
|
|
657
|
+
}) as never,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
return receipt
|
|
363
661
|
}
|
|
364
662
|
|
|
365
663
|
function composeFn(
|
|
@@ -385,10 +683,32 @@ export function create<
|
|
|
385
683
|
return compose(...(configured as ConfiguredHandler[]))
|
|
386
684
|
}
|
|
387
685
|
|
|
686
|
+
function onChallengeCreated(
|
|
687
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'challenge.created'>,
|
|
688
|
+
) {
|
|
689
|
+
return serverEvents.on('challenge.created', handler)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function onPaymentFailed(
|
|
693
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.failed'>,
|
|
694
|
+
) {
|
|
695
|
+
return serverEvents.on('payment.failed', handler)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function onPaymentSuccess(
|
|
699
|
+
handler: ServerEventHandler<FlattenMethods<methods>, transport, 'payment.success'>,
|
|
700
|
+
) {
|
|
701
|
+
return serverEvents.on('payment.success', handler)
|
|
702
|
+
}
|
|
703
|
+
|
|
388
704
|
return {
|
|
389
705
|
methods,
|
|
390
706
|
challenge: challengeHandlers,
|
|
391
707
|
compose: composeFn,
|
|
708
|
+
on: serverEvents.on,
|
|
709
|
+
onChallengeCreated,
|
|
710
|
+
onPaymentFailed,
|
|
711
|
+
onPaymentSuccess,
|
|
392
712
|
realm: realm as string | undefined,
|
|
393
713
|
transport,
|
|
394
714
|
verifyCredential: verifyCredentialFn,
|
|
@@ -421,7 +741,18 @@ function createMethodFn<
|
|
|
421
741
|
): createMethodFn.ReturnType<method, transport, defaults>
|
|
422
742
|
// biome-ignore lint/correctness/noUnusedVariables: _
|
|
423
743
|
function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
|
|
424
|
-
const {
|
|
744
|
+
const {
|
|
745
|
+
authorize,
|
|
746
|
+
defaults,
|
|
747
|
+
events,
|
|
748
|
+
method,
|
|
749
|
+
realm,
|
|
750
|
+
respond,
|
|
751
|
+
secretKey,
|
|
752
|
+
stableBinding,
|
|
753
|
+
transport,
|
|
754
|
+
verify,
|
|
755
|
+
} = parameters
|
|
425
756
|
|
|
426
757
|
return (options) => {
|
|
427
758
|
const { description, meta, scope, ...rest } = options
|
|
@@ -429,8 +760,16 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
429
760
|
|
|
430
761
|
return Object.assign(
|
|
431
762
|
async (input: Transport.InputOf): Promise<MethodFn.Response> => {
|
|
763
|
+
if (method.html && isServiceWorkerRequest(input))
|
|
764
|
+
return {
|
|
765
|
+
status: 402,
|
|
766
|
+
challenge: createServiceWorkerResponse(),
|
|
767
|
+
} as MethodFn.Response<Transport.Http>
|
|
768
|
+
|
|
432
769
|
const expires =
|
|
433
|
-
'expires' in options
|
|
770
|
+
'expires' in options
|
|
771
|
+
? normalizeExpires(options.expires as z.DatetimeInput | undefined)
|
|
772
|
+
: Expires.minutes(5)
|
|
434
773
|
const capturedRequest = await captureRequest(transport, input)
|
|
435
774
|
const effectiveMeta =
|
|
436
775
|
scope === undefined && input instanceof globalThis.Request
|
|
@@ -446,7 +785,61 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
446
785
|
return [null, e as Error] as const
|
|
447
786
|
}
|
|
448
787
|
})()
|
|
449
|
-
|
|
788
|
+
|
|
789
|
+
const emitChallenge = async (parameters: {
|
|
790
|
+
challenge: Challenge.Challenge
|
|
791
|
+
credential?: Credential.Credential | null | undefined
|
|
792
|
+
error?: Errors.PaymentError | undefined
|
|
793
|
+
html?: Method.Method['html'] | undefined
|
|
794
|
+
request: Record<string, unknown>
|
|
795
|
+
}) => {
|
|
796
|
+
const response = await transport.respondChallenge({
|
|
797
|
+
challenge: parameters.challenge,
|
|
798
|
+
input,
|
|
799
|
+
...(parameters.error && { error: parameters.error }),
|
|
800
|
+
...(parameters.html && { html: parameters.html }),
|
|
801
|
+
})
|
|
802
|
+
if (isIssuedChallengeResponse(response))
|
|
803
|
+
await events.emit(
|
|
804
|
+
'challenge.created',
|
|
805
|
+
createChallengeContext({
|
|
806
|
+
capturedRequest,
|
|
807
|
+
challenge: parameters.challenge,
|
|
808
|
+
credential: parameters.credential,
|
|
809
|
+
error: parameters.error,
|
|
810
|
+
input,
|
|
811
|
+
method,
|
|
812
|
+
request: parameters.request,
|
|
813
|
+
}) as never,
|
|
814
|
+
)
|
|
815
|
+
return response
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const emitPaymentFailed = async (parameters: {
|
|
819
|
+
challenge: Challenge.Challenge
|
|
820
|
+
credential: Credential.Credential | null
|
|
821
|
+
error: Errors.PaymentError
|
|
822
|
+
request: Record<string, unknown>
|
|
823
|
+
retryChallenge?: Challenge.Challenge | undefined
|
|
824
|
+
submittedChallenge?: Challenge.Challenge | undefined
|
|
825
|
+
}) => {
|
|
826
|
+
await events.emit(
|
|
827
|
+
'payment.failed',
|
|
828
|
+
createPaymentFailedContext({
|
|
829
|
+
capturedRequest,
|
|
830
|
+
challenge: parameters.challenge,
|
|
831
|
+
credential: parameters.credential,
|
|
832
|
+
error: parameters.error,
|
|
833
|
+
input,
|
|
834
|
+
method,
|
|
835
|
+
request: parameters.request,
|
|
836
|
+
retryChallenge: parameters.retryChallenge,
|
|
837
|
+
submittedChallenge: parameters.submittedChallenge,
|
|
838
|
+
}) as never,
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const routeChallenge = await resolveRouteChallenge({
|
|
450
843
|
capturedRequest,
|
|
451
844
|
credential,
|
|
452
845
|
defaults,
|
|
@@ -458,26 +851,156 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
458
851
|
request: parameters.request,
|
|
459
852
|
routeRequest: rest,
|
|
460
853
|
secretKey,
|
|
854
|
+
}).catch(async (e) => {
|
|
855
|
+
if (!(e instanceof Errors.PaymentError)) throw e
|
|
856
|
+
const challenge = createFallbackChallenge({
|
|
857
|
+
capturedRequest,
|
|
858
|
+
defaults: defaults ?? {},
|
|
859
|
+
description,
|
|
860
|
+
expires,
|
|
861
|
+
meta: effectiveMeta,
|
|
862
|
+
method,
|
|
863
|
+
realm,
|
|
864
|
+
routeRequest: rest,
|
|
865
|
+
secretKey,
|
|
866
|
+
})
|
|
867
|
+
if (credential)
|
|
868
|
+
await emitPaymentFailed({
|
|
869
|
+
challenge,
|
|
870
|
+
credential,
|
|
871
|
+
error: e,
|
|
872
|
+
request: challenge.request,
|
|
873
|
+
retryChallenge: challenge,
|
|
874
|
+
submittedChallenge: credential.challenge,
|
|
875
|
+
})
|
|
876
|
+
const response = await emitChallenge({
|
|
877
|
+
challenge,
|
|
878
|
+
credential,
|
|
879
|
+
request: challenge.request,
|
|
880
|
+
error: e,
|
|
881
|
+
html: method.html,
|
|
882
|
+
})
|
|
883
|
+
return { response }
|
|
461
884
|
})
|
|
885
|
+
if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 }
|
|
886
|
+
const { challenge, parsedRequest, request } = routeChallenge
|
|
462
887
|
|
|
463
888
|
// Credential was provided but malformed
|
|
464
889
|
if (credentialError) {
|
|
465
890
|
const reason = getSafeCredentialReason(credentialError)
|
|
466
|
-
const
|
|
891
|
+
const error = new Errors.MalformedCredentialError(reason ? { reason } : {})
|
|
892
|
+
await emitPaymentFailed({
|
|
467
893
|
challenge,
|
|
468
|
-
|
|
469
|
-
error
|
|
894
|
+
credential: null,
|
|
895
|
+
error,
|
|
896
|
+
request: parsedRequest,
|
|
897
|
+
retryChallenge: challenge,
|
|
898
|
+
})
|
|
899
|
+
const response = await emitChallenge({
|
|
900
|
+
challenge,
|
|
901
|
+
credential: null,
|
|
902
|
+
request: parsedRequest,
|
|
903
|
+
error,
|
|
470
904
|
html: method.html,
|
|
471
905
|
})
|
|
472
906
|
return { challenge: response, status: 402 }
|
|
473
907
|
}
|
|
474
908
|
|
|
909
|
+
const success = (
|
|
910
|
+
receiptData: Receipt.Receipt,
|
|
911
|
+
options: {
|
|
912
|
+
challengeId?: string | undefined
|
|
913
|
+
credentialForReceipt?: Credential.Credential | undefined
|
|
914
|
+
envelopeForReceipt?: Method.VerifiedChallengeEnvelope | undefined
|
|
915
|
+
managementResponse?: globalThis.Response | undefined
|
|
916
|
+
} = {},
|
|
917
|
+
): MethodFn.Response => {
|
|
918
|
+
const {
|
|
919
|
+
challengeId = challenge.id,
|
|
920
|
+
credentialForReceipt = { challenge, payload: {} } as Credential.Credential,
|
|
921
|
+
envelopeForReceipt,
|
|
922
|
+
managementResponse,
|
|
923
|
+
} = options
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
status: 200,
|
|
927
|
+
withReceipt<response>(response?: response) {
|
|
928
|
+
if (managementResponse) {
|
|
929
|
+
return transport.respondReceipt({
|
|
930
|
+
challengeId,
|
|
931
|
+
credential: credentialForReceipt,
|
|
932
|
+
...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
|
|
933
|
+
input,
|
|
934
|
+
receipt: receiptData,
|
|
935
|
+
response: managementResponse as never,
|
|
936
|
+
}) as response
|
|
937
|
+
}
|
|
938
|
+
if (!response) throw new MissingReceiptResponseError()
|
|
939
|
+
return transport.respondReceipt({
|
|
940
|
+
challengeId,
|
|
941
|
+
credential: credentialForReceipt,
|
|
942
|
+
...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}),
|
|
943
|
+
input,
|
|
944
|
+
receipt: receiptData,
|
|
945
|
+
response: response as never,
|
|
946
|
+
}) as response
|
|
947
|
+
},
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
475
951
|
// No credential provided—issue challenge
|
|
476
952
|
if (!credential) {
|
|
477
|
-
|
|
953
|
+
if (authorize && input instanceof globalThis.Request) {
|
|
954
|
+
try {
|
|
955
|
+
const authorized = await authorize({
|
|
956
|
+
challenge,
|
|
957
|
+
input,
|
|
958
|
+
request: challenge.request,
|
|
959
|
+
} as never)
|
|
960
|
+
if (authorized) {
|
|
961
|
+
await events.emit(
|
|
962
|
+
'payment.success',
|
|
963
|
+
createPaymentSuccessContext({
|
|
964
|
+
capturedRequest,
|
|
965
|
+
challenge,
|
|
966
|
+
input,
|
|
967
|
+
method,
|
|
968
|
+
receipt: authorized.receipt,
|
|
969
|
+
request: parsedRequest,
|
|
970
|
+
}) as never,
|
|
971
|
+
)
|
|
972
|
+
return success(authorized.receipt, {
|
|
973
|
+
managementResponse: authorized.response,
|
|
974
|
+
})
|
|
975
|
+
}
|
|
976
|
+
} catch (e) {
|
|
977
|
+
if (!(e instanceof Errors.PaymentError))
|
|
978
|
+
console.error('mppx: internal authorization error', e)
|
|
979
|
+
const error =
|
|
980
|
+
e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
|
|
981
|
+
await emitPaymentFailed({
|
|
982
|
+
challenge,
|
|
983
|
+
credential: null,
|
|
984
|
+
error,
|
|
985
|
+
request: parsedRequest,
|
|
986
|
+
retryChallenge: challenge,
|
|
987
|
+
})
|
|
988
|
+
const response = await emitChallenge({
|
|
989
|
+
challenge,
|
|
990
|
+
request: parsedRequest,
|
|
991
|
+
error,
|
|
992
|
+
html: method.html,
|
|
993
|
+
})
|
|
994
|
+
return { challenge: response, status: 402 }
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const error = new Errors.PaymentRequiredError({ description })
|
|
999
|
+
const response = await emitChallenge({
|
|
478
1000
|
challenge,
|
|
479
|
-
|
|
480
|
-
|
|
1001
|
+
credential: null,
|
|
1002
|
+
request: parsedRequest,
|
|
1003
|
+
error,
|
|
481
1004
|
html: method.html,
|
|
482
1005
|
})
|
|
483
1006
|
return { challenge: response, status: 402 }
|
|
@@ -495,13 +1018,23 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
495
1018
|
// (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
|
|
496
1019
|
// No database lookup is needed; the HMAC is stateless verification.
|
|
497
1020
|
if (!Challenge.verify(credential.challenge, { secretKey })) {
|
|
498
|
-
const
|
|
1021
|
+
const error = new Errors.InvalidChallengeError({
|
|
1022
|
+
id: credential.challenge.id,
|
|
1023
|
+
reason: 'challenge was not issued by this server',
|
|
1024
|
+
})
|
|
1025
|
+
await emitPaymentFailed({
|
|
499
1026
|
challenge,
|
|
500
|
-
|
|
501
|
-
error
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1027
|
+
credential,
|
|
1028
|
+
error,
|
|
1029
|
+
request: parsedRequest,
|
|
1030
|
+
retryChallenge: challenge,
|
|
1031
|
+
submittedChallenge: credential.challenge,
|
|
1032
|
+
})
|
|
1033
|
+
const response = await emitChallenge({
|
|
1034
|
+
challenge,
|
|
1035
|
+
credential,
|
|
1036
|
+
request: parsedRequest,
|
|
1037
|
+
error,
|
|
505
1038
|
html: method.html,
|
|
506
1039
|
})
|
|
507
1040
|
return { challenge: response, status: 402 }
|
|
@@ -530,15 +1063,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
530
1063
|
// `expires` still is not pinned here because its default is generated
|
|
531
1064
|
// per invocation, and `digest` is already bound by the echoed HMAC.
|
|
532
1065
|
{
|
|
533
|
-
const mismatch =
|
|
1066
|
+
const mismatch = getChallengeBindingMismatch(
|
|
1067
|
+
challenge,
|
|
1068
|
+
credential.challenge,
|
|
1069
|
+
stableBinding as never,
|
|
1070
|
+
)
|
|
534
1071
|
if (mismatch) {
|
|
535
|
-
const
|
|
1072
|
+
const error = new Errors.InvalidChallengeError({
|
|
1073
|
+
id: credential.challenge.id,
|
|
1074
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
1075
|
+
})
|
|
1076
|
+
await emitPaymentFailed({
|
|
536
1077
|
challenge,
|
|
537
|
-
|
|
538
|
-
error
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1078
|
+
credential,
|
|
1079
|
+
error,
|
|
1080
|
+
request: parsedRequest,
|
|
1081
|
+
retryChallenge: challenge,
|
|
1082
|
+
submittedChallenge: credential.challenge,
|
|
1083
|
+
})
|
|
1084
|
+
const response = await emitChallenge({
|
|
1085
|
+
challenge,
|
|
1086
|
+
credential,
|
|
1087
|
+
request: parsedRequest,
|
|
1088
|
+
error,
|
|
542
1089
|
html: method.html,
|
|
543
1090
|
})
|
|
544
1091
|
return { challenge: response, status: 402 }
|
|
@@ -549,21 +1096,44 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
549
1096
|
try {
|
|
550
1097
|
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
551
1098
|
} catch (error) {
|
|
552
|
-
|
|
1099
|
+
await emitPaymentFailed({
|
|
553
1100
|
challenge,
|
|
554
|
-
|
|
1101
|
+
credential,
|
|
1102
|
+
error: error as Errors.PaymentError,
|
|
1103
|
+
request: parsedRequest,
|
|
1104
|
+
retryChallenge: challenge,
|
|
1105
|
+
submittedChallenge: credential.challenge,
|
|
1106
|
+
})
|
|
1107
|
+
const response = await emitChallenge({
|
|
1108
|
+
challenge,
|
|
1109
|
+
credential,
|
|
1110
|
+
request: parsedRequest,
|
|
555
1111
|
error: error as Errors.PaymentError,
|
|
556
1112
|
})
|
|
557
1113
|
return { challenge: response, status: 402 }
|
|
558
1114
|
}
|
|
559
1115
|
// Validate payload structure against method schema
|
|
1116
|
+
let parsedCredential: Credential.Credential
|
|
560
1117
|
try {
|
|
561
|
-
|
|
1118
|
+
parsedCredential = withParsedCredentialPayload(
|
|
1119
|
+
credential,
|
|
1120
|
+
method.schema.credential.payload.parse(credential.payload),
|
|
1121
|
+
)
|
|
562
1122
|
} catch {
|
|
563
|
-
const
|
|
1123
|
+
const error = new Errors.InvalidPayloadError()
|
|
1124
|
+
await emitPaymentFailed({
|
|
564
1125
|
challenge,
|
|
565
|
-
|
|
566
|
-
error
|
|
1126
|
+
credential,
|
|
1127
|
+
error,
|
|
1128
|
+
request: parsedRequest,
|
|
1129
|
+
retryChallenge: challenge,
|
|
1130
|
+
submittedChallenge: credential.challenge,
|
|
1131
|
+
})
|
|
1132
|
+
const response = await emitChallenge({
|
|
1133
|
+
challenge,
|
|
1134
|
+
credential,
|
|
1135
|
+
request: parsedRequest,
|
|
1136
|
+
error,
|
|
567
1137
|
})
|
|
568
1138
|
return { challenge: response, status: 402 }
|
|
569
1139
|
}
|
|
@@ -571,22 +1141,31 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
571
1141
|
const envelope: Method.VerifiedChallengeEnvelope = Object.freeze({
|
|
572
1142
|
capturedRequest,
|
|
573
1143
|
challenge: credential.challenge,
|
|
574
|
-
credential,
|
|
575
|
-
request,
|
|
1144
|
+
credential: parsedCredential,
|
|
1145
|
+
request: parsedRequest,
|
|
576
1146
|
})
|
|
577
1147
|
|
|
578
1148
|
// User-provided verification (e.g., check signature, submit tx, verify payment).
|
|
579
1149
|
// If verification fails, re-issue the challenge so the client can retry.
|
|
580
1150
|
let receiptData: Receipt.Receipt
|
|
581
1151
|
try {
|
|
582
|
-
receiptData = await verify({ credential, envelope, request } as never)
|
|
1152
|
+
receiptData = await verify({ credential: parsedCredential, envelope, request } as never)
|
|
583
1153
|
} catch (e) {
|
|
584
1154
|
if (!(e instanceof Errors.PaymentError))
|
|
585
1155
|
console.error('mppx: internal verification error', e)
|
|
586
1156
|
const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
|
|
587
|
-
|
|
1157
|
+
await emitPaymentFailed({
|
|
588
1158
|
challenge,
|
|
589
|
-
|
|
1159
|
+
credential: parsedCredential,
|
|
1160
|
+
error,
|
|
1161
|
+
request: parsedRequest,
|
|
1162
|
+
retryChallenge: challenge,
|
|
1163
|
+
submittedChallenge: credential.challenge,
|
|
1164
|
+
})
|
|
1165
|
+
const response = await emitChallenge({
|
|
1166
|
+
challenge,
|
|
1167
|
+
credential: parsedCredential,
|
|
1168
|
+
request: parsedRequest,
|
|
590
1169
|
error,
|
|
591
1170
|
})
|
|
592
1171
|
return { challenge: response, status: 402 }
|
|
@@ -598,33 +1177,35 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
598
1177
|
// return the management response directly. If undefined, `withReceipt()`
|
|
599
1178
|
// expects the caller to pass the user handler's response instead.
|
|
600
1179
|
const managementResponse = respond
|
|
601
|
-
? await respond({
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
return {
|
|
605
|
-
status: 200,
|
|
606
|
-
withReceipt<response>(response?: response) {
|
|
607
|
-
if (managementResponse) {
|
|
608
|
-
return transport.respondReceipt({
|
|
609
|
-
challengeId: credential.challenge.id,
|
|
610
|
-
credential,
|
|
611
|
-
envelope,
|
|
612
|
-
input,
|
|
613
|
-
receipt: receiptData,
|
|
614
|
-
response: managementResponse as never,
|
|
615
|
-
}) as response
|
|
616
|
-
}
|
|
617
|
-
if (!response) throw new Error('withReceipt() requires a response argument')
|
|
618
|
-
return transport.respondReceipt({
|
|
619
|
-
challengeId: credential.challenge.id,
|
|
620
|
-
credential,
|
|
1180
|
+
? await respond({
|
|
1181
|
+
credential: parsedCredential,
|
|
621
1182
|
envelope,
|
|
622
1183
|
input,
|
|
623
1184
|
receipt: receiptData,
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
|
|
1185
|
+
request,
|
|
1186
|
+
} as never)
|
|
1187
|
+
: undefined
|
|
1188
|
+
|
|
1189
|
+
await events.emit(
|
|
1190
|
+
'payment.success',
|
|
1191
|
+
createPaymentSuccessContext({
|
|
1192
|
+
capturedRequest,
|
|
1193
|
+
challenge: credential.challenge,
|
|
1194
|
+
credential: parsedCredential,
|
|
1195
|
+
envelope,
|
|
1196
|
+
input,
|
|
1197
|
+
method,
|
|
1198
|
+
receipt: receiptData,
|
|
1199
|
+
request: parsedRequest,
|
|
1200
|
+
}) as never,
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
return success(receiptData, {
|
|
1204
|
+
challengeId: credential.challenge.id,
|
|
1205
|
+
credentialForReceipt: parsedCredential,
|
|
1206
|
+
envelopeForReceipt: envelope,
|
|
1207
|
+
managementResponse,
|
|
1208
|
+
})
|
|
628
1209
|
},
|
|
629
1210
|
{
|
|
630
1211
|
_internal: {
|
|
@@ -635,6 +1216,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
635
1216
|
name: method.name,
|
|
636
1217
|
intent: method.intent,
|
|
637
1218
|
_canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
|
|
1219
|
+
_stableBinding: stableBinding as never,
|
|
638
1220
|
},
|
|
639
1221
|
},
|
|
640
1222
|
)
|
|
@@ -658,14 +1240,16 @@ function createChallengeFn(parameters: {
|
|
|
658
1240
|
return async (options) => {
|
|
659
1241
|
const { description, meta, scope, ...rest } = options as {
|
|
660
1242
|
description?: string
|
|
661
|
-
expires?:
|
|
1243
|
+
expires?: z.DatetimeInput
|
|
662
1244
|
meta?: Record<string, string>
|
|
663
1245
|
scope?: string
|
|
664
1246
|
[key: string]: unknown
|
|
665
1247
|
}
|
|
666
1248
|
const effectiveMeta = Scope.merge({ meta, scope })
|
|
667
1249
|
const expires =
|
|
668
|
-
'expires' in options
|
|
1250
|
+
'expires' in options
|
|
1251
|
+
? normalizeExpires(options.expires as z.DatetimeInput | undefined)
|
|
1252
|
+
: Expires.minutes(5)
|
|
669
1253
|
|
|
670
1254
|
return resolveRouteChallenge({
|
|
671
1255
|
defaults,
|
|
@@ -681,25 +1265,21 @@ function createChallengeFn(parameters: {
|
|
|
681
1265
|
}
|
|
682
1266
|
}
|
|
683
1267
|
|
|
684
|
-
function getSafeCredentialReason(error: unknown): string | undefined {
|
|
685
|
-
if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
|
|
686
|
-
if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
|
|
687
|
-
if (error instanceof Credential.MissingPaymentSchemeError) return error.message
|
|
688
|
-
return undefined
|
|
689
|
-
}
|
|
690
|
-
|
|
691
1268
|
declare namespace createMethodFn {
|
|
692
1269
|
type Parameters<
|
|
693
1270
|
method extends Method.Method = Method.Method,
|
|
694
1271
|
transport extends Transport.AnyTransport = Transport.Http,
|
|
695
1272
|
defaults extends Record<string, unknown> = Record<string, unknown>,
|
|
696
1273
|
> = {
|
|
1274
|
+
authorize?: Method.AuthorizeFn<method>
|
|
697
1275
|
defaults?: defaults
|
|
698
1276
|
method: method
|
|
1277
|
+
events: ServerEventDispatcher<readonly [method], transport>
|
|
699
1278
|
realm: string | undefined
|
|
700
1279
|
request?: Method.RequestFn<method>
|
|
701
1280
|
respond?: Method.RespondFn<method>
|
|
702
1281
|
secretKey: string
|
|
1282
|
+
stableBinding?: Method.StableBindingFn<method>
|
|
703
1283
|
transport: transport
|
|
704
1284
|
verify: Method.VerifyFn<method>
|
|
705
1285
|
}
|
|
@@ -711,10 +1291,314 @@ declare namespace createMethodFn {
|
|
|
711
1291
|
> = MethodFn<method, transport, defaults>
|
|
712
1292
|
}
|
|
713
1293
|
|
|
1294
|
+
type ServerEventDispatcher<
|
|
1295
|
+
methods extends readonly Method.Method[],
|
|
1296
|
+
transport extends Transport.AnyTransport,
|
|
1297
|
+
> = {
|
|
1298
|
+
emit<name extends keyof ServerEventMap<methods, transport>>(
|
|
1299
|
+
name: name,
|
|
1300
|
+
context: ServerEventMap<methods, transport>[name],
|
|
1301
|
+
): Promise<void>
|
|
1302
|
+
on<name extends ServerEventName<methods, transport>>(
|
|
1303
|
+
name: name,
|
|
1304
|
+
handler: ServerEventHandler<methods, transport, name>,
|
|
1305
|
+
): Unsubscribe
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function createServerEventDispatcher<
|
|
1309
|
+
methods extends readonly Method.Method[],
|
|
1310
|
+
transport extends Transport.AnyTransport,
|
|
1311
|
+
>(): ServerEventDispatcher<methods, transport> {
|
|
1312
|
+
const handlers = {
|
|
1313
|
+
'*': new Set<ServerEventHandler<methods, transport, '*'>>(),
|
|
1314
|
+
'challenge.created': new Set<ServerEventHandler<methods, transport, 'challenge.created'>>(),
|
|
1315
|
+
'payment.failed': new Set<ServerEventHandler<methods, transport, 'payment.failed'>>(),
|
|
1316
|
+
'payment.success': new Set<ServerEventHandler<methods, transport, 'payment.success'>>(),
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const on: ServerEventDispatcher<methods, transport>['on'] = (name, handler) => {
|
|
1320
|
+
switch (name) {
|
|
1321
|
+
case '*':
|
|
1322
|
+
case 'challenge.created':
|
|
1323
|
+
case 'payment.failed':
|
|
1324
|
+
case 'payment.success':
|
|
1325
|
+
handlers[name].add(handler as never)
|
|
1326
|
+
return () => handlers[name].delete(handler as never)
|
|
1327
|
+
default:
|
|
1328
|
+
throw new Error(`Unknown server event "${String(name)}".`)
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
return {
|
|
1333
|
+
async emit(name, context) {
|
|
1334
|
+
await emitServerEventHandlers(handlers[name], context)
|
|
1335
|
+
await emitServerEventHandlers(handlers['*'], toServerEventEnvelope(name, context))
|
|
1336
|
+
},
|
|
1337
|
+
on,
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function toServerEventEnvelope<
|
|
1342
|
+
methods extends readonly Method.Method[],
|
|
1343
|
+
transport extends Transport.AnyTransport,
|
|
1344
|
+
name extends keyof ServerEventMap<methods, transport>,
|
|
1345
|
+
>(
|
|
1346
|
+
name: name,
|
|
1347
|
+
payload: ServerEventMap<methods, transport>[name],
|
|
1348
|
+
): ServerEventPayload<methods, transport, '*'> {
|
|
1349
|
+
return Object.freeze({ name, payload }) as ServerEventPayload<methods, transport, '*'>
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async function emitServerEventHandlers(
|
|
1353
|
+
handlers: ReadonlySet<(context: never) => MaybePromise<void>>,
|
|
1354
|
+
context: unknown,
|
|
1355
|
+
): Promise<void> {
|
|
1356
|
+
for (const handler of handlers) {
|
|
1357
|
+
try {
|
|
1358
|
+
await handler(context as never)
|
|
1359
|
+
} catch {
|
|
1360
|
+
// Errors are isolated, but handlers are still awaited inline.
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function assertNoReservedMppxKeys(methods: readonly Method.AnyServer[]) {
|
|
1366
|
+
for (const method of methods) {
|
|
1367
|
+
if (reservedMppxKeys.has(method.name as ReservedKey))
|
|
1368
|
+
throw new Error(`Method name "${method.name}" conflicts with a reserved Mppx property.`)
|
|
1369
|
+
if (reservedMppxKeys.has(method.intent as ReservedKey))
|
|
1370
|
+
throw new Error(`Method intent "${method.intent}" conflicts with a reserved Mppx property.`)
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function createChallengeContext(parameters: {
|
|
1375
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
1376
|
+
challenge: Challenge.Challenge
|
|
1377
|
+
credential?: Credential.Credential | null | undefined
|
|
1378
|
+
error?: Errors.PaymentError | undefined
|
|
1379
|
+
input?: unknown
|
|
1380
|
+
method: Method.Method | ServerMethodDescriptor
|
|
1381
|
+
request: Record<string, unknown>
|
|
1382
|
+
}): ChallengeContext {
|
|
1383
|
+
return Object.freeze({
|
|
1384
|
+
...(parameters.capturedRequest
|
|
1385
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
1386
|
+
: {}),
|
|
1387
|
+
challenge: snapshotValue(parameters.challenge),
|
|
1388
|
+
credential:
|
|
1389
|
+
parameters.credential === undefined
|
|
1390
|
+
? undefined
|
|
1391
|
+
: snapshotNullableValue(parameters.credential),
|
|
1392
|
+
error: snapshotError(parameters.error),
|
|
1393
|
+
...snapshotInputProperty(parameters.input),
|
|
1394
|
+
method: snapshotMethod(parameters.method),
|
|
1395
|
+
request: snapshotValue(parameters.request),
|
|
1396
|
+
}) as never
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function createPaymentFailedContext(parameters: {
|
|
1400
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
1401
|
+
challenge: Challenge.Challenge
|
|
1402
|
+
credential: Credential.Credential | null
|
|
1403
|
+
error: Errors.PaymentError
|
|
1404
|
+
input?: unknown
|
|
1405
|
+
method: Method.Method | ServerMethodDescriptor
|
|
1406
|
+
request: Record<string, unknown>
|
|
1407
|
+
retryChallenge?: Challenge.Challenge | undefined
|
|
1408
|
+
submittedChallenge?: Challenge.Challenge | undefined
|
|
1409
|
+
}): PaymentFailedContext {
|
|
1410
|
+
return Object.freeze({
|
|
1411
|
+
...(parameters.capturedRequest
|
|
1412
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
1413
|
+
: {}),
|
|
1414
|
+
challenge: snapshotValue(parameters.challenge),
|
|
1415
|
+
credential: snapshotNullableValue(parameters.credential),
|
|
1416
|
+
error: snapshotError(parameters.error),
|
|
1417
|
+
...snapshotInputProperty(parameters.input),
|
|
1418
|
+
method: snapshotMethod(parameters.method),
|
|
1419
|
+
request: snapshotValue(parameters.request),
|
|
1420
|
+
...(parameters.retryChallenge
|
|
1421
|
+
? { retryChallenge: snapshotValue(parameters.retryChallenge) }
|
|
1422
|
+
: {}),
|
|
1423
|
+
...(parameters.submittedChallenge
|
|
1424
|
+
? { submittedChallenge: snapshotValue(parameters.submittedChallenge) }
|
|
1425
|
+
: {}),
|
|
1426
|
+
}) as never
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function createPaymentSuccessContext(parameters: {
|
|
1430
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
1431
|
+
challenge: Challenge.Challenge
|
|
1432
|
+
credential?: Credential.Credential | undefined
|
|
1433
|
+
envelope?: Method.VerifiedChallengeEnvelope | undefined
|
|
1434
|
+
input?: unknown
|
|
1435
|
+
method: Method.Method | ServerMethodDescriptor
|
|
1436
|
+
receipt: Receipt.Receipt
|
|
1437
|
+
request: Record<string, unknown>
|
|
1438
|
+
}): PaymentSuccessContext {
|
|
1439
|
+
return Object.freeze({
|
|
1440
|
+
...(parameters.capturedRequest
|
|
1441
|
+
? { capturedRequest: snapshotCapturedRequest(parameters.capturedRequest) }
|
|
1442
|
+
: {}),
|
|
1443
|
+
challenge: snapshotValue(parameters.challenge),
|
|
1444
|
+
...(parameters.credential ? { credential: snapshotValue(parameters.credential) } : {}),
|
|
1445
|
+
...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
|
|
1446
|
+
...snapshotInputProperty(parameters.input),
|
|
1447
|
+
method: snapshotMethod(parameters.method),
|
|
1448
|
+
receipt: snapshotValue(parameters.receipt),
|
|
1449
|
+
request: snapshotValue(parameters.request),
|
|
1450
|
+
}) as never
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function snapshotMethod<method extends Pick<Method.Method, 'intent' | 'name'>>(
|
|
1454
|
+
method: method,
|
|
1455
|
+
): ServerMethodDescriptor<method> {
|
|
1456
|
+
return Object.freeze({
|
|
1457
|
+
intent: method.intent,
|
|
1458
|
+
name: method.name,
|
|
1459
|
+
}) as ServerMethodDescriptor<method>
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function snapshotError<error extends Errors.PaymentError | undefined>(error: error): error {
|
|
1463
|
+
if (!error) return error
|
|
1464
|
+
const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error)
|
|
1465
|
+
Object.defineProperties(snapshot, {
|
|
1466
|
+
message: { value: error.message, enumerable: false },
|
|
1467
|
+
name: { value: error.name, enumerable: false },
|
|
1468
|
+
})
|
|
1469
|
+
return Object.freeze(snapshot) as error
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function snapshotVerifiedEnvelope(
|
|
1473
|
+
envelope: Method.VerifiedChallengeEnvelope,
|
|
1474
|
+
): Method.VerifiedChallengeEnvelope {
|
|
1475
|
+
return Object.freeze({
|
|
1476
|
+
capturedRequest: snapshotCapturedRequest(envelope.capturedRequest),
|
|
1477
|
+
challenge: snapshotValue(envelope.challenge),
|
|
1478
|
+
credential: snapshotValue(envelope.credential),
|
|
1479
|
+
request: snapshotValue(envelope.request),
|
|
1480
|
+
}) as Method.VerifiedChallengeEnvelope
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function snapshotCapturedRequest(capturedRequest: Method.CapturedRequest): Method.CapturedRequest {
|
|
1484
|
+
return Object.freeze({
|
|
1485
|
+
headers: new Headers(capturedRequest.headers),
|
|
1486
|
+
hasBody: capturedRequest.hasBody,
|
|
1487
|
+
method: capturedRequest.method,
|
|
1488
|
+
url: new URL(capturedRequest.url),
|
|
1489
|
+
})
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function snapshotNullableValue<value>(value: value | null): value | null {
|
|
1493
|
+
if (value === null) return null
|
|
1494
|
+
return snapshotValue(value)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function snapshotValue<value>(value: value): value {
|
|
1498
|
+
try {
|
|
1499
|
+
return freezeSnapshot(structuredClone(value))
|
|
1500
|
+
} catch {
|
|
1501
|
+
return freezeSnapshot(value)
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function snapshotInputProperty(input: unknown): { input: unknown } | {} {
|
|
1506
|
+
if (input === undefined) return {}
|
|
1507
|
+
const snapshot = snapshotTransportInput(input)
|
|
1508
|
+
return snapshot === undefined ? {} : { input: snapshot }
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function snapshotTransportInput<input>(input: input): input | undefined {
|
|
1512
|
+
if (input instanceof globalThis.Request) {
|
|
1513
|
+
try {
|
|
1514
|
+
return new globalThis.Request(input.url, {
|
|
1515
|
+
headers: new Headers(input.headers),
|
|
1516
|
+
method: input.method,
|
|
1517
|
+
}) as input
|
|
1518
|
+
} catch {
|
|
1519
|
+
return undefined
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
return freezeSnapshot(structuredClone(input))
|
|
1524
|
+
} catch {
|
|
1525
|
+
warnOnce(
|
|
1526
|
+
Warnings.transportInputSnapshot,
|
|
1527
|
+
'Could not clone server event input; omitting `context.input`. Use `capturedRequest` for request correlation.',
|
|
1528
|
+
)
|
|
1529
|
+
return undefined
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Event payloads are cloned before listeners see them; shallow freezing keeps
|
|
1534
|
+
// the guard simple while preventing top-level mutation of receipts/challenges.
|
|
1535
|
+
function freezeSnapshot<value>(value: value): value {
|
|
1536
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value
|
|
1537
|
+
Object.freeze(value)
|
|
1538
|
+
return value
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function isServiceWorkerRequest(input: unknown): input is Request {
|
|
1542
|
+
return (
|
|
1543
|
+
input instanceof globalThis.Request &&
|
|
1544
|
+
new URL(input.url).searchParams.has(Html.params.serviceWorker)
|
|
1545
|
+
)
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function createServiceWorkerResponse() {
|
|
1549
|
+
return new Response(serviceWorker, {
|
|
1550
|
+
status: 200,
|
|
1551
|
+
headers: {
|
|
1552
|
+
'Content-Type': 'application/javascript',
|
|
1553
|
+
'Cache-Control': 'no-store',
|
|
1554
|
+
},
|
|
1555
|
+
})
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function isIssuedChallengeResponse(response: unknown): boolean {
|
|
1559
|
+
return !(response instanceof globalThis.Response) || response.status === 402
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function getSafeCredentialReason(error: unknown): string | undefined {
|
|
1563
|
+
if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
|
|
1564
|
+
if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
|
|
1565
|
+
if (error instanceof Credential.MissingPaymentSchemeError) return error.message
|
|
1566
|
+
return undefined
|
|
1567
|
+
}
|
|
1568
|
+
|
|
714
1569
|
const defaultRealm = 'MPP Payment'
|
|
715
1570
|
const Warnings = {
|
|
716
1571
|
realmFallback: 'realm-fallback',
|
|
1572
|
+
transportInputSnapshot: 'transport-input-snapshot',
|
|
717
1573
|
} as const
|
|
1574
|
+
const missingReceiptResponseErrorName = 'MissingReceiptResponseError'
|
|
1575
|
+
const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument'
|
|
1576
|
+
|
|
1577
|
+
/** Error thrown when `withReceipt()` needs a response but none was provided. */
|
|
1578
|
+
export class MissingReceiptResponseError extends Error {
|
|
1579
|
+
override name = missingReceiptResponseErrorName
|
|
1580
|
+
|
|
1581
|
+
constructor() {
|
|
1582
|
+
super(missingReceiptResponseErrorMessage)
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Returns true when an error is the typed `withReceipt()` no-response sentinel. */
|
|
1587
|
+
export function isMissingReceiptResponseError(
|
|
1588
|
+
error: unknown,
|
|
1589
|
+
): error is MissingReceiptResponseError {
|
|
1590
|
+
if (error instanceof MissingReceiptResponseError) return true
|
|
1591
|
+
if (!error || typeof error !== 'object') return false
|
|
1592
|
+
const value = error as { message?: unknown; name?: unknown }
|
|
1593
|
+
return (
|
|
1594
|
+
value.name === missingReceiptResponseErrorName &&
|
|
1595
|
+
value.message === missingReceiptResponseErrorMessage
|
|
1596
|
+
)
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined {
|
|
1600
|
+
return expires === undefined ? undefined : z.toDatetimeString(expires)
|
|
1601
|
+
}
|
|
718
1602
|
|
|
719
1603
|
const _warned = new Set<string>()
|
|
720
1604
|
function warnOnce(key: string, message: string) {
|
|
@@ -750,6 +1634,7 @@ async function resolveRouteChallenge(parameters: {
|
|
|
750
1634
|
secretKey: string
|
|
751
1635
|
}): Promise<{
|
|
752
1636
|
challenge: Challenge.Challenge
|
|
1637
|
+
parsedRequest: Record<string, unknown>
|
|
753
1638
|
request: Record<string, unknown>
|
|
754
1639
|
}> {
|
|
755
1640
|
// Resolve the route's canonical request exactly as the handler path does:
|
|
@@ -772,19 +1657,47 @@ async function resolveRouteChallenge(parameters: {
|
|
|
772
1657
|
? resolveRealmFromCapturedRequest(parameters.capturedRequest)
|
|
773
1658
|
: defaultRealm)
|
|
774
1659
|
|
|
1660
|
+
const challenge = Challenge.fromMethod(parameters.method, {
|
|
1661
|
+
description: parameters.description,
|
|
1662
|
+
expires: parameters.expires,
|
|
1663
|
+
meta: parameters.meta,
|
|
1664
|
+
realm: effectiveRealm,
|
|
1665
|
+
request: request as never,
|
|
1666
|
+
secretKey: parameters.secretKey,
|
|
1667
|
+
})
|
|
1668
|
+
|
|
775
1669
|
return {
|
|
776
|
-
challenge
|
|
777
|
-
|
|
778
|
-
expires: parameters.expires,
|
|
779
|
-
meta: parameters.meta,
|
|
780
|
-
realm: effectiveRealm,
|
|
781
|
-
request: request as never,
|
|
782
|
-
secretKey: parameters.secretKey,
|
|
783
|
-
}),
|
|
1670
|
+
challenge,
|
|
1671
|
+
parsedRequest: challenge.request as Record<string, unknown>,
|
|
784
1672
|
request,
|
|
785
1673
|
}
|
|
786
1674
|
}
|
|
787
1675
|
|
|
1676
|
+
function createFallbackChallenge(parameters: {
|
|
1677
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
1678
|
+
defaults: Record<string, unknown>
|
|
1679
|
+
description?: string | undefined
|
|
1680
|
+
expires?: string | undefined
|
|
1681
|
+
meta?: Record<string, string> | undefined
|
|
1682
|
+
method: Method.Method
|
|
1683
|
+
realm?: string | undefined
|
|
1684
|
+
routeRequest: Record<string, unknown>
|
|
1685
|
+
secretKey: string
|
|
1686
|
+
}) {
|
|
1687
|
+
return Challenge.fromMethod(parameters.method, {
|
|
1688
|
+
description: parameters.description,
|
|
1689
|
+
expires: parameters.expires,
|
|
1690
|
+
meta: parameters.meta,
|
|
1691
|
+
realm:
|
|
1692
|
+
parameters.realm ??
|
|
1693
|
+
(parameters.capturedRequest
|
|
1694
|
+
? resolveRealmFromCapturedRequest(parameters.capturedRequest)
|
|
1695
|
+
: defaultRealm),
|
|
1696
|
+
request: { ...parameters.defaults, ...parameters.routeRequest } as never,
|
|
1697
|
+
secretKey: parameters.secretKey,
|
|
1698
|
+
})
|
|
1699
|
+
}
|
|
1700
|
+
|
|
788
1701
|
/**
|
|
789
1702
|
* Captures the transport request into a frozen snapshot at the start of the
|
|
790
1703
|
* verification flow. This snapshot is threaded through request() → verify() →
|
|
@@ -794,7 +1707,7 @@ async function resolveRouteChallenge(parameters: {
|
|
|
794
1707
|
*
|
|
795
1708
|
* Note: Object.freeze is shallow — it prevents reassigning top-level properties
|
|
796
1709
|
* but does not deep-freeze mutable class instances like Headers or URL. This is
|
|
797
|
-
* an accidental-mutation guard for trusted server
|
|
1710
|
+
* an accidental-mutation guard for trusted server events, not a security boundary.
|
|
798
1711
|
*/
|
|
799
1712
|
async function captureRequest(
|
|
800
1713
|
transport: Transport.AnyTransport,
|
|
@@ -831,6 +1744,26 @@ type CoreBindingField = (typeof coreBindingFields)[number]
|
|
|
831
1744
|
type MethodBindingField = (typeof methodBindingFields)[number]
|
|
832
1745
|
type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number]
|
|
833
1746
|
type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField
|
|
1747
|
+
type StableBinding = Record<string, unknown>
|
|
1748
|
+
|
|
1749
|
+
function getChallengeBindingMismatch(
|
|
1750
|
+
expectedChallenge: Challenge.Challenge,
|
|
1751
|
+
actualChallenge: Challenge.Challenge,
|
|
1752
|
+
stableBinding?: Method.StableBindingFn<Method.Method> | undefined,
|
|
1753
|
+
): string | undefined {
|
|
1754
|
+
if (!stableBinding) return getPinnedChallengeMismatch(expectedChallenge, actualChallenge)
|
|
1755
|
+
|
|
1756
|
+
for (const field of ['method', 'intent', 'realm'] as const) {
|
|
1757
|
+
if (actualChallenge[field] !== expectedChallenge[field]) return field
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque'
|
|
1761
|
+
|
|
1762
|
+
return getRequestBindingMismatch(
|
|
1763
|
+
getStableBinding(expectedChallenge.request as Record<string, unknown>, stableBinding),
|
|
1764
|
+
getStableBinding(actualChallenge.request as Record<string, unknown>, stableBinding),
|
|
1765
|
+
)
|
|
1766
|
+
}
|
|
834
1767
|
|
|
835
1768
|
/**
|
|
836
1769
|
* Compares only the fields that MUST be stable across request-hook transforms.
|
|
@@ -911,6 +1844,44 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
|
|
|
911
1844
|
}
|
|
912
1845
|
}
|
|
913
1846
|
|
|
1847
|
+
function getRequestBindingMismatch(
|
|
1848
|
+
expected: StableBinding,
|
|
1849
|
+
actual: StableBinding,
|
|
1850
|
+
): string | undefined {
|
|
1851
|
+
const fields = [
|
|
1852
|
+
...Object.keys(expected),
|
|
1853
|
+
...Object.keys(actual).filter((key) => !(key in expected)),
|
|
1854
|
+
]
|
|
1855
|
+
|
|
1856
|
+
return fields.find(
|
|
1857
|
+
(field) =>
|
|
1858
|
+
!isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])),
|
|
1859
|
+
)
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function getStableBinding(
|
|
1863
|
+
request: Record<string, unknown>,
|
|
1864
|
+
stableBinding: Method.StableBindingFn<Method.Method>,
|
|
1865
|
+
): StableBinding {
|
|
1866
|
+
return stableBinding(request as never)
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** Top-level economic fields that should never drift after challenge issuance. */
|
|
1870
|
+
type CoreBinding = {
|
|
1871
|
+
[field in CoreBindingField]?: string
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** Method-specific fields that are pinned by the fallback binding check. */
|
|
1875
|
+
type MethodBinding = {
|
|
1876
|
+
[field in MethodBindingField]?: unknown
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/** Normalized request subset used when a method does not provide a custom stable binding. */
|
|
1880
|
+
type PinnedRequestBinding = {
|
|
1881
|
+
coreBinding: CoreBinding
|
|
1882
|
+
methodBinding: MethodBinding
|
|
1883
|
+
}
|
|
1884
|
+
|
|
914
1885
|
function normalizeScalar(value: unknown): string | undefined {
|
|
915
1886
|
return value === undefined ? undefined : String(value)
|
|
916
1887
|
}
|
|
@@ -958,19 +1929,15 @@ function hydrateCredentialMeta<payload>(
|
|
|
958
1929
|
}
|
|
959
1930
|
}
|
|
960
1931
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
type PinnedRequestBinding = {
|
|
970
|
-
coreBinding: CoreBinding
|
|
971
|
-
methodBinding: MethodBinding
|
|
1932
|
+
function withParsedCredentialPayload<payload>(
|
|
1933
|
+
credential: Credential.Credential,
|
|
1934
|
+
payload: payload,
|
|
1935
|
+
): Credential.Credential<payload> {
|
|
1936
|
+
return {
|
|
1937
|
+
...credential,
|
|
1938
|
+
payload,
|
|
1939
|
+
}
|
|
972
1940
|
}
|
|
973
|
-
|
|
974
1941
|
export type MethodFn<
|
|
975
1942
|
method extends Method.Method,
|
|
976
1943
|
transport extends Transport.AnyTransport,
|
|
@@ -991,8 +1958,8 @@ declare namespace MethodFn {
|
|
|
991
1958
|
> = {
|
|
992
1959
|
/** Optional human-readable description of the payment. */
|
|
993
1960
|
description?: string | undefined
|
|
994
|
-
/** Optional challenge expiration timestamp (ISO 8601). */
|
|
995
|
-
expires?:
|
|
1961
|
+
/** Optional challenge expiration timestamp (ISO 8601) or Date. */
|
|
1962
|
+
expires?: z.DatetimeInput | undefined
|
|
996
1963
|
/** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */
|
|
997
1964
|
meta?: Record<string, string> | undefined
|
|
998
1965
|
/** Optional route/resource scope bound via reserved challenge metadata. */
|
|
@@ -1019,6 +1986,7 @@ type ConfiguredHandler = ((input: Request) => Promise<MethodFn.Response<Transpor
|
|
|
1019
1986
|
meta?: Record<string, string> | undefined
|
|
1020
1987
|
scope?: string | undefined
|
|
1021
1988
|
_canonicalRequest: Record<string, unknown>
|
|
1989
|
+
_stableBinding?: Method.StableBindingFn<Method.Method> | undefined
|
|
1022
1990
|
}
|
|
1023
1991
|
}
|
|
1024
1992
|
|
|
@@ -1139,15 +2107,20 @@ export function compose(
|
|
|
1139
2107
|
// transformed fields (e.g. amount with decimals) match correctly.
|
|
1140
2108
|
// Also checks inside methodDetails for fields moved there by transforms.
|
|
1141
2109
|
const candidates = handlers.filter((h) => {
|
|
1142
|
-
|
|
1143
|
-
|
|
2110
|
+
try {
|
|
2111
|
+
const internal = (h as ConfiguredHandler)._internal
|
|
2112
|
+
if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
|
|
2113
|
+
return false
|
|
2114
|
+
const mismatch = internal._stableBinding
|
|
2115
|
+
? getRequestBindingMismatch(
|
|
2116
|
+
getStableBinding(internal._canonicalRequest, internal._stableBinding),
|
|
2117
|
+
getStableBinding(credReq, internal._stableBinding),
|
|
2118
|
+
)
|
|
2119
|
+
: getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq)
|
|
2120
|
+
return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta)
|
|
2121
|
+
} catch {
|
|
1144
2122
|
return false
|
|
1145
|
-
|
|
1146
|
-
if (!canonical) return true
|
|
1147
|
-
return (
|
|
1148
|
-
!getPinnedRequestBindingMismatch(canonical, credReq) &&
|
|
1149
|
-
opaqueValuesMatch(internal.meta, credential.challenge.meta)
|
|
1150
|
-
)
|
|
2123
|
+
}
|
|
1151
2124
|
})
|
|
1152
2125
|
|
|
1153
2126
|
const match =
|
|
@@ -1164,8 +2137,14 @@ export function compose(
|
|
|
1164
2137
|
return handlers[0]!(input)
|
|
1165
2138
|
}
|
|
1166
2139
|
|
|
1167
|
-
// No credential —
|
|
1168
|
-
|
|
2140
|
+
// No credential — evaluate handlers sequentially so authorize()/renewal hooks
|
|
2141
|
+
// can safely claim the request without racing each other.
|
|
2142
|
+
const results: MethodFn.Response<Transport.Http>[] = []
|
|
2143
|
+
for (const handler of handlers) {
|
|
2144
|
+
const result = await handler(input)
|
|
2145
|
+
if (result.status === 200) return result
|
|
2146
|
+
results.push(result)
|
|
2147
|
+
}
|
|
1169
2148
|
|
|
1170
2149
|
const challengeEntries = (() => {
|
|
1171
2150
|
const entries: {
|
|
@@ -1316,6 +2295,12 @@ export function toNodeListener(
|
|
|
1316
2295
|
if (result.status === 402) {
|
|
1317
2296
|
await NodeListener.sendResponse(res, result.challenge as globalThis.Response)
|
|
1318
2297
|
} else {
|
|
2298
|
+
const managementResponse = getManagementResponse(result)
|
|
2299
|
+
if (managementResponse) {
|
|
2300
|
+
await NodeListener.sendResponse(res, managementResponse)
|
|
2301
|
+
return { challenge: managementResponse, status: 402 }
|
|
2302
|
+
}
|
|
2303
|
+
|
|
1319
2304
|
const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response
|
|
1320
2305
|
res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!)
|
|
1321
2306
|
}
|
|
@@ -1324,6 +2309,19 @@ export function toNodeListener(
|
|
|
1324
2309
|
}
|
|
1325
2310
|
}
|
|
1326
2311
|
|
|
2312
|
+
function getManagementResponse(
|
|
2313
|
+
result: Extract<MethodFn.Response<Transport.Http>, { status: 200 }>,
|
|
2314
|
+
): globalThis.Response | null {
|
|
2315
|
+
try {
|
|
2316
|
+
return (result.withReceipt as () => globalThis.Response)()
|
|
2317
|
+
} catch (error) {
|
|
2318
|
+
if (isMissingReceiptResponseError(error)) {
|
|
2319
|
+
return null
|
|
2320
|
+
}
|
|
2321
|
+
throw error
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
1327
2325
|
/**
|
|
1328
2326
|
* Flattens a methods config tuple, preserving positional types.
|
|
1329
2327
|
* @internal
|