feat: scaffold unibus_admin gateway (Go REST + embed SPA placeholder)
Single Go binary: serves an embedded Mantine SPA and a small REST API over the unibus control plane. Holds the operator ADMIN identity, signs every control-plane request, never exposes a private key to the browser. - internal/admin: Repo interface + mock + bus implementations, REST server - repo_bus: rooms via pkg/client, members via signed GET (CanonicalRequest + SignEd25519), cluster via /healthz (CA-pinned), users via membership.Store - identity loaded from pass entry or 0600 file (operator-identity JSON) - go build CGO_ENABLED=0 green; go vet clean Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// 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"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUsersUnavailable is returned by the users operations when the gateway was
|
||||
// started without a membership store (no --db / no KV access). The bus control
|
||||
// plane exposes no user-management HTTP endpoint — users live only in the store
|
||||
// — so the Users tab is read-and-write only when the gateway can reach that
|
||||
// store directly. Without it the tab degrades to an explanatory empty state
|
||||
// rather than failing opaquely.
|
||||
var ErrUsersUnavailable = errors.New("admin: user management requires direct store access (start with --db or a KV-backed store)")
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// MeInfo describes the gateway's own identity and which capabilities are wired,
|
||||
// so the SPA can render the operator endpoint and gate the Users tab.
|
||||
type MeInfo struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SignPub string `json:"sign_pub"`
|
||||
UsersBackend string `json:"users_backend"` // "sqlite" | "kv" | "none"
|
||||
Mock bool `json:"mock"`
|
||||
}
|
||||
|
||||
// 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). Available only with direct store access;
|
||||
// otherwise these return ErrUsersUnavailable.
|
||||
UsersWritable() bool
|
||||
ListUsers(ctx context.Context) ([]UserView, error)
|
||||
AddUser(ctx context.Context, req AddUserReq) error
|
||||
RevokeUser(ctx context.Context, signPub string) error
|
||||
}
|
||||
Reference in New Issue
Block a user