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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user