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
+67 -4
View File
@@ -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
}