Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
596 lines
22 KiB
TypeScript
596 lines
22 KiB
TypeScript
import type {
|
|
AccessResult,
|
|
ApiError,
|
|
AutostartPolicy,
|
|
BackupView,
|
|
BanlistResult,
|
|
Build,
|
|
CreateServerRequest,
|
|
CreateUserRequest,
|
|
FleetServer,
|
|
Identity,
|
|
KickResult,
|
|
LinkResult,
|
|
LinkStatus,
|
|
BindResult,
|
|
PatchUserRequest,
|
|
PlayersResult,
|
|
QuotaInput,
|
|
QuotaView,
|
|
ServerInfo,
|
|
SessionView,
|
|
UserDetail,
|
|
UserView,
|
|
WhitelistImage,
|
|
WhitelistResult,
|
|
Submission,
|
|
UpdateWindow,
|
|
} 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;
|
|
}
|
|
|
|
async function requestRaw<T>(
|
|
method: string,
|
|
path: string,
|
|
body: Blob,
|
|
headers?: Record<string, string>,
|
|
): Promise<T> {
|
|
const { apiBase } = await loadConfig();
|
|
const res = await fetch(`${apiBase}${path}`, {
|
|
method,
|
|
credentials: "include",
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 = {
|
|
// 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<BindResult>("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<any>("POST", "/auth/passkey/login/begin", { email }),
|
|
|
|
authPasskeyLoginFinish: (email: string, assertion: any) =>
|
|
request<any>("POST", "/auth/passkey/login/finish", { email, assertion }),
|
|
|
|
authPasskeyDiscoverableBegin: () =>
|
|
request<any>("POST", "/auth/passkey/login/discoverable/begin", {}),
|
|
|
|
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
|
|
request<any>("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 <request_id>`; 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", `/auth/op-login/status/${encodeURIComponent(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<SetupState>("POST", "/auth/setup/redeem", { token }),
|
|
|
|
setupStatus: () =>
|
|
request<SetupState>("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<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 }),
|
|
|
|
accessLuckPermsInfo: (name: string, player: string) =>
|
|
request<{
|
|
player: string;
|
|
groups: string[];
|
|
permissions: { node: string; value: boolean; world?: string }[];
|
|
}>("GET", `/servers/${name}/access/luckperms/${player}`),
|
|
|
|
accessPermission: (
|
|
name: string,
|
|
action: "set" | "unset",
|
|
player: string,
|
|
node: string,
|
|
value?: boolean,
|
|
world?: string
|
|
) =>
|
|
request<AccessResult & { node: string; value?: boolean; world?: string }>(
|
|
"POST",
|
|
`/servers/${name}/access/permission`,
|
|
{ action, player, node, value, world }
|
|
),
|
|
|
|
accessGroup: (
|
|
name: string,
|
|
action: "add" | "remove",
|
|
player: string,
|
|
group: string
|
|
) =>
|
|
request<AccessResult & { group: string }>(
|
|
"POST",
|
|
`/servers/${name}/access/group`,
|
|
{ action, player, group }
|
|
),
|
|
|
|
listImages: () =>
|
|
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
|
|
|
|
addImage: (imageRef: string) =>
|
|
request<WhitelistImage>("POST", "/images", { image_ref: imageRef }),
|
|
|
|
removeImage: (imageRef: string) =>
|
|
request<void>("DELETE", `/images?ref=${encodeURIComponent(imageRef)}`),
|
|
|
|
buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) =>
|
|
request<Build>("POST", "/images/build", req),
|
|
|
|
getBuild: (id: string) =>
|
|
request<Build>("GET", `/images/build/${id}`),
|
|
|
|
cancelBuild: (id: string) =>
|
|
request<Build>("POST", `/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;
|
|
memory?: string;
|
|
resources?: {
|
|
cpu?: string;
|
|
cpuRequest?: string;
|
|
memory?: string;
|
|
memoryRequest?: string;
|
|
};
|
|
}) =>
|
|
request<{ name: string; desiredState: string }>(
|
|
"PATCH",
|
|
`/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. 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 }),
|
|
|
|
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<any>("POST", "/account/passkey/register/begin"),
|
|
|
|
passkeyRegisterFinish: (name: string, attestation: any) =>
|
|
request<any>("POST", "/account/passkey/register/finish", { name, attestation }),
|
|
|
|
passkeyList: () =>
|
|
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
|
|
|
|
passkeyDelete: (id: string) =>
|
|
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
|
|
|
// 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<any>("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 ?? []),
|
|
|
|
approveSubmission: (id: string) =>
|
|
request<Submission>("POST", `/submissions/${id}/approve`),
|
|
|
|
rejectSubmission: (id: string, reason: string) =>
|
|
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
|
|
|
listMySubmissions: () =>
|
|
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
|
|
|
|
createSubmission: (displayName: string) =>
|
|
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
|
|
|
uploadSubmissionContext: (id: string, file: Blob) =>
|
|
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
|
"Content-Type": "application/x-gzip",
|
|
}),
|
|
|
|
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
|
|
|
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
|
|
|
|
// ---- 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<UserDetail>("GET", `/users/${id}`),
|
|
|
|
createUser: (req: CreateUserRequest) =>
|
|
request<UserView>("POST", "/users", req),
|
|
|
|
patchUser: (id: string, patch: PatchUserRequest) =>
|
|
request<UserView>("PATCH", `/users/${id}`, patch),
|
|
|
|
deleteUser: (id: string) =>
|
|
request<{ deleted: boolean }>("DELETE", `/users/${id}`),
|
|
|
|
disableUser: (id: string, disabled: boolean) =>
|
|
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
|
|
|
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
|
|
|
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
|
request<QuotaView>("PUT", `/users/${id}/quotas`, quotas),
|
|
|
|
listUserSessions: (id: string) =>
|
|
request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []),
|
|
|
|
revokeUserSessions: (id: string) =>
|
|
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`),
|
|
|
|
revokeUserSession: (id: string, hash: string) =>
|
|
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
|
|
|
|
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
|
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
|
"POST",
|
|
`/users/${id}/links`,
|
|
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
|
|
),
|
|
|
|
unlinkAccount: (id: string, mcUuid: string) =>
|
|
request<{ ok: boolean; mc_uuid: string }>(
|
|
"DELETE",
|
|
`/users/${id}/links/${encodeURIComponent(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`;
|
|
}
|
|
|
|
/** 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<ApiError>;
|
|
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 "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 "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");
|
|
}
|
|
}
|