fix(panel): mark platform system services read-only in the fleet table

login/lobby carry reserved names, so every per-server route rejects them —
yet the cockpit offered claim/stop/wake and a console link on their rows,
each answering 400 bad_name. The fleet view now marks them (system:true,
shared naming.IsSystemServer) and the panel renders a plain label instead
of dead actions.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:51:45 +08:00
1 parent 0a36b3fda9
commit 2f90851c03
9 files changed
+99 -4

No files matched your search

+38
View File
@@ -1809,6 +1809,44 @@ func TestFleetAdminRead(t *testing.T) {
}
})
t.Run("system services are marked read-only", func(t *testing.T) {
// The login gate and the lobby carry reserved names, so every per-server
// route rejects them; the fleet row must say "system" so the cockpit
// renders them without actions that would 400.
sysCl := newFakeCluster()
sysCl.list = []ServerInfo{
{Name: "login", Phase: "Running", Ready: true},
{Name: "lobby", Phase: "Running", Ready: true},
{Name: "survival", Phase: "Stopped"},
}
api := newTestAPI(newFakeRepo(), sysCl)
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
Role: "admin", ViaAdminAccess: true}}
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got struct {
Servers []struct {
Name string `json:"name"`
System bool `json:"system"`
} `json:"servers"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v", err)
}
byName := map[string]bool{}
for _, r := range got.Servers {
byName[r.Name] = r.System
}
if !byName["login"] || !byName["lobby"] {
t.Errorf("system flags = %+v, want login+lobby marked", byName)
}
if byName["survival"] {
t.Errorf("survival marked system; only platform services are")
}
})
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
repo := newFakeRepo()
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
+8 -2
View File
@@ -259,7 +259,8 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
owners, _ := a.Repo.ServerOwners(r.Context())
views := make([]fleetServerView, len(servers))
for i, s := range servers {
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name]}
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name],
System: naming.IsSystemServer(s.Name)}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
}
@@ -267,13 +268,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity
// joined alongside. The embed keeps every lifecycle field flat in the JSON so the
// shape is a strict superset of ServerInfo; Owner is the only addition.
// shape is a strict superset of ServerInfo.
type fleetServerView struct {
ServerInfo
// Owner is the claiming user's display identity (email, or username when the
// address is absent), or "" when the server is unclaimed or the best-effort
// owner lookup failed — the cockpit renders "" as "unclaimed".
Owner string `json:"owner,omitempty"`
// System marks a platform-provisioned system service (the login gate and the
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
// API route rejects them — the cockpit must render them read-only rather than
// offer claim/wake/stop/console actions that would answer 400.
System bool `json:"system,omitempty"`
}
// createServerRequest is the structured §15 create-server form. This is the
+9
View File
@@ -46,6 +46,15 @@ const (
SystemLobbyServer = "lobby"
)
// IsSystemServer reports whether name is one of the platform-provisioned system
// services above. They carry reserved names on purpose, and the API's per-server
// routes reject those names outright (ValidateServerName) — so a caller that only
// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of
// offering actions (claim/wake/stop/console) that would answer 400.
func IsSystemServer(name string) bool {
return name == SystemLoginServer || name == SystemLobbyServer
}
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
// credential Secret (spec §7). They are one source of truth shared across
// subsystems: the platform renderer wires this Secret into the felis-api
+15
View File
@@ -42,6 +42,21 @@ func TestValidateServerName(t *testing.T) {
// ValidateSystemServerName keeps the format rule but drops the reservation
// check, so the platform can provision the reserved system names (login, lobby)
// that ValidateServerName correctly refuses to hand to users.
func TestIsSystemServer(t *testing.T) {
// Exactly the platform's two system services answer true; a user server that
// merely sounds systemic does not.
for _, name := range []string{"login", "lobby"} {
if !naming.IsSystemServer(name) {
t.Errorf("IsSystemServer(%q) = false, want true", name)
}
}
for _, name := range []string{"survival", "admin", "login2", "", "lobby-"} {
if naming.IsSystemServer(name) {
t.Errorf("IsSystemServer(%q) = true, want false", name)
}
}
}
func TestValidateSystemServerName(t *testing.T) {
cases := []struct {
name string