# Report 0018 — unibus: cuentas estilo WhatsApp (alta por invitación + baja por hard-delete)

- **Fecha:** 07/06/2026
- **Autor:** Claude (Opus 4.8)
- **Ámbito:** bus `unibus` (`pkg/membership`, `pkg/client`) + panel `unibus_admin` (gateway + SPA)
- **Estado:** done (código + tests + verificación e2e local). Gap de despliegue: el cluster vivo sigue en 0.11.0; el rollout a 0.12.0 lo hace el orquestador.

## Resumen

Capa de CUENTAS sobre el modelo wallet: la clave privada de cada usuario nace y vive en SU dispositivo; el servidor solo guarda la pública. El admin **crea** usuarios generando un **enlace de invitación** de un solo uso (nunca ve la privada) y **elimina** usuarios con **hard-delete** real (purga del allowlist), manteniendo `revoke` como acción distinta (status flip auditable). El usuario abre el enlace en su cliente, genera ahí su par de claves, y se registra con `POST /register` (autorizado por el token, no por firma admin).

Dos repos tocados, ambos en ramas (NINGUNO mergeado a master):
- Bus `unibus` → rama `quick/user-accounts` (6 commits, pusheada). Bump 0.11.0 → **0.12.0**.
- Panel `unibus_admin` → rama `quick/user-accounts-ui` (3 commits, pusheada). Bump 0.1.0 → **0.2.0**.

## Parte 1 — Bus (`quick/user-accounts`)

### Hard-delete
`Store.DeleteUser(signPub) error` en ambos backends: SQLite `DELETE FROM users` y jetstreamStore `users.Delete(signPubHex)` (con `Get` previo para devolver `ErrNotFound` en un miss, ya que KV `Delete` es idempotente). Borra SOLO el allowlist; las membresías de rooms del ex-usuario quedan **inertes** (ya no puede autenticarse en ningún plano). NO se hace limpieza parcial de membresías — un owner expulsa/rekey su room para forward secrecy. `revoke` se mantiene intacto.

Fix de consistencia colateral: el `GetUser` de SQLite ahora mapea `sql.ErrNoRows` → `ErrNotFound` como el KV y como documenta `store.go` ("every lookup miss returns ErrNotFound"). Antes devolvía el error crudo del driver.

### Invites (nuevo backend de datos, ambos stores)
Tipo `Invite{Token, Handle, Role, ExpiresAt, Used, CreatedAt}` + campos de auditoría del consumo (`UsedAt/UsedSignPub/UsedKexPub`, vacíos hasta consumir). Métodos en `Store`:
- `CreateInvite(handle, role, ttlSecs) (Invite, error)` — token = 32 bytes `crypto/rand` en hex (64 chars); TTL default **7 días** si `ttlSecs <= 0`; role admin|member (vacío → member).
- `GetInvite(token)`, `ListInvites()`.
- `ConsumeInvite(token, signPub, kexPub) error` — valida (existe, no usado, no caducado) → registra el `signPub` con el handle/role del invite → marca usado. **Single-use atómico**: SQLite usa una transacción guardada por `used = 0`; KV usa un compare-and-swap sobre la revisión de la entry (mark-first). Burn-on-claim idéntico en ambos: si el `signPub` ya está registrado, devuelve `ErrUserExists` con el invite YA gastado.
- `CancelInvite(token)` — hard-delete del invite pendiente (para la futura cancelación admin).

Persistencia: migración aditiva numerada `003_invites.sql` (raíz + copia embebida byte-idéntica) con tabla `invites` (+ índice `idx_invites_used`); bucket KV nuevo `UNIBUS_invites`. Nada borrado/recreado.

### Endpoints HTTP (`pkg/membership/server.go`)
| Método/ruta | Auth | Qué hace |
|---|---|---|
| `POST /invites` | admin-only (`requireAdmin`) | body `{handle, role, ttl_secs?}` → `{token, expires_at}` |
| `GET /invites` | admin-only | lista de invites **pendientes** (filtra usados/caducados) |
| `DELETE /invites/{token}` | admin-only | cancela un invite |
| `DELETE /users/{signpub}` | admin-only | hard-delete (purga) |
| `POST /register` | **token** (sin firma admin) | redime un invite (ver contrato abajo) |

`/register` es la única ruta mutante exenta de firma admin. Se separó `isRateExempt` (solo `/healthz`) de `isAuthExempt` (`/healthz` + `POST /register`): `/register` **salta la firma admin pero sigue sujeto al rate-limit por IP** del control plane (20/s, burst 40). Validación estricta de las dos claves hex (`ValidateSignPubHex` + nuevo `ValidateKexPubHex`, 32 bytes / 64 hex) ANTES de gastar el token; handle y role los fija el invite (sin escalado).

