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