From 7e2f62520d3e13a9486962c1c263c082abd2b35a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 22:14:44 +0200 Subject: [PATCH] =?UTF-8?q?docs(unibus):=20bump=200.12.0=20=E2=80=94=20acc?= =?UTF-8?q?ounts=20via=20single-use=20invites=20+=20hard-delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the wallet-model account layer: invite-link account creation and real hard-delete, both gotcha and capability growth log entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- app.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/app.md b/app.md index f131a986..24fe8b6d 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: unibus lang: go domain: infra -version: 0.11.0 +version: 0.12.0 description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo." tags: [service, messaging, nats, e2e] uses_functions: @@ -137,6 +137,26 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en firme el primer `POST /users`); a partir de ahí toda la gestión es HTTP admin-only. El alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable. +- **Cuentas estilo WhatsApp: alta por invitación, baja por hard-delete.** Sobre la API + admin anterior, `unibus` añade el modelo wallet de cuentas. El admin NO genera claves: + `POST /invites` (admin-only) acuña un enlace de invitación de un solo uso con caducidad + (token de 32 bytes `crypto/rand` en hex; TTL default 7 días), fijando `handle` y `role`. + El nuevo usuario abre el enlace en SU cliente, que genera el par de claves localmente + (la privada nunca sale del dispositivo) y llama `POST /register` con `{token, sign_pub, + kex_pub}`. `/register` es la ÚNICA ruta que añade al allowlist sin firma admin — + autorizada por el TOKEN, porque la identidad nueva aún no está en el allowlist y no puede + firmar. Está endurecida: token fuerte de un solo uso (consumo atómico, doble uso → 409), + caducidad (→ 410), `handle`/`role` fijados por el invite (sin escalado), validación + estricta de ambas claves hex de 64 chars, y rate-limit por IP heredado del control plane + (solo `/healthz` está exento). El borrado de cuenta es `DELETE /users/{signpub}` + (admin-only): hard-delete real del allowlist, distinto del `revoke` (que se mantiene: + revoke = quitar acceso dejando rastro auditable; delete = purga). Tras hard-delete, las + membresías de rooms del ex-usuario quedan inertes (ya no puede autenticarse en ningún + plano); NO se limpian a medias — un owner expulsa/rekey su room si quiere forward secrecy. + Invites y users viven en el MISMO store (SQLite `invites`/`users`, KV `UNIBUS_invites`/ + `UNIBUS_users`). `pkg/client` gana `CreateInvite/ListInvites/CancelInvite/Register/ + DeleteUser`; solo `Register` va sin firmar. Recovery: hard-delete del último admin se + recupera con la CLI local `membershipd user add` (mismo seam que siembra el admin #0). - **Identidad = secreto crítico.** El archivo de identidad (`worker.id`, `chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600. Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH. @@ -169,6 +189,35 @@ agent..{in,out} inbox/outbox de agente LLM (agent.scout.in) ## Capability growth log +- v0.12.0 (2026-06-07) — capa de CUENTAS estilo WhatsApp sobre el modelo wallet: alta de + usuario por enlace de invitación de un solo uso + baja por hard-delete real. El admin + nunca ve la clave privada del usuario. (1) **Invites**: nuevo backend de datos en ambos + stores (SQLite `invites` vía migración aditiva `003_invites.sql`; KV `UNIBUS_invites`). + Tipo `Invite{Token, Handle, Role, ExpiresAt, Used, CreatedAt}` + campos de auditoría del + consumo (`UsedAt/UsedSignPub/UsedKexPub`). Métodos `Store.CreateInvite` (token 32 bytes + `crypto/rand` hex, TTL default 7d), `GetInvite`, `ListInvites`, `ConsumeInvite` (valida + existe/no-usado/no-caducado → registra el sign_pub con el handle/role del invite → marca + usado, atómico) y `CancelInvite`. Consumo single-use garantizado en ambos backends: tx + SQLite (mark guard `used=0` + insert) y CAS sobre la revisión KV (mark-first); burn-on- + claim idéntico si la clave ya existe. (2) **Hard-delete**: `Store.DeleteUser` (SQLite + `DELETE FROM users`, KV `users.Delete`) purga el allowlist — distinto del `revoke` + (status flip, conservado). Las membresías de rooms del ex-usuario quedan inertes + (documentado, sin limpieza parcial). (3) **Endpoints HTTP**: `POST /invites`, `GET + /invites` (solo pendientes), `DELETE /invites/{token}`, `DELETE /users/{signpub}` + (todos admin-only vía `requireAdmin`) y `POST /register` — la única ruta auth-exempt de + firma admin (autorizada por el token), rate-limited (se separa `isRateExempt`, solo + `/healthz`, de `isAuthExempt`) y con validación hex estricta de `sign_pub`+`kex_pub` + ANTES de gastar el token. Errores mapeados: token desconocido 404, usado 409, caducado + 410, identidad ya registrada 409. (4) **pkg/client**: `CreateInvite/ListInvites/ + CancelInvite/Register/DeleteUser`; `Register` va sin firma vía un helper `doUnsigned`. + (5) Fix de consistencia: el `GetUser` de SQLite ahora mapea `sql.ErrNoRows` → `ErrNotFound` + como el KV y como documenta `store.go`. Tests nuevos: suite de invites store-level en + AMBOS backends (golden + single-use + token desconocido + caducado + cancel + hard-delete + + burn-on-claim), suite HTTP (crear invite → register sin auth → aparece en allowlist → + re-register 409 → caducado 410 → no-admin 403 en las 4 rutas admin → hard-delete purga), + y test de cliente end-to-end (admin acuña invite → joiner no-registrado redime sin firma → + aparece → hard-delete desaparece). Cambios 100% aditivos: el comportamiento previo no + cambia; build/vet/test verdes (`CGO_ENABLED=0`). - v0.11.0 (2026-06-07) — flag dedicado `UNIBUS_NATS_MONITOR` que abre el endpoint de monitoring HTTP del nats-server embebido (`127.0.0.1:8222`, loopback only) de forma DESACOPLADA del debug-log. Antes el monitoring solo se abría con