diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 255302f..b8ac9ed 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -1582,7 +1582,9 @@ async function handleServerRoute(ctx: SessionContext): Promise { } if (is("GET", ctx) && ctx.parts[4] === "status") { - sendJSON(ctx.res, 200, projectServer(serverInfo, ctx.account)); + // The real status projection (api.ServerInfo) carries no owned/claimable. + const { owned: _o, claimable: _c, ...status } = projectServer(serverInfo, ctx.account); + sendJSON(ctx.res, 200, status); return true; } if (is("GET", ctx) && ctx.parts[4] === "console") { diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 9174fa9..cd0f91e 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -20,6 +20,7 @@ import type { ServerFileEntry, ServerJob, ServerInfo, + ServerStatus, SessionView, UserDetail, UserView, @@ -200,7 +201,7 @@ export const api = { fleet: () => request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), - status: (name: string) => request("GET", `/servers/${name}/status`), + status: (name: string) => request("GET", `/servers/${name}/status`), wake: (name: string) => request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), diff --git a/panel/src/lib/ownership.test.ts b/panel/src/lib/ownership.test.ts new file mode 100644 index 0000000..b5cffaa --- /dev/null +++ b/panel/src/lib/ownership.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { canManage, ownershipPending } from "./ownership"; +import type { ServerInfo } from "./types"; + +// The owner-tier gate once read `owned` off /servers/{name}/status, which never +// sends it, so owners lost the LuckPerms entry. These cases pin that ownership +// comes only from the /me/servers row for this exact server. + +const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned }); + +describe("canManage", () => { + it("lets an admin in without any /me/servers rows", () => { + expect(canManage(true, null, "lobby")).toBe(true); + }); + + it("lets the owner in from their /me/servers row", () => { + expect(canManage(false, [row("other", false), row("lobby", true)], "lobby")).toBe(true); + }); + + it("keeps a non-owner out", () => { + expect(canManage(false, [row("lobby", false)], "lobby")).toBe(false); + expect(canManage(false, [row("lobby")], "lobby")).toBe(false); + expect(canManage(false, [row("other", true)], "lobby")).toBe(false); + }); + + it("keeps everyone out while /me/servers is unanswered", () => { + expect(canManage(false, null, "lobby")).toBe(false); + expect(canManage(false, undefined, "lobby")).toBe(false); + }); +}); + +describe("ownershipPending", () => { + it("waits for the tier", () => { + expect(ownershipPending(true, false, [], null)).toBe(true); + }); + + it("waits for a non-admin's /me/servers", () => { + expect(ownershipPending(false, false, null, null)).toBe(true); + expect(ownershipPending(false, false, [], null)).toBe(false); + }); + + it("stops waiting once the read failed, so the page can offer a retry", () => { + expect(ownershipPending(false, false, null, { status: 503 })).toBe(false); + }); + + it("never waits on an admin", () => { + expect(ownershipPending(false, true, null, null)).toBe(false); + }); +}); diff --git a/panel/src/lib/ownership.ts b/panel/src/lib/ownership.ts new file mode 100644 index 0000000..5e8e756 --- /dev/null +++ b/panel/src/lib/ownership.ts @@ -0,0 +1,25 @@ +import type { ServerInfo } from "./types"; + +// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide +// who may act from GET /me/servers: the status projection never carries +// `owned`, so reading it there hides owner tools from every non-admin owner. + +/** canManage reports whether the caller may use a server's owner-tier tools. */ +export function canManage( + isAdmin: boolean, + mine: readonly ServerInfo[] | null | undefined, + name: string, +): boolean { + return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true); +} + +/** ownershipPending is true while the answer is still unknown: the tier is + * loading, or a non-admin's /me/servers read has neither landed nor failed. */ +export function ownershipPending( + tierLoading: boolean, + isAdmin: boolean, + mine: readonly ServerInfo[] | null | undefined, + mineError: unknown, +): boolean { + return tierLoading || (!isAdmin && mine == null && !mineError); +} diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 9d425d2..338d07b 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -17,8 +17,8 @@ export type Phase = export type AutostartPolicy = "ownerOnly" | "public" | "allowlist"; -/** ServerInfo is the projection returned by GET /servers/{name}/status and - * GET /me/servers (the latter wraps a list under { servers: [...] }). */ +/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }). + * Only this projection says whether the caller owns or may claim a server. */ export interface ServerInfo { name: string; subdomain: string; @@ -38,6 +38,10 @@ export interface ServerInfo { cpu?: string; } +/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or + * `claimable`; owner-tier gates read /me/servers via lib/ownership. */ +export type ServerStatus = Omit; + /** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access). * `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done * server-side (parseWhitelistOutput); `output` is the raw RCON text and is the diff --git a/panel/src/pages/ServerBackups.tsx b/panel/src/pages/ServerBackups.tsx index 9524ccb..d9fb6aa 100644 --- a/panel/src/pages/ServerBackups.tsx +++ b/panel/src/pages/ServerBackups.tsx @@ -30,6 +30,7 @@ import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; +import { canManage, ownershipPending } from "@/lib/ownership"; import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { BackupView } from "@/lib/types"; @@ -376,8 +377,8 @@ export function ServerBackups() { // While it is pending show the header with a spinner rather than flashing the list // at someone who may not own it; if that read itself failed, break to a retry so a // real owner never fails closed to NotYours on a transient blip. - const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error); - const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true); + const pending = ownershipPending(tierLoading, isAdmin, mineQ.data, mineQ.error); + const owned = canManage(isAdmin, mineQ.data, name); // The async world-operation history (the backup/restore Jobs behind every 202). // Read only once the viewer is resolved as owner-or-admin (the route 403s @@ -482,7 +483,7 @@ export function ServerBackups() { <> {back} {header} - {ownershipPending ? ( + {pending ? ( ) : mineQ.error ? ( diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index efd8725..bf9081d 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -12,6 +12,7 @@ import { Loading, ErrorState } from "@/components/States"; import { api, consoleStreamURL, humanizeError } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; +import { canManage } from "@/lib/ownership"; import { hostFor } from "@/lib/config"; import type { Phase, AutostartPolicy } from "@/lib/types"; import { EditServerDialog } from "@/components/EditServerDialog"; @@ -193,6 +194,11 @@ export function ServerConsole() { () => api.status(name), [name], ); + const { data: mine } = useAsync( + () => (isAdmin ? Promise.resolve([]) : api.myServers()), + [isAdmin, name], + ); + const owned = canManage(isAdmin, mine, name); // The read-side stream (spec §8) follows the running pod's log. A pod exists // during BOTH Starting and Running — the operator marks Starting once the pod @@ -283,7 +289,7 @@ export function ServerConsole() { onUpdated={reload} /> )} - {(data.owned || isAdmin) && ( + {owned && ( (isAdmin ? Promise.resolve([]) : api.myServers()), [isAdmin, name], ); - const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error); - const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true); + const pending = ownershipPending(tierLoading, isAdmin, mineQ.data, mineQ.error); + const owned = canManage(isAdmin, mineQ.data, name); const stopped = statusQ.data?.phase === "Stopped"; const [dir, setDir] = useState(""); @@ -241,7 +242,7 @@ export function ServerFiles() { <> {back} {header} - {ownershipPending ? ( + {pending ? ( ) : mineQ.error ? ( diff --git a/panel/src/pages/ServerLuckPerms.tsx b/panel/src/pages/ServerLuckPerms.tsx index de61302..58eb828 100644 --- a/panel/src/pages/ServerLuckPerms.tsx +++ b/panel/src/pages/ServerLuckPerms.tsx @@ -21,6 +21,7 @@ import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; +import { canManage, ownershipPending } from "@/lib/ownership"; import type { Phase } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -299,8 +300,8 @@ export function ServerLuckPerms() { } if (!data) return back; - const ownershipPending = ownershipPendingCheck(tierLoading, isAdmin, mine, mineError); - const owned = isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true); + const pending = ownershipPending(tierLoading, isAdmin, mine, mineError); + const owned = canManage(isAdmin, mine, name); const phase: Phase = data.phase; const header = ( @@ -317,7 +318,7 @@ export function ServerLuckPerms() { <> {back} {header} - {ownershipPending ? ( + {pending ? ( ) : mineError ? ( @@ -801,12 +802,3 @@ export function ServerLuckPerms() { ); } - -function ownershipPendingCheck( - tierLoading: boolean, - isAdmin: boolean, - mine: any[] | null, - mineError: any -): boolean { - return tierLoading || (!isAdmin && mine === null && !mineError); -} diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx index da5c1ca..dff09d7 100644 --- a/panel/src/pages/ServerPlayers.tsx +++ b/panel/src/pages/ServerPlayers.tsx @@ -11,6 +11,7 @@ import { BansSection } from "@/components/players/BansSection"; import { api } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; +import { canManage, ownershipPending } from "@/lib/ownership"; import type { Phase } from "@/lib/types"; /** ServerPlayers is the per-server player-management subpage (/servers/:name/players): @@ -59,10 +60,8 @@ export function ServerPlayers() { // /me/servers read itself failed, `mineError` breaks the pending state (below) so a // real owner sees a retry instead of an eternal spinner — never fail closed to // NotYours, which would wrongly tell an owner the server isn't theirs on a blip. - const ownershipPending = - tierLoading || (!isAdmin && mine === null && !mineError); - const owned = - isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true); + const pending = ownershipPending(tierLoading, isAdmin, mine, mineError); + const owned = canManage(isAdmin, mine, name); const phase: Phase = data.phase; const header = ( @@ -78,7 +77,7 @@ export function ServerPlayers() { <> {back} {header} - {ownershipPending ? ( + {pending ? ( ) : mineError ? (