From f65271dc92973b97e171eee04e4f80582fbc2dcc Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 22:28:44 +0200 Subject: [PATCH] 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) --- internal/admin/repo.go | 40 ++++++++++++ internal/admin/repo_bus.go | 120 ++++++++++++++++++++++++++++++++++++ internal/admin/repo_mock.go | 71 +++++++++++++++++++-- internal/admin/server.go | 45 ++++++++++++++ main.go | 16 +++++ 5 files changed, 288 insertions(+), 4 deletions(-) diff --git a/internal/admin/repo.go b/internal/admin/repo.go index a4ff29c..0589ae6 100644 --- a/internal/admin/repo.go +++ b/internal/admin/repo.go @@ -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) } diff --git a/internal/admin/repo_bus.go b/internal/admin/repo_bus.go index a8e6d7a..84707de 100644 --- a/internal/admin/repo_bus.go +++ b/internal/admin/repo_bus.go @@ -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) +} diff --git a/internal/admin/repo_mock.go b/internal/admin/repo_mock.go index 9c4f775..fb82e86 100644 --- a/internal/admin/repo_mock.go +++ b/internal/admin/repo_mock.go @@ -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 +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 238cf21..eac9231 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -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 diff --git a/main.go b/main.go index b3a1fc4..e587c4b 100644 --- a/main.go +++ b/main.go @@ -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)