feat(gateway): invite and hard-delete REST endpoints + repo methods

Wire the bus's new account surface into the admin gateway:

- POST /api/invites, GET /api/invites: mint and list single-use registration
  invites (CreateInvite/ListInvites on the Repo). The gateway pre-builds the
  shareable join link (JoinURL) from a configurable end-user client base URL so
  the SPA does not need to know where the client lives.
- DELETE /api/users/{pub}: hard-delete (purge) a user, distinct from the existing
  revoke.
- Both backends covered: signed control-plane (cluster default) via the unibus
  client's CreateInvite/ListInvites/DeleteUser, and the direct membership store
  (single-node --db fallback). For the direct store, ListInvites filters to
  pending (the control plane already does so server-side).
- New --join-base-url flag / UNIBUS_JOIN_BASE_URL env feeds the join link base
  URL (the END-USER client, NOT the panel's own URL); surfaced on /api/me.
- Mock repo gains the same methods for UI iteration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:28:44 +02:00
parent 1b19f8e60f
commit f65271dc92
5 changed files with 288 additions and 4 deletions
+40
View File
@@ -84,6 +84,30 @@ type AddUserReq struct {
Role string `json:"role"`
}
// CreateInviteReq is the create-invite payload from the SPA. The admin fixes the
// handle and role the future user will receive; TTLSecs is optional (0 uses the
// bus default of 7 days). The admin never supplies a key — the user's client
// generates its own keypair and publishes only its public keys at /register.
type CreateInviteReq struct {
Handle string `json:"handle"`
Role string `json:"role"`
TTLSecs int `json:"ttl_secs"`
}
// InviteView is a single-use registration invite as the admin panel sees it. The
// token is the bearer secret the admin turns into a join link; JoinURL is that
// link, pre-built by the gateway from the configured client base URL so the SPA
// does not have to know where the client lives.
type InviteView struct {
Token string `json:"token"`
Handle string `json:"handle"`
Role string `json:"role"`
ExpiresAt string `json:"expires_at"`
Used bool `json:"used"`
CreatedAt string `json:"created_at"`
JoinURL string `json:"join_url"`
}
// MeInfo describes the gateway's own identity and which capabilities are wired,
// so the SPA can render the operator endpoint and label the Users tab's backend.
type MeInfo struct {
@@ -91,6 +115,12 @@ type MeInfo struct {
SignPub string `json:"sign_pub"`
UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
Mock bool `json:"mock"`
// JoinBaseURL is the base URL of the END-USER client (the page that hosts
// /join?token=…), configured on the gateway (--join-base-url / env
// UNIBUS_JOIN_BASE_URL). It is NOT the admin panel's own URL: the join link
// the admin shares points at the user-facing client, a separate app. Empty
// when unconfigured; the SPA then falls back to its own origin and warns.
JoinBaseURL string `json:"join_base_url"`
}
// Repo is the data source behind the REST API. Two implementations exist:
@@ -121,4 +151,14 @@ type Repo interface {
ListUsers(ctx context.Context) ([]UserView, error)
AddUser(ctx context.Context, req AddUserReq) error
RevokeUser(ctx context.Context, signPub string) error
// DeleteUser hard-deletes a user (purge), distinct from RevokeUser's status
// flip. The admin panel maps its "Eliminar (permanente)" action here.
DeleteUser(ctx context.Context, signPub string) error
// Invites (the wallet-model account-creation path). CreateInvite mints a
// single-use registration link the admin shares; the user redeems it from
// their own client without the admin ever handling a private key. ListInvites
// returns the pending links.
CreateInvite(ctx context.Context, req CreateInviteReq) (InviteView, error)
ListInvites(ctx context.Context) ([]InviteView, error)
}
+120
View File
@@ -45,6 +45,11 @@ type busRepo struct {
// signed control-plane API on r.cli instead — see ListUsers/AddUser/RevokeUser.
store membership.Store
storeBackend string // "control-plane" (cli) | "sqlite" (direct store fallback)
// joinBaseURL is the base URL of the end-user client that hosts /join?token=…
// (NOT the admin panel). The gateway builds the shareable join link from it so
// the SPA never has to know where the client lives. Empty when unconfigured.
joinBaseURL string
}
// BusConfig wires a live gateway.
@@ -58,6 +63,8 @@ type BusConfig struct {
Nodes []NodeTarget // nodes to probe for /healthz
Store membership.Store
StoreBackend string
// JoinBaseURL is the end-user client base URL used to build invite join links.
JoinBaseURL string
}
// NewBusRepo connects the unibus client with the admin identity and builds the
@@ -108,9 +115,20 @@ func NewBusRepo(cfg BusConfig) (*busRepo, error) {
nodes: cfg.Nodes,
store: cfg.Store,
storeBackend: backend,
joinBaseURL: strings.TrimRight(cfg.JoinBaseURL, "/"),
}, nil
}
// joinURL builds the shareable registration link for a token from the configured
// client base URL. It returns "" when no base URL is configured, so the SPA can
// fall back to its own origin (and warn that the link should be configured).
func (r *busRepo) joinURL(token string) string {
if r.joinBaseURL == "" {
return ""
}
return r.joinBaseURL + "/join?token=" + token
}
// Close releases the bus client connection.
func (r *busRepo) Close() error {
if r.cli != nil {
@@ -125,6 +143,7 @@ func (r *busRepo) Me(context.Context) MeInfo {
SignPub: hex.EncodeToString(r.id.SignPub),
UsersBackend: r.storeBackend,
Mock: false,
JoinBaseURL: r.joinBaseURL,
}
}
@@ -389,3 +408,104 @@ func (r *busRepo) RevokeUser(_ context.Context, signPub string) error {
}
return r.store.RevokeUser(signPub)
}
// DeleteUser hard-deletes a user (purge), distinct from RevokeUser. Like the
// other user ops it goes through the signed control plane in cluster, or the
// direct store in the single-node fallback.
func (r *busRepo) DeleteUser(_ context.Context, signPub string) error {
if r.store == nil {
return r.cli.DeleteUser(signPub)
}
return r.store.DeleteUser(signPub)
}
// ---- invites --------------------------------------------------------------
// CreateInvite mints a single-use registration invite and returns it with the
// shareable join link pre-built. Cluster path goes through the signed control
// plane; the single-node fallback hits the store directly.
func (r *busRepo) CreateInvite(_ context.Context, req CreateInviteReq) (InviteView, error) {
if r.store == nil {
inv, err := r.cli.CreateInvite(req.Handle, req.Role, req.TTLSecs)
if err != nil {
return InviteView{}, err
}
return InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
JoinURL: r.joinURL(inv.Token),
}, nil
}
inv, err := r.store.CreateInvite(req.Handle, req.Role, req.TTLSecs)
if err != nil {
return InviteView{}, err
}
return InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
}, nil
}
// ListInvites returns the PENDING invites (not used, not expired) with their join
// links. The control-plane GET /invites already filters to pending; the direct
// store returns everything, so we filter here for parity.
func (r *busRepo) ListInvites(_ context.Context) ([]InviteView, error) {
if r.store == nil {
invs, err := r.cli.ListInvites()
if err != nil {
return nil, err
}
out := make([]InviteView, 0, len(invs))
for _, inv := range invs {
out = append(out, InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
Used: inv.Used,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
})
}
return out, nil
}
invs, err := r.store.ListInvites()
if err != nil {
return nil, err
}
out := make([]InviteView, 0, len(invs))
for _, inv := range invs {
if !invitePending(inv.ExpiresAt, inv.Used) {
continue
}
out = append(out, InviteView{
Token: inv.Token,
Handle: inv.Handle,
Role: inv.Role,
ExpiresAt: inv.ExpiresAt,
Used: inv.Used,
CreatedAt: inv.CreatedAt,
JoinURL: r.joinURL(inv.Token),
})
}
return out, nil
}
// invitePending reports whether an invite is live (not used, not past its
// deadline). A malformed deadline is treated as expired (fail closed).
func invitePending(expiresAt string, used bool) bool {
if used {
return false
}
exp, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return false
}
return time.Now().UTC().Before(exp)
}
+67 -4
View File
@@ -2,19 +2,26 @@ package admin
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
)
// mockJoinBaseURL is the sample client base URL the mock uses to build join links.
const mockJoinBaseURL = "https://chat.unibus.example"
// mockRepo serves sample data so the SPA can be iterated and demoed without a
// live bus. It is selected with --mock. All mutations are kept in memory so the
// UI feels real during a session (create a room, see it appear) without touching
// any control plane.
type mockRepo struct {
mu sync.Mutex
rooms []RoomView
users []UserView
mem map[string][]MemberView
mu sync.Mutex
rooms []RoomView
users []UserView
mem map[string][]MemberView
invites []InviteView
}
// NewMockRepo returns a Repo backed by in-memory sample data (--mock).
@@ -50,6 +57,7 @@ func (m *mockRepo) Me(context.Context) MeInfo {
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
UsersBackend: "sqlite",
Mock: true,
JoinBaseURL: mockJoinBaseURL,
}
}
@@ -155,3 +163,58 @@ func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
}
return nil
}
func (m *mockRepo) DeleteUser(_ context.Context, signPub string) error {
m.mu.Lock()
defer m.mu.Unlock()
kept := m.users[:0]
for _, u := range m.users {
if u.SignPub != signPub {
kept = append(kept, u)
}
}
m.users = kept
return nil
}
func (m *mockRepo) CreateInvite(_ context.Context, req CreateInviteReq) (InviteView, error) {
m.mu.Lock()
defer m.mu.Unlock()
role := req.Role
if role == "" {
role = "member"
}
tokRaw := make([]byte, 32)
if _, err := rand.Read(tokRaw); err != nil {
return InviteView{}, err
}
token := hex.EncodeToString(tokRaw)
ttl := time.Duration(req.TTLSecs) * time.Second
if req.TTLSecs <= 0 {
ttl = 7 * 24 * time.Hour
}
now := time.Now().UTC()
inv := InviteView{
Token: token,
Handle: req.Handle,
Role: role,
ExpiresAt: now.Add(ttl).Format(time.RFC3339Nano),
Used: false,
CreatedAt: now.Format(time.RFC3339Nano),
JoinURL: mockJoinBaseURL + "/join?token=" + token,
}
m.invites = append(m.invites, inv)
return inv, nil
}
func (m *mockRepo) ListInvites(context.Context) ([]InviteView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]InviteView, 0, len(m.invites))
for _, inv := range m.invites {
if invitePending(inv.ExpiresAt, inv.Used) {
out = append(out, inv)
}
}
return out, nil
}
+45
View File
@@ -54,6 +54,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/users", s.handleListUsers)
s.mux.HandleFunc("POST /api/users", s.handleAddUser)
s.mux.HandleFunc("POST /api/users/revoke", s.handleRevokeUser)
// Hard-delete (purge) a user by signing key — distinct from revoke.
s.mux.HandleFunc("DELETE /api/users/{pub}", s.handleDeleteUser)
// Invites — the wallet-model account-creation path.
s.mux.HandleFunc("GET /api/invites", s.handleListInvites)
s.mux.HandleFunc("POST /api/invites", s.handleCreateInvite)
// Everything else is the SPA (and its assets). Registered last as the catch-all.
s.mux.Handle("/", s.spa)
@@ -179,6 +185,45 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
pub := strings.TrimSpace(r.PathValue("pub"))
if pub == "" {
writeErr(w, http.StatusBadRequest, "sign_pub required")
return
}
if err := s.repo.DeleteUser(r.Context(), pub); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.repo.ListInvites(r.Context())
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
var req CreateInviteReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Handle) == "" {
writeErr(w, http.StatusBadRequest, "handle required")
return
}
inv, err := s.repo.CreateInvite(r.Context(), req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
// ---- SPA serving ----------------------------------------------------------
// spaHandler serves the embedded SPA. A request for an existing asset is served
+16
View File
@@ -37,10 +37,20 @@ func main() {
identityFile = flag.String("identity-file", "", "path to the admin identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the admin identity JSON, e.g. unibus/operator-identity")
dbPath = flag.String("db", "", "OPTIONAL membership SQLite path for single-node user management. Empty (default) = manage users via the signed control-plane API, which works in cluster")
joinBaseURL = flag.String("join-base-url", "", "base URL of the END-USER client that hosts /join?token=… (e.g. https://chat.unibus.example). Used to build shareable invite links. Falls back to env UNIBUS_JOIN_BASE_URL")
mock = flag.Bool("mock", false, "serve sample data instead of talking to the bus (UI iteration)")
)
flag.Parse()
// The end-user client base URL (for invite join links) comes from the flag or,
// if unset, the env var. It is NOT the admin panel's own URL — the join link
// points at the user-facing client, a separate app. Empty leaves the SPA to
// fall back to its own origin and warn.
joinBase := *joinBaseURL
if joinBase == "" {
joinBase = os.Getenv("UNIBUS_JOIN_BASE_URL")
}
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[unibus_admin] ")
@@ -83,6 +93,7 @@ func main() {
Nodes: nodes,
Store: store,
StoreBackend: backend,
JoinBaseURL: joinBase,
})
if err != nil {
log.Fatalf("%v", err)
@@ -98,6 +109,11 @@ func main() {
tls = "ON (CA " + *caPath + ")"
}
log.Printf("bus TLS+nkey: %s", tls)
if joinBase != "" {
log.Printf("invite join base: %s", joinBase)
} else {
log.Printf("invite join base: (unset; SPA falls back to its own origin — set --join-base-url or UNIBUS_JOIN_BASE_URL)")
}
}
srv := admin.NewServer(repo, files)