Files
Felis/panel/dev/mockApi.ts
T

1207 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import type {
AutostartPolicy,
BackupView,
Build,
CreateServerRequest,
FleetServer,
Identity,
LoginResult,
Phase,
ServerInfo,
WhitelistImage,
} from "../src/lib/types";
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
type AccountID = (typeof ACCOUNT_IDS)[number];
type Role = "admin" | "user";
type Method = "GET" | "POST" | "DELETE";
type CreateError =
| "bad_request"
| "already_exists"
| "subdomain_taken"
| "image_not_whitelisted";
interface MockAccount {
id: AccountID;
role: Role;
email: string;
linked: boolean;
mustChangePassword: boolean;
}
interface MockServer extends ServerInfo {
owner: AccountID | null;
}
interface AccessState {
whitelist: string[];
banned: string[];
// online is the mock's stand-in for the live RCON "list" roster. Kick and ban
// splice a player out of it so the demo roster reflects the action on reload.
online: string[];
}
interface MockState {
accounts: Record<AccountID, MockAccount>;
servers: MockServer[];
images: WhitelistImage[];
access: Record<string, AccessState>;
backups: BackupView[];
builds: Build[];
}
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
// rejects a malformed player exactly as the real API would (400 bad_request),
// keeping the panel's error path exercisable in dev.
const PLAYER_NAME = /^[A-Za-z0-9_]{1,16}$/;
interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
state: MockState;
method: string;
parts: string[];
}
interface SessionContext extends RequestContext {
account: MockAccount;
}
const SESSION_COOKIE = "felis_mock_session";
const ROOT_DOMAIN = "dev.felis.localhost";
const API_BASE = "/api/v1";
const MOCK_PASSWORD = "devpassword";
const MOCK_LINK_CODE = "LINK1234";
const MC_UUID = "00000000-0000-4000-8000-000000000001";
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
const LOGIN_HINT_STYLE = `
#felis-mock-login-hint {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483647;
max-width: min(360px, calc(100vw - 40px));
border: 1px dashed hsl(217 33% 22%);
border-radius: 8px;
background: hsl(222 47% 8% / 0.96);
color: hsl(215 20% 70%);
box-shadow: 0 18px 60px hsl(222 47% 3% / 0.45);
padding: 12px;
font: 12px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#felis-mock-login-hint strong {
display: block;
color: hsl(210 40% 98%);
font-size: 13px;
margin-bottom: 4px;
}
#felis-mock-login-hint code {
color: hsl(210 40% 98%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
`;
const LOGIN_HINT_SCRIPT = `
(() => {
const id = "felis-mock-login-hint";
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in</strong><div>Admin: <code>owner</code> / <code>${MOCK_PASSWORD}</code></div><div>User: <code>user</code> / <code>${MOCK_PASSWORD}</code> (not linked)</div><div>User: <code>linked</code> / <code>${MOCK_PASSWORD}</code> (linked)</div><div>First login: <code>setup</code> / <code>${MOCK_PASSWORD}</code></div><div>Link code: <code>${MOCK_LINK_CODE}</code></div></aside>';
const sync = () => {
const existing = document.getElementById(id);
if (location.pathname === "/login") {
if (!existing) document.body.insertAdjacentHTML("beforeend", html);
return;
}
existing?.remove();
};
const notify = () => setTimeout(sync, 0);
for (const key of ["pushState", "replaceState"]) {
const original = history[key];
history[key] = function (...args) {
const out = original.apply(this, args);
notify();
return out;
};
}
addEventListener("popstate", notify);
addEventListener("DOMContentLoaded", sync);
sync();
})();
`;
// World-backup seed. A backup is written when the reaper archives an inactive
// world, so these read as "sleep saves": a handful for survival (recent through one
// nearly expired, to exercise the relative-time and near-expiry states), one for
// modded, none for the rest so the empty state shows too. former_owner is the
// archiving owner; GET /backups is scoped by it for non-admins (BackupsForUser).
const GiB = 1024 ** 3;
const DAY_MS = 86_400_000;
const RETENTION_DAYS = 90;
function backup(server: string, daysAgo: number, sizeBytes: number, formerOwner: string): BackupView {
const created = Date.now() - daysAgo * DAY_MS;
return {
id: `bk-${server}-${daysAgo}`,
server_name: server,
former_owner: formerOwner,
size_bytes: Math.round(sizeBytes),
reason: "inactive_15d",
status: "present",
created_at: new Date(created).toISOString(),
expires_at: new Date(created + RETENTION_DAYS * DAY_MS).toISOString(),
};
}
function mockBackups(): BackupView[] {
return [
backup("survival", 5, 1.4 * GiB, "owner"),
backup("survival", 20, 1.3 * GiB, "owner"),
backup("survival", 45, 1.2 * GiB, "owner"),
backup("survival", 88, 2.1 * GiB, "owner"), // ~2 days from expiry — exercises the urgency state
backup("modded", 12, 0.6 * GiB, "owner"),
];
}
function initialState(): MockState {
return {
accounts: {
owner: account("owner", "admin", true, false),
user: account("user", "user", false, false),
linked: account("linked", "user", true, false),
setup: account("setup", "admin", true, true),
},
images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
{ image_ref: "registry.felis.svc:5000/fabric-1.20.1:demo", enabled: true, source: "demo" },
{
image_ref: "registry.felis.svc:5000/forge-1.20.1:disabled",
enabled: false,
source: "demo",
},
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
players: 12,
maxPlayers: 20,
autostartPolicy: "public",
}),
server("lobby", "Hub Lobby", "Running", "linked", {
players: 28,
maxPlayers: 60,
autostartPolicy: "public",
}),
server("creative", "Creative Lab", "Stopped", "user", {
autostartPolicy: "public",
maxPlayers: 16,
}),
server("modded", "Modded Testbed", "Starting", "owner", {
autostartPolicy: "allowlist",
maxPlayers: 12,
}),
server("broken", "Broken Node", "Failed", "user", {
autostartPolicy: "ownerOnly",
maxPlayers: 8,
}),
server("claim-me", "Claimable Node", "Stopped", null, {
maxPlayers: 10,
}),
...generatedServers(),
],
access: {
// Seeded past a page (PAGE_SIZE=10) and the search threshold (>8) so the
// whitelist's paging + filter are both exercisable in the mock demo.
survival: {
whitelist: [
"mock_player", "test_player", "Notch", "jeb_", "Dinnerbone",
"Grumm", "Steve", "Alex", "Herobrine", "Technoblade",
"Dream", "GeorgeNotFound", "Sapnap", "BadBoyHalo", "Skeppy",
"Tommyinnit", "Tubbo", "Ranboo", "Wilbur_Soot", "Philza",
"Captain_Puffy", "Nihachu", "Fundy", "Quackity", "Karl_Jacobs",
],
// 12 banned names — past the search threshold (>8) and a page (>10) so the ban
// list's filter + paging demo too; kept distinct from the online roster so the
// mock reads like a real server (you don't ban who's currently on).
banned: [
"Griefer_99", "tnt_troll", "hack_client_x", "spam_bot_01", "lava_caster",
"dupe_glitcher", "griefKing", "nukebot", "AFK_farmer", "chat_spammer",
"xray_cheater", "fly_hacker",
],
// 12 online, matching the server's players:12 — past the search threshold (>8)
// and a page (>10) so the roster's filter + paging are both exercisable, with a
// few non-whitelisted names to try kick / ban on.
online: [
"mock_player", "test_player", "Notch", "Steve", "Alex", "jeb_",
"Dinnerbone", "Griefer_88", "rndGuest_7", "xX_Raider_Xx", "creeper_fan", "Herobrine",
],
},
},
backups: mockBackups(),
builds: [
{
id: "bld-1",
image_ref: "registry.felis.svc:5000/modpack-beta:1.0",
status: "succeeded",
requested_by: "[email protected]",
created_at: new Date(Date.now() - 3600000).toISOString(),
finished_at: new Date(Date.now() - 3500000).toISOString(),
},
{
id: "bld-2",
image_ref: "registry.felis.svc:5000/forge-broken:1.0",
status: "failed",
error: "trivy found a CRITICAL CVE: CVE-2026-12345 in library/forge",
requested_by: "[email protected]",
created_at: new Date(Date.now() - 1800000).toISOString(),
finished_at: new Date(Date.now() - 1700000).toISOString(),
},
],
};
}
// generatedServers fills the mock fleet past one page so the SysAdmin cockpit's
// pagination and fuzzy search are actually exercisable in dev. Deterministic (no
// Math.random) so the demo is stable across reloads: phase / owner / policy /
// capacity all cycle. 8 themes × 3 = 24 servers; with the 6 hand-authored ones the
// fleet is 30 → two pages at PAGE_SIZE 20.
function generatedServers(): MockServer[] {
const phases: Phase[] = ["Running", "Stopped", "Starting", "Failed", "Running", "Stopped"];
const owners: (AccountID | null)[] = ["owner", "linked", "user", null];
const policies: AutostartPolicy[] = ["ownerOnly", "public", "allowlist"];
const themes = ["smp", "creative", "skyblock", "anarchy", "minigames", "build", "pvp", "vanilla"];
const out: MockServer[] = [];
let i = 0;
for (const theme of themes) {
for (let n = 1; n <= 3; n++) {
const phase = phases[i % phases.length];
const max = 10 + ((i * 7) % 50);
out.push(
server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], {
players: phase === "Running" ? 1 + ((i * 3) % max) : 0,
maxPlayers: max,
autostartPolicy: policies[i % policies.length],
}),
);
i++;
}
}
return out;
}
function mockStartupMessage(): string {
return [
"",
" Felis mock API",
` API base: ${API_BASE}`,
` Root domain: ${ROOT_DOMAIN}`,
"",
" Accounts:",
` owner / ${MOCK_PASSWORD} admin, linked`,
` user / ${MOCK_PASSWORD} user, not linked`,
` linked / ${MOCK_PASSWORD} user, linked`,
` setup / ${MOCK_PASSWORD} admin, first-login password change`,
"",
` Link code: ${MOCK_LINK_CODE}`,
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
}
function account(
id: AccountID,
role: Role,
linked: boolean,
mustChangePassword: boolean,
): MockAccount {
return {
id,
role,
linked,
mustChangePassword,
email: `${id}@mock.felis.local`,
};
}
function server(
name: string,
displayName: string,
phase: Phase,
owner: AccountID | null,
overrides: Partial<ServerInfo> = {},
): MockServer {
return {
name,
subdomain: name,
displayName,
phase,
desiredState: phase === "Stopped" ? "Stopped" : "Running",
players: phase === "Running" ? 1 : 0,
maxPlayers: 20,
autostartPolicy: "ownerOnly",
owned: false,
claimable: false,
owner,
...overrides,
};
}
function sendJSON(res: ServerResponse, status: number, value: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(value));
}
function sendError(
res: ServerResponse,
status: number,
code: string,
message: string,
): void {
sendJSON(res, status, { error: { code, message } });
}
function route(ctx: RequestContext): string {
return `${ctx.method} ${ctx.parts.slice(2).join("/")}`;
}
function is(method: Method, ctx: RequestContext): boolean {
return ctx.method === method;
}
async function readJSON<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8");
return (text ? JSON.parse(text) : {}) as T;
}
function readAccount(req: IncomingMessage, state: MockState): MockAccount | null {
const ids = ACCOUNT_IDS.join("|");
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=(${ids})(?:;|$)`).exec(
req.headers.cookie ?? "",
);
return m ? state.accounts[m[1] as AccountID] : null;
}
function setSessionCookie(res: ServerResponse, accountID: AccountID): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
function loginAccount(username: string): AccountID | null {
const normalized = username.toLowerCase();
return ACCOUNT_IDS.includes(normalized as AccountID) ? (normalized as AccountID) : null;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
email: accountInfo.email,
role: accountInfo.role,
is_admin: accountInfo.role === "admin",
must_change_password: accountInfo.mustChangePassword,
};
}
function findServer(state: MockState, name: string): MockServer | null {
return state.servers.find((s) => s.name === name) ?? null;
}
function canSee(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id || serverInfo.owner === null;
}
function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id;
}
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo))
.map((serverInfo) => projectServer(serverInfo, accountInfo));
}
// fleetView projects the internal mock servers into the GET /fleet wire shape
// (the SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView:
// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the
// me/servers projection's players/maxPlayers — plus the owner joined as the email
// (COALESCE(email, username) server-side). Endpoint and live player counts are
// gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] {
return state.servers.map((s, i) => {
const ready = s.phase === "Running";
return {
name: s.name,
subdomain: s.subdomain,
phase: s.phase,
ready,
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
endpointMode: "domain",
endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: ready ? s.players ?? 0 : 0,
playersMax: s.maxPlayers ?? 0,
owner: s.owner ? state.accounts[s.owner].email : "",
};
});
}
function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo {
const { owner: _owner, ...wire } = serverInfo;
const owned = canManage(accountInfo, serverInfo);
return {
...wire,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
};
}
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.players = phase === "Running" ? Math.max(serverInfo.players ?? 0, 1) : 0;
}
function policy(value: unknown): AutostartPolicy {
return value === "public" || value === "allowlist" ? value : "ownerOnly";
}
function createServer(
state: MockState,
owner: AccountID,
req: Partial<CreateServerRequest>,
): MockServer | CreateError {
const name = req.name?.trim();
const subdomain = req.subdomain?.trim();
if (!name || !subdomain || !req.image) return "bad_request";
if (findServer(state, name)) return "already_exists";
if (state.servers.some((s) => s.subdomain === subdomain)) return "subdomain_taken";
if (!state.images.some((i) => i.enabled && i.image_ref === req.image)) {
return "image_not_whitelisted";
}
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
subdomain,
players: 0,
maxPlayers: 20,
autostartPolicy: policy(req.autostartPolicy),
});
state.servers.unshift(created);
return created;
}
function sendCreateError(res: ServerResponse, code: CreateError): void {
const status = code === "already_exists" || code === "subdomain_taken" ? 409 : 400;
sendError(res, status, code, code);
}
async function handlePublic(ctx: RequestContext): Promise<boolean> {
switch (route(ctx)) {
case "POST auth/login": {
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
const accountID = body.username ? loginAccount(body.username.trim()) : null;
if (!accountID || body.password !== MOCK_PASSWORD) {
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
return true;
}
const accountInfo = ctx.state.accounts[accountID];
setSessionCookie(ctx.res, accountID);
const out: LoginResult = {
user_id: `mock-${accountInfo.id}`,
role: accountInfo.role,
must_change_password: accountInfo.mustChangePassword,
};
sendJSON(ctx.res, 200, out);
return true;
}
case "POST auth/logout":
clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true });
return true;
default:
return false;
}
}
async function handleSession(ctx: SessionContext): Promise<boolean> {
switch (route(ctx)) {
case "GET me/servers":
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
return true;
case "GET me":
sendJSON(ctx.res, 200, identity(ctx.account));
return true;
case "GET fleet":
// Admin-tier, fleet-wide — mirrors the real adminOnly gate (a non-admin is
// 403'd before the handler) so the cockpit's RequireAdmin path is exercised.
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
return true;
case "POST auth/change-password":
ctx.account.mustChangePassword = false;
sendJSON(ctx.res, 200, { ok: true });
return true;
case "GET backups":
// Admin sees every archive; a user only worlds they formerly owned — mirrors
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
sendJSON(ctx.res, 200, {
backups: ctx.state.backups.filter(
(b) => ctx.account.role === "admin" || b.former_owner === ctx.account.id,
),
});
return true;
case "POST servers":
await createServerRoute(ctx);
return true;
case "POST account/link/start":
sendJSON(ctx.res, 200, { linked: ctx.account.linked });
return true;
case "POST account/link/verify":
await verifyLinkRoute(ctx);
return true;
default:
if (await handleImageRoute(ctx)) return true;
return await handleServerRoute(ctx);
}
}
async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
if (ctx.parts[2] !== "images") return false;
// GET /api/v1/images
if (is("GET", ctx) && ctx.parts.length === 3) {
sendJSON(ctx.res, 200, { images: ctx.state.images });
return true;
}
// POST /api/v1/images (add image)
if (is("POST", ctx) && ctx.parts.length === 3) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const body = await readJSON<{ image_ref?: string }>(ctx.req);
const ref = body.image_ref?.trim();
if (!ref) {
sendError(ctx.res, 400, "bad_request", "image_ref is required");
return true;
}
// Check if already exists in whitelist
let img = ctx.state.images.find((i) => i.image_ref === ref);
if (img) {
img.enabled = true;
} else {
img = { image_ref: ref, enabled: true, source: "external" };
ctx.state.images.unshift(img);
}
sendJSON(ctx.res, 201, img);
return true;
}
// DELETE /api/v1/images (remove image)
if (is("DELETE", ctx) && ctx.parts.length === 3) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const ref = url.searchParams.get("ref");
if (!ref) {
sendError(ctx.res, 400, "bad_request", "ref query parameter is required");
return true;
}
const idx = ctx.state.images.findIndex((i) => i.image_ref === ref);
if (idx < 0) {
sendError(ctx.res, 404, "not_found", "image not found");
return true;
}
ctx.state.images.splice(idx, 1);
ctx.res.statusCode = 204;
ctx.res.end();
return true;
}
// POST /api/v1/images/build (trigger build)
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const body = await readJSON<{ image_ref?: string; dockerfile?: string; context_ref?: string; base_image?: string }>(ctx.req);
if (!body.image_ref || !body.dockerfile || !body.context_ref) {
sendError(ctx.res, 400, "bad_request", "image_ref, dockerfile, and context_ref are required");
return true;
}
const newBuild: Build = {
id: `bld-${Date.now()}`,
image_ref: body.image_ref.trim(),
status: "building",
dockerfile: body.dockerfile,
context_ref: body.context_ref.trim(),
base_image: body.base_image?.trim(),
requested_by: ctx.account.email,
created_at: new Date().toISOString(),
};
ctx.state.builds.unshift(newBuild);
// Mock build progression in a timeout
setTimeout(() => {
const b = ctx.state.builds.find((x) => x.id === newBuild.id);
if (b && b.status === "building") {
b.status = "succeeded";
b.finished_at = new Date().toISOString();
// Add to whitelist images
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
}
}
}, 15000); // Succeeded after 15 seconds
sendJSON(ctx.res, 202, newBuild);
return true;
}
// GET /api/v1/images/build (list builds)
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, { builds: ctx.state.builds });
return true;
}
// GET /api/v1/images/build/{id} (get build)
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && ctx.parts.length === 5) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const build = ctx.state.builds.find((b) => b.id === ctx.parts[4]);
if (!build) {
sendError(ctx.res, 404, "not_found", "build not found");
return true;
}
sendJSON(ctx.res, 200, build);
return true;
}
// POST /api/v1/images/build/{id}/cancel (cancel build)
if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const buildID = ctx.parts[4];
const build = ctx.state.builds.find((b) => b.id === buildID);
if (!build) {
sendError(ctx.res, 404, "not_found", "build not found");
return true;
}
if (build.status === "succeeded" || build.status === "failed" || build.status === "cancelled") {
sendError(ctx.res, 409, "already_terminal", "build already terminal");
return true;
}
build.status = "cancelled";
build.finished_at = new Date().toISOString();
sendJSON(ctx.res, 200, build);
return true;
}
// GET /api/v1/images/build/{id}/logs (SSE logs stream)
if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "logs" && ctx.parts.length === 6) {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const buildID = ctx.parts[4];
const build = ctx.state.builds.find((b) => b.id === buildID);
if (!build) {
sendError(ctx.res, 404, "not_found", "build not found");
return true;
}
streamBuildLogs(ctx.req, ctx.res, buildID);
return true;
}
return false;
}
function streamBuildLogs(
req: IncomingMessage,
res: ServerResponse,
buildID: string
): void {
const lines = [
`[INFO] [Kaniko] Starting build for ID: ${buildID}`,
"[INFO] [Kaniko] Pulling base image library/postgres:15",
"[INFO] [Kaniko] Successfully pulled base image",
"[INFO] [Kaniko] Executing: RUN echo 'setup'",
"[INFO] [Kaniko] Pushing image to registry.felis.svc:5000",
"[INFO] [Trivy] Starting security scan...",
"[INFO] [Trivy] Scanning registry.felis.svc:5000/image",
"[INFO] [Trivy] No critical vulnerabilities found. Scan PASSED.",
`[INFO] [System] Build succeeded for ${buildID}`,
];
let i = 0;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(": connected\n\n");
const timer = setInterval(() => {
if (i < lines.length) {
res.write(`data: ${lines[i]}\n\n`);
i++;
} else {
clearInterval(timer);
}
}, 1000);
req.on("close", () => clearInterval(timer));
}
async function createServerRoute(ctx: SessionContext): Promise<void> {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return;
}
const body = await readJSON<Partial<CreateServerRequest>>(ctx.req);
const created = createServer(ctx.state, ctx.account.id, body);
if (typeof created === "string") {
sendCreateError(ctx.res, created);
return;
}
sendJSON(ctx.res, 201, {
name: created.name,
subdomain: created.subdomain,
desiredState: created.desiredState,
});
}
async function verifyLinkRoute(ctx: SessionContext): Promise<void> {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim().toUpperCase() !== MOCK_LINK_CODE) {
sendError(ctx.res, 400, "invalid_code", "invalid mock link code");
return;
}
ctx.account.linked = true;
sendJSON(ctx.res, 200, { linked: true, mc_uuid: MC_UUID });
}
async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
if (ctx.parts[2] !== "servers" || !ctx.parts[3]) return false;
const serverInfo = findServer(ctx.state, decodeURIComponent(ctx.parts[3]));
if (!serverInfo) {
sendError(ctx.res, 404, "not_found", "server not found");
return true;
}
if (!canSee(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "status") {
sendJSON(ctx.res, 200, projectServer(serverInfo, ctx.account));
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "console") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
streamConsole(ctx.req, ctx.res, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "wake") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "stop") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
setPhase(serverInfo, "Stopped");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Stopped" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "command") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
handleCommandMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "restore-backup") {
return await handleRestoreBackupMock(ctx, serverInfo);
}
if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo);
}
return false;
}
// handleRestoreBackupMock mirrors the backend's restore authorization order
// (handlers_backups.go): owner-or-admin → specific backup by id or latest present
// backup else 404 no_backup → non-admin former-owner match → stopped gate else
// 409 not_stopped → 202.
async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServer): Promise<boolean> {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
let backupId: string | undefined;
try {
const body = await readJSON<{ backup_id?: string }>(ctx.req);
backupId = body.backup_id;
} catch (e) {
// Ignore if body is empty or unparsable
}
let backup: any = null;
if (backupId) {
backup = ctx.state.backups.find((b) => b.id === backupId && b.server_name === serverInfo.name && b.status === "present");
if (!backup) {
sendError(ctx.res, 404, "no_backup", "no restorable backup exists for this server");
return true;
}
} else {
const latest = ctx.state.backups
.filter((b) => b.server_name === serverInfo.name && b.status === "present")
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0];
if (!latest) {
sendError(ctx.res, 404, "no_backup", "no restorable backup exists for this server");
return true;
}
backup = latest;
}
// Non-admins may restore only a world they formerly owned (spec §466).
if (ctx.account.role !== "admin" && backup.former_owner !== ctx.account.id) {
sendError(ctx.res, 403, "forbidden", "not the former owner of this world");
return true;
}
// The world PVC must be free — a running/starting server still holds it.
if (serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before restoring a backup");
return true;
}
sendJSON(ctx.res, 202, { name: serverInfo.name, status: "restoring", backup_id: backup.id });
return true;
}
function accessFor(state: MockState, name: string): AccessState {
let entry = state.access[name];
if (!entry) {
entry = { whitelist: [], banned: [], online: [] };
state.access[name] = entry;
}
return entry;
}
function whitelistOutput(players: string[]): string {
if (players.length === 0) return "There are no whitelisted players";
return `There are ${players.length} whitelisted player(s): ${players.join(", ")}`;
}
function listOutput(online: string[], max: number): string {
const head = `There are ${online.length} of a max of ${max} players online:`;
return online.length === 0 ? head : `${head} ${online.join(", ")}`;
}
// banlistOutput reproduces vanilla's multiline "banlist" reply: a header line then
// one "<name> was banned by <source>: <reason>" line per ban. The panel's parser
// (parseBanlistOutput) keys on the " was banned by " marker, so this exercises the
// real shape — header + reasons that carry their own colons and spaces — end to end.
function banlistOutput(banned: string[]): string {
if (banned.length === 0) return "There are no bans.";
const head = `There are ${banned.length} ban(s):`;
const lines = banned.map((p) => `${p} was banned by Server: Banned by an operator.`);
return [head, ...lines].join("\n");
}
// handleAccessMock mirrors issueAccessCommand's two gates — owner/admin AND the
// server being Running (RCON) — before dispatching the whitelist/ban routes. The GET
// whitelist read is behind the SAME Running gate as the writes, exactly as the real
// readiness check covers it (409 not_running on a cold server).
function handleAccessMock(ctx: SessionContext, serverInfo: MockServer): boolean {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.phase !== "Running") {
sendError(
ctx.res,
409,
"not_running",
"server is not running; wake it before managing access",
);
return true;
}
const sub = ctx.parts[5];
const access = accessFor(ctx.state, serverInfo.name);
if (is("GET", ctx) && sub === "whitelist") {
sendJSON(ctx.res, 200, {
name: serverInfo.name,
players: [...access.whitelist],
output: whitelistOutput(access.whitelist),
});
return true;
}
if (is("POST", ctx) && sub === "whitelist") {
void handleListMutation(ctx, serverInfo, access, "whitelist");
return true;
}
if (is("GET", ctx) && sub === "players") {
const max = serverInfo.maxPlayers ?? 0;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
online: access.online.length,
max,
players: [...access.online],
output: listOutput(access.online, max),
});
return true;
}
if (is("POST", ctx) && sub === "kick") {
void handleKickMock(ctx, serverInfo, access);
return true;
}
if (is("GET", ctx) && sub === "ban") {
sendJSON(ctx.res, 200, {
name: serverInfo.name,
players: [...access.banned],
output: banlistOutput(access.banned),
});
return true;
}
if (is("POST", ctx) && sub === "ban") {
void handleListMutation(ctx, serverInfo, access, "ban");
return true;
}
return false;
}
// handleKickMock backs POST .../access/kick: charset-validate the player, drop them
// from the online roster (so a reload reflects it), and echo {name, player, output}.
async function handleKickMock(
ctx: SessionContext,
serverInfo: MockServer,
access: AccessState,
): Promise<void> {
const body = await readJSON<{ player?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
const i = access.online.indexOf(player);
if (i >= 0) access.online.splice(i, 1);
sendJSON(ctx.res, 200, {
name: serverInfo.name,
player,
output: `[mock] kick ${player}`,
});
}
// handleListMutation backs both POST .../access/whitelist (add|remove) and
// POST .../access/ban (ban|pardon): the same structured {action, player} shape with
// a charset-validated player, echoing back {name, action, player, output}.
async function handleListMutation(
ctx: SessionContext,
serverInfo: MockServer,
access: AccessState,
kind: "whitelist" | "ban",
): Promise<void> {
const body = await readJSON<{ action?: string; player?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
const list = kind === "whitelist" ? access.whitelist : access.banned;
const addAction = kind === "whitelist" ? "add" : "ban";
const removeAction = kind === "whitelist" ? "remove" : "pardon";
if (body.action === addAction) {
if (!list.includes(player)) list.push(player);
// A ban also removes the player from the live server, so drop them from the
// online roster too — the real "ban" kicks them as a side effect.
if (kind === "ban") {
const oi = access.online.indexOf(player);
if (oi >= 0) access.online.splice(oi, 1);
}
} else if (body.action === removeAction) {
const i = list.indexOf(player);
if (i >= 0) list.splice(i, 1);
} else {
sendError(ctx.res, 400, "bad_request", "unknown action");
return;
}
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action: body.action,
player,
output: `[mock] ${body.action} ${player}`,
});
}
async function handleCommandMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
const body = await readJSON<{ command?: string }>(ctx.req);
const cmd = body.command?.trim();
if (!cmd) {
sendError(ctx.res, 400, "bad_request", "command is required");
return;
}
if (serverInfo.phase !== "Running") {
sendError(
ctx.res,
409,
"conflict",
"server is not running; wake it before sending console commands",
);
return;
}
const reply = mockCommandReply(cmd);
sendJSON(ctx.res, 200, { output: reply });
}
function mockCommandReply(cmd: string): string {
const lower = cmd.toLowerCase();
if (lower === "list") return "There are 2 of a max 20 players online: mock_player, test_player";
if (lower === "tps" || lower === "forge tps") return "TPS from last 5s, 10s, 1m, 5m, 15m: 20.00, 20.00, *19.87, 19.95, 19.98";
if (cmd.startsWith("say ")) return `[mock_server] ${cmd.slice(4)}`;
if (lower === "help") return "--- Showing help ---\n/felis\n/msg\n/list\n/rules";
return `[mock] command "${cmd}" executed`;
}
function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed");
return;
}
if (!ctx.account.linked) {
sendError(ctx.res, 412, "not_linked", "link a Minecraft account first");
return;
}
serverInfo.owner = ctx.account.id;
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, claimed: true });
}
function streamConsole(
req: IncomingMessage,
res: ServerResponse,
serverInfo: MockServer,
): void {
const lines = [
`[12:00:00] [Server thread/INFO]: Starting ${serverInfo.displayName ?? serverInfo.name}`,
"[12:00:01] [Server thread/INFO]: Loading properties",
"[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only",
"[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%",
"[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"",
];
let i = 0;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(": connected\n\n");
const timer = setInterval(() => {
res.write(`data: ${lines[i % lines.length]}\n\n`);
i++;
}, 900);
req.on("close", () => clearInterval(timer));
}
export function mockApiPlugin(): Plugin {
let state = initialState();
return {
name: "felis-mock-api",
transformIndexHtml() {
return [
{ tag: "style", attrs: { id: "felis-mock-login-style" }, children: LOGIN_HINT_STYLE },
{ tag: "script", children: LOGIN_HINT_SCRIPT },
];
},
configureServer(server) {
server.config.logger.info(mockStartupMessage());
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url ?? "/", "http://localhost");
const method = req.method ?? "GET";
const parts = url.pathname.split("/").filter(Boolean);
if (method === "GET" && url.pathname === "/config.json") {
sendJSON(res, 200, { apiBase: API_BASE, rootDomain: ROOT_DOMAIN });
return;
}
if (parts[0] !== "api" || parts[1] !== "v1") {
next();
return;
}
if (method === "POST" && parts[2] === "__mock" && parts[3] === "reset") {
state = initialState();
server.config.logger.info(" Felis mock API: state reset");
sendJSON(res, 200, { ok: true });
return;
}
const ctx: RequestContext = { req, res, state, method, parts };
try {
if (await handlePublic(ctx)) return;
const accountInfo = readAccount(req, state);
if (!accountInfo) {
sendError(res, 401, "unauthenticated", "mock session required");
return;
}
if (await handleSession({ ...ctx, account: accountInfo })) return;
sendError(res, 404, "not_found", "mock route not found");
} catch (err) {
sendError(res, 500, "mock_error", err instanceof Error ? err.message : "mock error");
}
});
},
};
}