# 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 `<>/join?token=XXX` donde `<>` 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 --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.