import type { AccessResult, ApiError, BackupView, BanlistResult, CreateServerRequest, FleetServer, Identity, KickResult, LinkResult, LinkStatus, LoginResult, PlayersResult, ServerInfo, WhitelistImage, WhitelistResult, } from "./types"; import { loadConfig } from "./config"; import i18next from "i18next"; // Typed client for the felis-api external face (spec §7). Credentials are sent so // the upstream Zero-Trust / Access cookie rides along; the panel never holds a // service token, and the RCON password is never requested (spec §8). function isApiError(x: unknown): x is { error: { code: string; message: string } } { return ( typeof x === "object" && x !== null && "error" in x && typeof (x as { error: unknown }).error === "object" ); } async function request(method: string, path: string, body?: unknown): Promise { const { apiBase } = await loadConfig(); const res = await fetch(`${apiBase}${path}`, { method, credentials: "include", headers: body ? { "Content-Type": "application/json" } : 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, }; throw err; } return parsed as T; } export const api = { // Local-password auth (spec §B1). login sets an HttpOnly session cookie as a // side effect — the panel never sees it — and returns only what to route on next // (must_change_password forces the change card before any other surface). The // username/password pair is the ONLY local credential; Passkey/PWA are Phase // B2/C. login may 403 `local_auth_disabled` on a Zero-Trust-only deployment. login: (username: string, password: string) => request("POST", "/auth/login", { username, password }), // logout is idempotent server-side (clears the session row + cookie); calling it // without a session still resolves 200. After it, refreshing /me yields 401, which // the tier model reads as `unauthenticated` and routes back to /login. logout: () => request<{ ok: boolean }>("POST", "/auth/logout"), // changePassword is callable during the first-login lockdown (the route is // AllowDuringPasswordChange): the server re-verifies current_password, rejects an // unchanged or weak (8–72 byte) new password, writes the new hash, and revokes // every OTHER session. The caller's own session is kept, so no re-login is needed. changePassword: (current_password: string, new_password: string) => request<{ ok: boolean }>("POST", "/auth/change-password", { current_password, new_password, }), // Identity (spec §7 GET /me) — the tier keystone. is_admin is server-computed // (Principal.IsAdmin); the panel reads it but re-deriving admin-ness is the // backend's job. Drives nav + route guards only; every admin route 403s on its // own regardless of what the panel renders. me: () => request("GET", "/me"), myServers: () => request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []), // fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every // server's CRD lifecycle view plus its owner. It 403s for a non-admin principal — // the panel only renders the cockpit link behind is_admin, and the route guards // again server-side regardless of what the UI shows. fleet: () => request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), status: (name: string) => request("GET", `/servers/${name}/status`), wake: (name: string) => request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`), stop: (name: string) => request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`), claim: (name: string) => request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`), /** sendCommand runs one RCON command against a running server (spec §8 写=RCON). * 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 * response body. */ sendCommand: (name: string, command: string) => request<{ output: string }>("POST", `/servers/${name}/command`, { command }), // Access control (spec §7 access). The backend translates these STRUCTURED fields // into RCON commands — every field is charset-validated server-side before it is // concatenated, so there is no free-text injection surface. All are owner-or-admin // gated and require the server to be Running (409 `not_running` otherwise), so the // panel only exposes them on a running server. The reply's `output` is the raw RCON // text, surfaced verbatim as confirmation. /** accessWhitelistList reads the server's whitelist. This GET ALSO requires a * Running server (the readiness gate covers the read, not just the writes), so * callers must gate the fetch on phase === "Running". */ accessWhitelistList: (name: string) => request("GET", `/servers/${name}/access/whitelist`), accessWhitelist: (name: string, action: "add" | "remove", player: string) => request("POST", `/servers/${name}/access/whitelist`, { action, player, }), /** accessBanList reads the server's ban list. Like accessWhitelistList this GET * requires a Running server (the readiness gate covers the read too), so callers * gate the fetch on phase === "Running". */ accessBanList: (name: string) => request("GET", `/servers/${name}/access/ban`), accessBan: (name: string, action: "ban" | "pardon", player: string) => request("POST", `/servers/${name}/access/ban`, { action, player, }), /** accessPlayers reads WHO is online (the only source of names — status carries * the count alone). Like accessWhitelistList this GET requires a Running server, * so callers gate the fetch on phase === "Running". */ accessPlayers: (name: string) => request("GET", `/servers/${name}/access/players`), accessKick: (name: string, player: string) => request("POST", `/servers/${name}/access/kick`, { player }), listImages: () => request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), createServer: (req: CreateServerRequest) => request<{ name: string; subdomain: string; desiredState: string }>( "POST", "/servers", req, ), // World backups (spec §7). listBackups is the app-tier read: an admin sees every // present backup, a user only the backups of worlds they formerly owned — the // scope is decided server-side from the principal, not by any client filter, so a // user cannot widen it. Only present (restorable) rows come back, newest first; // there is no per-server backups endpoint, so the panel filters by server_name // client-side and the first matching row is the one a restore would recover. listBackups: () => request<{ backups: BackupView[] }>("GET", "/backups").then((r) => r.backups ?? []), // restoreBackup starts an ASYNC restore of a server's world from a backup // (spec §7 POST restore-backup). It accepts an optional backupId in the body: when // absent the backend restores the latest backup and resolves its opaque ref // server-side — the client never names a backup by handle (spec §286). // Preconditions are enforced server-side and surfaced as codes: owner-or-admin + // former-owner match (403), a present backup must exist (404 no_backup), and the // server MUST be fully stopped (409 not_stopped) since the restore writes into // the live world volume. The reply is 202 {name, status:"restoring", backup_id} — // success means the restore Job was enqueued, not that the world is back yet. restoreBackup: (name: string, backupId?: string) => request<{ name: string; status: string; backup_id: string }>( "POST", `/servers/${name}/restore-backup`, backupId ? { backup_id: backupId } : undefined, ), // Account linking (spec §10). Both are POST: start reports status from the // session principal (no body, side-effect-free), verify consumes a code the // player was shown in-game. The panel can never mint a code — that is the // internal in-game face — so there is no client method for it. linkStatus: () => request("POST", "/account/link/start"), linkVerify: (code: string) => request("POST", "/account/link/verify", { code }), }; /** * consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors * request()'s `${apiBase}${path}` join, but is a plain string builder rather than * a fetch: an EventSource is *constructed* from a URL (and carries the Access * cookie via withCredentials), it is not requested through this module. The name * is percent-encoded defensively — server names are validated `[a-z0-9-]` * upstream, but the URL is built from a router param, so encoding keeps a stray * value from breaking the URL. */ export function consoleStreamURL(apiBase: string, name: string): string { return `${apiBase}/servers/${encodeURIComponent(name)}/console`; } /** humanizeError turns the stable error code into a user-facing line. */ export function humanizeError(e: unknown): string { const err = e as Partial; const t = i18next.getFixedT(null, "errors"); switch (err.code) { // Local-password auth (spec §B1). case "local_auth_disabled": return t("local_auth_disabled"); case "invalid_credentials": return t("invalid_credentials"); case "weak_password": return t("weak_password"); case "password_unchanged": return t("password_unchanged"); case "not_linked": return t("not_linked"); case "invalid_code": return t("invalid_code"); case "already_linked": return t("already_linked"); case "quota_exceeded": return t("quota_exceeded"); case "already_claimed": return t("already_claimed"); case "image_not_whitelisted": return t("image_not_whitelisted"); case "subdomain_taken": return t("subdomain_taken"); case "already_exists": return t("already_exists"); case "cooldown": return t("cooldown"); // Access control (spec §7): the server must be Running for any RCON-backed // access change; the panel gates on phase, but a stale phase can still race. case "not_running": return t("not_running"); case "console_unavailable": return t("console_unavailable"); // World restore (spec §7 restore-backup): the world volume must be free, so a // running/starting server 409s not_stopped; no present backup 404s no_backup; // the restore subsystem may be unwired (503 restore_unavailable). case "no_backup": return t("no_backup"); case "not_stopped": return t("not_stopped"); case "restore_unavailable": return t("restore_unavailable"); default: if (err.status === 401) return t("session_expired"); if (err.status === 403) return t("forbidden"); return err.message ?? t("generic"); } }