Loading panel/dev/mockApi.ts +3 −1 Changes for panel/dev/mockApi.ts: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -1582,7 +1582,9 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> { } 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") { Loading panel/src/lib/api.ts +2 −1 Changes for panel/src/lib/api.ts: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ import type { ServerFileEntry, ServerJob, ServerInfo, ServerStatus, SessionView, UserDetail, UserView, Loading Loading @@ -200,7 +201,7 @@ export const api = { fleet: () => request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), status: (name: string) => request<ServerInfo>("GET", `/servers/${name}/status`), status: (name: string) => request<ServerStatus>("GET", `/servers/${name}/status`), wake: (name: string) => request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), Loading panel/src/lib/ownership.test.ts 0 → 100644 +49 −0 Changes for panel/src/lib/ownership.test.ts: 49 added lines, 0 removed lines. Original line number Diff line number Diff line 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); }); }); panel/src/lib/ownership.ts 0 → 100644 +25 −0 Changes for panel/src/lib/ownership.ts: 25 added lines, 0 removed lines. Original line number Diff line number Diff line 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); } panel/src/lib/types.ts +6 −2 Changes for panel/src/lib/types.ts: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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<ServerInfo, "owned" | "claimable">; /** 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 Loading Loading
panel/dev/mockApi.ts +3 −1 Changes for panel/dev/mockApi.ts: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -1582,7 +1582,9 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> { } 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") { Loading
panel/src/lib/api.ts +2 −1 Changes for panel/src/lib/api.ts: 2 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -20,6 +20,7 @@ import type { ServerFileEntry, ServerJob, ServerInfo, ServerStatus, SessionView, UserDetail, UserView, Loading Loading @@ -200,7 +201,7 @@ export const api = { fleet: () => request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), status: (name: string) => request<ServerInfo>("GET", `/servers/${name}/status`), status: (name: string) => request<ServerStatus>("GET", `/servers/${name}/status`), wake: (name: string) => request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), Loading
panel/src/lib/ownership.test.ts 0 → 100644 +49 −0 Changes for panel/src/lib/ownership.test.ts: 49 added lines, 0 removed lines. Original line number Diff line number Diff line 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); }); });
panel/src/lib/ownership.ts 0 → 100644 +25 −0 Changes for panel/src/lib/ownership.ts: 25 added lines, 0 removed lines. Original line number Diff line number Diff line 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); }
panel/src/lib/types.ts +6 −2 Changes for panel/src/lib/types.ts: 6 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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<ServerInfo, "owned" | "claimable">; /** 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 Loading