Seguridad y validación: Zod, autorización y HttpError
Cada función de servidor es un endpoint público: cualquiera puede enviarle el payload que quiera, saltándose tu interfaz. De ahí tres disciplinas innegociables: validar la entrada en el servidor con Zod porque el tipo de TypeScript se borra en runtime, autorizar comprobando la sesión dentro del cuerpo y no en la UI, y cortar el flujo con las respuestas del framework —`redirect` y `json` de `@solidjs/router`, o un `Response` directo— más una clase `HttpError` propia para errores de estado tipados. El hilo conductor: la interfaz es una sugerencia; el endpoint es el perímetro de seguridad.
El tipo se borra: valida en el servidor con Zod
El engaño más peligroso es creer que la firma de TypeScript protege el endpoint. No lo hace: los tipos se borran en compilación y en tiempo de ejecución la función recibe lo que sea que llegó por la red. Un atacante no invoca tu stub tipado; hace un POST crudo con el cuerpo que elige. Por eso la entrada se valida en el servidor con un esquema real —Zod es el estándar—, que comprueba forma y contenido y produce un valor ya tipado y confiable.
import { z } from "zod";const NuevaTarea = z.object({ texto: z.string().min(1).max(280), prioridad: z.enum(["baja", "alta"]).default("baja"),});export async function crearTarea(entrada: unknown) { "use server"; const datos = NuevaTarea.parse(entrada); // lanza si la forma no encaja return db.tareas.crear(datos); // datos ya es confiable y tipado}Fíjate en el tipo del argumento: unknown, no la forma esperada. Declararlo unknown es honestidad sobre el runtime —no sabes qué llegó hasta que lo validas— y obliga a pasar por parse antes de tocar nada. Con FormData, el patrón es idéntico: extraes los campos y los pasas por el mismo esquema.
export async function crearDesdeFormulario(form: FormData) { "use server"; const datos = NuevaTarea.parse({ texto: form.get("texto"), prioridad: form.get("prioridad"), }); return db.tareas.crear(datos);}Autoriza dentro del cuerpo, no en la interfaz
Validar responde a «¿es esta entrada bien formada?»; autorizar responde a «¿tiene este usuario derecho a esta acción?». Son preguntas distintas y ambas viven en el servidor. Ocultar un botón en la interfaz no protege nada: el endpoint sigue ahí, y quien lo llame directo se salta la interfaz entera. La autorización se resuelve leyendo la sesión de la petición —vía getRequestEvent o el locals que dejó el middleware— y decidiendo dentro del cuerpo.
import { getRequestEvent } from "solid-js/web";import { redirect } from "@solidjs/router";export async function borrarTarea(id: string) { "use server"; const usuario = getRequestEvent()?.locals.usuario; if (!usuario) throw redirect("/login"); // sin sesion: fuera if (usuario.rol !== "admin") throw new HttpError(403, "Prohibido"); return db.tareas.borrar(id);}Cortar el flujo: redirect, json y HttpError
Desde el servidor puedes devolver o lanzar una respuesta, y el router de Solid sabe interpretar las suyas. redirect(url) produce una redirección; su tipo es never, así que no contamina el tipo de retorno de tu función y puedes lanzarla o devolverla con naturalidad. json(datos, opciones) empaqueta datos con cabeceras o estado a medida. Y siempre puedes lanzar un Response crudo cuando quieras control total.
import { json, redirect } from "@solidjs/router";export async function exportar() { "use server"; const evento = getRequestEvent(); if (!evento?.locals.usuario) throw redirect("/login"); return json({ url: await generarInforme() }, { headers: { "cache-control": "no-store" } });}Para errores de estado HTTP conviene una clase propia, HttpError, que transporte el código junto al mensaje. El framework no impone una: la defines en tres líneas, la lanzas desde cualquier función de servidor, y la traduces a un Response en tu límite de error o en un middleware. Su valor es semántico —un 403 es distinto de un 422— y hace que el manejo de errores sea uniforme.
export class HttpError extends Error { constructor(readonly status: number, mensaje: string) { super(mensaje); this.name = "HttpError"; }}Componer las tres disciplinas en una sola puerta
Repetir validación y autorización en cada función invita a olvidarlas justo en la que importa. El patrón robusto agrupa las mutaciones en un archivo con la directiva de archivo y extrae un guardián reutilizable que resuelve la sesión y el permiso; cada función exportada lo invoca antes de rozar los datos. Nada de esto vive en un cierre con directiva por función —que arrastraría problemas de serialización al capturar variables no transportables—: son funciones de servidor planas que comparten un helper, también de servidor por estar en el mismo archivo.
// data/tareas.ts — el archivo entero es de servidor"use server";import { getRequestEvent } from "solid-js/web";import { redirect } from "@solidjs/router";async function exigir(rol: "admin" | "editor") { const usuario = getRequestEvent()?.locals.usuario; if (!usuario) throw redirect("/login"); // sin sesion if (usuario.rol !== rol && usuario.rol !== "admin") { throw new HttpError(403, "Prohibido"); // sin permiso } return usuario;}export async function crearTarea(entrada: unknown) { const usuario = await exigir("editor"); // autoriza const datos = NuevaTarea.parse(entrada); // valida return db.tareas.crear({ ...datos, autor: usuario.id }); // ejecuta}El valor de esta arquitectura no es ahorrar líneas, sino hacer que la seguridad sea la ruta por defecto en lugar de una lista de comprobaciones que recordar cada vez. Cuando validación y autorización viven en el borde común por el que todas las mutaciones entran, olvidarlas deja de ser posible: no queda ninguna puerta trasera que alguien pueda dejar entornada por descuido. La disciplina, así dispuesta, se convierte en arquitectura.