Unverified Commit 248fa5d1 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 服主在控制台看得到 LuckPerms 入口,归属判定统一走 /me/servers

parent c7db7d41
Loading
Loading
Loading
Loading
+3 −1
Changes for panel/dev/mockApi.ts: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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") {
+2 −1
Changes for panel/src/lib/api.ts: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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<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`),
+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);
  });
});
+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);
}
+6 −2
Changes for panel/src/lib/types.ts: 6 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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<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