### `pkg/client`
`CreateInvite/ListInvites/CancelInvite/DeleteUser` firmados como admin; `Register(token, signPub, kexPub)` SIN firma vía un nuevo helper `doUnsigned` (failover entre control planes + surfacing del error estructurado del server).

### Tests (todos verdes, `CGO_ENABLED=0`)
- `pkg/membership/invites_test.go` — suite store-level sobre **ambos** backends (golden redeem, single-use, token desconocido, caducado [forzado], cancel, hard-delete) + burn-on-claim.
- `pkg/membership/invites_http_test.go` — HTTP: crear invite admin → `/register` sin auth 201 → aparece en `/users` → re-register 409 → caducado 410 → keys malformadas 400 → no-admin 403 en las 4 rutas admin → hard-delete purga (vs revoke).
- `pkg/client/invites_test.go` — e2e cliente: admin acuña invite → joiner NO registrado redime sin firma → aparece → hard-delete desaparece.

## CONTRATO de `POST /register` (para el agente del cliente `/join`)

La página `/join?token=` del cliente web consume este endpoint. Contrato exacto:

**Request** — `POST /register`, `Content-Type: application/json`, **sin** cabeceras de firma:
```json
{ "token": "<64-hex>", "sign_pub": "<64-hex Ed25519>", "kex_pub": "<64-hex X25519>" }
```
- `token`: el de `?token=` del enlace.
- `sign_pub`: clave pública Ed25519 generada localmente (identidad / firmas).
- `kex_pub`: clave pública X25519 generada localmente (key-exchange / sellado de room keys).
- El cliente genera el par localmente; la privada NUNCA se envía. `handle` y `role` NO van en el body (los fija el invite).

**Respuestas:**
| Código | Cuerpo | Significado |
|---|---|---|
| `201` | `{"status":"registered"}` | alta correcta; la identidad ya puede conectarse al bus |
| `400` | `{"error":"token required"}` / `{"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars)…"}` / `…kex-pub…` | body o claves malformadas |
| `404` | `{"error":"invalid or unknown invite token"}` | token inexistente |
| `409` | `{"error":"invite already used"}` | token ya consumido (single-use) |
| `409` | `{"error":"identity already registered"}` | el `sign_pub` ya está en el allowlist |
| `410` | `{"error":"invite expired"}` | invite caducado |
| `429` | `{"error":"rate limit exceeded"}` | rate-limit por IP |

Notas para el cliente: el endpoint es público pero rate-limited; un token es de un solo uso, así que tras `201` el cliente debe guardar su identidad localmente (cifrada con la contraseña que elija el usuario) — un reintento con el mismo token dará `409`.

## Parte 2 — Panel `unibus_admin` (`quick/user-accounts-ui`)

### Gateway
- `POST /api/invites` → `client.CreateInvite`; `GET /api/invites` → `client.ListInvites`; `DELETE /api/users/{pub}` → `client.DeleteUser`. (list/add/revoke existentes intactos.)
- Doble vía como el resto de users: plano de control firmado (cluster) / store directo (`--db` single-node).
- **Base URL del enlace**: el gateway construye `<<APP_BASE>>/join?token=XXX` donde `<<APP_BASE>>` es la URL del **cliente final** (la app que hospeda `/join`, NO el panel). Se configura con el flag `--join-base-url https://chat.unibus.example` o la env `UNIBUS_JOIN_BASE_URL`; se expone en `/api/me` (`join_base_url`). Si no se configura, la SPA usa su propio origen como respaldo y avisa.

### SPA (Mantine, dark, índigo)
- Botón **"Crear usuario"** → modal (handle + rol + caducidad en días) → `POST /api/invites` → muestra el **enlace copiable** + alerta de caducidad + aviso si el base URL no está configurado.
- Card **"Invitaciones pendientes"**: handle, rol, token parcial, caducidad, copiar enlace.
- Botón **"Eliminar"** por fila → modal de **confirmación FUERTE** que exige teclear el handle y advierte en rojo "BORRADO PERMANENTE" (distinto del revoke, que sigue siendo un `window.confirm`) → `DELETE /api/users/{pub}`.
- Se conserva "Añadir user (clave conocida)" para el caso avanzado.

### Nota de build (revertida)
Para compilar el panel contra los métodos nuevos del cliente mientras `quick/user-accounts` no está en master, se apuntó temporalmente `go.mod` del panel: `replace github.com/enmanuel/unibus => /tmp/unibus_useraccounts`. **Revertido a `../unibus`** tras verificar (confirmado en el commit). Consecuencia: el panel en `../unibus` no compilará hasta que el bus llegue a master; por eso la rama del panel se mergea DESPUÉS del bus.

## Verificación (evidencia ejecutable)

