wandres.dev
ACTIONS · funciones de servidor tipadas

Errores tipados, seguridad y patrones de Actions

Lanzar fallos con ActionError y un código legible que el cliente ramifica, entender que validar la forma con input no es autorizar al usuario en el handler, y llevar las actions a la práctica con dos patrones: la mutación que devuelve el nuevo estado y la UI optimista servida desde una isla que reconcilia al fallar.

⏱ 17 min

Una action robusta no solo produce datos: produce fallos legibles. ActionError es la herramienta para lanzar, desde el handler, un error con un código semántico —UNAUTHORIZED, NOT_FOUND, CONFLICT— que viaja tipado hasta el cliente y le permite ramificar con precisión. Pero los errores bien tipados son la mitad amable de esta lección; la otra es incómoda y esencial: la seguridad. Que el cliente hable con el servidor por un canal tipado no lo vuelve de fiar. El handler sigue siendo la única muralla, y su trabajo no termina en validar la forma de los datos: empieza de verdad al decidir si quien los envía tiene derecho a hacerlo.

🎯 Al terminar esta lección sabrás
  • Lanzar fallos desde el handler con ActionError y un code legible.
  • Separar la validación de forma con input de la autorización dentro del handler.
  • Usar el context del handlerlocals, session, cookies— para decidir permisos.
  • Aplicar dos patrones: la mutación que devuelve estado y la UI optimista en una isla.

ActionError: el fallo como valor tipado

Dentro de un handler, cuando algo debe fallar de forma controlada, lanzas un ActionError. Su constructor recibe un code —una versión legible de un código de estado HTTP— y un message opcional. Astro convierte ese error lanzado en el error que el cliente recibe en su { data, error }, con el mismo code intacto, de modo que ambos lados hablan el mismo vocabulario de fallos.

import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro:schema';

export const server = {
  borrarTarea: defineAction({
    input: z.object({ id: z.string() }),
    handler: async (input, context) => {
      const usuario = context.locals.usuario;
      if (!usuario) {
        throw new ActionError({ code: 'UNAUTHORIZED', message: 'Inicia sesion' });
      }
      const tarea = await buscarTarea(input.id);
      if (!tarea) throw new ActionError({ code: 'NOT_FOUND' });
      if (tarea.dueno !== usuario.id) throw new ActionError({ code: 'FORBIDDEN' });
      await eliminar(input.id);
      return { borrada: input.id };
    },
  }),
};

El code no es decorativo: es un vocabulario cerrado tomado de los códigos de estado HTTP —BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR—, y Astro lo traduce además al código HTTP correspondiente en la respuesta. En el cliente, isActionError estrecha el tipo y te deja ramificar según ese code, dando a cada fallo la respuesta de interfaz que merece: un UNAUTHORIZED lleva al login, un CONFLICT avisa de un choque, un NOT_FOUND explica que el recurso ya no existe.

Que el code se corresponda con un estado HTTP no es un adorno: es lo que hace que una action encaje con el resto de la web. Cuando el formulario se envía sin JavaScript, ese código tiñe la respuesta HTTP real —un FORBIDDEN sale como un 403—, de modo que cachés, proxies y buscadores lo interpretan bien. Con JavaScript, en cambio, el code te llega como una cadena legible para ramificar en la interfaz. Un mismo error habla así dos idiomas a la vez: el de las máquinas de la web y el de tu código de cliente.

⚠️
El message viaja al cliente: no filtres nada

Todo lo que pongas en el message de un ActionError llega al navegador. Es el sitio para una explicación útil al usuario, no para el detalle interno del fallo: nunca vuelques ahí una traza, una consulta SQL ni un mensaje crudo de la base de datos, porque estarías regalando a un atacante un mapa de tus tripas. Para el diagnóstico interno, registra en el servidor con todo el detalle; para el cliente, un code semántico y un message sobrio que oriente sin exponer.

ℹ️
El usuario llega por locals, no por el cliente

La identidad con la que autorizas no viaja en el input: la deja tu middleware en context.locals tras verificar la sesión o el token en cada petición. Confiar en un usuarioId que el cliente envíe como argumento sería el error clásico —cualquiera puede escribir el identificador de otro—; en cambio, locals.usuario procede de una comprobación que el servidor hizo por su cuenta. La distinción entre lo que el cliente afirma y lo que el servidor verifica es la línea que separa una action segura de una que solo lo parece.

