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
+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