# unibus — API HTTP admin-only de gestión de usuarios

- Slug: `unibus-users-http-admin-api`
- Fecha: 2026-06-07
- Rama: `quick/users-http-admin` (basada en `quick/0011-deploy-gaps`)
- Worktree: `/tmp/unibus_usersapi`
- Versión: 0.9.0 → 0.10.0

## Resumen

Se cierra la última asimetría del control plane de `unibus`. Antes, las rooms
tenían una superficie HTTP firmada (`POST /rooms`, `POST /rooms/{id}/invite`,
etc.) pero los usuarios solo se gestionaban por CLI local (`membershipd user
add|list|revoke`) o por acceso directo al store. Esto obligaba al panel de
administración a tener `--db` o acceso directo al KV del cluster, rompiendo el
modelo de "el panel habla firmado como un cliente más".

Ahora los usuarios tienen API HTTP admin-only, simétrica con rooms:

- `GET /users` — lista completa del allowlist (incluye revocados).
- `POST /users` — alta `{sign_pub, handle, role}`.
- `POST /users/{signpub}/revoke` — revocación (flip de status).

El server ejecuta las mutaciones contra el **mismo** store privilegiado que ya
usa para las rooms (`s.store`): SQLite en single-node, JetStream KV replicado
(`UNIBUS_users`) en cluster. No se abre ninguna conexión KV nueva ni se usa la
identidad interna; el storage no cambia de sitio. El panel firma como admin y
deja de necesitar `--db`/KV directo. Funciona idéntico single-node y cluster.

## Modelo

- **Storage unificado (sin cambios).** `pkg/membership/store.go` define UNA
  interface `Store` que abstrae rooms + members + sealed-keys + users, con dos
  backends elegidos por `--store`: `sqlite` (single-node) y `kv` (cluster,
  JetStream KV). El `Server` ya tiene el store privilegiado abierto y escribe ahí
  para todo. Este cambio solo expone `s.store.AddUser/ListUsers/RevokeUser` por
  HTTP; no altera dónde viven los datos.
- **HTTP admin-only y no acceso directo al KV.** El panel (gateway Go) habla
  firmado como admin al control plane, igual que un cliente normal habla para
  rooms. El server (que sirve el KV con permisos plenos en cada nodo) ejecuta la
  mutación. El panel no necesita `--db`, ni la identidad interna `internal.id`, ni
  correr en un nodo del cluster.
- **Bootstrap (huevo-gallina).** La CLI `membershipd user add --store kv` (de la
  rama base) sigue existiendo SOLO para sembrar el admin #0: sin un admin sembrado
  no hay quién firme el primer `POST /users`. A partir de ahí, toda la gestión es
  HTTP admin-only.

## Cambios por archivo

### `pkg/membership/server.go`
- El contexto de request ahora lleva, además del `endpoint` del firmante, su
  `sign_pub` hex. Motivo: `EndpointID(signPub) = base64url(sha256(signPub))` es
  one-way, así que el endpoint no se puede revertir a la clave para mirar al
  firmante en el allowlist. `withSigner(ctx, endpoint, pubHex)` + `signerPubHex(r)`.
- Helper `requireAdmin(w, r) (string, bool)`: default-deny. Devuelve `true` solo
  si hay firmante autenticado Y `s.store.GetUser(pubHex)` confirma `role == admin`
  y `status == active`; cualquier otro caso (sin firmante, no-admin, revocado,
  error de store) → 403, fail-closed. A diferencia de `requireMember`, NO relaja
  en AuthOff/dev: una operación que concede/revoca acceso nunca corre sin admin
  verificado.
- Tres handlers, todos tras `requireAdmin`, registrados en el mux:
  - `handleListUsers` → `s.store.ListUsers()` → `[]userJSON` (incluye status).
  - `handleAddUser` → valida `sign_pub` (hex 64 vía `ValidateSignPubHex`) y role
    (`admin|member`, vacío = member); `ErrUserExists` → 409 (no sobrescribe);
    éxito → 201.
  - `handleRevokeUser` → valida el path `{signpub}`; `s.store.RevokeUser(...)`;
    desconocido/ya-revocado → 404; éxito → 200.
- `/healthz` sigue exento de auth; el resto hereda firma+nonce+TLS+enforce.

### `pkg/membership/users.go`
- Nueva `ValidateSignPubHex(signPub) error` (hex de 64 chars = Ed25519 de 32
  bytes), single source of truth compartida por la CLI y los handlers.

### `cmd/membershipd/users_cli.go`
- `validateSignPubHex` delega ahora en `membership.ValidateSignPubHex` (se elimina
  el import `encoding/hex` que quedaba huérfano). Misma validación, una sola
  implementación.

### `pkg/client/client.go`
- `UserInfo` (tipo plano para el panel) + métodos firmados vía `doJSON`:
  - `ListUsers() ([]UserInfo, error)` → `GET /users`.
  - `AddUser(signPub, handle, role string) error` → `POST /users`.
  - `RevokeUser(signPub string) error` → `POST /users/{signpub}/revoke`.
- Mirrors de wire types `userJSON` / `addUserReq`.

## Contrato JSON de los endpoints

Todos requieren las cabeceras de firma de transporte ya existentes
(`X-Unibus-Pub/Ts/Nonce/Sig`, sobre `CanonicalRequest`) Y que el firmante sea un
admin activo. Errores en el envoltorio estándar `{"error": "..."}`.

