# Report 0008 — unibus_admin: pestaña Users cableada a la API del plano de control - **Fecha:** 07/06/2026 - **Autor:** agente (Claude Opus 4.8) - **Ámbito:** `projects/message_bus/apps/unibus_admin` (gateway Go + SPA Mantine), sub-repo `dataforge/unibus_admin`, rama `master` - **Estado:** done (código + verificación E2E del código) — con un gap de **despliegue** del bus documentado abajo ## Resumen La pestaña Users del panel de administración de unibus estaba degradada en cluster porque el gateway sólo sabía gestionar la allowlist abriendo el `membership.Store` directo con `--db`, y caía a `users_backend=none` (estado informativo) cuando no había acceso directo al store. El control plane de unibus ahora expone una API HTTP admin-only de users, y `pkg/client` la envuelve con `ListUsers/AddUser/RevokeUser` firmados. Este trabajo cablea el gateway a esos métodos (camino principal: API HTTP firmada, funciona en cluster), deja la pestaña Users operativa (sin el estado "degradado"), y verifica la cadena completa list → add → revoke (con idempotencia 409) end-to-end contra un `membershipd` master real. `--db` queda como fallback single-node explícito. ## Cambios | Archivo | Cambio | |---|---| | `internal/admin/repo.go` | Eliminado `ErrUsersUnavailable` y el camino "none"; comentarios del interface `Repo` y de `MeInfo.UsersBackend` actualizados (`"control-plane"` \| `"sqlite"`). | | `internal/admin/repo_bus.go` | `ListUsers/AddUser/RevokeUser` usan `r.cli.*` (API firmada) cuando no hay store directo; con `--db` siguen usando el store. `UsersWritable()` → `true` (el gateway conectado siempre puede; el control real es `requireAdmin` en runtime). Backend por defecto `"control-plane"`, `"sqlite"` con `--db`. | | `internal/admin/server.go` | Handlers `/api/users` simplificados (cualquier error del control plane → 502 con su mensaje, que ya trae el código embebido, p.ej. `(HTTP 409)`); quitado el manejo de `ErrUsersUnavailable` y el import `errors`. | | `main.go` | `--db` documentado como opcional (fallback single-node); por defecto el backend es `control-plane`. Logs de arranque acordes. | | `web/src/pages/UsersPage.tsx` | Quitado el `Alert` "Gestión de users no disponible" y el gating `writable` (botón Add deshabilitado / revoke oculto). El badge muestra `backend: `. La tabla (handle/rol/estado/sign_pub/creado), el modal Add (handle + sign-pub hex64 + rol) y revoke con confirmación se mantienen. | | `web/dist/*` | Bundle SPA recompilado (embebido por el binario). | | `app.md` | Diagrama, tabla de capacidades, ejemplos de arranque y gaps actualizados al cableado control-plane + el gap de despliegue del bus. | El gateway **reutiliza** los métodos del cliente del bus (`pkg/client`), no reimplementa firma ni HTTP. No se tocó el repo `unibus` (sólo se importa por `replace => ../unibus`). ## Verificación ### Mecánica (verde) ``` go vet ./... → VET_EXIT:0 go build -o unibus_admin . → BUILD_EXIT:0 (19 MB, embebe la SPA) cd web && pnpm build → tsc -b + vite build OK (sin errores de tipo) ``` ### Cobertura E2E del código (verde) — contra `membershipd` master real El cluster de producción aún no expone la ruta `/users` (ver Gaps), así que la cadena del panel se verificó contra un `membershipd` compilado de master (con la ruta) arrancado localmente (sqlite, `--bus-auth soft`), con el operador sembrado como admin (`membershipd user add --role admin`). El gateway firma como el operador; el bus verifica con `requireAdmin`. Secuencia ejecutada **por la API del panel** (`/api/users`): | Paso | Comando (gateway en :18481) | Resultado | |---|---|---| | list (antes) | `GET /api/users` | `[operator(admin,active)]` | | add | `POST /api/users {gapcheck_admin_ui, member}` | `201 {"status":"added"}` | | list | `GET /api/users` | `gapcheck_admin_ui` **active/member** + operator | | re-alta (idempotencia) | `POST /api/users` (mismo sign_pub) | `409` `user already registered (unchanged); revoke it first` | | revoke | `POST /api/users/revoke {sign_pub}` | `200 {"status":"revoked"}` | | list (final) | `GET /api/users` | `gapcheck_admin_ui` **revoked** + `revoked_at`; operator intacto | `sign_pub` de prueba: `1cb658d9d3f23e6bf5791a3865a1eec731f3df30ad4b85f6fca0074b8bdc98a4` (aleatorio, role member). Limpieza: revocado al final (revoke es la limpieza; no hay hard delete). El operador admin y la DB de prueba vivían en `/tmp` efímero, ya borrados. **No se tocó la allowlist del cluster de producción.** ### SPA (verde) — verificada visualmente (browser MCP) Panel servido en `:18481`, pestaña Users: - Badge `backend: control-plane` (sin Alert degradado), botón "Añadir user" **activo**. - Tabla con `gapcheck_admin_ui` MEMBER/REVOKED (sin botón revoke por estar revocado) y `operator` ADMIN/ACTIVE (con botón revoke). - Modal "Añadir user al bus" renderiza Handle + `sign_pub (hex, 64)` + Rol(member) + Cancelar/Añadir. ### Conectividad contra el cluster vivo (verde, salvo la ruta ausente) - Gateway local apuntado a homer (`141.94.69.66:8470`) + datardos (`51.91.100.142:8470`), TLS+nkey con la CA del bus, identidad operator desde `pass`: **conecta y firma** (`/api/me` devuelve el endpoint real del operador `vI8HXcintzK-…`). `GET /api/users` → `404 page not found` desde el control plane (ruta ausente en esos binarios). - Panel efímero arrancado **en magnus** (puerto 18482, sin tocar el `unibus-admin.service`) contra el control plane loopback de magnus (KV, enforce, TLS): `/api/me` OK (pasa el `enforce` de magnus firmando como operador), `GET /api/users` → `404`. El binario efímero y su log se borraron; el service real quedó `active`. ## Estado del deploy en magnus **No se re-desplegó el service.** El panel `unibus-admin.service` sigue `active` en magnus (healthz 200). Build linux/amd64 del binario nuevo: **listo** (probado en magnus en puerto efímero). El re-deploy queda **bloqueado por una dependencia**: re-desplegar ahora cambiaría la pestaña Users de un Alert informativo a un error `502 (GET /users → 404)`, porque el `membershipd` de magnus aún no expone la ruta. Procedimiento para cuando el bus esté en v0.10.0 (aditivo, sin tocar otros sites del Caddyfile): ```bash cd web && pnpm install && pnpm build && cd .. CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o unibus_admin . scp unibus_admin magnus:/opt/unibus_admin/unibus_admin ssh magnus 'systemctl restart unibus-admin.service' # el unit ya arranca sin --db (backend control-plane) ssh magnus 'curl -fsS http://127.0.0.1:8480/healthz' # + comprobar la pestaña Users vía el subdominio Caddy ``` El `deploy/unibus-admin.service` no necesita cambios para el camino control-plane (no usa `--db`). Verificar tras el restart que `ExecStart` no incluye `--db` (si lo incluyera, quitarlo para usar la API del plano de control). ## Gaps / pendientes 1. **Cluster sin la ruta `/users` (despliegue).** Los `membershipd` desplegados (magnus/homer/datardos) son anteriores al merge de la API de users y devuelven 404. La verificación E2E del panel contra el cluster vivo **de producción** no es posible hasta actualizar el bus a v0.10.0. La cadena está verificada contra un `membershipd` master real (mismo código que correrá en el cluster), y el gateway ya conecta y firma bien contra los nodos reales. Acción fuera de este aislamiento (toca el repo `unibus`/su deploy): actualizar los binarios del cluster. 2. **Mapeo de código de estado.** Un `409` (re-alta idempotente) del control plane llega a la SPA como `502` con el mensaje del bus (que incluye `(HTTP 409)`). La SPA muestra el mensaje íntegro, así que la UX es correcta y accionable; no se parseó el string de error para propagar el código exacto (se evitó un parser frágil — KISS). Mejora menor opcional si `pkg/client` expone el status estructuradamente. 3. **Replicación `followers 2/2`.** No se inspeccionó la replicación KV cross-node porque la ruta `/users` no está viva en el cluster; el panel no expone métricas de JetStream (varz/jsz) — gap ya conocido de la pestaña Cluster.