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; servers: MockServer[]; images: WhitelistImage[]; access: Record; 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 = ''; 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: "owner@mock.felis.local", 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: "owner@mock.felis.local", 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 = {}, ): 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(req: IncomingMessage): Promise { 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, ): 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 { 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 { 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 { 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 { if (ctx.account.role !== "admin") { sendError(ctx.res, 403, "forbidden", "admin account required"); return; } const body = await readJSON>(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 { 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 { 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 { 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 " was banned by : " 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 { 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 { 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 { 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"); } }); }, }; }