fix(panel): 路径参数逐段编码并拒绝点段,非 JSON 响应保留状态码,会话过期带回跳送回登录页,连不上时显示横幅

This commit is contained in:
Lemon-miaow committed 2026-09-25 09:34:15 +08:00
1 parent e0fa0268e0
commit 6595a2c581
20 files changed
+635 -141

No files matched your search

+17 -6
View File
@@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth"; import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner"; import { RequireOwner } from "@/components/RequireOwner";
import { ValidParam } from "@/components/ValidParam";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login"; import { Login } from "@/pages/Login";
import { Setup } from "@/pages/Setup"; import { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard"; import { Dashboard } from "@/pages/Dashboard";
@@ -70,11 +72,18 @@ export default function App() {
{/* User-Side — app-tier */} {/* User-Side — app-tier */}
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="servers" element={<ServersPage />} /> <Route path="servers" element={<ServersPage />} />
<Route path="servers/:name" element={<ServerConsole />} /> {/* A :name that is not a server name (a crafted link carrying
<Route path="servers/:name/players" element={<ServerPlayers />} /> "/", "?" or "..") renders "not found" before any request. */}
<Route path="servers/:name/backups" element={<ServerBackups />} /> <Route
<Route path="servers/:name/files" element={<ServerFiles />} /> path="servers/:name"
<Route path="servers/:name/luckperms" element={<ServerLuckPerms />} /> element={<ValidParam param="name" pattern={SERVER_NAME_PARAM} />}
>
<Route index element={<ServerConsole />} />
<Route path="players" element={<ServerPlayers />} />
<Route path="backups" element={<ServerBackups />} />
<Route path="files" element={<ServerFiles />} />
<Route path="luckperms" element={<ServerLuckPerms />} />
</Route>
<Route path="submissions" element={<MySubmissionsPage />} /> <Route path="submissions" element={<MySubmissionsPage />} />
<Route path="account" element={<Account />} /> <Route path="account" element={<Account />} />
@@ -90,7 +99,9 @@ export default function App() {
{/* Owner-gated: user management (one level above admin). */} {/* Owner-gated: user management (one level above admin). */}
<Route element={<RequireOwner />}> <Route element={<RequireOwner />}>
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="users/:id" element={<UserDetailPage />} /> <Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
<Route index element={<UserDetailPage />} />
</Route>
</Route> </Route>
</Route> </Route>
+34 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select"; import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier";
import { visibleSections, type NavSection } from "@/lib/nav"; import { visibleSections, type NavSection } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
import { api } from "@/lib/api"; import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
@@ -137,6 +138,36 @@ function ThemeToggle() {
); );
} }
// ConnectionBanner shows while API calls get no response at all (api.ts
// CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to
// its login page. It clears on the next call that gets through (the pages'
// polling makes one soon); an expired Access sign-in only passes on a full page
// load, hence the reload button.
function ConnectionBanner() {
const { t } = useTranslation("common");
const [lost, setLost] = useState(isConnectionLost);
useEffect(() => {
const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok);
window.addEventListener(CONNECTION_EVENT, onChange);
return () => window.removeEventListener(CONNECTION_EVENT, onChange);
}, []);
if (!lost) return null;
return (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200"
>
<span className="flex min-w-0 items-center gap-2">
<WifiOff className="h-4 w-4 shrink-0" />
{t("connection_lost")}
</span>
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
{t("reload_page")}
</Button>
</div>
);
}
export function AppShell() { export function AppShell() {
const { isAdmin, isOwner } = useTier(); const { isAdmin, isOwner } = useTier();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -190,6 +221,7 @@ export function AppShell() {
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto"> <div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
<ConnectionBanner />
<header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden"> <header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Cat className="h-5 w-5 text-primary" /> <Cat className="h-5 w-5 text-primary" />
+11 -4
View File
@@ -1,4 +1,4 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -7,7 +7,9 @@ import { useTier } from "@/lib/tier";
// and everything under it). It encodes the three-way verdict from the tier model: // and everything under it). It encodes the three-way verdict from the tier model:
// //
// loading → a full-screen spinner (never flash login during boot /me) // loading → a full-screen spinner (never flash login during boot /me)
// unauthenticated → /login (a genuine 401: no/expired session) // unauthenticated → /login (a genuine 401: no/expired session), carrying
// the page to return to (?next=) and, when a session
// ended under an open page, state.sessionEnded
// otherwise → render the app (<Outlet/>) // otherwise → render the app (<Outlet/>)
// //
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case // The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
@@ -15,8 +17,9 @@ import { useTier } from "@/lib/tier";
// app still renders User-Side, exactly as before local auth existed. Only a true // app still renders User-Side, exactly as before local auth existed. Only a true
// 401 bounces to /login. Every admin route remains independently server-guarded. // 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() { export function RequireAuth() {
const { loading, unauthenticated } = useTier(); const { loading, unauthenticated, sessionEnded } = useTier();
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const location = useLocation();
if (loading) { if (loading) {
return ( return (
@@ -26,6 +29,10 @@ export function RequireAuth() {
</div> </div>
); );
} }
if (unauthenticated) return <Navigate to="/login" replace />; if (unauthenticated) {
const back = location.pathname + location.search;
const to = back === "/" ? "/login" : `/login?next=${encodeURIComponent(back)}`;
return <Navigate to={to} replace state={sessionEnded ? { sessionEnded: true } : undefined} />;
}
return <Outlet />; return <Outlet />;
} }
+17 -1
View File
@@ -1,4 +1,4 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -71,6 +71,22 @@ export function NotAuthorized() {
); );
} }
export function NotFound() {
const { t } = useTranslation("common");
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<SearchX className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{t("not_found_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("not_found_body")}</p>
</div>
<Link to="/" className="text-sm font-medium text-primary hover:underline">
{t("back_to_dashboard")}
</Link>
</div>
);
}
/** /**
* PendingBackend is an HONEST placeholder for a surface whose backend read does * PendingBackend is an HONEST placeholder for a surface whose backend read does
* not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend * not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
+10
View File
@@ -0,0 +1,10 @@
import { Outlet, useParams } from "react-router-dom";
import { NotFound } from "@/components/States";
// ValidParam is a layout route that renders its children only when one URL
// parameter has the expected shape (lib/params.ts), and "not found" otherwise.
export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) {
const value = useParams()[param];
if (value === undefined || !pattern.test(value)) return <NotFound />;
return <Outlet />;
}
+2 -1
View File
@@ -46,5 +46,6 @@
"setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.", "setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.",
"setup_create_passkey": "Create passkey", "setup_create_passkey": "Create passkey",
"setup_registering": "Registering…", "setup_registering": "Registering…",
"setup_default_passkey_name": "Default passkey" "setup_default_passkey_name": "Default passkey",
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on."
} }
+5 -1
View File
@@ -26,5 +26,9 @@
"crash_updated_body": "A new version of the panel was just released. Reload to open this page.", "crash_updated_body": "A new version of the panel was just released. Reload to open this page.",
"crash_reload": "Reload", "crash_reload": "Reload",
"crash_details": "Error details", "crash_details": "Error details",
"crash_copy": "Copy error details" "crash_copy": "Copy error details",
"not_found_title": "Page not found",
"not_found_body": "The name in the link is not valid, or the page does not exist.",
"connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.",
"reload_page": "Reload page"
} }
+5 -1
View File
@@ -79,5 +79,9 @@
"too_many_streams": "Too many live streams are open — close some pages and try again.", "too_many_streams": "Too many live streams are open — close some pages and try again.",
"protected_admin": "That name belongs to a linked administrator and can't be reclaimed.", "protected_admin": "That name belongs to a linked administrator and can't be reclaimed.",
"auth_unavailable": "The sign-in service isn't available right now.", "auth_unavailable": "The sign-in service isn't available right now.",
"setup_token_invalid": "That setup token is invalid or expired." "setup_token_invalid": "That setup token is invalid or expired.",
"network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
"upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
"bad_path_param": "The name in this link is not valid. Open it again from the list.",
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent."
} }
+2 -1
View File
@@ -46,5 +46,6 @@
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。", "setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。",
"setup_create_passkey": "创建通行密钥", "setup_create_passkey": "创建通行密钥",
"setup_registering": "注册中…", "setup_registering": "注册中…",
"setup_default_passkey_name": "默认通行密钥" "setup_default_passkey_name": "默认通行密钥",
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。"
} }
+5 -1
View File
@@ -26,5 +26,9 @@
"crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。", "crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。",
"crash_reload": "重新加载", "crash_reload": "重新加载",
"crash_details": "错误信息", "crash_details": "错误信息",
"crash_copy": "复制错误信息" "crash_copy": "复制错误信息",
"not_found_title": "找不到这个页面",
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
"reload_page": "刷新页面"
} }
+5 -1
View File
@@ -79,5 +79,9 @@
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。", "too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
"protected_admin": "该名字属于已绑定的管理员账户,不能被认领。", "protected_admin": "该名字属于已绑定的管理员账户,不能被认领。",
"auth_unavailable": "登录服务当前不可用。", "auth_unavailable": "登录服务当前不可用。",
"setup_token_invalid": "初始化令牌无效或已过期。" "setup_token_invalid": "初始化令牌无效或已过期。",
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
"bad_path_param": "链接里的名称无效,请从列表重新打开。",
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。"
} }
+140 -1
View File
@@ -12,7 +12,8 @@ vi.mock("./config", () => ({
})); }));
// Imported after the mock so api.ts picks up the mocked loadConfig. // Imported after the mock so api.ts picks up the mocked loadConfig.
const { api, SETUP_REQUIRED_EVENT } = await import("./api"); const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost } =
await import("./api");
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) { function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
return vi.fn(async () => ({ return vi.fn(async () => ({
@@ -784,3 +785,141 @@ describe("image whitelist and builds wire shapes", () => {
}); });
}); });
}); });
describe("path parameters", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function lastURL(spy: typeof fetch): string {
const calls = (spy as unknown as ReturnType<typeof vi.fn>).mock.calls;
return String(calls[calls.length - 1][0]);
}
it('encodes "/", "?" and "#" inside one segment', async () => {
const spy = fakeFetch({ player: "x", groups: [], permissions: [], output: "" });
vi.stubGlobal("fetch", spy);
await api.accessLuckPermsInfo("lobby", "a/b?c#d");
expect(lastURL(spy)).toBe("/servers/lobby/access/luckperms/a%2Fb%3Fc%23d");
});
it("keeps a decoded router param that climbs the path inside its segment", async () => {
// react-router decodes "..%2F..%2Fusers%2Fu1%3F" to this before a page sees it.
const spy = fakeFetch({ name: "x", desiredState: "Stopped" });
vi.stubGlobal("fetch", spy);
await api.stop("../../users/u1?");
expect(lastURL(spy)).toBe("/servers/..%2F..%2Fusers%2Fu1%3F/stop");
});
it("refuses dot segments and empty ones before sending anything", async () => {
const spy = fakeFetch({});
vi.stubGlobal("fetch", spy);
for (const bad of ["..", ".", ""]) {
await expect(api.stop(bad)).rejects.toMatchObject({ status: 0, code: "bad_path_param" });
await expect(api.deleteUser(bad)).rejects.toMatchObject({ code: "bad_path_param" });
}
expect(spy).not.toHaveBeenCalled();
expect(humanizeError({ status: 0, code: "bad_path_param" })).toMatch(/not valid/i);
});
it("does not encode a value twice", async () => {
const spy = fakeFetch({ ok: true });
vi.stubGlobal("fetch", spy);
await api.revokeUserSession("u1", "ab/cd");
expect(lastURL(spy)).toBe("/users/u1/sessions/ab%2Fcd");
await api.listServerFiles("lobby", "world/level.dat");
expect(lastURL(spy)).toBe("/servers/lobby/files?path=world%2Flevel.dat");
});
});
describe("responses that are not the API's JSON", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function htmlFetch(status: number, statusText: string) {
return vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
statusText,
text: async () => "<!DOCTYPE html><html><body>Bad gateway</body></html>",
})) as unknown as typeof fetch;
}
it("turns an HTML 502 page into upstream_unavailable with the status kept", async () => {
vi.stubGlobal("fetch", htmlFetch(502, "Bad Gateway"));
const err = await api.status("lobby").catch((e) => e);
expect(err).toMatchObject({ status: 502, code: "upstream_unavailable" });
expect(humanizeError(err)).toMatch(/unavailable/i);
expect(humanizeError(err)).not.toMatch(/JSON|DOCTYPE/);
});
it("keeps the status of an HTML 401 so the session branch still runs", async () => {
const target = new EventTarget();
vi.stubGlobal("window", target);
let expired = 0;
target.addEventListener(SESSION_EXPIRED_EVENT, () => (expired += 1));
vi.stubGlobal("fetch", htmlFetch(401, "Unauthorized"));
const err = await api.status("lobby").catch((e) => e);
expect(err).toMatchObject({ status: 401 });
expect(humanizeError(err)).toMatch(/session/i);
expect(expired).toBe(1);
});
it("refuses a 200 whose body is not JSON", async () => {
vi.stubGlobal("fetch", htmlFetch(200, "OK"));
await expect(api.me()).rejects.toMatchObject({ status: 200, code: "upstream_unavailable" });
});
it("maps an unknown 5xx code to the unavailable line", () => {
expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i);
expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i);
});
});
describe("session and connection signals", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function listen(name: string) {
const target = new EventTarget();
vi.stubGlobal("window", target);
const seen: unknown[] = [];
target.addEventListener(name, (e) => seen.push((e as CustomEvent).detail ?? true));
return seen;
}
it("announces a 401 from a protected route", async () => {
const seen = listen(SESSION_EXPIRED_EVENT);
vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
await expect(api.myServers()).rejects.toMatchObject({ status: 401 });
expect(seen).toHaveLength(1);
});
it("stays silent for /me and the sign-in doors", async () => {
const seen = listen(SESSION_EXPIRED_EVENT);
vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
await expect(api.me()).rejects.toMatchObject({ status: 401 });
await expect(api.setupStatus()).rejects.toMatchObject({ status: 401 });
expect(seen).toHaveLength(0);
});
it("reports a fetch that got no response, and the next one that did", async () => {
const seen = listen(CONNECTION_EVENT);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
}),
);
const err = await api.myServers().catch((e) => e);
expect(err).toMatchObject({ status: 0, code: "network_error" });
expect(humanizeError(err)).toMatch(/Cloudflare Access/);
await api.myServers().catch(() => {});
expect(seen).toEqual([{ ok: false }]);
expect(isConnectionLost()).toBe(true);
vi.stubGlobal("fetch", fakeFetch({ servers: [] }));
await api.myServers();
expect(seen).toEqual([{ ok: false }, { ok: true }]);
expect(isConnectionLost()).toBe(false);
});
});
+206 -108
View File
@@ -38,12 +38,9 @@ import i18next from "i18next";
// service token, and the RCON password is never requested (spec §8). // service token, and the RCON password is never requested (spec §8).
function isApiError(x: unknown): x is { error: { code: string; message: string } } { function isApiError(x: unknown): x is { error: { code: string; message: string } } {
return ( if (typeof x !== "object" || x === null || !("error" in x)) return false;
typeof x === "object" && const e = (x as { error: unknown }).error;
x !== null && return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string";
"error" in x &&
typeof (x as { error: unknown }).error === "object"
);
} }
// A locked session — one that still owes the forced onboarding (passkey // A locked session — one that still owes the forced onboarding (passkey
@@ -61,57 +58,167 @@ function announceSetupRequired(err: ApiError): void {
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT)); window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
} }
async function request<T>(method: string, path: string, body?: unknown): Promise<T> { // A 401 from a protected route means the session ended under the page (it
// expired, or an admin revoked it). TierProvider hears this and re-reads /me,
// and RequireAuth then sends the person to /login with the page to come back
// to. /me is left out because it is that re-read, and /auth/* because the
// sign-in doors run without a session by design.
export const SESSION_EXPIRED_EVENT = "felis:session-expired";
function announceSessionExpired(err: ApiError, path: string): void {
if (err.status !== 401 || typeof window === "undefined") return;
if (path === "/me" || path.startsWith("/auth/")) return;
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
// CONNECTION_EVENT reports when requests stop reaching the API (detail.ok =
// false) and when one gets through again (true). A fetch that rejects never
// saw a response: the network is down, or Cloudflare Access redirected the
// call to its cross-origin login page because the Access session expired,
// which fetch reports as the same TypeError. AppShell shows a banner with a
// reload, since only a full page load can go through the Access login.
export const CONNECTION_EVENT = "felis:connection";
let connectionLost = false;
/** isConnectionLost reports whether the last call got no response. */
export function isConnectionLost(): boolean {
return connectionLost;
}
function reportConnection(ok: boolean): void {
if (connectionLost === !ok) return;
connectionLost = !ok;
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(CONNECTION_EVENT, { detail: { ok } }));
}
function networkError(e: unknown): ApiError {
return {
status: 0,
code: "network_error",
message: e instanceof Error ? e.message : String(e),
};
}
// urlPath builds an API path from literal text and route values, percent-
// encoding each value as one path segment. Values come from router params and
// form fields, and react-router hands params over decoded, so "%2F" arrives as
// "/": interpolated raw, a crafted link could point a button at another
// endpoint, carrying the viewer's cookie. encodeURIComponent covers "/", "?"
// and "#"; "." and ".." survive it and fetch would still walk the path up, so
// those and "" are refused before anything is sent.
export function urlPath(strings: TemplateStringsArray, ...values: string[]): string {
let out = strings[0];
values.forEach((v, i) => {
const seg = String(v);
if (seg === "" || seg === "." || seg === "..") {
const err: ApiError = {
status: 0,
code: "bad_path_param",
message: `refusing ${JSON.stringify(seg)} as a path segment`,
};
throw err;
}
out += encodeURIComponent(seg) + strings[i + 1];
});
return out;
}
// fetchOK performs one call and returns the response when it is 2xx, else
// throws an ApiError that always keeps the HTTP status. A body that is not the
// API's JSON envelope (an HTML 502 or 524 page from the tunnel while the API
// restarts, a bare 503 from the ingress) becomes `upstream_unavailable`, so the
// setup and session branches still see the status and the person reads "try
// again shortly" instead of a JSON parse error.
async function fetchOK(path: string, init: RequestInit): Promise<Response> {
const { apiBase } = await loadConfig(); const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}${path}`, { let res: Response;
try {
res = await fetch(`${apiBase}${path}`, { ...init, credentials: "include" });
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") throw e;
reportConnection(false);
throw networkError(e);
}
reportConnection(true);
if (res.ok) return res;
let parsed: unknown = null;
try {
parsed = JSON.parse(await res.text());
} catch {
/* no body, or not JSON: an ingress or tunnel answered */
}
const err: ApiError = isApiError(parsed)
? { status: res.status, code: parsed.error.code, message: parsed.error.message }
: {
status: res.status,
code: res.status >= 500 ? "upstream_unavailable" : "error",
message: res.statusText,
};
announceSetupRequired(err);
announceSessionExpired(err, path);
throw err;
}
// send returns a 2xx response's parsed JSON body (null for an empty one).
async function send<T>(path: string, init: RequestInit): Promise<T> {
const res = await fetchOK(path, init);
let text: string;
try {
text = await res.text();
} catch (e) {
reportConnection(false);
throw networkError(e);
}
if (!text) return null as T;
try {
return JSON.parse(text) as T;
} catch {
const err: ApiError = {
status: res.status,
code: "upstream_unavailable",
message: "the response was not JSON",
};
throw err;
}
}
function request<T>(method: string, path: string, body?: unknown): Promise<T> {
return send<T>(path, {
method, method,
credentials: "include",
headers: body ? { "Content-Type": "application/json" } : undefined, headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
const text = await res.text();
const parsed: unknown = text ? JSON.parse(text) : null;
if (!res.ok) {
const err: ApiError = {
status: res.status,
code: isApiError(parsed) ? parsed.error.code : "error",
message: isApiError(parsed) ? parsed.error.message : res.statusText,
};
announceSetupRequired(err);
throw err;
}
return parsed as T;
} }
async function requestRaw<T>( function requestRaw<T>(
method: string, method: string,
path: string, path: string,
body: Blob, body: Blob,
headers?: Record<string, string>, headers?: Record<string, string>,
): Promise<T> { ): Promise<T> {
const { apiBase } = await loadConfig(); return send<T>(path, { method, headers, body });
const res = await fetch(`${apiBase}${path}`, { }
method,
credentials: "include",
headers,
body,
});
const text = await res.text(); // rejectingSync turns a synchronous throw inside an api method (urlPath refusing
const parsed: unknown = text ? JSON.parse(text) : null; // a segment) into a rejected promise, so every caller handles it the way it
// handles any failed call.
if (!res.ok) { function rejectingSync<T extends Record<string, unknown>>(methods: T): T {
const err: ApiError = { const out: Record<string, unknown> = {};
status: res.status, for (const [key, fn] of Object.entries(methods)) {
code: isApiError(parsed) ? parsed.error.code : "error", out[key] =
message: isApiError(parsed) ? parsed.error.message : res.statusText, typeof fn === "function"
}; ? (...args: unknown[]) => {
announceSetupRequired(err); try {
throw err; return fn(...args);
} catch (e) {
return Promise.reject(e);
}
}
: fn;
} }
return parsed as T; return out as T;
} }
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for // Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
@@ -127,7 +234,7 @@ export interface SetupState {
setup_required: boolean; setup_required: boolean;
} }
export const api = { export const api = rejectingSync({
// Session doors (spec §B). The product is passwordless: a session is minted only // Session doors (spec §B). The product is passwordless: a session is minted only
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door // by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a // sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
@@ -168,7 +275,7 @@ export const api = {
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }), request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
opLoginStatus: (id: string) => opLoginStatus: (id: string) =>
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`), request<{ approved: boolean }>("GET", urlPath`/auth/op-login/status/${id}`),
opLoginFinish: (request_id: string, code: string) => opLoginFinish: (request_id: string, code: string) =>
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", { request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
@@ -201,23 +308,23 @@ export const api = {
fleet: () => fleet: () =>
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
status: (name: string) => request<ServerStatus>("GET", `/servers/${name}/status`), status: (name: string) => request<ServerStatus>("GET", urlPath`/servers/${name}/status`),
wake: (name: string) => wake: (name: string) =>
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
stop: (name: string) => stop: (name: string) =>
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`), request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`),
claim: (name: string) => claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`), request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON). /** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
* The backend strips a leading "/", rejects control characters (newline → 400) * The backend strips a leading "/", rejects control characters (newline → 400)
* and caps the command at 1000 bytes. The reply is the server's plain-text * and caps the command at 1000 bytes. The reply is the server's plain-text
* response body. */ * response body. */
sendCommand: (name: string, command: string) => sendCommand: (name: string, command: string) =>
request<{ output: string }>("POST", `/servers/${name}/command`, { command }), request<{ output: string }>("POST", urlPath`/servers/${name}/command`, { command }),
// Access control (spec §7 access). The backend translates these STRUCTURED fields // Access control (spec §7 access). The backend translates these STRUCTURED fields
// into RCON commands — every field is charset-validated server-side before it is // into RCON commands — every field is charset-validated server-side before it is
@@ -230,10 +337,10 @@ export const api = {
* Running server (the readiness gate covers the read, not just the writes), so * Running server (the readiness gate covers the read, not just the writes), so
* callers must gate the fetch on phase === "Running". */ * callers must gate the fetch on phase === "Running". */
accessWhitelistList: (name: string) => accessWhitelistList: (name: string) =>
request<WhitelistResult>("GET", `/servers/${name}/access/whitelist`), request<WhitelistResult>("GET", urlPath`/servers/${name}/access/whitelist`),
accessWhitelist: (name: string, action: "add" | "remove", player: string) => accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
request<AccessResult>("POST", `/servers/${name}/access/whitelist`, { request<AccessResult>("POST", urlPath`/servers/${name}/access/whitelist`, {
action, action,
player, player,
}), }),
@@ -242,10 +349,10 @@ export const api = {
* requires a Running server (the readiness gate covers the read too), so callers * requires a Running server (the readiness gate covers the read too), so callers
* gate the fetch on phase === "Running". */ * gate the fetch on phase === "Running". */
accessBanList: (name: string) => accessBanList: (name: string) =>
request<BanlistResult>("GET", `/servers/${name}/access/ban`), request<BanlistResult>("GET", urlPath`/servers/${name}/access/ban`),
accessBan: (name: string, action: "ban" | "pardon", player: string) => accessBan: (name: string, action: "ban" | "pardon", player: string) =>
request<AccessResult>("POST", `/servers/${name}/access/ban`, { request<AccessResult>("POST", urlPath`/servers/${name}/access/ban`, {
action, action,
player, player,
}), }),
@@ -254,10 +361,10 @@ export const api = {
* the count alone). Like accessWhitelistList this GET requires a Running server, * the count alone). Like accessWhitelistList this GET requires a Running server,
* so callers gate the fetch on phase === "Running". */ * so callers gate the fetch on phase === "Running". */
accessPlayers: (name: string) => accessPlayers: (name: string) =>
request<PlayersResult>("GET", `/servers/${name}/access/players`), request<PlayersResult>("GET", urlPath`/servers/${name}/access/players`),
accessKick: (name: string, player: string) => accessKick: (name: string, player: string) =>
request<KickResult>("POST", `/servers/${name}/access/kick`, { player }), request<KickResult>("POST", urlPath`/servers/${name}/access/kick`, { player }),
accessLuckPermsInfo: (name: string, player: string) => accessLuckPermsInfo: (name: string, player: string) =>
request<{ request<{
@@ -265,7 +372,7 @@ export const api = {
groups: string[]; groups: string[];
permissions: { node: string; value: boolean; world?: string }[]; permissions: { node: string; value: boolean; world?: string }[];
output: string; output: string;
}>("GET", `/servers/${name}/access/luckperms/${player}`), }>("GET", urlPath`/servers/${name}/access/luckperms/${player}`),
accessPermission: ( accessPermission: (
name: string, name: string,
@@ -277,7 +384,7 @@ export const api = {
) => ) =>
request<AccessResult & { node: string; value?: boolean; world?: string }>( request<AccessResult & { node: string; value?: boolean; world?: string }>(
"POST", "POST",
`/servers/${name}/access/permission`, urlPath`/servers/${name}/access/permission`,
{ action, player, node, value, world } { action, player, node, value, world }
), ),
@@ -289,7 +396,7 @@ export const api = {
) => ) =>
request<AccessResult & { group: string }>( request<AccessResult & { group: string }>(
"POST", "POST",
`/servers/${name}/access/group`, urlPath`/servers/${name}/access/group`,
{ action, player, group } { action, player, group }
), ),
@@ -306,10 +413,10 @@ export const api = {
request<Build>("POST", "/images/build", req), request<Build>("POST", "/images/build", req),
getBuild: (id: string) => getBuild: (id: string) =>
request<Build>("GET", `/images/build/${id}`), request<Build>("GET", urlPath`/images/build/${id}`),
cancelBuild: (id: string) => cancelBuild: (id: string) =>
request<Build>("POST", `/images/build/${id}/cancel`), request<Build>("POST", urlPath`/images/build/${id}/cancel`),
createServer: (req: CreateServerRequest) => createServer: (req: CreateServerRequest) =>
request<{ name: string; subdomain: string; desiredState: string }>( request<{ name: string; subdomain: string; desiredState: string }>(
@@ -337,7 +444,7 @@ export const api = {
}) => }) =>
request<{ name: string; desiredState: string }>( request<{ name: string; desiredState: string }>(
"PATCH", "PATCH",
`/servers/${name}`, urlPath`/servers/${name}`,
req, req,
), ),
@@ -368,7 +475,7 @@ export const api = {
if (!safetySnapshot) body.safety_snapshot = false; if (!safetySnapshot) body.safety_snapshot = false;
return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>( return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>(
"POST", "POST",
`/servers/${name}/restore-backup`, urlPath`/servers/${name}/restore-backup`,
Object.keys(body).length ? body : undefined, Object.keys(body).length ? body : undefined,
); );
}, },
@@ -380,7 +487,7 @@ export const api = {
// {name, status:"backing_up"}: the Job is enqueued, not done — watch // {name, status:"backing_up"}: the Job is enqueued, not done — watch
// serverJobs for the outcome. // serverJobs for the outcome.
backupNow: (name: string) => backupNow: (name: string) =>
request<{ name: string; status: string }>("POST", `/servers/${name}/backup`), request<{ name: string; status: string }>("POST", urlPath`/servers/${name}/backup`),
// serverJobs lists the newest backup/restore Jobs of one server, newest first // serverJobs lists the newest backup/restore Jobs of one server, newest first
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's // (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
@@ -389,7 +496,7 @@ export const api = {
serverJobs: (name: string) => serverJobs: (name: string) =>
request<{ server: string; jobs: ServerJob[] }>( request<{ server: string; jobs: ServerJob[] }>(
"GET", "GET",
`/servers/${name}/jobs`, urlPath`/servers/${name}/jobs`,
).then((r) => r.jobs ?? []), ).then((r) => r.jobs ?? []),
// Server file editor (spec §7). All three routes are owner-or-admin gated and // Server file editor (spec §7). All three routes are owner-or-admin gated and
@@ -401,7 +508,7 @@ export const api = {
listServerFiles: (name: string, path: string) => listServerFiles: (name: string, path: string) =>
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>( request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
"GET", "GET",
`/servers/${name}/files?path=${encodeURIComponent(path)}`, urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`,
), ),
// readServerFile returns one file's bytes (base64) and the sha256 of the file // readServerFile returns one file's bytes (base64) and the sha256 of the file
@@ -410,7 +517,7 @@ export const api = {
readServerFile: (name: string, path: string) => readServerFile: (name: string, path: string) =>
request<{ path: string; content: string; sha256: string }>( request<{ path: string; content: string; sha256: string }>(
"GET", "GET",
`/servers/${name}/file?path=${encodeURIComponent(path)}`, urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
), ),
// writeServerFile atomically replaces a file's contents (creating it if // writeServerFile atomically replaces a file's contents (creating it if
@@ -421,7 +528,7 @@ export const api = {
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) => writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
request<{ path: string; status: string; sha256: string }>( request<{ path: string; status: string; sha256: string }>(
"PUT", "PUT",
`/servers/${name}/file?path=${encodeURIComponent(path)}`, urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
expectSha256 ? { content, expect_sha256: expectSha256 } : { content }, expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
), ),
@@ -456,7 +563,7 @@ export const api = {
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"), request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
passkeyDelete: (id: string) => passkeyDelete: (id: string) =>
request<void>("DELETE", `/account/passkey/credentials/${id}`), request<void>("DELETE", urlPath`/account/passkey/credentials/${id}`),
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the // Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP // web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
@@ -504,14 +611,14 @@ export const api = {
// expectedDigest is the sha256 of the context the reviewer looked at; the API // expectedDigest is the sha256 of the context the reviewer looked at; the API
// refuses the approval (409 context_changed) when the upload has since changed. // refuses the approval (409 context_changed) when the upload has since changed.
approveSubmission: (id: string, expectedDigest: string) => approveSubmission: (id: string, expectedDigest: string) =>
request<Submission>("POST", `/submissions/${id}/approve`, { expected_digest: expectedDigest }), request<Submission>("POST", urlPath`/submissions/${id}/approve`, { expected_digest: expectedDigest }),
rejectSubmission: (id: string, reason: string) => rejectSubmission: (id: string, reason: string) =>
request<Submission>("POST", `/submissions/${id}/reject`, { reason }), request<Submission>("POST", urlPath`/submissions/${id}/reject`, { reason }),
// Retire a submission outright (row + uploaded context) — the review queue's // Retire a submission outright (row + uploaded context) — the review queue's
// lifecycle valve, the only way an upload is reclaimed from the PVC. // lifecycle valve, the only way an upload is reclaimed from the PVC.
deleteSubmission: (id: string) => request<Submission>("DELETE", `/submissions/${id}`), deleteSubmission: (id: string) => request<Submission>("DELETE", urlPath`/submissions/${id}`),
// The reviewer's read path to the uploaded build context: the executed // The reviewer's read path to the uploaded build context: the executed
// Dockerfile lives inside the tarball, so approving without this would be // Dockerfile lives inside the tarball, so approving without this would be
@@ -520,27 +627,7 @@ export const api = {
// Resolves to the sha256 the API vouched for while streaming these bytes (it // Resolves to the sha256 the API vouched for while streaming these bytes (it
// aborts the transfer on a mismatch), so the approval can name what was read. // aborts the transfer on a mismatch), so the approval can name what was read.
downloadSubmissionContext: async (id: string): Promise<string | null> => { downloadSubmissionContext: async (id: string): Promise<string | null> => {
const { apiBase } = await loadConfig(); const res = await fetchOK(urlPath`/submissions/${id}/context`, { method: "GET" });
const res = await fetch(`${apiBase}/submissions/${id}/context`, {
method: "GET",
credentials: "include",
});
if (!res.ok) {
let code = "error";
let message = res.statusText;
try {
const parsed = JSON.parse(await res.text()) as unknown;
if (isApiError(parsed)) {
code = parsed.error.code;
message = parsed.error.message;
}
} catch {
/* non-JSON error body (e.g. an ingress page): keep the status line */
}
const err: ApiError = { status: res.status, code, message };
announceSetupRequired(err);
throw err;
}
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null; const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -559,13 +646,13 @@ export const api = {
request<Submission>("POST", "/me/submissions", { display_name: displayName }), request<Submission>("POST", "/me/submissions", { display_name: displayName }),
uploadSubmissionContext: (id: string, file: Blob) => uploadSubmissionContext: (id: string, file: Blob) =>
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, { requestRaw<Submission>("POST", urlPath`/me/submissions/${id}/context`, file, {
"Content-Type": "application/x-gzip", "Content-Type": "application/x-gzip",
}), }),
// Retract the caller's own pending submission (and its uploaded context), which // Retract the caller's own pending submission (and its uploaded context), which
// frees their pending slot and storage budget. Reviewed submissions are frozen. // frees their pending slot and storage budget. Reviewed submissions are frozen.
withdrawSubmission: (id: string) => request<Submission>("DELETE", `/me/submissions/${id}`), withdrawSubmission: (id: string) => request<Submission>("DELETE", urlPath`/me/submissions/${id}`),
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"), getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
@@ -596,54 +683,54 @@ export const api = {
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 })); ).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
}, },
getUser: (id: string) => request<UserDetail>("GET", `/users/${id}`), getUser: (id: string) => request<UserDetail>("GET", urlPath`/users/${id}`),
createUser: (req: CreateUserRequest) => createUser: (req: CreateUserRequest) =>
request<UserView>("POST", "/users", req), request<UserView>("POST", "/users", req),
patchUser: (id: string, patch: PatchUserRequest) => patchUser: (id: string, patch: PatchUserRequest) =>
request<UserView>("PATCH", `/users/${id}`, patch), request<UserView>("PATCH", urlPath`/users/${id}`, patch),
deleteUser: (id: string) => deleteUser: (id: string) =>
request<{ deleted: boolean }>("DELETE", `/users/${id}`), request<{ deleted: boolean }>("DELETE", urlPath`/users/${id}`),
disableUser: (id: string, disabled: boolean) => disableUser: (id: string, disabled: boolean) =>
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }), request<{ id: string; disabled: boolean }>("POST", urlPath`/users/${id}/disable`, { disabled }),
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`), getUserQuotas: (id: string) => request<QuotaView>("GET", urlPath`/users/${id}/quotas`),
setUserQuotas: (id: string, quotas: QuotaInput) => setUserQuotas: (id: string, quotas: QuotaInput) =>
request<QuotaView>("PUT", `/users/${id}/quotas`, quotas), request<QuotaView>("PUT", urlPath`/users/${id}/quotas`, quotas),
listUserSessions: (id: string) => listUserSessions: (id: string) =>
request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []), request<{ sessions: SessionView[] }>("GET", urlPath`/users/${id}/sessions`).then((r) => r.sessions ?? []),
revokeUserSessions: (id: string) => revokeUserSessions: (id: string) =>
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`), request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions`),
revokeUserSession: (id: string, hash: string) => revokeUserSession: (id: string, hash: string) =>
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`), request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions/${hash}`),
// unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account // unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account
// remediation for a lost or compromised authenticator). It is deliberately not // remediation for a lost or compromised authenticator). It is deliberately not
// a lockout — the account keeps its other doors (email OTP, in-game op-login // a lockout — the account keeps its other doors (email OTP, in-game op-login
// re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op. // re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op.
unbindUserPasskeys: (id: string) => unbindUserPasskeys: (id: string) =>
request<{ ok: boolean }>("DELETE", `/users/${id}/passkeys`), request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/passkeys`),
linkAccount: (id: string, mcUuid: string, authSource?: string) => linkAccount: (id: string, mcUuid: string, authSource?: string) =>
request<{ ok: boolean; mc_uuid: string; auth_source: string }>( request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
"POST", "POST",
`/users/${id}/links`, urlPath`/users/${id}/links`,
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" }, { mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
), ),
unlinkAccount: (id: string, mcUuid: string) => unlinkAccount: (id: string, mcUuid: string) =>
request<{ ok: boolean; mc_uuid: string }>( request<{ ok: boolean; mc_uuid: string }>(
"DELETE", "DELETE",
`/users/${id}/links/${encodeURIComponent(mcUuid)}`, urlPath`/users/${id}/links/${mcUuid}`,
), ),
}; });
/** /**
* consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors * consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors
@@ -867,9 +954,20 @@ export function humanizeError(e: unknown): string {
return t("auth_unavailable"); return t("auth_unavailable");
case "setup_token_invalid": case "setup_token_invalid":
return t("setup_token_invalid"); return t("setup_token_invalid");
// The request never got a response: the network is down, or Cloudflare
// Access sent it to its login page because the Access session expired.
case "network_error":
return t("network_error");
// A proxy or the tunnel answered for the API (it is restarting or down).
case "upstream_unavailable":
return t("upstream_unavailable");
case "bad_path_param":
return t("bad_path_param");
default: default:
if (err.status === 401) return t("session_expired"); if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden"); if (err.status === 403) return t("forbidden");
if (err.status === 413) return t("payload_too_large");
if (err.status !== undefined && err.status >= 500) return t("upstream_unavailable");
return err.message ?? t("generic"); return err.message ?? t("generic");
} }
} }
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { deriveAuth, isUnauthorized } from "./auth"; import { deriveAuth, isUnauthorized, loginReturnPath } from "./auth";
import type { Identity } from "./types"; import type { Identity } from "./types";
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login // deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
@@ -69,3 +69,16 @@ describe("deriveAuth", () => {
expect(s.unauthenticated).toBe(false); expect(s.unauthenticated).toBe(false);
}); });
}); });
describe("loginReturnPath", () => {
it("returns an in-app path as given", () => {
expect(loginReturnPath("/servers/lobby/files?x=1")).toBe("/servers/lobby/files?x=1");
expect(loginReturnPath("/admin/users/abc")).toBe("/admin/users/abc");
});
it("falls back to the dashboard for anything that could leave the site or loop", () => {
for (const next of [null, "", "servers", "https://evil.test", "//evil.test", "/\\evil.test", "/\t/evil.test", "/login", "/login?next=/x", "/setup"]) {
expect(loginReturnPath(next), JSON.stringify(next)).toBe("/");
}
});
});
+13
View File
@@ -50,3 +50,16 @@ export function deriveAuth(
unauthenticated: !loading && identity === null && isUnauthorized(error), unauthenticated: !loading && identity === null && isUnauthorized(error),
}; };
} }
/** loginReturnPath reads the ?next= a sign-in redirect carried and returns the
* in-app path to land on afterwards. Only a same-origin absolute path counts:
* "//host" is a protocol-relative URL, and a browser reads a backslash as a
* slash and drops tabs and newlines, so "/\host" or "/<tab>/host" would be one
* too; the sign-in pages themselves would loop. Each falls back to the
* dashboard. */
export function loginReturnPath(next: string | null): string {
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
if (/[\\\u0000-\u001f]/.test(next)) return "/";
if (/^\/(login|setup)(\/|\?|$)/.test(next)) return "/";
return next;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "./params";
describe("route param shapes", () => {
it("accepts real server names", () => {
for (const name of ["lobby", "login", "test-one", "a1", "x"]) {
expect(SERVER_NAME_PARAM.test(name), name).toBe(true);
}
});
it("refuses names a crafted link would carry", () => {
for (const name of ["..", ".", "", "a/b", "../users/x", "a?b", "a#b", "-a", "a-", "Lobby", "a b", "a".repeat(64)]) {
expect(SERVER_NAME_PARAM.test(name), JSON.stringify(name)).toBe(false);
}
});
it("accepts hex and UUID user ids, refuses path characters", () => {
expect(USER_ID_PARAM.test("0123456789abcdef0123456789abcdef")).toBe(true);
expect(USER_ID_PARAM.test("3f2b8c1e-9a4d-4c7e-8b1a-2d3e4f5a6b7c")).toBe(true);
for (const id of ["..", ".", "", "a/b", "a?b", "a%2Fb", "a.b"]) {
expect(USER_ID_PARAM.test(id), JSON.stringify(id)).toBe(false);
}
});
});
+12
View File
@@ -0,0 +1,12 @@
// Shapes of the URL parameters routes accept. react-router hands a param over
// decoded ("%2F" becomes "/"), and pages build API paths from it, so a route
// whose param does not fit renders "not found" before any request is made
// (components/ValidParam.tsx). api.ts's urlPath encodes and refuses on its own;
// this keeps a crafted link from reaching a page at all.
/** Server names are DNS-1123 labels; internal/naming holds the stricter rule a
* new server's name must meet, so every existing server fits this one. */
export const SERVER_NAME_PARAM = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
/** User ids are opaque: 32 hex characters or a UUID today. */
export const USER_ID_PARAM = /^[A-Za-z0-9_-]{1,128}$/;
+82 -5
View File
@@ -8,11 +8,11 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { Identity } from "./types"; import type { Identity } from "./types";
import { api } from "./api"; import { api, SESSION_EXPIRED_EVENT } from "./api";
import { deriveAuth, type AuthState } from "./auth"; import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing // TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
// the result through context. Three design rules, all load-bearing: // the result through context. Four design rules, all load-bearing:
// //
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in // 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
// flight (`identity === null`) or after it rejects, isAdmin is false — admin // flight (`identity === null`) or after it rejects, isAdmin is false — admin
@@ -29,6 +29,12 @@ import { deriveAuth, type AuthState } from "./auth";
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()` // 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
// re-reads /me after a login / logout so the gate re-evaluates without a reload. // re-reads /me after a login / logout so the gate re-evaluates without a reload.
// //
// 4. Session-aware while open: a 401 from any protected call (api.ts announces
// SESSION_EXPIRED_EVENT), the tab coming back into view, or the window
// regaining focus re-reads /me through `revalidate()`. It leaves `loading`
// alone, so the app stays mounted during the check; only a 401 changes what
// is shown, and `sessionEnded` tells the login page why the person landed there.
//
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1. // Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
export interface TierState extends AuthState { export interface TierState extends AuthState {
@@ -38,8 +44,17 @@ export interface TierState extends AuthState {
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a /** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
* navigation after the context has settled (login → refresh → redirect). */ * navigation after the context has settled (login → refresh → redirect). */
refresh: () => Promise<void>; refresh: () => Promise<void>;
/** Re-read /me in the background, keeping the app mounted. A 401 signs the
* person out (with sessionEnded set); any other failure changes nothing. */
revalidate: () => Promise<void>;
/** True once a signed-in session was found to have ended (expired or revoked). */
sessionEnded: boolean;
} }
// A tab returning to view re-checks /me at most this often; a 401 from a real
// call re-checks at once.
const REVALIDATE_EVERY_MS = 60_000;
const TierContext = createContext<TierState>({ const TierContext = createContext<TierState>({
identity: null, identity: null,
loading: true, loading: true,
@@ -47,6 +62,8 @@ const TierContext = createContext<TierState>({
isOwner: false, isOwner: false,
unauthenticated: false, unauthenticated: false,
refresh: async () => {}, refresh: async () => {},
revalidate: async () => {},
sessionEnded: false,
}); });
export function TierProvider({ children }: { children: ReactNode }) { export function TierProvider({ children }: { children: ReactNode }) {
@@ -58,24 +75,67 @@ export function TierProvider({ children }: { children: ReactNode }) {
// resolving after a subsequent login's): only the latest call commits its result. // resolving after a subsequent login's): only the latest call commits its result.
const seq = useRef(0); const seq = useRef(0);
const [sessionEnded, setSessionEnded] = useState(false);
const loadingRef = useRef(true);
const identityRef = useRef<Identity | null>(null);
const revalidating = useRef(false);
const lastCheck = useRef(0);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const ticket = ++seq.current; const ticket = ++seq.current;
loadingRef.current = true;
setLoading(true); setLoading(true);
try { try {
const id = await api.me(); const id = await api.me();
if (ticket === seq.current) { if (ticket === seq.current) {
identityRef.current = id;
setIdentity(id); setIdentity(id);
setError(null); setError(null);
setSessionEnded(false);
} }
} catch (e) { } catch (e) {
// Keep the error so deriveAuth can tell a 401 (→ login) from a transient // Keep the error so deriveAuth can tell a 401 (→ login) from a transient
// failure (→ stay functional). Identity is cleared either way. // failure (→ stay functional). Identity is cleared either way.
if (ticket === seq.current) { if (ticket === seq.current) {
identityRef.current = null;
setIdentity(null); setIdentity(null);
setError(e); setError(e);
} }
} finally { } finally {
if (ticket === seq.current) setLoading(false); if (ticket === seq.current) {
loadingRef.current = false;
setLoading(false);
}
lastCheck.current = Date.now();
}
}, []);
const revalidate = useCallback(async () => {
// A refresh in flight answers the same question and owns the loading flag;
// bumping the ticket under it would leave the spinner up for good.
if (loadingRef.current || revalidating.current) return;
revalidating.current = true;
const ticket = ++seq.current;
try {
const id = await api.me();
if (ticket === seq.current) {
// An unchanged identity keeps its object so nothing re-renders.
if (JSON.stringify(id) !== JSON.stringify(identityRef.current)) {
identityRef.current = id;
setIdentity(id);
}
setError(null);
}
} catch (e) {
if (ticket === seq.current && isUnauthorized(e)) {
if (identityRef.current !== null) setSessionEnded(true);
identityRef.current = null;
setIdentity(null);
setError(e);
}
} finally {
revalidating.current = false;
lastCheck.current = Date.now();
} }
}, []); }, []);
@@ -83,6 +143,23 @@ export function TierProvider({ children }: { children: ReactNode }) {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
useEffect(() => {
const onExpired = () => void revalidate();
const onReturn = () => {
if (document.visibilityState !== "visible") return;
if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return;
void revalidate();
};
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.addEventListener("focus", onReturn);
document.addEventListener("visibilitychange", onReturn);
return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
window.removeEventListener("focus", onReturn);
document.removeEventListener("visibilitychange", onReturn);
};
}, [revalidate]);
const state = deriveAuth(identity, error, loading); const state = deriveAuth(identity, error, loading);
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the // isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
// backend grades by operation), but isOwner gates user management. Both are // backend grades by operation), but isOwner gates user management. Both are
@@ -90,7 +167,7 @@ export function TierProvider({ children }: { children: ReactNode }) {
const isOwner = identity?.is_owner === true; const isOwner = identity?.is_owner === true;
return ( return (
<TierContext.Provider value={{ ...state, isOwner, refresh }}> <TierContext.Provider value={{ ...state, isOwner, refresh, revalidate, sessionEnded }}>
{children} {children}
</TierContext.Provider> </TierContext.Provider>
); );
+9
View File
@@ -5,6 +5,7 @@ import {
type StreamStatus, type StreamStatus,
type LogLine, type LogLine,
} from "./logstream"; } from "./logstream";
import { useTier } from "./tier";
// browserEventSource is the production factory: a native EventSource with // browserEventSource is the production factory: a native EventSource with
// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's // credentials, so the Cloudflare Access cookie rides along exactly as api.ts's
@@ -46,6 +47,14 @@ export function useLogStream(url: string): UseLogStream {
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
// EventSource reports a refused stream (a 401 included) only as an error, so
// an ended stream re-checks the session: an expired one takes the person to
// sign in, and otherwise the console shows the stream as ended.
const { revalidate } = useTier();
useEffect(() => {
if (snapshot.status === "ended") void revalidate();
}, [snapshot.status, revalidate]);
const clear = useCallback(() => controller.clear(), [controller]); const clear = useCallback(() => controller.clear(), [controller]);
const reconnect = useCallback(() => controller.reconnect(), [controller]); const reconnect = useCallback(() => controller.reconnect(), [controller]);
+22 -7
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, type FormEvent } from "react"; import { useState, useEffect, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react"; import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { loginReturnPath } from "@/lib/auth";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { loadConfig } from "@/lib/config"; import { loadConfig } from "@/lib/config";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
@@ -20,11 +21,17 @@ import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
// re-evaluates and land on the dashboard. // re-evaluates and land on the dashboard.
// //
// Reaching this page already-authenticated (e.g. typing /login while signed in) // Reaching this page already-authenticated (e.g. typing /login while signed in)
// short-circuits to the dashboard rather than showing the form. // short-circuits to the dashboard rather than showing the form. RequireAuth sends
// a signed-out visitor here with ?next= (the page they were on) and, when their
// session ended under an open page, state.sessionEnded, which shows why; after
// signing in they land back on that page.
export function Login() { export function Login() {
const { loading, identity, refresh } = useTier(); const { loading, identity, refresh } = useTier();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth"); const { t } = useTranslation("auth");
const [searchParams] = useSearchParams();
const next = loginReturnPath(searchParams.get("next"));
const sessionEnded = (useLocation().state as { sessionEnded?: boolean } | null)?.sessionEnded === true;
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main"); const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -89,7 +96,7 @@ export function Login() {
</AuthLayout> </AuthLayout>
); );
} }
if (identity) return <Navigate to="/" replace />; if (identity) return <Navigate to={next} replace />;
async function handleBindSubmit(e: FormEvent) { async function handleBindSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -100,7 +107,7 @@ export function Login() {
try { try {
await api.bind(code); await api.bind(code);
await refresh(); await refresh();
navigate("/", { replace: true }); navigate(next, { replace: true });
} catch (err) { } catch (err) {
setError(humanizeError(err)); setError(humanizeError(err));
setSubmitting(false); setSubmitting(false);
@@ -130,7 +137,7 @@ export function Login() {
try { try {
await api.authEmailVerify(email.trim(), otpCode.trim()); await api.authEmailVerify(email.trim(), otpCode.trim());
await refresh(); await refresh();
navigate("/", { replace: true }); navigate(next, { replace: true });
} catch (err) { } catch (err) {
setError(humanizeError(err)); setError(humanizeError(err));
setSubmitting(false); setSubmitting(false);
@@ -220,7 +227,7 @@ export function Login() {
} }
await refresh(); await refresh();
navigate("/", { replace: true }); navigate(next, { replace: true });
} catch (err: any) { } catch (err: any) {
setError(humanizeError(err)); setError(humanizeError(err));
} finally { } finally {
@@ -251,7 +258,7 @@ export function Login() {
try { try {
await api.opLoginFinish(opRequestId, opCode.trim()); await api.opLoginFinish(opRequestId, opCode.trim());
await refresh(); await refresh();
navigate("/", { replace: true }); navigate(next, { replace: true });
} catch (err) { } catch (err) {
setError(humanizeError(err)); setError(humanizeError(err));
setSubmitting(false); setSubmitting(false);
@@ -268,6 +275,14 @@ export function Login() {
title={t("login_title")} title={t("login_title")}
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")} subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
> >
{sessionEnded && (
<p
role="status"
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
{t("session_ended_notice")}
</p>
)}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
{activeTab === "main" && ( {activeTab === "main" && (