From 9eb38ccda95269b635a14836d5ddb9061999ecb7 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 31 Jul 2026 09:06:55 -0400 Subject: [PATCH] fix(auth): type the registration ttl as a number RegistrationSuccessSchema.ttl was z.string(), describing what the auth API sent rather than what the value is. It was the only ttl in the auth schemas a consumer could not treat like the others. That had a real consequence. The Fastify adapter hands the value to a cookie library that requires an integer, so registration failed there with `TypeError: option maxAge is invalid: 300`. The Express adapter multiplies it into milliseconds, which coerces the string, so the same response worked and the mismatch stayed hidden. Breaking for anyone reading the field as a string. Taken as a minor because the package is pre-1.0. Verified with npm run typecheck, npm test (162 passing), npm run lint, and npm run format:check. --- .changeset/registration-ttl-is-a-number.md | 16 ++++++++++++++++ src/schemas/auth/auth.schema.test.ts | 21 +++++++++++++++++++++ src/schemas/auth/auth.schema.ts | 7 ++++++- 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/registration-ttl-is-a-number.md diff --git a/.changeset/registration-ttl-is-a-number.md b/.changeset/registration-ttl-is-a-number.md new file mode 100644 index 0000000..7fdc1c6 --- /dev/null +++ b/.changeset/registration-ttl-is-a-number.md @@ -0,0 +1,16 @@ +--- +'@seamless-auth/types': minor +--- + +`RegistrationSuccessSchema.ttl` is a number, matching every other `ttl` in the contract. + +It was `z.string()`, which described what the auth API sent rather than what the value is. That made +it the only `ttl` in the auth schemas a consumer could not treat like the others, and it had a real +consequence: the Fastify adapter passes the value straight to a cookie library that requires an +integer, so registration failed there with `TypeError: option maxAge is invalid: 300`. The Express +adapter multiplies it into milliseconds, which coerces the string, so the same response worked. + +This is a breaking contract change, taken as a minor because the package is pre-1.0. It breaks anyone +reading `RegistrationSuccessResponse['ttl']` as a string, or parsing a registration body that still +carries one. The auth API is fixed in the same round to send a number, and `@seamless-auth/core` +parses the value defensively regardless of what arrives. diff --git a/src/schemas/auth/auth.schema.test.ts b/src/schemas/auth/auth.schema.test.ts index 1eb112a..f539dd8 100644 --- a/src/schemas/auth/auth.schema.test.ts +++ b/src/schemas/auth/auth.schema.test.ts @@ -6,6 +6,7 @@ import { OTPVerifyTokenSuccessSchema, RefreshSuccessResponseSchema, RegistrationRequestSchema, + RegistrationSuccessSchema, } from './auth.schema.js'; describe('LoginRequestSchema', () => { @@ -85,3 +86,23 @@ describe('RegistrationRequestSchema', () => { expect(() => RegistrationRequestSchema.parse({ email: 'nope' })).toThrow(); }); }); + +describe('RegistrationSuccessSchema', () => { + const base = { message: 'Registration started', sub: 'user-123', token: 't' }; + + it('accepts a ttl in seconds', () => { + expect(() => RegistrationSuccessSchema.parse({ ...base, ttl: 300 })).not.toThrow(); + }); + + // This field was a string, which is what the API sent. It was the only ttl in + // this file a consumer could not treat like the others, and handing a string + // to a cookie library that requires an integer fails the request. + it('rejects a ttl as a string, the way every other ttl here does', () => { + expect(() => RegistrationSuccessSchema.parse({ ...base, ttl: '300' })).toThrow(); + expect(() => LoginSuccessResponseSchema.parse({ message: 'ok', ttl: '300' })).toThrow(); + }); + + it('leaves the ttl optional', () => { + expect(() => RegistrationSuccessSchema.parse(base)).not.toThrow(); + }); +}); diff --git a/src/schemas/auth/auth.schema.ts b/src/schemas/auth/auth.schema.ts index 59df5e5..74eb60c 100644 --- a/src/schemas/auth/auth.schema.ts +++ b/src/schemas/auth/auth.schema.ts @@ -71,7 +71,12 @@ export const RegistrationSuccessSchema = z.object({ message: z.string(), sub: z.string().optional(), token: z.string().optional(), - ttl: z.string().optional(), + // Seconds, as everywhere else a ttl appears in this file. It was a string, + // matching what the API sent, which left this the only ttl in the contract a + // consumer could not treat like the others. A string reaching a cookie library + // that requires an integer is a failed request, so the type states what the + // value has to be rather than what it happened to be. + ttl: z.number().optional(), delivery: AuthDeliverySchema.optional(), });