Contracts (@app/contracts)
Por que os schemas Zod do client importam do workspace compartilhado, e o padrão de bug que motivou a regra.
A regra
Quando um form, mutation, ou query toca um endpoint HTTP, o schema Zod do lado client deve vir de @app/contracts. Nunca redefina um campo conhecido do server (email, password, name, …) em apps/client/lib/validations/* ou em qualquer objeto Zod client-only.
Por quê
O client tipado (openapi-fetch + apps/client/lib/api/openapi-types.ts) pega drift de tipos, não drift de payload em runtime.
Já enviamos exatamente este bug: um form de register tinha seu próprio schema com fullName, enquanto o server esperava name. O typecheck passou (o campo era string em ambos os mundos), o form submeteu, o server retornou 400, o usuário viu um erro misterioso.
O pacote contracts é a única fonte de verdade. Ambos os lados importam de lá; ambos os lados quebram em CI quando a forma muda.
Como aplicar
1. Procure primeiro em packages/contracts/
packages/contracts/src/
auth.ts Login, register, password reset.
user.ts Perfil, list, update.
common.ts Paginação, IDs.
index.ts Barrel.Se já existe um schema para o que você precisa, re-exporte. Não bifurque.
2. Estenda, não redefina
Para validação client-only (ex. confirmPassword batendo com password num form de register), importe o schema do contract e .extend(...).refine(...) em cima:
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. Tire os campos client-only antes do submit
O padrão canônico:
const onSubmit = async (data: RegisterFormValues) => {
const { confirmPassword: _, ...payload } = data;
await api.POST('/auth/register', { body: payload });
};O nome da variável descartada (_) sinaliza a intenção.
4. O client de auth é cookie-based
BetterAuth usa cookies HTTP-only. apiClient define withCredentials: true para que os cookies trafeguem em toda request. Nunca reintroduza plumbing de bearer-token / localStorage — sessions vivem no Postgres, não no browser.
Quando você muda um schema
Se você muda um campo num schema do contract, a spec OpenAPI muda, o client tipado muda, e o job api-types-fresh do CI falha até você regenerar. Essa é a rede de segurança.
bun run generate:api
git diff apps/server/openapi.json apps/client/lib/api/openapi-types.tsCommit ambos os arquivos no mesmo change. Veja API contract.