Contracts (@app/contracts)
Por qué los schemas Zod del client importan desde el workspace compartido, y el patrón de bug que motivó la regla.
La regla
Cuando un form, mutation, o query toca un endpoint HTTP, el schema Zod del lado client debe venir de @app/contracts. Nunca redefinas un campo conocido del server (email, password, name, …) en apps/client/lib/validations/* ni en ningún objeto Zod client-only.
Por qué
El client tipado (openapi-fetch + apps/client/lib/api/openapi-types.ts) detecta drift de tipos, no drift de payload en runtime.
Mandamos exactamente este bug: un form de register tenía su propio schema con fullName, mientras el server esperaba name. El typecheck pasó (el campo era string en ambos mundos), el form se envió, el server devolvió 400, el user vio un error misterioso.
El paquete contracts es la única fuente de verdad. Ambos lados importan de ahí; ambos lados rompen en CI cuando la forma cambia.
Cómo aplicarlo
1. Buscá primero en packages/contracts/
packages/contracts/src/
auth.ts Login, register, password reset.
user.ts Perfil, list, update.
common.ts Paginación, IDs.
index.ts Barrel.Si ya existe un schema para lo que necesitás, re-exportalo. No bifurques.
2. Extendé, no redefinas
Para validación client-only (ej. confirmPassword matcheando password en un form de register), importá el schema del contract y .extend(...).refine(...) por encima:
import { auth } from '@app/contracts';
import { z } from 'zod';
export const registerFormSchema = auth.SignUpInput
.extend({ confirmPassword: z.string() })
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});3. Quitá los campos client-only antes del submit
El patrón canónico:
const onSubmit = async (data: RegisterFormValues) => {
const { confirmPassword: _, ...payload } = data;
await api.POST('/auth/register', { body: payload });
};El nombre de la variable descartada (_) señala la intención.
4. El client de auth es cookie-based
BetterAuth usa cookies HTTP-only. apiClient setea withCredentials: true así que las cookies viajan en cada request. Nunca reintroduzcas plumbing de bearer-token / localStorage — las sessions viven en Postgres, no en el browser.
Cuando cambiás un schema
Si cambiás un campo en un schema del contract, la spec OpenAPI cambia, el client tipado cambia, y el job api-types-fresh de CI falla hasta que regeneres. Esa es la red de seguridad.
bun run generate:api
git diff apps/server/openapi.json apps/client/lib/api/openapi-types.tsCommiteá ambos archivos en el mismo cambio. Ver API contract.