mppx 0.8.13 → 0.8.14
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 +16 -0
- package/dist/Errors.d.ts +10 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +11 -2
- package/dist/Errors.js.map +1 -1
- package/dist/Method.d.ts +105 -3
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +102 -2
- package/dist/Method.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +12 -2
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +17 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
- package/dist/client/internal/MethodChallenge.js +11 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -0
- package/dist/server/Mppx.d.ts +23 -4
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +91 -82
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +7 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +2 -1
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +38 -77
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +289 -209
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +12 -1
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +48 -0
- package/dist/tempo/server/Relay.d.ts.map +1 -0
- package/dist/tempo/server/Relay.js +177 -0
- package/dist/tempo/server/Relay.js.map +1 -0
- package/dist/tempo/server/SponsorBudget.d.ts +59 -0
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
- package/dist/tempo/server/SponsorBudget.js +144 -0
- package/dist/tempo/server/SponsorBudget.js.map +1 -0
- 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/session/client/ChannelOps.d.ts +7 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +9 -4
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +5 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +37 -19
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Runtime.d.ts +8 -0
- package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
- package/dist/tempo/session/client/Runtime.js +17 -0
- package/dist/tempo/session/client/Runtime.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +15 -5
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +93 -36
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +8 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +54 -26
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +7 -5
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +48 -24
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +96 -8
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/precompile/Channel.d.ts +2 -0
- package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Channel.js +4 -0
- package/dist/tempo/session/precompile/Channel.js.map +1 -1
- package/dist/tempo/session/server/RequestState.js +3 -1
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/package.json +1 -1
- package/src/Errors.test.ts +23 -0
- package/src/Errors.ts +21 -2
- package/src/Method.test.ts +102 -1
- package/src/Method.ts +241 -5
- package/src/client/Mppx.test-d.ts +3 -3
- package/src/client/internal/Fetch.ts +16 -7
- package/src/client/internal/MethodChallenge.ts +30 -0
- package/src/server/Mppx.test-d.ts +2 -0
- package/src/server/Mppx.test.ts +205 -0
- package/src/server/Mppx.ts +167 -103
- package/src/tempo/PublicExports.test-d.ts +20 -0
- package/src/tempo/internal/fee-payer.ts +7 -3
- package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
- package/src/tempo/legacy/client/ChannelOps.ts +2 -1
- package/src/tempo/server/Charge.test.ts +337 -63
- package/src/tempo/server/Charge.ts +435 -267
- package/src/tempo/server/Methods.ts +11 -0
- package/src/tempo/server/Relay.test.ts +523 -0
- package/src/tempo/server/Relay.ts +288 -0
- package/src/tempo/server/SponsorBudget.test.ts +125 -0
- package/src/tempo/server/SponsorBudget.ts +213 -0
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/ChannelOps.test.ts +17 -2
- package/src/tempo/session/client/ChannelOps.ts +12 -4
- package/src/tempo/session/client/CredentialState.ts +61 -20
- package/src/tempo/session/client/Runtime.test.ts +12 -0
- package/src/tempo/session/client/Runtime.ts +22 -0
- package/src/tempo/session/client/Session.test.ts +235 -2
- package/src/tempo/session/client/Session.ts +130 -41
- package/src/tempo/session/client/SessionManager.test.ts +102 -4
- package/src/tempo/session/client/SessionManager.ts +71 -27
- package/src/tempo/session/client/Transports.test.ts +32 -1
- package/src/tempo/session/client/Transports.ts +79 -35
- package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
- package/src/tempo/session/precompile/Chain.test.ts +197 -10
- package/src/tempo/session/precompile/Chain.ts +102 -8
- package/src/tempo/session/precompile/Channel.test.ts +9 -0
- package/src/tempo/session/precompile/Channel.ts +5 -0
- package/src/tempo/session/server/RequestState.test.ts +8 -1
- package/src/tempo/session/server/RequestState.ts +2 -2
package/src/Method.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type * as Challenge from './Challenge.js'
|
|
2
|
-
import
|
|
2
|
+
import * as Constants from './Constants.js'
|
|
3
|
+
import * as Credential from './Credential.js'
|
|
4
|
+
import * as Errors from './Errors.js'
|
|
5
|
+
import * as Expires from './Expires.js'
|
|
3
6
|
import type { ExactPartial, LooseOmit, MaybePromise } from './internal/types.js'
|
|
4
7
|
import type * as Receipt from './Receipt.js'
|
|
5
8
|
import type * as Html from './server/internal/html/config.js'
|
|
@@ -117,12 +120,72 @@ export type VerifyContext<method extends Method> = {
|
|
|
117
120
|
request: z.input<method['schema']['request']>
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
/** Validation hook parameters for a single method. */
|
|
124
|
+
export type ValidateContext<method extends Method> = VerifyContext<method>
|
|
125
|
+
|
|
120
126
|
/** Response hook parameters for a single method. */
|
|
121
127
|
export type RespondContext<method extends Method> = VerifyContext<method> & {
|
|
122
128
|
input: globalThis.Request
|
|
123
129
|
receipt: Receipt.Receipt
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Non-mutating method-specific validation result.
|
|
134
|
+
*
|
|
135
|
+
* Returned by {@link ValidateFn} and exposed from `mppx.validateCredential()`.
|
|
136
|
+
* This confirms that a credential is currently acceptable to the payment
|
|
137
|
+
* method; it does not settle, reserve, or otherwise consume the payment.
|
|
138
|
+
*/
|
|
139
|
+
export type Validation<method extends Method = Method, details = unknown> = Readonly<{
|
|
140
|
+
/**
|
|
141
|
+
* The challenge echoed by the credential and accepted by the method.
|
|
142
|
+
*
|
|
143
|
+
* Callers using `Mppx.validateCredential()` receive a challenge whose HMAC,
|
|
144
|
+
* expiry, route binding, and method identity have already been checked.
|
|
145
|
+
* Lower-level `Method.validateCredential()` callers must verify challenge
|
|
146
|
+
* issuance and route binding themselves.
|
|
147
|
+
*/
|
|
148
|
+
challenge: Challenge.Challenge<
|
|
149
|
+
z.output<method['schema']['request']>,
|
|
150
|
+
method['intent'],
|
|
151
|
+
method['name']
|
|
152
|
+
>
|
|
153
|
+
/**
|
|
154
|
+
* The submitted credential with its method-specific payload parsed by the
|
|
155
|
+
* selected method schema. This is the exact credential validation examined.
|
|
156
|
+
*/
|
|
157
|
+
credential: Credential.Credential<
|
|
158
|
+
z.output<method['schema']['credential']['payload']>,
|
|
159
|
+
Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
|
|
160
|
+
>
|
|
161
|
+
/**
|
|
162
|
+
* Method-defined, non-mutating validation data.
|
|
163
|
+
*
|
|
164
|
+
* Its shape is intentionally method-specific—for example, a recovered payer
|
|
165
|
+
* address or a simulation result. Consumers must not assume a particular
|
|
166
|
+
* shape or use it as a settlement receipt.
|
|
167
|
+
*/
|
|
168
|
+
details: details
|
|
169
|
+
/** The validated payment intent, repeated for convenient dispatch. */
|
|
170
|
+
intent: method['intent']
|
|
171
|
+
/** The validated payment method, repeated for convenient dispatch. */
|
|
172
|
+
method: method['name']
|
|
173
|
+
/**
|
|
174
|
+
* The validated method request after the method's schema transforms.
|
|
175
|
+
*
|
|
176
|
+
* For `Mppx.validateCredential()` with route options, this is the
|
|
177
|
+
* authoritative route request after defaults and request-hook transforms.
|
|
178
|
+
*/
|
|
179
|
+
request: z.output<method['schema']['request']>
|
|
180
|
+
/**
|
|
181
|
+
* Optional payer identity declared by the credential.
|
|
182
|
+
*
|
|
183
|
+
* This is an asserted identity, not independent proof of control; methods
|
|
184
|
+
* that rely on it must validate the relationship to the credential payload.
|
|
185
|
+
*/
|
|
186
|
+
source?: string | undefined
|
|
187
|
+
}>
|
|
188
|
+
|
|
126
189
|
/**
|
|
127
190
|
* A server-side configured method with verification logic.
|
|
128
191
|
*/
|
|
@@ -141,8 +204,15 @@ export type Server<
|
|
|
141
204
|
preflight?: PreflightFn<method> | undefined
|
|
142
205
|
request?: RequestFn<method> | undefined
|
|
143
206
|
respond?: RespondFn<method> | undefined
|
|
207
|
+
broadcast?: BroadcastFn<method> | undefined
|
|
144
208
|
stableBinding?: StableBindingFn<method> | undefined
|
|
145
209
|
transport?: transportOverride | undefined
|
|
210
|
+
validate?: ValidateFn<method> | undefined
|
|
211
|
+
/**
|
|
212
|
+
* @deprecated Use `validate` for the non-mutating pre-check and `broadcast`
|
|
213
|
+
* for settlement. `verify` combines both operations and may consume payment
|
|
214
|
+
* state, so it cannot support a safe pre-check endpoint.
|
|
215
|
+
*/
|
|
146
216
|
verify: VerifyFn<method>
|
|
147
217
|
}
|
|
148
218
|
export type AnyServer = Server<any, any, any, any, any>
|
|
@@ -221,11 +291,148 @@ export type StableBindingFn<method extends Method> = (
|
|
|
221
291
|
request: z.output<method['schema']['request']>,
|
|
222
292
|
) => Record<string, unknown>
|
|
223
293
|
|
|
224
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* Legacy combined validation and settlement function for a single method.
|
|
296
|
+
*
|
|
297
|
+
* @deprecated Implement `validate` and `broadcast` instead. `validate` must
|
|
298
|
+
* be non-mutating; `broadcast` performs the terminal payment operation. This
|
|
299
|
+
* hook remains only for existing methods that cannot yet split those phases.
|
|
300
|
+
*/
|
|
225
301
|
export type VerifyFn<method extends Method> = (
|
|
226
302
|
parameters: VerifyContext<method>,
|
|
227
303
|
) => Promise<Receipt.Receipt>
|
|
228
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Non-mutating validation function for a single method.
|
|
307
|
+
*
|
|
308
|
+
* @returns A {@link Validation} record describing the credential that was
|
|
309
|
+
* accepted. It must not settle, reserve, or otherwise consume payment state.
|
|
310
|
+
*/
|
|
311
|
+
export type ValidateFn<method extends Method> = (
|
|
312
|
+
parameters: ValidateContext<method>,
|
|
313
|
+
) => Promise<Validation<method>>
|
|
314
|
+
|
|
315
|
+
/** Terminal payment function for a single method. */
|
|
316
|
+
export type BroadcastFn<method extends Method> = (
|
|
317
|
+
parameters: VerifyContext<method>,
|
|
318
|
+
) => Promise<Receipt.Receipt>
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Validates a credential against one of the configured methods.
|
|
322
|
+
*
|
|
323
|
+
* This checks credential structure, challenge expiry, and method-specific
|
|
324
|
+
* validation. It does not prove that the challenge was issued by a particular
|
|
325
|
+
* server; hosts that issue challenges must verify that binding separately.
|
|
326
|
+
*/
|
|
327
|
+
export async function validateCredential<const methods extends readonly AnyServer[]>(
|
|
328
|
+
methods: methods,
|
|
329
|
+
input: string | Credential.Credential,
|
|
330
|
+
): Promise<Validation<methods[number]>> {
|
|
331
|
+
const prepared = prepareCredential(methods, input)
|
|
332
|
+
if (!prepared.method.validate)
|
|
333
|
+
throw new Errors.VerificationFailedError({
|
|
334
|
+
details: { intent: prepared.method.intent, method: prepared.method.name },
|
|
335
|
+
reason: `${prepared.method.name}/${prepared.method.intent} does not support non-mutating credential validation`,
|
|
336
|
+
})
|
|
337
|
+
return prepared.method.validate({
|
|
338
|
+
credential: prepared.credential,
|
|
339
|
+
request: prepared.request,
|
|
340
|
+
} as never) as Promise<Validation<methods[number]>>
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Re-validates and performs the terminal payment operation for a credential.
|
|
345
|
+
*
|
|
346
|
+
* This does not prove that the challenge was issued by a particular server;
|
|
347
|
+
* hosts that issue challenges must verify that binding separately.
|
|
348
|
+
*/
|
|
349
|
+
export async function broadcastCredential<const methods extends readonly AnyServer[]>(
|
|
350
|
+
methods: methods,
|
|
351
|
+
input: string | Credential.Credential,
|
|
352
|
+
): Promise<Receipt.Receipt> {
|
|
353
|
+
const prepared = prepareCredential(methods, input)
|
|
354
|
+
const { method } = prepared
|
|
355
|
+
|
|
356
|
+
if (method.broadcast && method.validate)
|
|
357
|
+
await method.validate({ credential: prepared.credential, request: prepared.request } as never)
|
|
358
|
+
|
|
359
|
+
const broadcast = method.broadcast ?? method.verify
|
|
360
|
+
return broadcast({ credential: prepared.credential, request: prepared.request } as never)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Parses a submitted credential into the inputs required for method execution.
|
|
365
|
+
*
|
|
366
|
+
* Dispatch is based on the challenge method and intent. When more than one
|
|
367
|
+
* server method handles the same wire identity, session protocol details select
|
|
368
|
+
* the appropriate implementation. The helper then asserts challenge expiry and
|
|
369
|
+
* parses the method-specific credential payload before returning the selected
|
|
370
|
+
* method and the unmodified challenge request.
|
|
371
|
+
*
|
|
372
|
+
* This intentionally does not verify that the challenge was issued by a
|
|
373
|
+
* particular host, authorize the caller or requested resource, validate the
|
|
374
|
+
* method request, or invoke method lifecycle hooks. Hosts that issue challenges
|
|
375
|
+
* must verify their challenge binding before accepting the credential.
|
|
376
|
+
*/
|
|
377
|
+
function prepareCredential(
|
|
378
|
+
methods: readonly AnyServer[],
|
|
379
|
+
input: string | Credential.Credential,
|
|
380
|
+
): {
|
|
381
|
+
credential: Credential.Credential
|
|
382
|
+
method: AnyServer
|
|
383
|
+
request: Record<string, unknown>
|
|
384
|
+
} {
|
|
385
|
+
const credential = typeof input === 'string' ? Credential.deserialize(input) : input
|
|
386
|
+
const candidates = methods.filter(
|
|
387
|
+
(method) =>
|
|
388
|
+
method.name === credential.challenge.method && method.intent === credential.challenge.intent,
|
|
389
|
+
)
|
|
390
|
+
const method = selectServerMethod(candidates, credential.challenge)
|
|
391
|
+
if (!method)
|
|
392
|
+
throw new Errors.InvalidChallengeError({
|
|
393
|
+
id: credential.challenge.id,
|
|
394
|
+
reason: `no registered method for ${credential.challenge.method}/${credential.challenge.intent}`,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
398
|
+
|
|
399
|
+
let payload: unknown
|
|
400
|
+
try {
|
|
401
|
+
payload = method.schema.credential.payload.parse(credential.payload)
|
|
402
|
+
} catch (error) {
|
|
403
|
+
throw new Errors.InvalidPayloadError(error instanceof Error ? { reason: error.message } : {})
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
credential: { ...credential, payload },
|
|
408
|
+
method,
|
|
409
|
+
request: credential.challenge.request,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** @internal */
|
|
414
|
+
export function selectServerMethod(
|
|
415
|
+
methods: readonly AnyServer[],
|
|
416
|
+
challenge: Challenge.Challenge,
|
|
417
|
+
): AnyServer | undefined {
|
|
418
|
+
if (methods.length <= 1) return methods[0]
|
|
419
|
+
if (
|
|
420
|
+
challenge.method !== Constants.Methods.tempo ||
|
|
421
|
+
challenge.intent !== Constants.Intents.session
|
|
422
|
+
)
|
|
423
|
+
return methods[0]
|
|
424
|
+
|
|
425
|
+
const sessionProtocol = Constants.getMethodDetail(
|
|
426
|
+
challenge.request.methodDetails,
|
|
427
|
+
Constants.MethodDetailKeys.sessionProtocol,
|
|
428
|
+
)
|
|
429
|
+
if (sessionProtocol === undefined || sessionProtocol === Constants.SessionProtocols.v1)
|
|
430
|
+
return methods.find((method) => method.alias === 'sessionLegacy') ?? methods[0]
|
|
431
|
+
if (sessionProtocol === Constants.SessionProtocols.v2)
|
|
432
|
+
return methods.find((method) => method.alias === undefined) ?? methods[0]
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
|
|
229
436
|
/**
|
|
230
437
|
* Optional respond function for a server-side method.
|
|
231
438
|
*
|
|
@@ -336,10 +543,22 @@ export function toServer<
|
|
|
336
543
|
preflight,
|
|
337
544
|
request,
|
|
338
545
|
respond,
|
|
546
|
+
broadcast,
|
|
339
547
|
stableBinding,
|
|
340
548
|
transport,
|
|
549
|
+
validate,
|
|
341
550
|
verify,
|
|
342
551
|
} = options
|
|
552
|
+
const effectiveVerify =
|
|
553
|
+
verify ??
|
|
554
|
+
(async (parameters: VerifyContext<method>) => {
|
|
555
|
+
if (validate) await validate(parameters)
|
|
556
|
+
if (!broadcast)
|
|
557
|
+
throw new Errors.VerificationFailedError({
|
|
558
|
+
reason: `${method.name}/${method.intent} does not support credential broadcast`,
|
|
559
|
+
})
|
|
560
|
+
return broadcast(parameters)
|
|
561
|
+
})
|
|
343
562
|
return {
|
|
344
563
|
...method,
|
|
345
564
|
alias,
|
|
@@ -350,9 +569,11 @@ export function toServer<
|
|
|
350
569
|
preflight,
|
|
351
570
|
request,
|
|
352
571
|
respond,
|
|
572
|
+
broadcast,
|
|
353
573
|
stableBinding,
|
|
354
574
|
transport,
|
|
355
|
-
|
|
575
|
+
validate,
|
|
576
|
+
verify: effectiveVerify,
|
|
356
577
|
} as Server<method, defaults, transportOverride, extensions, toServer.Alias<options>>
|
|
357
578
|
}
|
|
358
579
|
|
|
@@ -376,6 +597,21 @@ export declare namespace toServer {
|
|
|
376
597
|
respond?: RespondFn<method> | undefined
|
|
377
598
|
stableBinding?: StableBindingFn<method> | undefined
|
|
378
599
|
transport?: transportOverride | Transport.AnyTransport | undefined
|
|
379
|
-
|
|
380
|
-
|
|
600
|
+
} & (
|
|
601
|
+
| {
|
|
602
|
+
broadcast: BroadcastFn<method>
|
|
603
|
+
validate?: ValidateFn<method> | undefined
|
|
604
|
+
verify?: undefined
|
|
605
|
+
}
|
|
606
|
+
| {
|
|
607
|
+
broadcast?: undefined
|
|
608
|
+
validate?: undefined
|
|
609
|
+
/**
|
|
610
|
+
* @deprecated Use `validate` and `broadcast` for new methods. This
|
|
611
|
+
* combined hook may mutate payment state and cannot power a safe
|
|
612
|
+
* validation-only endpoint.
|
|
613
|
+
*/
|
|
614
|
+
verify: VerifyFn<method>
|
|
615
|
+
}
|
|
616
|
+
)
|
|
381
617
|
}
|
|
@@ -262,9 +262,9 @@ describe('fetch context', () => {
|
|
|
262
262
|
type FetchInit = NonNullable<Parameters<typeof _mppx.fetch>[1]>
|
|
263
263
|
type Context = NonNullable<FetchInit['context']>
|
|
264
264
|
|
|
265
|
-
// Context is a union of charge and session contexts.
|
|
266
|
-
// `account` exists on both; `autoSwap` only on charge.
|
|
265
|
+
// Context is a union of charge and session contexts; both support auto-swap.
|
|
267
266
|
expectTypeOf<Context>().toHaveProperty('account')
|
|
268
|
-
expectTypeOf<
|
|
267
|
+
expectTypeOf<Context>().toHaveProperty('autoSwap')
|
|
268
|
+
expectTypeOf<Context['autoSwap']>().toEqualTypeOf<AutoSwap.resolve.Value | undefined>()
|
|
269
269
|
})
|
|
270
270
|
})
|
|
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
|
|
|
6
6
|
import type * as Method from '../../Method.js'
|
|
7
7
|
import type * as z from '../../zod.js'
|
|
8
8
|
import * as Transport from '../Transport.js'
|
|
9
|
+
import * as MethodChallenge from './MethodChallenge.js'
|
|
9
10
|
import * as MethodResponse from './MethodResponse.js'
|
|
10
11
|
|
|
11
12
|
// We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
|
|
@@ -241,9 +242,22 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
241
242
|
mi = selected.method
|
|
242
243
|
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
243
244
|
|
|
245
|
+
const paymentInput = resolvePaymentRetryInput(
|
|
246
|
+
response,
|
|
247
|
+
initialRequest.input,
|
|
248
|
+
initialRequest.input,
|
|
249
|
+
)
|
|
244
250
|
const createCredential = memoizeCreateCredential(
|
|
245
|
-
(overrideContext?: AnyContextFor<methods>) =>
|
|
246
|
-
|
|
251
|
+
async (overrideContext?: AnyContextFor<methods>) => {
|
|
252
|
+
const credentialContext = overrideContext ?? context
|
|
253
|
+
await MethodChallenge.handle(selected.method, {
|
|
254
|
+
challenge: selectedChallenge,
|
|
255
|
+
context: credentialContext,
|
|
256
|
+
fetch: baseFetch,
|
|
257
|
+
input: paymentInput,
|
|
258
|
+
})
|
|
259
|
+
return resolveCredential(selectedChallenge, selected.method, credentialContext)
|
|
260
|
+
},
|
|
247
261
|
)
|
|
248
262
|
const eventCredential = await events.emit(
|
|
249
263
|
'challenge.received',
|
|
@@ -278,11 +292,6 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
278
292
|
}),
|
|
279
293
|
)
|
|
280
294
|
|
|
281
|
-
const paymentInput = resolvePaymentRetryInput(
|
|
282
|
-
response,
|
|
283
|
-
initialRequest.input,
|
|
284
|
-
initialRequest.input,
|
|
285
|
-
)
|
|
286
295
|
response = await baseFetch(
|
|
287
296
|
paymentInput,
|
|
288
297
|
transport.setCredential(
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type * as Challenge from '../../Challenge.js'
|
|
2
|
+
import type { MaybePromise } from '../../internal/types.js'
|
|
3
|
+
import type * as Method from '../../Method.js'
|
|
4
|
+
|
|
5
|
+
const handlers = new WeakMap<Method.AnyClient, Handler>()
|
|
6
|
+
|
|
7
|
+
/** Inputs available before a client method creates a challenge credential. */
|
|
8
|
+
export type HandlerParameters = {
|
|
9
|
+
challenge: Challenge.Challenge
|
|
10
|
+
context?: unknown
|
|
11
|
+
fetch: typeof globalThis.fetch
|
|
12
|
+
input: RequestInfo | URL
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Internal client-method challenge hook. */
|
|
16
|
+
export type Handler = (parameters: HandlerParameters) => MaybePromise<void>
|
|
17
|
+
|
|
18
|
+
/** Registers an internal challenge hook without changing the public method shape. */
|
|
19
|
+
export function register<const method extends Method.AnyClient>(
|
|
20
|
+
method: method,
|
|
21
|
+
handler: Handler,
|
|
22
|
+
): method {
|
|
23
|
+
handlers.set(method, handler)
|
|
24
|
+
return method
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs method-specific work before creating a challenge credential. */
|
|
28
|
+
export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void> {
|
|
29
|
+
return Promise.resolve(handlers.get(method)?.(parameters))
|
|
30
|
+
}
|
|
@@ -223,6 +223,8 @@ describe('Mppx type tests', () => {
|
|
|
223
223
|
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
|
|
224
224
|
|
|
225
225
|
expectTypeOf(mppx.verifyCredential).toBeFunction()
|
|
226
|
+
expectTypeOf(mppx.broadcastCredential).toBeFunction()
|
|
227
|
+
expectTypeOf(mppx.validateCredential).toBeFunction()
|
|
226
228
|
})
|
|
227
229
|
|
|
228
230
|
test('server events receive typed method context', () => {
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -4872,6 +4872,211 @@ describe('verifyCredential', () => {
|
|
|
4872
4872
|
expect(verifyArgs).toBeDefined()
|
|
4873
4873
|
})
|
|
4874
4874
|
|
|
4875
|
+
test('validateCredential uses pure method validation without broadcast', async () => {
|
|
4876
|
+
const calls: string[] = []
|
|
4877
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
4878
|
+
async validate({ credential, request }) {
|
|
4879
|
+
calls.push('validate')
|
|
4880
|
+
return {
|
|
4881
|
+
challenge: credential.challenge,
|
|
4882
|
+
credential,
|
|
4883
|
+
details: { token: credential.payload.token },
|
|
4884
|
+
intent: 'charge',
|
|
4885
|
+
method: 'alpha',
|
|
4886
|
+
request,
|
|
4887
|
+
source: credential.source,
|
|
4888
|
+
}
|
|
4889
|
+
},
|
|
4890
|
+
async broadcast() {
|
|
4891
|
+
calls.push('broadcast')
|
|
4892
|
+
return mockReceipt('broadcast')
|
|
4893
|
+
},
|
|
4894
|
+
})
|
|
4895
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
4896
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
4897
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
4898
|
+
|
|
4899
|
+
const validation = await mppx.validateCredential(credential)
|
|
4900
|
+
|
|
4901
|
+
expect(validation.details).toEqual({ token: 'valid' })
|
|
4902
|
+
expect(calls).toEqual(['validate'])
|
|
4903
|
+
})
|
|
4904
|
+
|
|
4905
|
+
test('broadcastCredential revalidates and uses method broadcast', async () => {
|
|
4906
|
+
const calls: string[] = []
|
|
4907
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
4908
|
+
async validate({ credential, request }) {
|
|
4909
|
+
calls.push('validate')
|
|
4910
|
+
return {
|
|
4911
|
+
challenge: credential.challenge,
|
|
4912
|
+
credential,
|
|
4913
|
+
details: {},
|
|
4914
|
+
intent: 'charge',
|
|
4915
|
+
method: 'alpha',
|
|
4916
|
+
request,
|
|
4917
|
+
source: credential.source,
|
|
4918
|
+
}
|
|
4919
|
+
},
|
|
4920
|
+
async broadcast() {
|
|
4921
|
+
calls.push('broadcast')
|
|
4922
|
+
return mockReceipt('broadcast')
|
|
4923
|
+
},
|
|
4924
|
+
})
|
|
4925
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
4926
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
4927
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
4928
|
+
|
|
4929
|
+
const receipt = await mppx.broadcastCredential(credential)
|
|
4930
|
+
|
|
4931
|
+
expect(receipt.method).toBe('broadcast')
|
|
4932
|
+
expect(calls).toEqual(['validate', 'broadcast'])
|
|
4933
|
+
})
|
|
4934
|
+
|
|
4935
|
+
test('verifyCredential remains a legacy alias for broadcast', async () => {
|
|
4936
|
+
const calls: string[] = []
|
|
4937
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
4938
|
+
async validate({ credential, request }) {
|
|
4939
|
+
calls.push('validate')
|
|
4940
|
+
return {
|
|
4941
|
+
challenge: credential.challenge,
|
|
4942
|
+
credential,
|
|
4943
|
+
details: {},
|
|
4944
|
+
intent: 'charge',
|
|
4945
|
+
method: 'alpha',
|
|
4946
|
+
request,
|
|
4947
|
+
source: credential.source,
|
|
4948
|
+
}
|
|
4949
|
+
},
|
|
4950
|
+
async broadcast() {
|
|
4951
|
+
calls.push('broadcast')
|
|
4952
|
+
return mockReceipt('broadcast')
|
|
4953
|
+
},
|
|
4954
|
+
})
|
|
4955
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
4956
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
4957
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
4958
|
+
|
|
4959
|
+
const receipt = await mppx.verifyCredential(credential)
|
|
4960
|
+
|
|
4961
|
+
expect(receipt.method).toBe('broadcast')
|
|
4962
|
+
expect(calls).toEqual(['validate', 'broadcast'])
|
|
4963
|
+
})
|
|
4964
|
+
|
|
4965
|
+
test('validateCredential rejects legacy-only methods without emitting payment failure', async () => {
|
|
4966
|
+
const events: string[] = []
|
|
4967
|
+
const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey })
|
|
4968
|
+
mppx.onPaymentFailed((context) => {
|
|
4969
|
+
events.push(context.error.name)
|
|
4970
|
+
})
|
|
4971
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
4972
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
4973
|
+
|
|
4974
|
+
await expect(mppx.validateCredential(credential)).rejects.toThrow(
|
|
4975
|
+
'does not support non-mutating credential validation',
|
|
4976
|
+
)
|
|
4977
|
+
|
|
4978
|
+
expect(events).toEqual([])
|
|
4979
|
+
})
|
|
4980
|
+
|
|
4981
|
+
test('validateCredential enforces supplied route requirements', async () => {
|
|
4982
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
4983
|
+
async validate({ credential, request }) {
|
|
4984
|
+
return {
|
|
4985
|
+
challenge: credential.challenge,
|
|
4986
|
+
credential,
|
|
4987
|
+
details: { amount: request.amount },
|
|
4988
|
+
intent: 'charge',
|
|
4989
|
+
method: 'alpha',
|
|
4990
|
+
request,
|
|
4991
|
+
source: credential.source,
|
|
4992
|
+
}
|
|
4993
|
+
},
|
|
4994
|
+
async broadcast() {
|
|
4995
|
+
return mockReceipt('broadcast')
|
|
4996
|
+
},
|
|
4997
|
+
})
|
|
4998
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
4999
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
5000
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
5001
|
+
|
|
5002
|
+
const validation = await mppx.validateCredential(credential, { request: challengeOpts })
|
|
5003
|
+
expect(validation.details).toEqual({ amount: '1000' })
|
|
5004
|
+
|
|
5005
|
+
await expect(
|
|
5006
|
+
mppx.validateCredential(credential, {
|
|
5007
|
+
request: {
|
|
5008
|
+
...challengeOpts,
|
|
5009
|
+
amount: '2000',
|
|
5010
|
+
},
|
|
5011
|
+
}),
|
|
5012
|
+
).rejects.toThrow('credential amount does not match this route')
|
|
5013
|
+
})
|
|
5014
|
+
|
|
5015
|
+
test('broadcastCredential emits payment failure when split validation fails', async () => {
|
|
5016
|
+
const calls: string[] = []
|
|
5017
|
+
const events: string[] = []
|
|
5018
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
5019
|
+
async validate() {
|
|
5020
|
+
calls.push('validate')
|
|
5021
|
+
throw new Errors.VerificationFailedError({ reason: 'risk denied' })
|
|
5022
|
+
},
|
|
5023
|
+
async broadcast() {
|
|
5024
|
+
calls.push('broadcast')
|
|
5025
|
+
return mockReceipt('broadcast')
|
|
5026
|
+
},
|
|
5027
|
+
})
|
|
5028
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
5029
|
+
mppx.onPaymentFailed((context) => {
|
|
5030
|
+
events.push(context.error.name)
|
|
5031
|
+
})
|
|
5032
|
+
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
|
|
5033
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
5034
|
+
|
|
5035
|
+
await expect(mppx.broadcastCredential(credential)).rejects.toThrow('risk denied')
|
|
5036
|
+
|
|
5037
|
+
expect(calls).toEqual(['validate'])
|
|
5038
|
+
expect(events).toEqual(['VerificationFailedError'])
|
|
5039
|
+
})
|
|
5040
|
+
|
|
5041
|
+
test('route handlers revalidate before broadcast for split methods', async () => {
|
|
5042
|
+
const calls: string[] = []
|
|
5043
|
+
const splitServer = Method.toServer(mockCharge, {
|
|
5044
|
+
async validate({ credential, request }) {
|
|
5045
|
+
calls.push('validate')
|
|
5046
|
+
return {
|
|
5047
|
+
challenge: credential.challenge,
|
|
5048
|
+
credential,
|
|
5049
|
+
details: {},
|
|
5050
|
+
intent: 'charge',
|
|
5051
|
+
method: 'alpha',
|
|
5052
|
+
request,
|
|
5053
|
+
source: credential.source,
|
|
5054
|
+
}
|
|
5055
|
+
},
|
|
5056
|
+
async broadcast() {
|
|
5057
|
+
calls.push('broadcast')
|
|
5058
|
+
return mockReceipt('broadcast')
|
|
5059
|
+
},
|
|
5060
|
+
})
|
|
5061
|
+
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
|
|
5062
|
+
const firstResult = await mppx.charge(challengeOpts)(
|
|
5063
|
+
new Request('https://api.example.com/resource'),
|
|
5064
|
+
)
|
|
5065
|
+
expect(firstResult.status).toBe(402)
|
|
5066
|
+
if (firstResult.status !== 402) throw new Error()
|
|
5067
|
+
|
|
5068
|
+
const challenge = Challenge.fromResponse(firstResult.challenge)
|
|
5069
|
+
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
|
|
5070
|
+
const result = await mppx.charge(challengeOpts)(
|
|
5071
|
+
new Request('https://api.example.com/resource', {
|
|
5072
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
5073
|
+
}),
|
|
5074
|
+
)
|
|
5075
|
+
|
|
5076
|
+
expect(result.status).toBe(200)
|
|
5077
|
+
expect(calls).toEqual(['validate', 'broadcast'])
|
|
5078
|
+
})
|
|
5079
|
+
|
|
4875
5080
|
test('verifies a parsed Credential object (charge)', async () => {
|
|
4876
5081
|
verifyArgs = undefined
|
|
4877
5082
|
const mppx = Mppx.create({
|