From c03092cf9ee4dbed76a280ecb5a4995381fc6647 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Wed, 1 Jul 2026 12:19:21 +0300 Subject: [PATCH] refactor(ack-pay): extract and harden expiresAt timestamp schema The expiresAt transform called new Date(input).toISOString() directly, so an unparseable value threw a RangeError out of safeParse instead of producing a validation error. Extract a shared timestampSchema that rejects unparseable dates before normalizing to ISO, keeping valibot and zod in accept/reject parity. Revives #104 (closed after #116 changed the schema layout), rescoped to the current valibot.ts + zod.ts layout per maintainer request. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/dedupe-timestamp-schema.md | 12 +++++++ packages/ack-pay/src/schemas.test.ts | 46 +++++++++++++++++++++++++ packages/ack-pay/src/schemas/valibot.ts | 13 +++---- packages/ack-pay/src/schemas/zod.ts | 21 ++++++++--- 4 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 .changeset/dedupe-timestamp-schema.md create mode 100644 packages/ack-pay/src/schemas.test.ts diff --git a/.changeset/dedupe-timestamp-schema.md b/.changeset/dedupe-timestamp-schema.md new file mode 100644 index 0000000..cf1293a --- /dev/null +++ b/.changeset/dedupe-timestamp-schema.md @@ -0,0 +1,12 @@ +--- +"@agentcommercekit/ack-pay": patch +--- + +Reject invalid `expiresAt` values in `paymentRequestSchema` instead of throwing. + +Previously the `expiresAt` transform called `new Date(input).toISOString()` +directly, so an unparseable value (e.g. `"invalid-date"`) threw a `RangeError` +out of `safeParse` rather than producing a validation error. A shared +`timestampSchema` now validates the date is parseable before normalizing it to +an ISO string, and the valibot and zod schemas share the same accept/reject +behavior. diff --git a/packages/ack-pay/src/schemas.test.ts b/packages/ack-pay/src/schemas.test.ts new file mode 100644 index 0000000..802b45c --- /dev/null +++ b/packages/ack-pay/src/schemas.test.ts @@ -0,0 +1,46 @@ +import * as v from "valibot" +import { describe, expect, it } from "vitest" + +import { paymentRequestSchema as valibotPaymentRequestSchema } from "./schemas/valibot" +import { paymentRequestSchema as zodPaymentRequestSchema } from "./schemas/zod" + +const paymentRequest = { + id: "test-payment-request-id", + paymentOptions: [ + { + id: "test-payment-option-id", + amount: 10, + decimals: 2, + currency: "USD", + recipient: "sol:123", + }, + ], +} + +describe("paymentRequestSchema", () => { + it("rejects invalid expiresAt strings instead of throwing", () => { + const input = { + ...paymentRequest, + expiresAt: "invalid-date", + } + + expect(v.safeParse(valibotPaymentRequestSchema, input).success).toBe(false) + expect(zodPaymentRequestSchema.safeParse(input).success).toBe(false) + }) + + it("normalizes valid expiresAt inputs to an ISO string", () => { + const expected = "2024-12-31T23:59:59.000Z" + for (const expiresAt of [ + new Date("2024-12-31T23:59:59Z"), + "2024-12-31T23:59:59Z", + ]) { + const input = { ...paymentRequest, expiresAt } + + const valibot = v.safeParse(valibotPaymentRequestSchema, input) + expect(valibot.success && valibot.output.expiresAt).toBe(expected) + + const zod = zodPaymentRequestSchema.safeParse(input) + expect(zod.success && zod.data.expiresAt).toBe(expected) + } + }) +}) diff --git a/packages/ack-pay/src/schemas/valibot.ts b/packages/ack-pay/src/schemas/valibot.ts index 06a58f1..d97ed94 100644 --- a/packages/ack-pay/src/schemas/valibot.ts +++ b/packages/ack-pay/src/schemas/valibot.ts @@ -4,6 +4,12 @@ import * as v from "valibot" const urlOrDidUri = v.union([v.pipe(v.string(), v.url()), didUriSchema]) +const timestampSchema = v.pipe( + v.union([v.date(), v.string()]), + v.check((input) => !Number.isNaN(new Date(input).getTime()), "Invalid date"), + v.transform((input) => new Date(input).toISOString()), +) + export const paymentOptionSchema = v.object({ id: v.string(), amount: v.union([v.pipe(v.number(), v.integer(), v.gtValue(0)), v.string()]), @@ -19,12 +25,7 @@ export const paymentRequestSchema = v.object({ id: v.string(), description: v.optional(v.string()), serviceCallback: v.optional(v.pipe(v.string(), v.url())), - expiresAt: v.optional( - v.pipe( - v.union([v.date(), v.string()]), - v.transform((input) => new Date(input).toISOString()), - ), - ), + expiresAt: v.optional(timestampSchema), paymentOptions: v.pipe( v.tupleWithRest([paymentOptionSchema], paymentOptionSchema), v.nonEmpty(), diff --git a/packages/ack-pay/src/schemas/zod.ts b/packages/ack-pay/src/schemas/zod.ts index 66ceffd..0f676fc 100644 --- a/packages/ack-pay/src/schemas/zod.ts +++ b/packages/ack-pay/src/schemas/zod.ts @@ -4,6 +4,22 @@ import * as z from "zod" const urlOrDidUri = z.union([z.url(), didUriSchema]) +const timestampSchema = z + .union([z.date(), z.string()]) + .transform((val, ctx) => { + const date = new Date(val) + if (Number.isNaN(date.getTime())) { + ctx.addIssue({ + code: "custom", + message: "Invalid date", + input: val, + }) + return z.NEVER + } + + return date.toISOString() + }) + export const paymentOptionSchema = z.object({ id: z.string(), amount: z.union([z.number().int().positive(), z.string()]), @@ -19,10 +35,7 @@ export const paymentRequestSchema = z.object({ id: z.string(), description: z.string().optional(), serviceCallback: z.url().optional(), - expiresAt: z - .union([z.date(), z.string()]) - .transform((val) => new Date(val).toISOString()) - .optional(), + expiresAt: timestampSchema.optional(), paymentOptions: z.array(paymentOptionSchema).nonempty(), })