@quadcore-lib/input-validation
v0.3.0
Published
Schemas de validación de inputs isomórficos (zod) compartidos entre frontend y backend de QuadCore.
Readme
@quadcore-lib/input-validation
Schemas de validación de inputs isomórficos (una sola definición con zod) compartidos entre el frontend (React) y el backend (NestJS) de QuadCore. Una regla se escribe una vez y la usan las dos puntas.
Validación ≠ anti-inyección. Este paquete valida forma, tipo y allowlist del input y acota la superficie de entrada (largos máximos, caracteres permitidos,
.strict()para descartar campos desconocidos). No previene por sí solo XSS, SQLi, OS command injection ni SSTI — eso se maneja en el sink: queries parametrizadas (TypeORM), escape en el render (React),execFilesin shell, etc. Usar ambas capas.
Instalación
npm install @quadcore-lib/input-validationUso
Definición única
import { z } from 'zod';
import { createAddressSchema, type CreateAddressInput } from '@quadcore-lib/input-validation';Backend (NestJS) — con ZodValidationPipe de @quadcore-lib/core-server
import { ZodValidationPipe } from '@quadcore-lib/core-server';
import { createAddressSchema, type CreateAddressInput } from '@quadcore-lib/input-validation';
@Post()
create(@Body(new ZodValidationPipe(createAddressSchema)) dto: CreateAddressInput) { /* ... */ }Por defecto el 400 devuelve un mensaje genérico ({ message: 'Datos inválidos' }),
sin filtrar el contrato de validación a tráfico público. El detalle por campo lo
resuelve el cliente con su propio safeParse. En endpoints internos/admin podés
pedir el detalle con new ZodValidationPipe(schema, { exposeIssues: true }).
Frontend (React) — con react-hook-form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createAddressSchema, type CreateAddressInput } from '@quadcore-lib/input-validation';
const form = useForm<CreateAddressInput>({ resolver: zodResolver(createAddressSchema) });
// Validación en cliente + tipos, con las mismas reglas que valida el backend.Sin framework
const result = createAddressSchema.safeParse(input);
if (!result.success) { /* result.error.issues */ }API
Primitivas
| Export | Tipo | Descripción |
|---|---|---|
| alphanumeric({min?,max?}) | factory | Allowlist estricta A-Za-z0-9. |
| pattern(regex, msg?, {min?,max?}) | factory | Allowlist configurable por regex propia. |
| safeText({min?,max?,multiline?}) | factory | Texto libre con tope de largo, sin caracteres de control. multiline: true permite \n/\r/\t (para <textarea>). |
| slug | schema | Slug en minúsculas (a-z, 0-9, guiones). |
| email | schema | Email normalizado a minúsculas (máx 254). |
| uuid | schema | UUID. |
| postalCode | schema | Código postal genérico (letras, dígitos, espacios, guiones; 2–12). Default de los dominios. |
| cuit | schema | CUIT/CUIL AR con dígito verificador; normaliza guiones. |
| postalCodeAR | schema | Código postal AR (4 dígitos o CPA). Opt-in para proyectos que exijan formato AR. |
| phone | schema | Teléfono internacional, formato E.164 flexible (opcional +, 8–15 dígitos). Solo forma, no verifica existencia. |
| phoneAR | schema | Alias de phone (la regex nunca fue específica de AR). |
| password | schema | 8–128, con minúscula + mayúscula + dígito. |
| money, percentage, positiveInt, nonNegativeInt | schema | Numéricos comunes. |
Schemas de dominio
| Export | Descripción |
|---|---|
| createAddressSchema / CreateAddressInput | Alta de dirección (piloto). .strict(). |
| updateAddressSchema / UpdateAddressInput | Actualización parcial de dirección. |
| listUsersQuerySchema / ListUsersQueryInput | Query de listado de usuarios. |
| updateRoleSchema / UpdateRoleInput | Cambio de rol de usuario. |
| updateStatusSchema / UpdateStatusInput | Activar/desactivar cuenta de usuario. |
| createCategorySchema / CreateCategoryInput | Alta de categoría. |
| updateCategorySchema / UpdateCategoryInput | Actualización parcial de categoría. |
| listCategoriesQuerySchema / ListCategoriesQueryInput | Query de listado de categorías. |
| createProductSchema / CreateProductInput | Alta de producto. |
| updateProductSchema / UpdateProductInput | Actualización parcial de producto. |
| adjustStockSchema / AdjustStockInput | Ajuste de stock (delta/set). |
| listProductsQuerySchema / ListProductsQueryInput | Query de listado de productos. |
| createOrderSchema / CreateOrderInput | Alta de orden (precio/envío/cupón resueltos server-side). |
| listOrdersQuerySchema / ListOrdersQueryInput | Query de listado de órdenes. |
| updateOrderStatusSchema / UpdateOrderStatusInput | Cambio de estado de orden. ORDER_STATUSES. |
| createCouponSchema / CreateCouponInput | Alta de cupón. COUPON_TYPES. |
| updateCouponSchema / UpdateCouponInput | Actualización parcial de cupón. |
| validateCouponSchema / ValidateCouponInput | Validación pública de cupón en checkout. |
| createPaymentSchema / CreatePaymentInput | Alta de pago (monto derivado server-side). |
| createShippingMethodSchema / CreateShippingMethodInput | Alta de método de envío. |
| updateShippingMethodSchema / UpdateShippingMethodInput | Actualización parcial de método de envío. |
| createTicketSchema / CreateTicketInput | Alta pública de ticket. |
| updateTicketSchema / UpdateTicketInput | Gestión admin de ticket. TICKET_STATUSES. |
| listTicketsQuerySchema / ListTicketsQueryInput | Query de listado de tickets. |
| upsertSettingSchema / UpsertSettingInput | Alta/actualización de setting. SETTING_TYPES. |
| createNotificationSchema / CreateNotificationInput | Alta admin de notificación. |
Los *_STATUSES/*_TYPES (ORDER_STATUSES, COUPON_TYPES, TICKET_STATUSES,
SETTING_TYPES) se definen acá y la entity de cada *-server los reexporta
(export { ORDER_STATUSES } from '@quadcore-lib/input-validation'). La dependencia
va en esa dirección — los *-server dependen de input-validation, no al revés —
así que hay una sola lista y no dos que se puedan desincronizar. Agregar o renombrar
un estado se hace en un solo lugar.
Campos opcionales y la cadena vacía
Dos helpers, porque "" no significa lo mismo en todos lados:
| Helper | Qué hace con "" | Dónde se usa |
|---|---|---|
| optionalText({ max }) | lo preserva | texto libre de un body (description, notes, label): mandar "" es como un formulario limpia un campo que ya tenía valor |
| blankToUndefined(schema) | lo vuelve undefined | query strings (?search=, ?categoryId=) y campos de body de tipo cerrado (email, uuid, enum), donde "" quiere decir "no lo mandé" |
Un safeText() pelado arranca en min: 1, así que sigue rechazando "" — que es lo
correcto para un campo obligatorio.
Schema vs DTO
Con class-validator, el DTO era una clase que hacía dos cosas a la vez: definía la
forma/tipo del body y cargaba las reglas de validación como decoradores
(@IsString(), etc.). Forma y validación fusionadas en un artefacto escrito a mano.
Con zod eso se separa en una sola fuente: el schema valida en runtime y de él
se infiere el tipo (type CreateAddressInput = z.infer<typeof createAddressSchema>).
Ya no hace falta una clase DTO: lo único que queda es ese tipo inferido, que sale gratis
del schema. Por eso en los paquetes migrados no verás CreateAddressDto (clase), sino
CreateAddressInput (tipo derivado del schema).
- Validación de input (runtime) → la hace el schema.
- "DTO" en el sentido de "forma de lo que viaja" → es el tipo inferido, sin duplicación.
- Los
*.dto.tscon class-validator que todavía existan en otros paquetes son deuda de migración, no el patrón nuevo.
Manejo de errores: público vs cliente
Son dos mensajes distintos, a propósito:
| Capa | Quién lo ve | Qué dice |
|---|---|---|
| Respuesta HTTP 400 (backend) | Tráfico público (cualquiera que pegue a la API) | { message: "Datos inválidos", fields: ["postalCode", ...] }. Los nombres de los campos que fallaron (para que un cliente sin el schema sepa qué revisar), sin los mensajes de validación. |
| Errores inline del form (frontend) | La persona que completa el formulario | Descriptivo por campo ("Código postal inválido"). Se calcula en el browser con schema.safeParse — nunca sale del server. |
El detalle textual lo resuelve el cliente con el mismo schema. Endpoints internos/admin
que quieran los mensajes en la respuesta: new ZodValidationPipe(schema, { exposeIssues: true })
(agrega issues con path + message).
Recordar: esto valida forma/tipo/allowlist. Las inyecciones (XSS, SQLi, OSi, SSTI) se previenen en el sink, no acá — ver la nota del inicio.
Requisitos
zod >= 3.23(dependencia directa del paquete).- Backend:
ZodValidationPipede@quadcore-lib/core-server. - Frontend:
schema.safeParsedirecto, oreact-hook-form+@hookform/resolverssi el form crece.
Build: CJS + ESM
El paquete es isomórfico (lo consumen *-server en CommonJS y account en ESM), así que
compila dos veces desde la misma fuente:
dist/
cjs/ ← module: commonjs (require/module.exports)
esm/ ← module: ESNext (import/export nativo)
types/ ← .d.ts, generado una sola vez desde la build CJSpackage.json declara exports condicional para que cada consumidor resuelva el que
le corresponde (require → dist/cjs, import → dist/esm, default → dist/cjs
para cualquier resolver con otras condiciones, como jsdom).
scripts/postbuild.mjs (los dos pasos corren sobre dist/, el src queda intacto):
- Escribe
dist/cjs/package.json(type: commonjs) ydist/esm/package.json(type: module). Node decide el tipo de módulo por elpackage.jsonmás cercano al.jsque carga, y el raíz no declara"type". - Le agrega la extensión
.jsa los imports relativos del emitido endist/esm. ElsrcusamoduleResolution: "bundler", así que tsc los emite sin extensión — un bundler los resuelve, pero el loader ESM de Node exige la extensión explícita y sin esto tiraríaERR_MODULE_NOT_FOUND.
Se hace en el postbuild y no escribiendo .js en los imports del src a propósito: de
esa forma el código fuente no se ensucia, jest sigue resolviendo sin moduleNameMapper,
y cualquier archivo nuevo del paquete entra solo sin que haya que acordarse de nada.
Verificado en el build: require('@quadcore-lib/input-validation') y
import '@quadcore-lib/input-validation' (Node ESM nativo, sin bundler) resuelven los dos.
Pendiente conocido: dist/types se emite una sola vez desde la build CJS, así que un
consumidor con moduleResolution: "node16"/"nodenext" va a ver tipos en formato CJS
colgando de la condición import. Hoy no molesta (los *-server usan node y los
paquetes de frontend bundler), pero si aparece un consumidor nodenext hay que emitir
tipos por build.
