Files
unibus_admin/internal/admin/repo_mock.go
T
egutierrez f65271dc92 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>
2026-06-07 22:28:44 +02:00

221 lines
6.5 KiB
Go

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
invites []InviteView
}
// NewMockRepo returns a Repo backed by in-memory sample data (--mock).
func NewMockRepo() Repo { return newMockRepo() }
func newMockRepo() *mockRepo {
return &mockRepo{
rooms: []RoomView{
{RoomID: "01HV...GENERAL", Subject: "team.general", Epoch: 1, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
{RoomID: "01HV...BOARD", Subject: "board.private", Epoch: 3, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
{RoomID: "01HV...BOTS", Subject: "bots.echo", Epoch: 1, Encrypt: false, Persist: false, SignMsgs: false, Role: "member"},
{RoomID: "01HV...INFRA", Subject: "infra.alerts", Epoch: 2, Encrypt: true, Persist: true, SignMsgs: true, Role: "owner"},
},
users: []UserView{
{SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa", Handle: "operator", Role: "admin", Status: "active", CreatedAt: "2026-06-01T10:00:00Z"},
{SignPub: "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00", Handle: "ana", Role: "member", Status: "active", CreatedAt: "2026-06-02T11:30:00Z"},
{SignPub: "ffeeddccbbaa99887766554433221100f0e1d2c3b4a5968778695a4b3c2d1e0f", Handle: "lucas", Role: "member", Status: "active", CreatedAt: "2026-06-03T09:15:00Z"},
{SignPub: "0011223344556677889900aabbccddeeff112233445566778899aabbccddeeff", Handle: "leo-revoked", Role: "member", Status: "revoked", CreatedAt: "2026-05-20T08:00:00Z", RevokedAt: "2026-06-04T14:00:00Z"},
},
mem: map[string][]MemberView{
"01HV...GENERAL": {
{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."},
{Endpoint: "ep-ana", Role: "member", SignPub: "a1b2c3d4...", KexPub: "7c2b..."},
{Endpoint: "ep-lucas", Role: "member", SignPub: "ffeeddcc...", KexPub: "5e1d..."},
},
},
}
}
func (m *mockRepo) Me(context.Context) MeInfo {
return MeInfo{
Endpoint: "ep-operator",
SignPub: "48bc0dc829571a1332b4b2deb0bd78326a06bcf149e7d560728d8dc0b98173fa",
UsersBackend: "sqlite",
Mock: true,
JoinBaseURL: mockJoinBaseURL,
}
}
func (m *mockRepo) Cluster(context.Context) []NodeHealth {
p := Posture{Enforce: true, ACL: true, TLS: true, Cluster: true, Store: "kv"}
return []NodeHealth{
{Name: "magnus", URL: "https://127.0.0.1:8470", Up: true, Posture: p, LatencyMs: 4},
{Name: "homer", URL: "https://10.0.0.2:8470", Up: true, Posture: p, LatencyMs: 11},
{Name: "datardos", URL: "https://10.0.0.3:8470", Up: false, Posture: Posture{}, LatencyMs: 0, Error: "dial tcp: i/o timeout"},
}
}
func (m *mockRepo) ListRooms(context.Context) ([]RoomView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RoomView, len(m.rooms))
copy(out, m.rooms)
return out, nil
}
func (m *mockRepo) CreateRoom(_ context.Context, req CreateRoomReq) (RoomView, error) {
m.mu.Lock()
defer m.mu.Unlock()
rv := RoomView{
RoomID: fmt.Sprintf("01HV...NEW%d", len(m.rooms)),
Subject: req.Subject,
Epoch: 1,
Encrypt: req.Encrypt,
Persist: req.Persist,
SignMsgs: req.SignMsgs,
Role: "owner",
}
m.rooms = append(m.rooms, rv)
return rv, nil
}
func (m *mockRepo) ListMembers(_ context.Context, roomID string) ([]MemberView, error) {
m.mu.Lock()
defer m.mu.Unlock()
if ms, ok := m.mem[roomID]; ok {
return ms, nil
}
return []MemberView{{Endpoint: "ep-operator", Role: "owner", SignPub: "48bc0dc8...", KexPub: "9f3a..."}}, nil
}
func (m *mockRepo) Invite(context.Context, string, InviteReq) error { return nil }
func (m *mockRepo) KickMember(_ context.Context, roomID, endpoint string) error {
m.mu.Lock()
defer m.mu.Unlock()
if ms, ok := m.mem[roomID]; ok {
kept := ms[:0]
for _, mv := range ms {
if mv.Endpoint != endpoint {
kept = append(kept, mv)
}
}
m.mem[roomID] = kept
}
for i := range m.rooms {
if m.rooms[i].RoomID == roomID {
m.rooms[i].Epoch++
}
}
return nil
}
func (m *mockRepo) UsersWritable() bool { return true }
func (m *mockRepo) ListUsers(context.Context) ([]UserView, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]UserView, len(m.users))
copy(out, m.users)
return out, nil
}
func (m *mockRepo) AddUser(_ context.Context, req AddUserReq) error {
m.mu.Lock()
defer m.mu.Unlock()
role := req.Role
if role == "" {
role = "member"
}
m.users = append(m.users, UserView{
SignPub: req.SignPub,
Handle: req.Handle,
Role: role,
Status: "active",
CreatedAt: "2026-06-07T12:00:00Z",
})
return nil
}
func (m *mockRepo) RevokeUser(_ context.Context, signPub string) error {
m.mu.Lock()
defer m.mu.Unlock()
for i := range m.users {
if m.users[i].SignPub == signPub {
m.users[i].Status = "revoked"
m.users[i].RevokedAt = "2026-06-07T12:30:00Z"
}
}
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
}