El código de la consola¶
Arquitectura es el backend. Esta es la otra mitad: la
aplicación Next.js en frontend/, para quien está a punto de tocarla.
Stack. Next.js 15 (App Router) · React 19 · TypeScript strict · Tailwind ·
next-intl · TanStack Query · Zustand · vitest y Testing Library ·
Playwright. Gestor de paquetes y runner: bun.
Dónde vive cada cosa¶
| Ruta | |
|---|---|
src/app/[locale]/(dashboard)/… |
El producto. Las rutas llevan prefijo de locale |
src/app/api/… |
Route handlers que hacen de proxy hacia el backend |
src/lib/ |
Clientes de API tipados, query-keys.ts y los registries de más abajo |
src/hooks/ |
Uno por recurso — use-agents, use-permissions, … |
src/stores/ |
Zustand, uno por asunto |
src/components/<domain>/ |
La UI por dominio; los primitivos en ui/, los estados vacíos y de error en states/ |
Los Server Components son lo predeterminado. "use client" es para state,
effects y handlers, no por costumbre.
El navegador nunca llama al backend¶
Cada petición va a /api/* de esta app, que la reenvía a FastAPI con el access
token tomado de una cookie HttpOnly. Eso es lo que mantiene el token fuera
del JavaScript y la URL del backend fuera del bundle del cliente.
Lo hace un único reenviador — src/lib/platform-proxy.ts — en lugar de un
archivo de ruta hecho a mano por endpoint que repite las mismas doce líneas.
Una respuesta sin Cache-Control no es una respuesta que nadie cachee
El proxy estampa no-store en todo lo que el backend dejó sin marcar. Cada
respuesta de aquí depende de una cookie, de un conjunto de permisos y de la
cabecera de organización, y una lista que se vuelve a pedir tras una
escritura tiene que llegar al servidor.
Un archivo de ruta hecho a mano debe la misma cabecera — el proxy es el único sitio que la aplica por ti.
Los datos, y dónde vive el state¶
Todo acceso a la API pasa por un cliente de src/lib/ y se consume a través
de un hook. Nada de fetch en un componente.
Los datos del servidor viven en la capa de queries; los stores guardan solo
state de UI y efímero. Registra cada query key en query-keys.ts para que la
invalidación tras una escritura se mantenga consistente.
Los permisos son una decisión de renderizado¶
use-permissions.ts da el conjunto de permisos efectivo para la organización
activa. Un control que quien llama no puede usar no se renderiza — no se
renderiza, en vez de renderizarse y devolver 403.
Dos trampas, y las dos ya han llegado a producción aquí:
- Qué roles ofrece un selector es aritmética, no una lista.
assignableRolesrefleja la regla del servidor sobre el catálogo de permisos: un rol solo se ofrece cuando el de quien llama lo supera estrictamente. Un "todos los roles menos owner" cableado a mano es lo que le ofreció a un Admin la opción Admin y devolvió 403 una vez tecleado el correo. - Una página que nombra una organización en su URL es esa organización. El
cliente de API estampa
X-Organization-Iddesde la organización activa, así que una página que actúa sobre la organización de su ruta mientras lee los permisos de la activa decide sobre los miembros de Acme según tu rol en Globex. La adopción vive enActiveOrgGuard, una sola vez.
Cada string visible para el usuario pasa por next-intl¶
make lint lo impone en las dos direcciones: un string legible metido en un
componente falla, y también falla una clave que el catálogo guarda y que ningún
componente lee.
Tres reglas con las que la gente tropieza:
- Un recuento es un
pluralde ICU, nunca un ternario.{n} file{n === 1 ? "" : "s"}es una frase que solo el inglés construye así. - Un sustantivo con el que concuerda la frase no es un parámetro.
{matched} of {total} {noun}renderiza3 of 40 skillsen polaco. El sustantivo va dentro delpluralo delselect. - El catálogo guarda copy, y solo copy. Un falso positivo se responde con un
i18n-exempty su motivo; nunca con una clave. Responder a uno moviendo una lista de clases de Tailwind aen.jsones la forma en que traducir un string una vez dejó a un componente sin sus estilos.
El inglés es la lengua de origen y se fusiona bajo cada locale, así que una traducción que falta renderiza inglés en lugar de la clave.
Los nombres propios del producto siguen en inglés en cada locale
agent, spec, capability, skill, embed, budget, run, prompt, provider, token, vault, workspace, sandbox, MCP. Nombran cosas que un cliente también encuentra en la documentación, en la API y en el YAML exportado — traducirlos en la UI y en ningún otro sitio crea dos vocabularios para un mismo producto. Flexiónalos, no los sustituyas.
Cuatro registries, y ninguna segunda fuente¶
Cada uno de ellos es una tabla que leen varias partes de la UI. Añadir a la tabla es el cambio entero; añadir una segunda fuente es el bug.
| Guarda | Se añade con | |
|---|---|---|
lib/tool-catalog.ts |
El icono, el rótulo mientras corre, el nombre al terminar y el renderizador de cada tool que registra el backend | Una fila indexada por el id de tool de la capability. Un test de backend compara las dos en ambas direcciones |
lib/brand-glyphs.generated.ts |
Cada marca de servicio, de connector y de provider, como datos de path en bruto | Una fila en scripts/gen-brand-icons.ts, y después bun run gen:brand-icons. Nunca un import de un paquete de iconos |
lib/dashboard/registry.ts |
Los widgets del dashboard y el permiso al que está sujeto cada uno | Cinco ediciones, enumeradas más abajo en la página |
lib/dialog-sizes.ts |
Un token de anchura y un token de forma por diálogo | Eligiendo un token, nunca una altura a medida |
Dos cosas que debe una superficie nueva¶
Las dos son registries con el mismo modo de fallo: una página añadida en cualquier otro sitio simplemente no está, nada falla, y la funcionalidad se publica invisible.
Una parada del recorrido. lib/onboarding/tour.ts es el recorrido pasivo
que reproduce el "?" de una página, y flows.ts es la creación guiada que
ofrece al final. Una página sin parada no renderiza ningún "?" — así que una
página nueva cuya cabecera no tiene botón de ayuda no se ha registrado. Sujeta
el paso al permiso que lleva su control, márcalo optional cuando el control
necesita que existan datos, y áncralo en algo acotado.
Un widget del dashboard, si la funcionalidad produce un estado que alguien
querría ver de un vistazo. Cinco ediciones: el id y la definición en
dashboard/registry.ts, el componente en components/dashboard/widgets/, una
colocación en layouts.ts, el id replicado en
backend/app/schemas/dashboard_layout.py — un test falla cuando esos dos se
desvían — y el copy tanto en en.json como en pl.json.
Verificar¶
Desde frontend/. En la raíz del repositorio vitest no encuentra configuración,
informa de unos 164 fallos fantasma y deja un directorio de caché perdido.
Una vez, antes de hacer push — desde la raíz del repositorio:
test:coverage, no test:run
El job que ejecuta CI mide la cobertura y falla por debajo del 100 % de líneas, sentencias y funciones, o del 97,5 % de ramas. Una suite en la que pasan todos los tests puede estar roja igualmente, y lo ha estado.
Borrar una rama muerta es más fácil que cubrirla: un ?? "" detrás de una
comprobación que ya demostró el valor es de los que el gate hace bien en
señalar.
Un spec que agota su tiempo suele ser la máquina. testTimeout es 15 s y
asyncUtilTimeout 5 s, los dos medidos y no adivinados. Ninguno de los dos es
razón para conservar un spec que monta más de lo que leen sus assertions.
Recapitulación¶
- El navegador habla con
/api/*de esta app, nunca con FastAPI — un único reenviador, y estampano-store. - Los datos del servidor viven en la capa de queries; los stores guardan solo state de UI.
- Un control que quien llama no puede usar no se renderiza, y los selectores de rol son calculados, no listados.
- El copy pasa por
next-intl, los recuentos son plurales de ICU, y el catálogo guarda copy y solo copy. - Una superficie nueva debe una parada del recorrido, y un widget si tiene estado legible de un vistazo — las dos son registries silenciosos.