diff --git a/.changeset/curly-challenges-deny-empty-id.md b/.changeset/curly-challenges-deny-empty-id.md new file mode 100644 index 00000000..55d394df --- /dev/null +++ b/.changeset/curly-challenges-deny-empty-id.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Rejected empty Payment challenge IDs during construction and deserialization. diff --git a/src/Challenge.test.ts b/src/Challenge.test.ts index 17b5183b..4f5069e8 100644 --- a/src/Challenge.test.ts +++ b/src/Challenge.test.ts @@ -66,6 +66,18 @@ describe('from', () => { expect(challenge.expires).toBe('2025-01-06T12:00:00.000Z') }) + test('error: rejects empty id', () => { + expect(() => + Challenge.from({ + id: '', + realm: 'api.example.com', + method: 'tempo', + intent: 'charge', + request: { amount: '1000000' }, + }), + ).toThrow() + }) + // --------------------------------------------------------------------------- // HMAC Challenge ID Test Vectors // @@ -417,6 +429,22 @@ describe('fromMethod', () => { }), ).toThrow() }) + + test('error: rejects explicit empty id instead of falling back to secretKey', () => { + expect(() => + Challenge.fromMethod(Methods.charge, { + id: '', + secretKey: 'my-secret', + realm: 'api.example.com', + request: { + amount: '1', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + }, + } as any), + ).toThrow() + }) }) describe('serialize', () => { @@ -635,6 +663,11 @@ describe('deserialize', () => { 'Payment id="a", realm="api", method="tempo", intent="charge", request="e30", ="value"', error: 'Malformed auth-param.', }, + { + name: 'empty id', + header: 'Payment id="", realm="api", method="tempo", intent="charge", request="e30"', + error: 'Invalid input', + }, ])('error: throws for $name', ({ header, error }) => { expect(() => Challenge.deserialize(header)).toThrow(error) }) diff --git a/src/Challenge.ts b/src/Challenge.ts index ff1aadc8..4ac329c9 100644 --- a/src/Challenge.ts +++ b/src/Challenge.ts @@ -27,7 +27,7 @@ export const Schema = z.object({ /** Optional expiration timestamp (ISO 8601). */ expires: z.optional(z.datetime()), /** Unique challenge identifier (HMAC-bound). */ - id: z.string(), + id: z.string().check(z.minLength(1)), /** Intent type (e.g., "charge", "session"). */ intent: z.string(), /** Payment method (e.g., "tempo", "stripe"). */ @@ -241,7 +241,7 @@ export function fromMethod( const request = PaymentRequest.fromMethod(method, parameters.request) return from({ - ...(id ? { id } : { secretKey }), + ...(id !== undefined ? { id } : { secretKey }), realm, method: methodName, intent: intent,