f65271dc92
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>
165 lines
6.7 KiB
Go
165 lines
6.7 KiB
Go
// Package admin is the gateway behind the unibus admin panel: it holds the
|
|
// operator's ADMIN identity, talks to the unibus control plane (signing every
|
|
// request), and exposes a small REST API the embedded SPA consumes. The browser
|
|
// never signs, never touches NATS, and never sees a private key — every
|
|
// privileged action is mediated here.
|
|
package admin
|
|
|
|
import (
|
|
"context"
|
|
)
|
|
|
|
// Posture is the security posture a membershipd node publishes on /healthz. It
|
|
// mirrors membership.Posture but is duplicated here so the wire shape the SPA
|
|
// consumes is owned by the gateway, not coupled to the bus package's struct tags.
|
|
type Posture struct {
|
|
Enforce bool `json:"enforce"`
|
|
ACL bool `json:"acl"`
|
|
TLS bool `json:"tls"`
|
|
Cluster bool `json:"cluster"`
|
|
Store string `json:"store"`
|
|
}
|
|
|
|
// NodeHealth is one cluster node's liveness + posture as seen from the gateway.
|
|
type NodeHealth struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Up bool `json:"up"`
|
|
Posture Posture `json:"posture"`
|
|
LatencyMs int64 `json:"latency_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// RoomView is a room as the admin sees it (a room the admin owns or belongs to).
|
|
type RoomView struct {
|
|
RoomID string `json:"room_id"`
|
|
Subject string `json:"subject"`
|
|
Epoch int `json:"epoch"`
|
|
Encrypt bool `json:"encrypt"`
|
|
Persist bool `json:"persist"`
|
|
SignMsgs bool `json:"sign_msgs"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// MemberView is one member of a room with public keys rendered as hex (the
|
|
// browser never needs the raw bytes).
|
|
type MemberView struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Role string `json:"role"`
|
|
SignPub string `json:"sign_pub"`
|
|
KexPub string `json:"kex_pub"`
|
|
}
|
|
|
|
// UserView is one bus allowlist entry.
|
|
type UserView struct {
|
|
SignPub string `json:"sign_pub"`
|
|
Handle string `json:"handle"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
RevokedAt string `json:"revoked_at,omitempty"`
|
|
}
|
|
|
|
// CreateRoomReq is the room-creation payload from the SPA.
|
|
type CreateRoomReq struct {
|
|
Subject string `json:"subject"`
|
|
Encrypt bool `json:"encrypt"`
|
|
Persist bool `json:"persist"`
|
|
SignMsgs bool `json:"sign_msgs"`
|
|
}
|
|
|
|
// InviteReq is the invite payload. The invitee's public keys are supplied as hex
|
|
// because an encrypted room seals the room key to the invitee's X25519 key, and
|
|
// that key is not derivable from the endpoint id alone.
|
|
type InviteReq struct {
|
|
Endpoint string `json:"endpoint"`
|
|
SignPub string `json:"sign_pub"`
|
|
KexPub string `json:"kex_pub"`
|
|
}
|
|
|
|
// AddUserReq is the user-registration payload.
|
|
type AddUserReq struct {
|
|
SignPub string `json:"sign_pub"`
|
|
Handle string `json:"handle"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// CreateInviteReq is the create-invite payload from the SPA. The admin fixes the
|
|
// handle and role the future user will receive; TTLSecs is optional (0 uses the
|
|
// bus default of 7 days). The admin never supplies a key — the user's client
|
|
// generates its own keypair and publishes only its public keys at /register.
|
|
type CreateInviteReq struct {
|
|
Handle string `json:"handle"`
|
|
Role string `json:"role"`
|
|
TTLSecs int `json:"ttl_secs"`
|
|
}
|
|
|
|
// InviteView is a single-use registration invite as the admin panel sees it. The
|
|
// token is the bearer secret the admin turns into a join link; JoinURL is that
|
|
// link, pre-built by the gateway from the configured client base URL so the SPA
|
|
// does not have to know where the client lives.
|
|
type InviteView struct {
|
|
Token string `json:"token"`
|
|
Handle string `json:"handle"`
|
|
Role string `json:"role"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
Used bool `json:"used"`
|
|
CreatedAt string `json:"created_at"`
|
|
JoinURL string `json:"join_url"`
|
|
}
|
|
|
|
// MeInfo describes the gateway's own identity and which capabilities are wired,
|
|
// so the SPA can render the operator endpoint and label the Users tab's backend.
|
|
type MeInfo struct {
|
|
Endpoint string `json:"endpoint"`
|
|
SignPub string `json:"sign_pub"`
|
|
UsersBackend string `json:"users_backend"` // "control-plane" (signed HTTP) | "sqlite" (single-node fallback)
|
|
Mock bool `json:"mock"`
|
|
// JoinBaseURL is the base URL of the END-USER client (the page that hosts
|
|
// /join?token=…), configured on the gateway (--join-base-url / env
|
|
// UNIBUS_JOIN_BASE_URL). It is NOT the admin panel's own URL: the join link
|
|
// the admin shares points at the user-facing client, a separate app. Empty
|
|
// when unconfigured; the SPA then falls back to its own origin and warns.
|
|
JoinBaseURL string `json:"join_base_url"`
|
|
}
|
|
|
|
// Repo is the data source behind the REST API. Two implementations exist:
|
|
// busRepo (the real control-plane + store gateway) and mockRepo (sample data for
|
|
// UI iteration). Keeping it an interface lets the SPA be developed and demoed
|
|
// against mock data with the exact same handlers the live bus uses.
|
|
type Repo interface {
|
|
Me(ctx context.Context) MeInfo
|
|
|
|
// Cluster liveness + posture of every configured node.
|
|
Cluster(ctx context.Context) []NodeHealth
|
|
|
|
// Rooms the admin owns / belongs to, plus mutations the control plane allows.
|
|
ListRooms(ctx context.Context) ([]RoomView, error)
|
|
CreateRoom(ctx context.Context, req CreateRoomReq) (RoomView, error)
|
|
ListMembers(ctx context.Context, roomID string) ([]MemberView, error)
|
|
Invite(ctx context.Context, roomID string, req InviteReq) error
|
|
// KickMember removes a member and rotates the room key to a new epoch
|
|
// (forward secrecy). This is the rekey-on-kick primitive the bus exposes.
|
|
KickMember(ctx context.Context, roomID, endpoint string) error
|
|
|
|
// Users (the bus allowlist). The live gateway manages these against the bus
|
|
// control plane's admin-only user endpoints, signing each request as the
|
|
// operator's admin identity — so user management works in cluster without
|
|
// direct store/KV access. A single-node deployment may instead point the
|
|
// gateway at the SQLite store directly (--db) as an explicit fallback.
|
|
UsersWritable() bool
|
|
ListUsers(ctx context.Context) ([]UserView, error)
|
|
AddUser(ctx context.Context, req AddUserReq) error
|
|
RevokeUser(ctx context.Context, signPub string) error
|
|
// DeleteUser hard-deletes a user (purge), distinct from RevokeUser's status
|
|
// flip. The admin panel maps its "Eliminar (permanente)" action here.
|
|
DeleteUser(ctx context.Context, signPub string) error
|
|
|
|
// Invites (the wallet-model account-creation path). CreateInvite mints a
|
|
// single-use registration link the admin shares; the user redeems it from
|
|
// their own client without the admin ever handling a private key. ListInvites
|
|
// returns the pending links.
|
|
CreateInvite(ctx context.Context, req CreateInviteReq) (InviteView, error)
|
|
ListInvites(ctx context.Context) ([]InviteView, error)
|
|
}
|