266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||
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<LoginResult>("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<Identity>("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<ServerInfo>("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<WhitelistResult>("GET", `/servers/${name}/access/whitelist`),
|
||
|
||
accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
|
||
request<AccessResult>("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<BanlistResult>("GET", `/servers/${name}/access/ban`),
|
||
|
||
accessBan: (name: string, action: "ban" | "pardon", player: string) =>
|
||
request<AccessResult>("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<PlayersResult>("GET", `/servers/${name}/access/players`),
|
||
|
||
accessKick: (name: string, player: string) =>
|
||
request<KickResult>("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<LinkStatus>("POST", "/account/link/start"),
|
||
|
||
linkVerify: (code: string) =>
|
||
request<LinkResult>("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<ApiError>;
|
||
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");
|
||
}
|
||
}
|