Validar no es autorizar

Aquí está la distinción que separa una action segura de una vulnerable. El input garantiza la forma de los datos: que el id sea una cadena, que el email tenga pinta de correo. No dice absolutamente nada sobre la legitimidad de la petición: si quien la envía ha iniciado sesión, si es el dueño del recurso que quiere borrar, si su plan le permite esa operación. Un input impecable acepta encantado la petición de un atacante bien formada; la forma correcta no implica el permiso correcto.

Por eso la autorización vive en el handler, no en el input, y se apoya en el segundo argumento del handler: el context, un subconjunto del contexto de Astro con piezas como locals, session, cookies, request y url. De locals sacas el usuario que tu middleware dejó tras verificar la sesión; con él decides, dentro del handler, si la operación procede. La regla es tajante: toda action que muta o expone algo sensible comprueba permisos en su cuerpo, y lo hace desconfiando de cada dato que viene del cliente, porque ese dato pudo fabricarlo cualquiera.

flowchart TD
REQ[peticion a la action] --> V{input valido}
V -->|no| E1[ActionError BAD_REQUEST]
V -->|si| A{usuario autorizado}
A -->|no| E2[ActionError UNAUTHORIZED o FORBIDDEN]
A -->|si| B{regla de negocio cumple}
B -->|no| E3[ActionError CONFLICT]
B -->|si| OK[data de exito]
style OK fill:#a6e3a1,color:#11111b
style E1 fill:#f38ba8,color:#11111b
style E2 fill:#f38ba8,color:#11111b
style E3 fill:#f38ba8,color:#11111b

El diagrama ordena las cuatro puertas que una petición cruza en secuencia: primero la forma —la valida el input—, luego la identidad y el permiso, después la regla de negocio, y solo al final el éxito. Cada puerta tiene su ActionError con su código, y ninguna suple a otra. Añadir un límite de peticiones por usuario y devolver TOO_MANY_REQUESTS cuando se supera cierra el flanco del abuso, otra decisión que solo el servidor puede tomar y que el cliente, por definición, no puede garantizar.

Una consecuencia práctica de esta separación es que la seguridad de una action no se puede leer en su input: dos actions con el mismo esquema pueden tener niveles de riesgo opuestos según lo que su handler compruebe. Por eso auditar la seguridad de un proyecto de Astro es, sobre todo, leer los handler uno a uno, buscando la comprobación de permisos que debería estar y que a veces falta.

Patrones: mutación y UI optimista

Las actions brillan en las mutaciones: crear, actualizar, borrar. Un patrón sólido es que la action devuelva el nuevo estado resultante, no un simple acuse de recibo; así el cliente no tiene que adivinar cómo quedó el mundo tras la operación, sino que recibe la verdad ya actualizada y repinta con ella. Devolver el estado nuevo convierte cada mutación en su propia fuente de datos frescos.

<!-- Isla de UI optimista: actualiza ya, reconcilia si falla -->
<button data-id={id}>Me gusta ({likes})</button>
<script>
  import { actions } from 'astro:actions';
  const boton = document.currentScript.previousElementSibling;
  boton.addEventListener('click', async () => {
    const previo = boton.textContent;
    boton.textContent = 'Me gusta (optimista)'; // pinta antes de confirmar
    const { data, error } = await actions.darLike({ id: boton.dataset.id });
    if (error) {
      boton.textContent = previo; // rollback: revierte al estado anterior
      return;
    }
    boton.textContent = `Me gusta (${data.likes})`; // reconcilia con la verdad
  });
</script>

La UI optimista es el segundo patrón, y una isla es su hogar natural. La idea: actualizar la interfaz antes de que el servidor confirme, apostando a que la operación tendrá éxito —lo habitual—, para que la respuesta se sienta instantánea. Si la action devuelve data, reconcilias la interfaz con el valor real que trae; si devuelve error, deshaces el cambio y vuelves al estado previo. El { data, error } de la action es justo el material que este patrón necesita: una rama para confirmar, otra para revertir, sin excepciones que se escapen.

