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