### `GET /users`  (admin-only)
Respuesta `200`:
```json
[
  {"sign_pub":"<64-hex>","handle":"alice","role":"admin","status":"active","created_at":"2026-06-07T..."},
  {"sign_pub":"<64-hex>","handle":"carol","role":"member","status":"revoked","created_at":"...","revoked_at":"..."}
]
```
- `403` si el firmante no es admin activo.

### `POST /users`  (admin-only)
Body:
```json
{"sign_pub":"<64-hex>","handle":"carol","role":"member"}
```
- `role` opcional (vacío = `member`).
- `201` `{"status":"added"}` en éxito.
- `400` si `sign_pub`/`handle` vacíos, hex inválido, o role fuera de `{admin,member}`.
- `409` `{"error":"user already registered (unchanged); revoke it first to replace"}`
  si la clave ya está registrada (idéntica semántica que la CLI: no sobrescribe ni
  eleva rol).
- `403` si el firmante no es admin activo.

### `POST /users/{signpub}/revoke`  (admin-only)
- Sin body.
- `200` `{"status":"revoked"}` en éxito (flip de status, sin hard-delete: la
  identidad queda auditable; `IsAuthorized` la deniega en ambos planos al instante).
- `400` si `{signpub}` no es hex de 64 chars.
- `404` si no hay user activo con esa clave.
- `403` si el firmante no es admin activo.

## Authz admin (default-deny)

```
firma+nonce+TLS+enforce (middleware existente)  →  requireAdmin
                                                     │
   signerPubHex(r) ausente ───────────────────────► 403
   GetUser(pubHex) error / role!=admin / status!=active ► 403
   admin activo ──────────────────────────────────► handler
```

No se baja la posture: la firma anti-replay, el enforce, el TLS y la ACL por
subject quedan intactos. La autorización admin se suma encima, consultando el
store en cada request (un admin recién revocado es denegado de inmediato).

## Métodos del cliente (para el panel)

```go
type UserInfo struct {
    SignPub, Handle, Role, Status, CreatedAt, RevokedAt string
}

func (c *Client) ListUsers() ([]UserInfo, error)
func (c *Client) AddUser(signPub, handle, role string) error
func (c *Client) RevokeUser(signPub string) error
```

El panel (`unibus_admin`) construye un `client.Client` con la identidad del admin
(la misma que firma para rooms) y llama estos tres métodos. Un `403` del server se
propaga como `error`. La pestaña Users del panel deja de necesitar `--db` o acceso
KV directo.

## Evidencia ejecutable

Build sin CGO con `go.work` externo (el worktree vive en `/tmp`, donde el
`replace fn-registry => ../../../../` del go.mod no resuelve; no se commitea):

```
printf 'go 1.26.4\nuse /tmp/unibus_usersapi\nreplace fn-registry => /home/enmanuel/fn_registry\n' > /tmp/usersapi.work
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go vet ./...     # exit 0
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go build ./...   # exit 0
GOWORK=/tmp/usersapi.work CGO_ENABLED=0 go test ./pkg/membership/ ./pkg/client/ -count=1
#   ok  github.com/enmanuel/unibus/pkg/membership  8.399s
#   ok  github.com/enmanuel/unibus/pkg/client      6.166s
```

Tests nuevos (verbose):

```
=== RUN   TestUsersHTTP_NonAdminForbidden   --- PASS   (403 en GET/POST/revoke)
=== RUN   TestUsersHTTP_AdminRoundtrip      --- PASS   (add 201 → list activa → revoke 200 → list revocada)
=== RUN   TestUsersHTTP_Validation          --- PASS   (hex 400, role 400, re-alta 409, fila intacta)
=== RUN   TestClientUsersAdminAPI           --- PASS   (cliente admin add/list/revoke; member → 403 en los 3)
```

Cobertura de los casos pedidos:
- **403 no-admin** en los tres endpoints (`TestUsersHTTP_NonAdminForbidden`,
  y la mitad no-admin de `TestClientUsersAdminAPI`).
- **Roundtrip admin** add → list (aparece, activa) → revoke (status revocado)
  (`TestUsersHTTP_AdminRoundtrip`, `TestClientUsersAdminAPI`).
- **Validación**: hex inválido → 400, role inválido → 400, re-alta → 409, y
  verificación de que el 409 no muta la fila existente (`TestUsersHTTP_Validation`).
- **Test de cliente** contra un membershipd embebido bajo enforce
  (`TestClientUsersAdminAPI`).

## Gaps / notas para el integrador

- **Bootstrap sigue por CLL.** El admin #0 se siembra con `membershipd user add
  --store kv` (rama base). La API HTTP no puede crear el primer admin (no habría
  quién firme). Documentado en `app.md` (gotcha + growth log).
- **Revoke de admins.** Nada impide a un admin revocarse a sí mismo o al último
  admin. No se añadió un guard "no te quedes sin admins" (fuera de alcance de esta
  tarea; el flujo de bootstrap por CLI permite recuperarse). Candidato a endurecer
  si el panel lo necesita.
- **`revoke` mapea desconocido y ya-revocado a 404 indistintamente.** `RevokeUser`
  del store no distingue ambos casos; se mapea a 404 con mensaje genérico. Si el
  panel quiere idempotencia en revoke, conviene un cambio en el store (fuera de
  alcance).
- **El gotcha histórico "ni auth en las rutas GET" de `app.md`** describe la
  posture v1 y precede al enforce; no se tocó (es histórico, no de esta tarea).
- **Integración.** El operador mergea esta rama junto con `quick/0011-deploy-gaps`.
  No se mergeó a master desde aquí.
