- reports/0001-2026-06-07-unibus-grafana-monitoring.md - reports/0008-2026-06-07-unibus-admin-users-wired.md - reports/0008-2026-06-07-unibus-decentralization-audit.md - reports/0009-2026-06-07-unibus-cluster-hardening.md - reports/0010-2026-06-07-unibus-android-native.md - reports/0011-2026-06-07-unibus-cluster-deploy.md - reports/0012-2026-06-07-unibus-deploy-gaps-closed.md - reports/0013-2026-06-07-unibus-admin-panel.md - reports/0014-2026-06-07-unibus-users-http-admin-api.md - reports/0015-2026-06-07-unibus-web-wired.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
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) + panelunibus_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→ ramaquick/user-accounts(6 commits, pusheada). Bump 0.11.0 → 0.12.0. - Panel
unibus_admin→ ramaquick/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 bytescrypto/randen hex (64 chars); TTL default 7 días sittlSecs <= 0; role admin|member (vacío → member).GetInvite(token),ListInvites().ConsumeInvite(token, signPub, kexPub) error— valida (existe, no usado, no caducado) → registra elsignPubcon el handle/role del invite → marca usado. Single-use atómico: SQLite usa una transacción guardada porused = 0; KV usa un compare-and-swap sobre la revisión de la entry (mark-first). Burn-on-claim idéntico en ambos: si elsignPubya está registrado, devuelveErrUserExistscon 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 →/registersin 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:
{ "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.
handleyroleNO 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 (
--dbsingle-node). - Base URL del enlace: el gateway construye
<<APP_BASE>>/join?token=XXXdonde<<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.exampleo la envUNIBUS_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; SPApnpm buildOK (tsc + vite).
Gaps / pendientes
- Rollout del cluster a 0.12.0 (orquestador). El cluster vivo está en 0.11.0;
/invites,/registeryDELETE /usersno 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 -> 401en 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, porquego.moddel panel apunta a../unibusy consume los métodos nuevos del cliente. kex_pubvalidado pero no persistido en el allowlist./registervalida elkex_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 solosign_pub(igual que antes de este cambio). No hay un directorio global dekex_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/registerlisto y el contrato documentado arriba;web/no se tocó. - Cancelar invite desde el panel. El bus expone
DELETE /invites/{token}yclient.CancelInvite, pero la UI del panel solo muestra/copia invites pendientes (no los cancela). Fácil de añadir; fuera del scope pedido. go.worklocal. El worktree del bus en/tmpusa ungo.work(gitignored) conreplace fn-registry => /home/enmanuel/fn_registrypara 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.