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.
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.
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.
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.
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.