import type { AccessResult, ApiError, AutostartPolicy, BackupView, BanlistResult, Build, CreateServerRequest, CreateUserRequest, FleetServer, Identity, KickResult, LinkResult, LinkStatus, BindResult, PasskeyCredential, PatchUserRequest, PlayersResult, QuotaInput, QuotaView, ServerFileEntry, ServerJob, MyServerView, ServerStatus, SessionView, UserDetail, UserView, WhitelistImage, WhitelistResult, Submission, UpdateWindow, DBBackupStatus, } 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 } } { if (typeof x !== "object" || x === null || !("error" in x)) return false; const e = (x as { error: unknown }).error; return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string"; } // A locked session — one that still owes the forced onboarding (passkey // enrollment) — gets `403 setup_required` from every protected route. Rendered as // a generic permission error that reads as "you may not do this", when the truth // is "one step remains and completing it unlocks the app" (#8). api.ts cannot // navigate (no router here), so it announces the code on a window event; the // App-shell listener routes the person to /setup, which resumes from the session // without needing a token. Non-browser callers keep the plain error. export const SETUP_REQUIRED_EVENT = "felis:setup-required"; function announceSetupRequired(err: ApiError): void { if (err.status !== 403 || err.code !== "setup_required") return; if (typeof window === "undefined") return; window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT)); } // 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 { const { apiBase } = await loadConfig(); 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(path: string, init: RequestInit): Promise { 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(method: string, path: string, body?: unknown): Promise { return send(path, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, }); } function requestRaw( method: string, path: string, body: Blob, headers?: Record, ): Promise { return send(path, { method, headers, body }); } // rejectingSync turns a synchronous throw inside an api method (urlPath refusing // a segment) into a rejected promise, so every caller handles it the way it // handles any failed call. function rejectingSync>(methods: T): T { const out: Record = {}; for (const [key, fn] of Object.entries(methods)) { out[key] = typeof fn === "function" ? (...args: unknown[]) => { try { return fn(...args); } catch (e) { return Promise.reject(e); } } : fn; } return out as T; } // Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for // a lockdown session; the response (and /setup/status) reports which onboarding // steps remain so the Setup wizard can drive email verification + passkey enrollment. export interface SetupState { user_id: string; username: string; role: string; email: string | null; email_verified: boolean; has_passkey: boolean; setup_required: boolean; } export const api = rejectingSync({ // 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 // sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a // Zero-Trust-only deployment. // 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"), bind: (code: string) => request("POST", "/auth/bind", { code }), authEmailStart: (email: string) => request<{ sent: boolean; expires_at: string }>("POST", "/auth/email/start", { email }), authEmailVerify: (email: string, code: string) => request<{ user_id: string; role: string }>("POST", "/auth/email/verify", { email, code }), authPasskeyLoginBegin: (email: string) => request("POST", "/auth/passkey/login/begin", { email }), authPasskeyLoginFinish: (email: string, assertion: any) => request("POST", "/auth/passkey/login/finish", { email, assertion }), authPasskeyDiscoverableBegin: () => request("POST", "/auth/passkey/login/discoverable/begin", {}), authPasskeyDiscoverableFinish: (login_id: string, assertion: any) => request("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }), // Op-login (spec §B): the staff door. start mails an OTP to a staff address and // returns a request handle; an online admin vouches in-game with // `/felis web op approve `; the panel polls status until approved, // then finish redeems {request_id, code} into a session. start answers 202 with a // request_id for ANY well-formed address (anti-enumeration), so the UI just waits. opLoginStart: (email: string) => request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }), opLoginStatus: (id: string) => request<{ approved: boolean }>("GET", urlPath`/auth/op-login/status/${id}`), opLoginFinish: (request_id: string, code: string) => request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", { request_id, code, }), // Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL // and mints a lockdown session (Public); status re-reads progress for a reload // mid-wizard (SetupAllowed — the surviving session, no token needed). setupRedeem: (token: string) => request("POST", "/auth/setup/redeem", { token }), setupStatus: () => request("GET", "/auth/setup/status"), // 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: MyServerView[] }>("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", urlPath`/servers/${name}/status`), wake: (name: string) => request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`), stop: (name: string) => request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`), claim: (name: string) => request<{ name: string; claimed: boolean }>("POST", urlPath`/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", urlPath`/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", urlPath`/servers/${name}/access/whitelist`), accessWhitelist: (name: string, action: "add" | "remove", player: string) => request("POST", urlPath`/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", urlPath`/servers/${name}/access/ban`), accessBan: (name: string, action: "ban" | "pardon", player: string) => request("POST", urlPath`/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", urlPath`/servers/${name}/access/players`), accessKick: (name: string, player: string) => request("POST", urlPath`/servers/${name}/access/kick`, { player }), accessLuckPermsInfo: (name: string, player: string) => request<{ player: string; groups: string[]; permissions: { node: string; value: boolean; world?: string }[]; output: string; }>("GET", urlPath`/servers/${name}/access/luckperms/${player}`), accessPermission: ( name: string, action: "set" | "unset", player: string, node: string, value?: boolean, world?: string ) => request( "POST", urlPath`/servers/${name}/access/permission`, { action, player, node, value, world } ), accessGroup: ( name: string, action: "add" | "remove", player: string, group: string ) => request( "POST", urlPath`/servers/${name}/access/group`, { action, player, group } ), listImages: () => request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), addImage: (imageRef: string) => request("POST", "/images", { image_ref: imageRef }), removeImage: (imageRef: string) => request("DELETE", `/images?ref=${encodeURIComponent(imageRef)}`), buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) => request("POST", "/images/build", req), /** One page of the build history, newest first; rows leave out the Dockerfile. */ listBuilds: (params?: { query?: string; limit?: number; offset?: number }) => { const sp = new URLSearchParams(); if (params?.query) sp.set("query", params.query); if (params?.limit) sp.set("limit", String(params.limit)); if (params?.offset) sp.set("offset", String(params.offset)); const qs = sp.toString(); return request<{ builds: Build[]; total: number }>( "GET", `/images/build${qs ? `?${qs}` : ""}`, ).then((r) => ({ builds: r.builds ?? [], total: r.total ?? 0 })); }, getBuild: (id: string) => request("GET", urlPath`/images/build/${id}`), cancelBuild: (id: string) => request("POST", urlPath`/images/build/${id}/cancel`), createServer: (req: CreateServerRequest) => request<{ name: string; subdomain: string; desiredState: string }>( "POST", "/servers", req, ), patchServer: (name: string, req: { displayName?: string; autostartPolicy?: AutostartPolicy; image?: string; /** Required with an image that moves the server to another build: the world is * opened by that build's Minecraft version, which cannot be undone. */ confirmImageChange?: boolean; memory?: string; /** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */ idleStopSeconds?: number; resources?: { cpu?: string; cpuRequest?: string; memory?: string; memoryRequest?: string; }; }) => request<{ name: string; desiredState: string }>( "PATCH", urlPath`/servers/${name}`, 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. By default the backend first backs up the world as it // is (a "pre_restore" backup) and starts the restore only once that succeeded; // safetySnapshot=false skips it. The reply is 202 {name, status:"restoring", // backup_id, safety_snapshot} — success means the work was enqueued, not that // the world is back yet; serverJobs shows the snapshot and the restore. restoreBackup: (name: string, backupId?: string, safetySnapshot = true) => { const body: { backup_id?: string; safety_snapshot?: boolean } = {}; if (backupId) body.backup_id = backupId; if (!safetySnapshot) body.safety_snapshot = false; return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>( "POST", urlPath`/servers/${name}/restore-backup`, Object.keys(body).length ? body : undefined, ); }, // backupNow enqueues a manual backup (spec §7 POST backup). Preconditions are // enforced server-side and surfaced as codes: owner-or-admin (403) and the // server MUST be fully stopped (409 not_stopped — the world volume is RWO), so // callers gate the action on phase === "Stopped". The reply is 202 // {name, status:"backing_up"}: the Job is enqueued, not done — watch // serverJobs for the outcome. backupNow: (name: string) => request<{ name: string; status: string }>("POST", urlPath`/servers/${name}/backup`), // 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 // failure text rides `message`. The backend answers 503 until the job-status // reader is wired, so callers should tolerate that error. serverJobs: (name: string) => request<{ server: string; jobs: ServerJob[] }>( "GET", urlPath`/servers/${name}/jobs`, ).then((r) => r.jobs ?? []), // Server file editor (spec §7). All three routes are owner-or-admin gated and // refuse with 409 not_stopped unless the server is fully stopped (the world // volume is RWO), so callers gate on phase === "Stopped". The path travels as a // query parameter — a file path contains "/" and never round-trips through a // path segment. Content is []byte on the wire, which Go's encoding/json renders // as base64, so it is binary-safe in both directions. listServerFiles: (name: string, path: string) => request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>( "GET", urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`, ), // readServerFile returns one file's bytes (base64) and the sha256 of the file // as stored. A file over the read ceiling is a 413, never a silent truncation, // because a later save of a truncated body would destroy the rest of the file. readServerFile: (name: string, path: string) => request<{ path: string; content: string; sha256: string }>( "GET", urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, ), // writeServerFile atomically replaces a file's contents (creating it if // absent). Sending an explicit "" is a deliberate truncate; the wire field is // required, but that is enforced by the caller (this method always sends one). // With expectSha256 (the hash the read returned) a file someone changed since // is refused with 409 file_changed; without it the write is unconditional. writeServerFile: (name: string, path: string, content: string, expectSha256?: string) => request<{ path: string; status: string; sha256: string }>( "PUT", urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, expectSha256 ? { content, expect_sha256: expectSha256 } : { content }, ), // 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 }), emailStart: (email: string) => request<{ sent: boolean; expires_at: string }>("POST", "/account/email/start", { email }), emailVerify: (code: string) => request<{ verified: boolean; email: string }>("POST", "/account/email/verify", { code }), // setEmail records the caller's address WITHOUT an OTP round-trip (the setup // wizard's Step 1). The bootstrap has no SMTP, so email_verified stays false; a // later Settings/SMTP flow verifies it via emailStart/emailVerify. setEmail: (email: string) => request<{ email: string }>("POST", "/account/email", { email }), passkeyRegisterBegin: () => request("POST", "/account/passkey/register/begin"), passkeyRegisterFinish: (name: string, attestation: any) => request("POST", "/account/passkey/register/finish", { name, attestation }), passkeyList: () => request<{ credentials: PasskeyCredential[] }>("GET", "/account/passkey/credentials"), passkeyDelete: (id: string) => request("DELETE", urlPath`/account/passkey/credentials/${id}`), // The caller's own sessions: every browser signed in to the account, the one // making the request marked current. Revoking the current one is a sign-out. listMySessions: () => request<{ sessions: SessionView[] }>("GET", "/account/sessions").then((r) => r.sessions ?? []), revokeMySession: (hash: string) => request<{ ok: boolean; signed_out: boolean }>("DELETE", urlPath`/account/sessions/${hash}`), revokeMyOtherSessions: () => request<{ revoked: number }>("POST", "/account/sessions/revoke-others"), // Re-authentication. Adding or removing a passkey and changing the email are // refused with 403 reauth_required unless this session proved a factor in the // last few minutes. Status names the factors that can prove it: "passkey", // "email" (a code to the verified address) or "sign_in" (operators sign in // again). Like the migrate begin, the passkey begin returns the raw // {"publicKey": {...}} document. reauthStatus: () => request<{ needed: boolean; until?: string; factors: string[] }>("GET", "/account/reauth"), reauthPasskeyBegin: () => request("POST", "/account/reauth/passkey/begin"), reauthPasskeyFinish: (assertion: any) => request<{ ok: boolean; until: string }>("POST", "/account/reauth/passkey/finish", { assertion }), reauthEmailStart: () => request<{ sent: boolean; expires_at: string }>("POST", "/account/reauth/email/start"), reauthEmailVerify: (code: string) => request<{ ok: boolean; until: string }>("POST", "/account/reauth/email/verify", { code }), // 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 // otherwise) → issue-code (source names the target account and reads the one-time // code) → redeem (the TARGET account spends the code; the source's servers move to // it and the source is retired). migrateStatus: () => request<{ active: boolean; state?: string; target_user_id?: string; confirm_factor?: string; code_expires_at?: string; }>("GET", "/account/migrate"), migrateConfirmOTPStart: () => request<{ sent: boolean; expires_at: string }>("POST", "/account/migrate/confirm/otp/start"), migrateConfirmOTPVerify: (code: string) => request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/otp/verify", { code }), migrateConfirmPasskeyBegin: () => request("POST", "/account/migrate/confirm/passkey/begin"), migrateConfirmPasskeyFinish: (assertion: any) => request<{ confirmed: boolean }>("POST", "/account/migrate/confirm/passkey/finish", { assertion, }), migrateIssueCode: (target_user_id: string) => request<{ code: string; expires_at: string }>("POST", "/account/migrate/issue-code", { target_user_id, }), migrateRedeem: (code: string) => request<{ migrated: boolean; servers_moved: number; servers: string[] }>( "POST", "/account/migrate/redeem", { code }, ), listSubmissions: () => request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []), // 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. approveSubmission: (id: string, expectedDigest: string) => request("POST", urlPath`/submissions/${id}/approve`, { expected_digest: expectedDigest }), rejectSubmission: (id: string, reason: string) => request("POST", urlPath`/submissions/${id}/reject`, { reason }), // Retire a submission outright (row + uploaded context) — the review queue's // lifecycle valve, the only way an upload is reclaimed from the PVC. deleteSubmission: (id: string) => request("DELETE", urlPath`/submissions/${id}`), // The reviewer's read path to the uploaded build context: the executed // Dockerfile lives inside the tarball, so approving without this would be // blind. The body is the attacker-supplied archive — download it, never // render it — which the API's attachment disposition enforces. // 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. downloadSubmissionContext: async (id: string): Promise => { const res = await fetchOK(urlPath`/submissions/${id}/context`, { method: "GET" }); const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${id}-context.tar.gz`; link.click(); URL.revokeObjectURL(url); return digest; }, listMySubmissions: () => request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []), createSubmission: (displayName: string) => request("POST", "/me/submissions", { display_name: displayName }), uploadSubmissionContext: (id: string, file: Blob) => requestRaw("POST", urlPath`/me/submissions/${id}/context`, file, { "Content-Type": "application/x-gzip", }), // Retract the caller's own pending submission (and its uploaded context), which // frees their pending slot and storage budget. Reviewed submissions are frozen. withdrawSubmission: (id: string) => request("DELETE", urlPath`/me/submissions/${id}`), getUpdateWindow: () => request("GET", "/updates/window"), setUpdateWindow: (window: UpdateWindow) => request("PUT", "/updates/window", window), // Freshness of the host's control-plane database backup (felis-db-backup.timer). getDBBackup: () => request("GET", "/platform/db-backup"), // ---- User admin (admin-tier, spec §7 user admin) ---- listUsers: (params?: { query?: string; role?: "admin" | "user"; disabled?: "true" | "false"; limit?: number; offset?: number; }) => { const sp = new URLSearchParams(); if (params?.query) sp.set("query", params.query); if (params?.role) sp.set("role", params.role); if (params?.disabled) sp.set("disabled", params.disabled); if (params?.limit) sp.set("limit", String(params.limit)); if (params?.offset) sp.set("offset", String(params.offset)); const qs = sp.toString(); return request<{ users: UserView[]; total: number }>( "GET", `/users${qs ? `?${qs}` : ""}`, ).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 })); }, getUser: (id: string) => request("GET", urlPath`/users/${id}`), createUser: (req: CreateUserRequest) => request("POST", "/users", req), patchUser: (id: string, patch: PatchUserRequest) => request("PATCH", urlPath`/users/${id}`, patch), deleteUser: (id: string) => request<{ deleted: boolean }>("DELETE", urlPath`/users/${id}`), disableUser: (id: string, disabled: boolean) => request<{ id: string; disabled: boolean }>("POST", urlPath`/users/${id}/disable`, { disabled }), getUserQuotas: (id: string) => request("GET", urlPath`/users/${id}/quotas`), setUserQuotas: (id: string, quotas: QuotaInput) => request("PUT", urlPath`/users/${id}/quotas`, quotas), listUserSessions: (id: string) => request<{ sessions: SessionView[] }>("GET", urlPath`/users/${id}/sessions`).then((r) => r.sessions ?? []), revokeUserSessions: (id: string) => request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions`), revokeUserSession: (id: string, hash: string) => request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions/${hash}`), // unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account // 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 // re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op. unbindUserPasskeys: (id: string) => request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/passkeys`), linkAccount: (id: string, mcUuid: string, authSource?: string) => request<{ ok: boolean; mc_uuid: string; auth_source: string }>( "POST", urlPath`/users/${id}/links`, { mc_uuid: mcUuid, auth_source: authSource ?? "mojang" }, ), unlinkAccount: (id: string, mcUuid: string) => request<{ ok: boolean; mc_uuid: string }>( "DELETE", urlPath`/users/${id}/links/${mcUuid}`, ), }); /** * 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`; } /** * buildLogsStreamURL builds the SSE endpoint for build logs. */ export function buildLogsStreamURL(apiBase: string, id: string): string { return `${apiBase}/images/build/${encodeURIComponent(id)}/logs`; } /** clientError is a failure the panel itself detects (no request was made), * shaped like a server error so humanizeError words it from the code. */ export function clientError(code: string): ApiError { return { status: 0, code, message: "" }; } /** humanizeError turns the stable error code into a user-facing line. */ export function humanizeError(e: unknown): string { const t = i18next.getFixedT(null, "errors"); if (e && typeof e === "object" && "name" in e) { const name = (e as any).name; if (name === "NotAllowedError") { return t("passkey_not_allowed"); } if (name === "AbortError") { return t("passkey_aborted"); } } const err = e as Partial; switch (err.code) { // Session doors (spec §B): every passwordless door 403s this when local // sessions are disabled on a Zero-Trust-only deployment. case "local_auth_disabled": return t("local_auth_disabled"); case "staff_account": return t("staff_account"); case "not_linked": return t("not_linked"); case "invalid_code": return t("invalid_code"); case "already_linked": return t("already_linked"); case "otp_resend_cooldown": return t("otp_resend_cooldown"); case "otp_locked": return t("otp_locked"); case "otp_account_locked": return t("otp_account_locked"); // Volumetric limits on the sign-in doors (internal/api/ratelimit.go): one // network calling too fast, or the install-wide mail budget spent. case "rate_limited": return t("rate_limited"); case "mail_rate_limited": return t("mail_rate_limited"); case "passkey_challenge_invalid": return t("passkey_challenge_invalid"); case "invalid_attestation": return t("invalid_attestation"); case "passkey_already_bound": return t("passkey_already_bound"); case "passkey_unavailable": return t("passkey_unavailable"); case "last_passkey": return t("last_passkey"); // User admin (internal/api/handlers_users.go): the caller's own account and // the owner account are refused, each for its own reason. case "self_protected": return t("self_protected"); case "owner_protected": return t("owner_protected"); case "session_not_found": return t("session_not_found"); case "quota_exceeded": return t("quota_exceeded"); case "already_claimed": return t("already_claimed"); case "image_not_whitelisted": return t("image_not_whitelisted"); // Image pinning (internal/imagepin): a server runs the exact build its tag // named when it was created or last changed, so the registry has to hold the // tag, and moving a world to another build needs an explicit confirmation. case "image_not_in_registry": return t("image_not_in_registry"); case "registry_unavailable": return t("registry_unavailable"); case "image_change_unconfirmed": return t("image_change_unconfirmed"); 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"); // The backup failed a read-back (sha256 or gzip/tar parse), so the server // refuses to extract it over the world. case "backup_corrupt": return t("backup_corrupt"); case "not_stopped": return t("not_stopped"); // World-volume lock: a restore, backup or file write is running on this // server's world, so a wake or a second world operation is refused until the // Job finishes (internal/maintenance). case "maintenance_in_progress": return t("maintenance_in_progress"); case "no_world_volume": return t("no_world_volume"); // File editor: the file changed after it was opened (another manager saved // it, or the server rewrote it), so the save was refused rather than // overwriting that edit. case "file_changed": return t("file_changed"); case "volume_full": return t("volume_full"); // On-demand backup rationing (data-durability-9): one per server per // cooldown, none while the shared backup store is at its cap. case "backup_cooldown": return t("backup_cooldown"); case "backup_store_full": return t("backup_store_full"); case "restore_unavailable": return t("restore_unavailable"); // Server create/edit (spec §22): the portability regex + reservation list are // enforced server-side, and the create form's own checks are weaker, so these // refusals reach the dialog as-is. case "bad_name": return t("bad_name"); case "bad_subdomain": return t("bad_subdomain"); case "at_capacity": return t("at_capacity"); case "storage_immutable": return t("storage_immutable"); case "bad_idle_stop": return t("bad_idle_stop"); // Email identity: the verified-email uniqueness index (migration 0020) plus // VerifyEmailOTP's guard make a second verified holder impossible; the OTP // relay can also refuse to deliver at all. case "email_taken": return t("email_taken"); case "mail_undeliverable": return t("mail_undeliverable"); // No [smtp] relay at all: every door that mails a code refuses before minting. case "mail_unavailable": return t("mail_unavailable"); // File editor (spec §7): path/size refusals from the sandboxed job, plus the // subsystem being unwired. case "bad_path": return t("bad_path"); case "too_large": return t("too_large"); case "files_timeout": return t("files_timeout"); case "files_unavailable": return t("files_unavailable"); case "jobs_unavailable": return t("jobs_unavailable"); // Builds, uploads and review: terminal-state conflicts and unwired subsystems. case "already_terminal": return t("already_terminal"); case "build_unavailable": return t("build_unavailable"); case "build_logs_unavailable": return t("build_logs_unavailable"); case "already_reviewed": return t("already_reviewed"); case "context_changed": return t("context_changed"); case "submission_quota_exceeded": return t("submission_quota_exceeded"); case "submission_cooldown": return t("submission_cooldown"); case "submissions_unavailable": return t("submissions_unavailable"); case "uploads_unavailable": return t("uploads_unavailable"); case "backup_unavailable": return t("backup_unavailable"); // Account migration + the re-auth steps it depends on: every refusal below is // an expected outcome of the Account → migrate flow, not a fault. case "account_retired": return t("account_retired"); case "no_migration": return t("no_migration"); case "not_confirmed": return t("not_confirmed"); case "already_confirmed": return t("already_confirmed"); case "invalid_target": return t("invalid_target"); case "target_not_found": return t("target_not_found"); case "target_unavailable": return t("target_unavailable"); case "no_passkey": return t("no_passkey"); case "passkey_required": return t("passkey_required"); case "no_step_up_factor": return t("no_step_up_factor"); case "passkey_login_failed": return t("passkey_login_failed"); case "passkey_login_invalid": return t("passkey_login_invalid"); case "too_many_challenges": return t("too_many_challenges"); // Re-authentication before a change to how the account signs in. case "reauth_required": return t("reauth_required"); case "staff_reauth": return t("staff_reauth"); case "no_session": return t("no_session"); // Operator-login approvals, live streams, and the remaining auth doors. case "op_login_invalid": return t("op_login_invalid"); case "op_login_not_found": return t("op_login_not_found"); case "too_many_streams": return t("too_many_streams"); case "protected_admin": return t("protected_admin"); case "auth_unavailable": return t("auth_unavailable"); case "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"); case "passkey_no_credential": return t("passkey_no_credential"); case "not_found": return t("not_found"); case "conflict": return t("conflict"); case "restore_in_progress": return t("restore_in_progress"); // 507: named on its own so the 5xx fallback below does not call it an // outage. case "uploads_full": return t("uploads_full"); case "unsupported_media_type": return t("unsupported_media_type"); // The detail says which field was wrong; the server writes it in English, // so it rides inside a localized sentence. case "bad_request": return err.message ? t("bad_request", { detail: err.message }) : t("generic"); default: if (err.status === 401) return t("session_expired"); 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"); } }