### A) Contrato 0.12.0 contra un `membershipd` local (enforce, admin sembrado)
```
$ membershipd user add --db … --handle operator --sign-pub <hex> --role admin
added user "operator" (0ce29047…) role=admin
# membershipd --bus-auth enforce → /healthz: {"posture":{"enforce":true,"acl":true,…},"status":"ok"}

# admin crea invite (firmado) → token 39503d27ae93…
# POST /register con curl (SIN firma):
register -> HTTP 201   body: {"status":"registered"}
# dora aparece en /users:  {"Handle":"operator",…}  {"Handle":"dora",…}   (2 filas)
# re-register MISMO token:
re-register -> HTTP 409   body: {"error":"invite already used"}
# hard-delete de dora → deleted ee64614216ae…
# /users tras delete:  {"Handle":"operator","Role":"admin","Status":"active"}   (1 fila — dora purgada)

# errores de /register:
POST /register token inexistente -> {"error":"invalid or unknown invite token"} (HTTP 404)
POST /register sign_pub "abcd"   -> {"error":"sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got 2 bytes"} (HTTP 400)
```

### B) Gateway del panel (`--mock`)
```
GET  /api/me           -> {"users_backend":"sqlite","mock":true,"join_base_url":"https://chat.unibus.example"}
POST /api/invites      -> {"handle":"ana","role":"member","token":"9455207f9098","join_url":"https://chat.unibus.example/join?token=9455207f9098…"}
GET  /api/invites      -> [{"handle":"ana","role":"member","join_url":"https://chat.unibus.example/join?token=…"}]
GET  /api/users (antes) -> ["operator","ana","lucas","leo-revoked"]
DELETE /api/users/{pub} -> {"status":"deleted"} HTTP 200
GET  /api/users (después)-> ["operator","lucas","leo-revoked"]   (ana purgada)
GET  /  -> HTTP 200 (SPA index)   GET /join -> HTTP 200 (fallback SPA)
```

### C) Cluster vivo (sin tocar usuarios reales)
```
# Salud (vía SSH, loopback TLS, CA pinada):
magnus   {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
homer    {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}
datardos {"posture":{"enforce":true,"acl":true,"tls":true,"cluster":true,"store":"kv"},"status":"ok"}

# Confirmación de que /register NO está desplegado aún (rollout 0.12.0 necesario):
cluster magnus (0.11.0):  POST /register -> HTTP 401  (no es ruta pública; pasa por auth y rechaza)
nodo local   (0.12.0):    POST /register -> HTTP 400  (ruta pública activa → token required)
```

### Build/test
- Bus (`/tmp/unibus_useraccounts`): `CGO_ENABLED=0 go build ./...` OK, `go vet ./pkg/...` OK, `go test ./...` OK (todos los paquetes verdes, incluida la suite nueva).
- Panel (contra el worktree, antes de revertir el replace): `CGO_ENABLED=0 go build ./...` OK, `go vet ./...` OK; SPA `pnpm build` OK (tsc + vite).

## Gaps / pendientes

- **Rollout del cluster a 0.12.0 (orquestador).** El cluster vivo está en 0.11.0; `/invites`, `/register` y `DELETE /users` no existen allí todavía. La verificación e2e de los endpoints NUEVOS contra el cluster vivo queda pendiente del rollout (verificado contra un nodo local 0.12.0 en su lugar). Confirmado con `/register -> 401` en los 3 nodos.
- **Orden de merge.** El panel (`quick/user-accounts-ui`) debe mergearse a master DESPUÉS de que el bus (`quick/user-accounts`) llegue a master, porque `go.mod` del panel apunta a `../unibus` y consume los métodos nuevos del cliente.
- **`kex_pub` validado pero no persistido en el allowlist.** `/register` valida el `kex_pub` (lo necesita el cliente para E2E) y lo guarda como dato de auditoría del consumo del invite (`used_kex_pub`), pero el allowlist de users sigue guardando solo `sign_pub` (igual que antes de este cambio). No hay un directorio global de `kex_pub`; sigue poblándose por-room al invitar a un room (sin cambio). No es regresión.
- **El cliente `/join` (web/) lo implementa otro agente.** Este trabajo deja `/register` listo y el contrato documentado arriba; `web/` no se tocó.
- **Cancelar invite desde el panel.** El bus expone `DELETE /invites/{token}` y `client.CancelInvite`, pero la UI del panel solo muestra/copia invites pendientes (no los cancela). Fácil de añadir; fuera del scope pedido.
- **`go.work` local.** El worktree del bus en `/tmp` usa un `go.work` (gitignored) con `replace fn-registry => /home/enmanuel/fn_registry` para resolver el registry desde `/tmp`. No commiteado.
- **Migración de invites en el snapshot SQLite→KV.** `importSnapshot/ExportSnapshot` (migración 0003c) no incluye invites; son efímeros (7 días) y de bajo valor, no se migran. Documentado, no bloqueante.
