From 7f04e34da925f50a63437f6e9c450719d5c38272 Mon Sep 17 00:00:00 2001 From: JasonOA888 <101583541+JasonOA888@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:20:38 +0800 Subject: [PATCH] fix: add client-side challenge expiration validation Adds early rejection of expired challenges on the client side, before attempting to create credentials. This improves UX by failing fast with a clear error instead of wasting user time on wallet interactions that will be rejected by the server. Two code paths now validate expiration: 1. Fetch.from() - auto-402 interceptor, after challenge matching 2. Mppx.createCredential() - manual API, after challenge parsing Both throw PaymentExpiredError (already defined in Errors.ts) when challenge.expires is set and in the past. This mirrors the existing server-side validation at src/server/Mppx.ts:359-367. Fixes #198 --- src/client/Mppx.ts | 6 ++++++ src/client/internal/Fetch.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/client/Mppx.ts b/src/client/Mppx.ts index ae3cbe7a..e919b67d 100644 --- a/src/client/Mppx.ts +++ b/src/client/Mppx.ts @@ -1,4 +1,5 @@ import type * as Challenge from '../Challenge.js' +import * as Errors from '../Errors.js' import type * as Method from '../Method.js' import type * as z from '../zod.js' import * as Fetch from './internal/Fetch.js' @@ -81,6 +82,11 @@ export function create< async createCredential(response: Transport.ResponseOf, context?: unknown) { const challenge = transport.getChallenge(response as never) as Challenge.Challenge + // Validate challenge expiration before creating credential (client-side early rejection) + if (challenge.expires && new Date(challenge.expires) < new Date()) { + throw new Errors.PaymentExpiredError({ expires: challenge.expires }) + } + const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent) if (!mi) throw new Error( diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 4cd0093e..57468b9e 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -1,4 +1,5 @@ import * as Challenge from '../../Challenge.js' +import * as Errors from '../../Errors.js' import type * as Method from '../../Method.js' import type * as z from '../../zod.js' @@ -72,6 +73,9 @@ export function from( `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`, ) + // Validate challenge expiration before creating credential (client-side early rejection) + validateChallengeExpiration(challenge) + const onChallengeCredential = onChallenge ? await onChallenge(challenge, { createCredential: async (overrideContext?: AnyContextFor) => @@ -240,6 +244,13 @@ function validateCredentialHeaderValue(credential: string): void { } } +/** @internal Validates that a challenge has not expired. */ +function validateChallengeExpiration(challenge: Challenge.Challenge): void { + if (challenge.expires && new Date(challenge.expires) < new Date()) { + throw new Errors.PaymentExpiredError({ expires: challenge.expires }) + } +} + /** @internal */ async function resolveCredential( challenge: Challenge.Challenge,