Unverified Commit 2f90851c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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.
parent 0a36b3fd
Loading
Loading
Loading
Loading
+7 −0
Changes for docs/openapi.yaml: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2731,6 +2731,13 @@ paths:
                                The owner's display identity (email, or username when
                                the address is absent). Absent for an unclaimed server
                                or when the best-effort owner lookup failed.
                            system:
                              type: boolean
                              description: >-
                                True for a platform-provisioned system service (the login
                                gate, the lobby). Their reserved names are rejected by
                                every per-server route, so the cockpit renders them
                                read-only instead of offering actions that would 400.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
+38 −0
Changes for internal/api/api_test.go: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/handlers_user.go: 8 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/naming/naming.go: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/naming/naming_test.go: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Loading