# 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: <control-plane\|sqlite>`. 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.