Un tercer patrón, menos vistoso pero crítico, es la revalidación. Tras una mutación exitosa, los datos que otras zonas de la página mostraban pueden haber quedado obsoletos; devolver el nuevo estado desde la action cubre el caso local, pero a veces conviene recargar o volver a pedir una porción de la interfaz para que todo refleje la verdad más reciente. Es el reverso responsable de la UI optimista: apostar por el éxito para ir rápido, pero reconciliar con el servidor para no quedarse mintiendo.

🛡️

ActionError

Lanza un fallo con code legible desde el handler. Llega tipado al cliente.

🔑

context

locals, session y cookies dentro del handler: la base para autorizar.

✍️

Mutacion

La action devuelve el nuevo estado, no un acuse, para que el cliente repinte.

UI optimista

La isla pinta antes de confirmar y reconcilia o revierte segun data o error.

Salidas de emergencia con getActionPath

A veces necesitas control fino sobre la petición que una action genera: añadir una cabecera de autorización, o usar sendBeacon para registrar un evento en el instante en que el usuario abandona la página, cuando un fetch normal no llegaría a completarse. getActionPath te da la ruta interna de una action para que la invoques tú con las opciones que quieras, sin renunciar a que la lógica siga viviendo en el handler tipado.

import { actions, getActionPath } from 'astro:actions';

navigator.sendBeacon(
  getActionPath(actions.registrarSalida),
  new Blob([JSON.stringify({ id })], { type: 'application/json' }),
);

Es una vía de escape, no el camino de todos los días: mientras actions.miAccion te baste, úsala; cuando necesites tocar la petición por debajo, getActionPath te devuelve las riendas sin sacarte del sistema tipado.

El type-safety no es seguridad: la muralla sigue siendo el handler

La trampa más peligrosa de las actions es psicológica: como el canal es tipado y la llamada parece una función local, es fácil bajar la guardia y tratar al cliente como si fuera de casa. No lo es, y ninguna cantidad de tipos lo vuelve de fiar. El type-safety es una propiedad del tiempo de compilación de tu código: garantiza que tú, el programador, no mezclas un número con una cadena, que no olvidas un campo, que el editor te avisa si rompes el contrato. Pero un atacante no compila tu código: abre las herramientas del navegador, ve la petición que la action genera y fabrica la suya a mano, con la forma exacta que tu input espera y los valores que le convienen. Para el servidor, esa petición forjada es indistinguible de una legítima, porque tiene la forma correcta. Ahí se revela la verdad incómoda: la validación de forma y la seguridad son planos ortogonales. El input responde a “¿tiene esto la pinta de una petición válida?”; la seguridad responde a “¿tiene derecho quien la envía a que la atienda?”, y la segunda pregunta el input no la puede ni rozar. Toda la autorización real —comprobar la sesión, verificar la propiedad del recurso, aplicar límites de uso, ocultar el detalle de los fallos— vive en el handler, en el servidor, en el único lugar que el cliente no controla. Esta es una ley general de los sistemas distribuidos que las actions, por lo cómodas que son, tientan a olvidar: nunca confíes en el cliente, valida siempre en el servidor, y trata cada dato que cruza la red como potencialmente hostil aunque tu propio código lo haya enviado. La ergonomía de una action colapsa la distancia entre las dos máquinas para escribir el programa; la seguridad exige recordar, en cada handler, que esa distancia sigue ahí y que del otro lado puede haber cualquiera. Dominar las actions no es saber lanzar un ActionError con el código bonito: es no dejar que la comodidad del puente te haga olvidar que sigue siendo una frontera.

⚔️ Blinda y pule una mutación
  1. Escribe una action borrarTarea que lance ActionError con UNAUTHORIZED si no hay usuario, NOT_FOUND si la tarea no existe y FORBIDDEN si el usuario no es su dueño.
  2. En el cliente, ramifica con isActionError y el code para llevar al login, avisar del choque o explicar la ausencia según el caso.
  3. Convierte la action en una mutación que devuelve el nuevo estado y repinta la lista con lo que retorna, sin volver a pedir los datos.
  4. Monta una isla de UI optimista para un botón de “me gusta” que actualiza al instante y revierte al estado previo si el resultado trae error.