Files
message_bus/reports/0018-2026-06-07-unibus-user-accounts.md
T
egutierrez d43ffae3ae chore: auto-commit (17 archivos)
- 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>
2026-06-08 01:57:00 +02:00

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) + 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.ErrNoRowsErrNotFound 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:

RequestPOST /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. 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/invitesclient.CreateInvite; GET /api/invitesclient.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.