Files
Felis/panel/dev/mockApi.ts
T

3189 lines
121 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 { createHash } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { gzipSync } from "node:zlib";
import type { Plugin } from "vite";
import type {
AuthSourceConfig,
AuthSourcesSettings,
WakePolicySettings,
AutostartPolicy,
BackupView,
Build,
BuildScan,
CreateServerRequest,
FleetServer,
Identity,
MyServerView,
Phase,
ServerJob,
ServerStatus,
WhitelistImage,
Submission,
UserView,
UserDetail,
CreateUserRequest,
PatchUserRequest,
QuotaView,
QuotaInput,
SessionView,
} from "../src/lib/types";
type AccountID = string;
type Role = "admin" | "user" | "owner";
type Method = "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
type CreateError =
| "bad_request"
| "already_exists"
| "subdomain_taken"
| "image_not_whitelisted";
function isAdmin(role: Role): boolean {
return role === "admin" || role === "owner";
}
function isOwner(role: Role): boolean {
return role === "owner";
}
interface MockAccount {
id: string;
role: Role;
email: string;
linked: boolean;
emailVerified: boolean;
disabled?: boolean;
created_at?: string;
updated_at?: string;
quota?: QuotaView;
sessions?: SessionView[];
// Until when this browser's session counts as re-authenticated (ms epoch),
// and the address an email-change code was last sent to.
reauthUntil?: number;
pendingEmail?: string;
}
interface MockServer extends ServerStatus {
owner: AccountID | null;
}
interface PlayerLuckPerms {
groups: string[];
permissions: { node: string; value: boolean; world?: string }[];
}
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[];
luckperms: Record<string, PlayerLuckPerms>;
}
interface MockState {
accounts: Record<AccountID, MockAccount>;
servers: MockServer[];
images: WhitelistImage[];
access: Record<string, AccessState>;
backups: BackupView[];
// Backup/restore Jobs by server, newest first. The mock finishes each at once.
jobs: Record<string, ServerJob[]>;
builds: Build[];
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
submissions: Submission[];
updateWindow: { start: string | null; end: string | null };
authSources: AuthSourcesSettings;
wakePolicy: WakePolicySettings;
// Each server's world volume, seeded on first visit.
files: Record<string, MockTree>;
}
// 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}$/;
const LP_NODE = /^[A-Za-z0-9_.*-]{1,64}$/;
const LP_CTX = /^[A-Za-z0-9_-]{1,48}$/;
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_LINK_CODE = "LINK1234";
const MOCK_OTP_CODE = "123456";
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 (passwordless)</strong><div>Email OTP: any email / code <code>${MOCK_OTP_CODE}</code> (signs in as <code>owner</code>, admin)</div><div>Link code: <code>${MOCK_LINK_CODE}</code> (signs in as <code>linked</code>, user)</div><div>Passkey: any assertion is accepted (signs in as <code>owner</code>)</div><div>Operator: any email / code <code>${MOCK_OTP_CODE}</code>, approved ~9s later; an address starting <code>expire</code> lapses in 20s</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;
// Uploaded build contexts by submission id. Seeded submissions get a small
// stand-in archive so the review page's download and digest check have bytes.
const contextBlobs = new Map<string, Buffer>();
// Staged chunked uploads by submission id, and the caps the mock reports. The
// part cap is small so a modest test file already goes up in several parts.
const stagedParts = new Map<string, Buffer>();
const MOCK_PART_MAX = 256 * 1024;
const MOCK_CONTEXT_MAX = 1024 * 1024 * 1024;
function contextBlob(id: string): Buffer {
let blob = contextBlobs.get(id);
if (!blob) {
blob = gzipSync(Buffer.from(`FROM eclipse-temurin:21-jre\n# mock build context for ${id}\n`));
contextBlobs.set(id, blob);
}
return blob;
}
function sha256Hex(b: Buffer): string {
return createHash("sha256").update(b).digest("hex");
}
const RETENTION_DAYS = 90;
let freshBackups = 0;
function backup(
server: string,
daysAgo: number,
sizeBytes: number,
formerOwner: string,
reason = "inactive_15d",
): BackupView {
const created = Date.now() - daysAgo * DAY_MS;
return {
// A seeded row is named by its age; one made now needs a sequence to stay unique.
id: daysAgo > 0 ? `bk-${server}-${daysAgo}` : `bk-${server}-now-${++freshBackups}`,
server_name: server,
former_owner: formerOwner,
size_bytes: Math.round(sizeBytes),
reason,
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"),
// The linked player's own server, so a player sees the restore rows too.
backup("lobby", 2, 0.8 * GiB, "linked", "manual"),
backup("lobby", 30, 0.7 * GiB, "linked"),
];
}
function initialState(): MockState {
return {
accounts: {
owner: account("owner", "owner", true, false),
user: account("user", "user", false, false),
linked: account("linked", "user", true, true),
},
images: [
whitelistImage("registry.felis.svc:5000/paper-1.21:demo", "recommended"),
whitelistImage("registry.felis.svc:5000/fabric-1.20.1:demo", "recommended"),
{ ...whitelistImage("registry.felis.svc:5000/forge-1.20.1:disabled", "external"), enabled: false },
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
playersOnline: 12,
playersMax: 20,
autostartPolicy: "public",
memory: "4Gi",
javaMemory: "3072M",
storageSize: "20Gi",
// Pinned the way felis-api stores it: the tag it was created from plus the
// digest that tag named then.
image:
"registry.felis.svc:5000/paper-1.21:demo@sha256:3b637d68843f53750a2e3c2d29c0346b6f81ffd22db2902b80bd5048ee04cc09",
}),
server("lobby", "Hub Lobby", "Running", "linked", {
playersOnline: 28,
playersMax: 60,
autostartPolicy: "public",
}),
server("creative", "Creative Lab", "Stopped", "user", {
autostartPolicy: "public",
playersMax: 16,
}),
server("modded", "Modded Testbed", "Starting", "owner", {
autostartPolicy: "allowlist",
playersMax: 12,
}),
server("broken", "Broken Node", "Failed", "user", {
autostartPolicy: "ownerOnly",
playersMax: 8,
}),
server("claim-me", "Claimable Node", "Stopped", null, {
playersMax: 10,
}),
server("login", "Login space", "Stopped", null, {
autostartPolicy: "public", idleStopSeconds: 0, reaperExempt: true,
image: "registry.felis.svc:5000/felis/limbo:demo", memory: "512Mi", storageSize: "1Gi",
}),
...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 playersOnline: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",
],
luckperms: {
Steve: {
groups: ["vip", "default"],
permissions: [
{ node: "essentials.fly", value: true },
{ node: "essentials.tpa", value: true },
{ node: "minecraft.command.gamemode", value: false },
],
},
Alex: {
groups: ["default"],
permissions: [
{ node: "essentials.tpa", value: true },
],
},
},
},
},
backups: mockBackups(),
jobs: {},
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: "the scan blocked the image: 1 CRITICAL, 1 HIGH (CVE-2026-12345, CVE-2025-24813)",
requested_by: "[email protected]",
created_at: new Date(Date.now() - 1800000).toISOString(),
finished_at: new Date(Date.now() - 1700000).toISOString(),
},
// Started by another admin: the list is the server's, so it shows here too.
{
id: "bld-3",
image_ref: "registry.felis.svc:5000/user-uploads/sub-9:latest",
status: "building",
requested_by: "[email protected]",
created_at: new Date(Date.now() - 300000).toISOString(),
},
// Older history, enough to need a second page.
...Array.from({ length: 11 }, (_, i): Build => ({
id: `bld-old-${i + 1}`,
image_ref: `registry.felis.svc:5000/paper-1.21:r${i + 1}`,
status: i % 4 === 3 ? "cancelled" : "succeeded",
requested_by: i % 2 ? "[email protected]" : "[email protected]",
created_at: new Date(Date.now() - (i + 2) * 86400000).toISOString(),
finished_at: new Date(Date.now() - (i + 2) * 86400000 + 240000).toISOString(),
})),
],
passkeys: {
owner: [
{ id: "pk-1", name: "YubiKey 5C", created_at: new Date(Date.now() - 30 * DAY_MS).toISOString() },
],
linked: [],
user: [],
setup: [],
},
submissions: [
{
id: "sub-1",
submitted_by: "[email protected]",
display_name: "Pixelmon Modpack V2",
context_ref: "minio/contexts/sub-1/context.tar.gz",
status: "pending_review",
created_at: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "sub-owner-1",
submitted_by: "[email protected]",
display_name: "RLCraft Survival Pack",
context_ref: "minio/contexts/sub-owner-1/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-1")),
status: "pending_review",
created_at: new Date(Date.now() - 1800000).toISOString(),
},
{
id: "sub-owner-2",
submitted_by: "[email protected]",
display_name: "ATM 9 Server Pack",
context_ref: "minio/contexts/sub-owner-2/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-2")),
status: "approved",
image_ref: "registry.felis.svc:5000/user-uploads/sub-owner-2:latest",
build_id: "bld-3",
reviewed_by: "[email protected]",
created_at: new Date(Date.now() - 86400000).toISOString(),
reviewed_at: new Date(Date.now() - 86300000).toISOString(),
},
{
id: "sub-owner-3",
submitted_by: "[email protected]",
display_name: "Oversized Custom Modpack",
context_ref: "minio/contexts/sub-owner-3/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-3")),
status: "rejected",
reviewed_by: "[email protected]",
created_at: new Date(Date.now() - 172800000).toISOString(),
reviewed_at: new Date(Date.now() - 172700000).toISOString(),
reject_reason: "Compressed file size exceeds the platform-wide 1GB threshold. Please reduce the size by removing heavy client-only mods (e.g. resourcepacks or local shaders).",
},
{
id: "sub-2",
submitted_by: "[email protected]",
display_name: "Create: Astral pack",
context_ref: "minio/contexts/sub-2/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-2")),
status: "approved",
image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest",
build_id: "bld-1",
reviewed_by: "[email protected]",
created_at: new Date(Date.now() - 7200000).toISOString(),
reviewed_at: new Date(Date.now() - 7100000).toISOString(),
},
{
id: "sub-3",
submitted_by: "[email protected]",
display_name: "Dangerous Modpack (Exploitative)",
context_ref: "minio/contexts/sub-3/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-3")),
status: "rejected",
reviewed_by: "[email protected]",
reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.",
created_at: new Date(Date.now() - 10800000).toISOString(),
reviewed_at: new Date(Date.now() - 10700000).toISOString(),
},
],
updateWindow: { start: null, end: null },
wakePolicy: { maxRunningServers: 0, wakeCooldownSeconds: 30, revision: "initial", managed: false },
authSources: {
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
revision: "installation-defaults",
managed: false,
},
files: {},
};
}
// 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], {
playersOnline: phase === "Running" ? 1 + ((i * 3) % max) : 0,
playersMax: max,
autostartPolicy: policies[i % policies.length],
}),
);
i++;
}
}
return out;
}
function mockStartupMessage(): string {
return [
"",
" Felis mock API",
` API base: ${API_BASE}`,
` Root domain: ${ROOT_DOMAIN}`,
"",
" Sign-in (passwordless):",
` Email OTP: any email / code ${MOCK_OTP_CODE} → owner (admin, linked)`,
` Link code: ${MOCK_LINK_CODE} → linked (user, linked)`,
" Passkey: any assertion accepted → owner (admin, linked)",
` Op login: any email / code ${MOCK_OTP_CODE}, approved ~9s later; an email starting "expire" lapses in 20s`,
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
}
function account(
id: AccountID,
role: Role,
linked: boolean,
emailVerified: boolean,
): MockAccount {
return {
id,
role,
linked,
emailVerified,
email: `${id}@mock.felis.local`,
};
}
function whitelistImage(image_ref: string, source: string): WhitelistImage {
return { image_ref, enabled: true, source, added_by: "owner", added_at: "2026-01-01T00:00:00Z" };
}
function server(
name: string,
displayName: string,
phase: Phase,
owner: AccountID | null,
overrides: Partial<ServerStatus> = {},
): MockServer {
return {
name,
subdomain: name,
displayName,
phase,
ready: phase === "Running",
desiredState: phase === "Stopped" ? "Stopped" : "Running",
playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20,
autostartPolicy: "ownerOnly",
idleStopSeconds: 900,
owner,
...overrides,
};
}
// mockScan is the scan build.Builder.Scan would keep for the seeded builds.
function mockScan(b: Build): BuildScan | null {
const at = b.finished_at ?? b.created_at;
if (b.id === "bld-2") {
return {
build_id: b.id, scanned_at: at, has_report: true, has_sbom: false,
summary: {
policy: { fail_on: ["CRITICAL", "HIGH"], fail_unfixed: false, accept: ["CVE-2021-35515", "CVE-2025-67030"] },
blocked: true, packages: 214,
counts: { CRITICAL: 1, HIGH: 4, MEDIUM: 6, LOW: 2 },
blocking_counts: { CRITICAL: 1, HIGH: 1 },
omitted: ["sbom"],
findings: [
{ id: "CVE-2026-12345", kind: "vulnerability", severity: "CRITICAL", package: "net.minecraftforge:forge",
installed: "47.1.0", fixed: "47.1.3", target: "libraries/net/minecraftforge/forge/47.1.0/forge.jar", blocking: true },
{ id: "CVE-2025-24813", kind: "vulnerability", severity: "HIGH", package: "org.apache.tomcat.embed:tomcat-embed-core",
installed: "9.0.97", fixed: "9.0.99, 10.1.35", target: "mods/webmap-2.4.jar", blocking: true },
{ id: "CVE-2021-35515", kind: "vulnerability", severity: "HIGH", package: "org.apache.commons:commons-compress",
installed: "1.5", fixed: "1.21", target: "paper/paper.jar", blocking: false, accepted: true },
{ id: "CVE-2024-6763", kind: "vulnerability", severity: "HIGH", package: "org.eclipse.jetty:jetty-http",
installed: "9.4.53.v20231009", target: "mods/webmap-2.4.jar", blocking: false },
{ id: "CVE-2023-2976", kind: "vulnerability", severity: "HIGH", package: "com.google.guava:guava",
installed: "31.1-jre", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false },
{ id: "CVE-2024-47554", kind: "vulnerability", severity: "MEDIUM", package: "commons-io:commons-io",
installed: "2.11.0", fixed: "2.14.0", target: "libraries/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", blocking: false },
{ id: "CVE-2020-8908", kind: "vulnerability", severity: "LOW", package: "com.google.guava:guava",
installed: "31.1-jre", fixed: "32.0.0-android", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false },
],
},
};
}
if (b.id === "bld-1") {
return {
build_id: b.id, scanned_at: at, has_report: true, has_sbom: true,
summary: {
policy: { fail_on: ["CRITICAL"], fail_unfixed: false },
blocked: false, packages: 187,
counts: { MEDIUM: 1, LOW: 1 },
blocking_counts: {},
findings: [
{ id: "CVE-2024-47554", kind: "vulnerability", severity: "MEDIUM", package: "commons-io:commons-io",
installed: "2.11.0", fixed: "2.14.0", target: "libraries/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", blocking: false },
{ id: "CVE-2020-8908", kind: "vulnerability", severity: "LOW", package: "com.google.guava:guava",
installed: "31.1-jre", fixed: "32.0.0-android", target: "libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar", blocking: false },
],
},
};
}
return null;
}
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 m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-zA-Z0-9_-]+)(?:;|$)`).exec(
req.headers.cookie ?? "",
);
return m ? state.accounts[m[1]] : null;
}
function setSessionCookie(res: ServerResponse, accountID: string): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
}
// signInProven opens a session through a door that proved a factor (email code,
// passkey, operator login), which like the real API counts as a re-auth.
function signInProven(ctx: RequestContext, accountID: AccountID): void {
setSessionCookie(ctx.res, accountID);
const acc = ctx.state.accounts[accountID];
if (acc) acc.reauthUntil = Date.now() + REAUTH_MS;
}
const REAUTH_MS = 5 * 60_000;
/** reauthFactors mirrors reauthState: a passkey, an email code to a verified
* address (users) or a fresh sign-in (operators). None → nothing to re-prove. */
function reauthFactors(state: MockState, acc: MockAccount): string[] {
const hasPasskey = (state.passkeys[acc.id] ?? []).length > 0;
if (!hasPasskey && !acc.emailVerified) return [];
const factors = hasPasskey ? ["passkey"] : [];
if (acc.role !== "user") factors.push("sign_in");
else if (acc.emailVerified) factors.push("email");
return factors;
}
/** refusedForReauth answers 403 reauth_required, as the real API does, for a
* change to how the account signs in without a recent proof. */
function refusedForReauth(ctx: SessionContext): boolean {
const acc = ctx.account;
if (reauthFactors(ctx.state, acc).length === 0 || (acc.reauthUntil ?? 0) > Date.now()) return false;
sendError(ctx.res, 403, "reauth_required", "confirm it's you first: this change needs your passkey or email code from the last few minutes");
return true;
}
function markReauth(ctx: SessionContext): void {
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { ok: true, until: new Date(ctx.account.reauthUntil).toISOString() });
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
// thisSessionHash is the session the mock cookie stands for: one per account,
// so the account page can mark "This device".
function thisSessionHash(acc: MockAccount): string {
return `mock-this-${acc.id}`;
}
/** accountSessions seeds, on first read, the browsers an account is signed in
* on: this one, a phone seen yesterday and a PC idle for a week. */
function accountSessions(acc: MockAccount): SessionView[] {
if (!acc.sessions) {
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const until = new Date(Date.now() + 30 * 86_400_000).toISOString();
acc.sessions = [
{
token_hash: thisSessionHash(acc),
created_at: ago(3 * 86_400_000),
expires_at: until,
last_seen_at: ago(0),
user_agent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
client_ip: "2001:db8::1",
},
{
token_hash: `mock-phone-${acc.id}`,
created_at: ago(9 * 86_400_000),
expires_at: until,
last_seen_at: ago(20 * 3_600_000),
user_agent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
client_ip: "203.0.113.24",
},
{
token_hash: `mock-pc-${acc.id}`,
created_at: ago(20 * 86_400_000),
expires_at: until,
last_seen_at: ago(7 * 86_400_000),
user_agent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2739.42",
client_ip: "198.51.100.7",
},
];
}
return acc.sessions;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
email: accountInfo.email,
role: accountInfo.role,
is_admin: isAdmin(accountInfo.role),
is_owner: isOwner(accountInfo.role),
email_verified: accountInfo.emailVerified,
};
}
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 isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id || serverInfo.owner === null;
}
function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id;
}
function visibleServers(state: MockState, accountInfo: MockAccount): MyServerView[] {
return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo))
.map((serverInfo) => myServerView(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*) plus the
// runtime `ready`/`endpoint*` fields, and 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. Ownership is
// decided by account id for the caller, as the Go handler does.
function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
return state.servers.map((s, i) => {
const { owner, ...wire } = s;
return {
...wire,
endpointMode: "domain",
endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id,
claimable: owner === null && !s.retiring && !s.reaperExempt,
system: s.reaperExempt,
};
});
}
// myServerView mirrors handleMyServers: ownership and claim state plus the live
// fields, with owner detail (desired state, policy, unreadable count) only on
// rows the caller owns.
function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServerView {
const owned = canManage(accountInfo, serverInfo);
return {
name: serverInfo.name,
subdomain: serverInfo.subdomain,
owned,
claimable: serverInfo.owner === null && !serverInfo.retiring && accountInfo.linked && !owned,
phase: serverInfo.phase,
playersOnline: serverInfo.playersOnline,
playersMax: serverInfo.playersMax,
displayName: serverInfo.displayName,
...(owned && {
desiredState: serverInfo.desiredState,
autostartPolicy: serverInfo.autostartPolicy,
playerCountUnknown: serverInfo.playerCountUnknown,
retiring: serverInfo.retiring,
}),
};
}
// statusView mirrors handleServerStatus: the whole projection for the owner or
// an admin, the public subset (publicServerInfo) for anyone else.
function statusView(serverInfo: MockServer, accountInfo: MockAccount): ServerStatus {
const { owner: _owner, ...wire } = serverInfo;
if (canManage(accountInfo, serverInfo)) return wire;
return {
name: wire.name,
subdomain: wire.subdomain,
displayName: wire.displayName,
phase: wire.phase,
ready: wire.ready,
playersOnline: wire.playersOnline,
playersMax: wire.playersMax,
idleStopSeconds: 0,
};
}
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.ready = phase === "Running";
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 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,
playersOnline: 0,
playersMax: 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);
}
// Op-login requests the mock has handed out. Nobody is in-game to vouch, so each
// one approves itself a few seconds after start; an "expire…" address never does
// and lapses quickly, to show the page's expiry path.
const opLogins = new Map<string, { approveAt: number; expiresAt: number }>();
async function handlePublic(ctx: RequestContext): Promise<boolean> {
const opStatus = route(ctx).match(/^GET auth\/op-login\/status\/(.+)$/);
if (opStatus) {
const req = opLogins.get(opStatus[1]);
const now = Date.now();
sendJSON(ctx.res, 200, { approved: !!req && now >= req.approveAt && now < req.expiresAt });
return true;
}
switch (route(ctx)) {
case "POST auth/op-login/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "email is required");
return true;
}
const lapses = body.email.startsWith("expire");
const id = `op-${opLogins.size + 1}`;
const now = Date.now();
const expiresAt = now + (lapses ? 20_000 : 600_000);
opLogins.set(id, { approveAt: lapses ? Infinity : now + 9_000, expiresAt });
sendJSON(ctx.res, 202, { request_id: id, expires_at: new Date(expiresAt).toISOString() });
return true;
}
case "POST auth/op-login/finish": {
const body = await readJSON<{ request_id?: string; code?: string }>(ctx.req);
const req = body.request_id ? opLogins.get(body.request_id) : undefined;
const now = Date.now();
if (!req || now < req.approveAt || now >= req.expiresAt || body.code !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "op_login_invalid", "operator login could not be completed");
return true;
}
opLogins.delete(body.request_id!);
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, { user_id: "mock-owner", role: "owner" });
return true;
}
case "POST auth/bind": {
const body = await readJSON<{ code?: string }>(ctx.req);
const code = body.code?.trim().toUpperCase();
if (!code) {
sendError(ctx.res, 400, "bad_request", "code is required");
return true;
}
if (code !== MOCK_LINK_CODE) {
sendError(ctx.res, 400, "invalid_code", "bind code is invalid or expired");
return true;
}
setSessionCookie(ctx.res, "linked");
sendJSON(ctx.res, 200, {
user_id: "mock-linked",
linked: true,
mc_uuid: MC_UUID,
auth_source: "mojang",
});
return true;
}
case "POST auth/passkey/login/discoverable/begin": {
sendJSON(ctx.res, 200, {
publicKey: {
challenge: "c29tZV9kaXNjb3ZlcmFibGVfY2hhbGxlbmdl",
rp: { name: "Felis Dev", id: "dev.felis.localhost" },
user: { id: "bW9ja191c2VyX2lk", name: "[email protected]", displayName: "owner" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
allowCredentials: [],
timeout: 60000,
},
login_id: "mock_login_id_1234"
});
return true;
}
case "POST auth/passkey/login/discoverable/finish": {
const body = await readJSON<{ login_id?: string; assertion?: any }>(ctx.req);
if (!body.login_id || !body.assertion) {
sendError(ctx.res, 400, "bad_request", "login_id and assertion are required");
return true;
}
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
});
return true;
}
case "POST auth/passkey/login/begin": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "email is required");
return true;
}
sendJSON(ctx.res, 200, {
challenge: "c29tZV9wYXNza2V5X2NoYWxsZW5nZQ",
rp: { name: "Felis Dev", id: "dev.felis.localhost" },
user: { id: "bW9ja191c2VyX2lk", name: body.email, displayName: "owner" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
allowCredentials: [{ type: "public-key", id: "cGstMQ" }],
timeout: 60000,
});
return true;
}
case "POST auth/passkey/login/finish": {
const body = await readJSON<{ email?: string; assertion?: any }>(ctx.req);
if (!body.email || !body.assertion) {
sendError(ctx.res, 400, "bad_request", "email and assertion are required");
return true;
}
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
});
return true;
}
// The mock install always has its Owner; the unbound page is covered by Login.test.tsx.
case "GET auth/owner-status":
sendJSON(ctx.res, 200, { owner_bound: true });
return true;
case "POST auth/email/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "email is required");
return true;
}
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST auth/email/verify": {
const body = await readJSON<{ email?: string; code?: string }>(ctx.req);
if (!body.email || body.code !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
signInProven(ctx, "owner");
sendJSON(ctx.res, 200, {
user_id: "mock-owner",
role: "owner"
});
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 settings/node-control":
if (!isOwner(ctx.account.role)) sendError(ctx.res, 403, "forbidden", "Owner account required");
else sendJSON(ctx.res, 200, { available: false, tasks: [] });
return true;
case "GET settings/wake-policy":
case "PUT settings/wake-policy": {
if (!isOwner(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "Owner account required");
} else if (is("GET", ctx)) {
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
} else {
const body = await readJSON<Omit<WakePolicySettings, "managed">>(ctx.req);
if (body.revision !== ctx.state.wakePolicy.revision) sendError(ctx.res, 409, "conflict", "policy changed; reload before saving");
else if (!Number.isInteger(body.maxRunningServers) || body.maxRunningServers < 0 || body.maxRunningServers > 10000 || !Number.isInteger(body.wakeCooldownSeconds) || body.wakeCooldownSeconds < 0 || body.wakeCooldownSeconds > 3600) sendError(ctx.res, 400, "bad_request", "invalid wake policy");
else {
ctx.state.wakePolicy = { ...body, revision: createHash("sha256").update(JSON.stringify(body)).digest("hex"), managed: true };
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
}
}
return true;
}
case "GET settings/auth-sources":
case "PUT settings/auth-sources":
case "POST settings/auth-sources/test": {
if (!isOwner(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "Owner account required");
return true;
}
if (is("GET", ctx)) {
sendJSON(ctx.res, 200, ctx.state.authSources);
} else if (is("PUT", ctx)) {
const body = await readJSON<{ sources: AuthSourceConfig[]; revision: string }>(ctx.req);
if (body.revision !== ctx.state.authSources.revision) {
sendError(ctx.res, 409, "auth_sources_changed", "authentication sources changed; reload before saving");
} else if (ctx.state.authSources.sources.some((source) => !body.sources.some((s) => s.tag === source.tag))) {
sendError(ctx.res, 409, "auth_source_tag_locked", "disable saved sources instead of removing or renaming them");
} else {
ctx.state.authSources = { sources: body.sources, revision: createHash("sha256").update(JSON.stringify(body.sources)).digest("hex"), managed: true };
sendJSON(ctx.res, 200, ctx.state.authSources);
}
} else {
await readJSON<AuthSourceConfig>(ctx.req);
sendJSON(ctx.res, 200, { ok: true, status: 204, elapsed_ms: 20 });
}
return true;
}
case "GET account/link/sources":
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] });
return true;
case "GET platform/db-backup": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Yesterday's daily run: fresh, so the card shows its healthy state.
const at = new Date(Date.now() - 9 * 3600 * 1000);
const stamp = at.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
sendJSON(ctx.res, 200, {
last: {
at: at.toISOString(),
name: `felis-db-${stamp}-daily.tar`,
label: "daily",
size_bytes: 3_482_112,
felis_version: "dev",
schema_version: 31,
dir: "/var/lib/felis/db-backups",
daily_at: at.toISOString(),
},
stale: false,
max_age_seconds: 26 * 3600,
});
return true;
}
case "GET updates/report": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Last night's timer run: one update waiting, one feed unreachable, one
// install unreadable, so the card shows every state it has.
sendJSON(ctx.res, 200, {
report: {
checked_at: new Date(Date.now() - 7 * 3600 * 1000).toISOString(),
felis: "v0.4.0",
components: [
{ name: "felis-api", current: "v0.4.0", latest: "v0.5.1", state: "available", selector: "panel" },
{ name: "velocity", current: "3.4.0-SNAPSHOT+b528", state: "current", selector: "velocity" },
{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" },
{ name: "cloudflared", current: "2026.6.1", latest: "2026.9.0", state: "available", selector: "cloudflared" },
{ name: "jre", current: "21.0.8+9", state: "unknown", selector: "jre", error: "adoptium: GET https://api.adoptium.net/v3/assets/latest/21/hotspot: context deadline exceeded" },
{ name: "postgresql", current: "13.22", state: "current", selector: "postgres", note: "PostgreSQL 13 reached its end of life on 2025-11-13; plan a major upgrade (docs/operations.md §4)" },
],
},
stale: false,
max_age_seconds: 26 * 3600,
});
return true;
}
case "GET updates/window":
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, ctx.state.updateWindow);
return true;
case "PUT updates/window": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
if (body.start === null && body.end === null) {
ctx.state.updateWindow = { start: null, end: null };
} else if (body.start === null || body.end === null) {
sendError(ctx.res, 400, "bad_request", "start and end must both be set or both be null");
return true;
} else {
const start = new Date(body.start);
const end = new Date(body.end);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
sendError(ctx.res, 400, "bad_request", "invalid start or end time format");
return true;
}
if (end <= start) {
sendError(ctx.res, 400, "bad_request", "end must be after start");
return true;
}
ctx.state.updateWindow = { start: body.start, end: body.end };
}
sendJSON(ctx.res, 200, ctx.state.updateWindow);
return true;
}
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 (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state, ctx.account) });
return true;
case "GET backups":
{
// Admin sees every archive; a user only worlds they formerly owned — mirrors
// AllBackups vs BackupsForUser. server narrows inside that scope, and rows
// come a page at a time, newest first, with the matching total.
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const server = url.searchParams.get("server") ?? "";
const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
const matched = ctx.state.backups
.filter((b) => b.status === "present")
.filter((b) => isAdmin(ctx.account.role) || b.former_owner === ctx.account.id)
.filter((b) => !server || b.server_name === server)
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
sendJSON(ctx.res, 200, { backups: matched.slice(offset, offset + limit), total: matched.length });
}
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;
case "POST account/email/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "invalid email");
return true;
}
if (refusedForReauth(ctx)) return true;
ctx.account.pendingEmail = body.email.trim();
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST account/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
const next = ctx.account.pendingEmail ?? ctx.account.email;
// Like the real API: replacing a verified address signs the other devices
// out, and proving a code counts as a re-auth.
if (ctx.account.emailVerified && next.toLowerCase() !== ctx.account.email.toLowerCase()) {
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
}
ctx.account.email = next;
ctx.account.pendingEmail = undefined;
ctx.account.emailVerified = true;
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
return true;
}
case "POST account/passkey/register/begin": {
if (refusedForReauth(ctx)) return true;
sendJSON(ctx.res, 200, {
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
rp: { name: "Felis Dev" },
user: {
id: "bW9ja191c2VyX2lk",
name: ctx.account.email,
displayName: ctx.account.email,
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
});
return true;
}
case "POST account/passkey/register/finish": {
const body = await readJSON<{ name?: string; attestation?: any }>(ctx.req);
if (!body.name || !body.attestation) {
sendError(ctx.res, 400, "bad_request", "name and attestation are required");
return true;
}
const newCred = {
id: `pk-${Date.now()}`,
name: body.name.trim(),
created_at: new Date().toISOString(),
};
if (!ctx.state.passkeys[ctx.account.id]) {
ctx.state.passkeys[ctx.account.id] = [];
}
ctx.state.passkeys[ctx.account.id].unshift(newCred);
ctx.account.reauthUntil = Date.now() + REAUTH_MS;
sendJSON(ctx.res, 201, newCred);
return true;
}
case "GET account/passkey/credentials": {
const list = ctx.state.passkeys[ctx.account.id] ?? [];
sendJSON(ctx.res, 200, { credentials: list });
return true;
}
case "GET account/reauth": {
const factors = reauthFactors(ctx.state, ctx.account);
const until = ctx.account.reauthUntil ?? 0;
if (factors.length === 0) sendJSON(ctx.res, 200, { needed: false, factors });
else if (until > Date.now()) sendJSON(ctx.res, 200, { needed: false, until: new Date(until).toISOString(), factors });
else sendJSON(ctx.res, 200, { needed: true, factors });
return true;
}
case "POST account/reauth/passkey/begin": {
const list = ctx.state.passkeys[ctx.account.id] ?? [];
if (list.length === 0) {
sendError(ctx.res, 409, "no_passkey", "no passkey enrolled; confirm with an email code instead");
return true;
}
sendJSON(ctx.res, 200, {
publicKey: {
challenge: "c29tZV9yZWF1dGhfY2hhbGxlbmdl",
rpId: "dev.felis.localhost",
allowCredentials: list.map(() => ({ type: "public-key", id: "cGstMQ" })),
userVerification: "preferred",
timeout: 60000,
},
});
return true;
}
case "POST account/reauth/passkey/finish": {
const body = await readJSON<{ assertion?: unknown }>(ctx.req);
if (!body.assertion) {
sendError(ctx.res, 400, "bad_request", "assertion is required");
return true;
}
markReauth(ctx);
return true;
}
case "POST account/reauth/email/start": {
if (ctx.account.role !== "user") {
sendError(ctx.res, 403, "staff_reauth", "operators confirm with a passkey or by signing in again");
return true;
}
if (!ctx.account.emailVerified) {
sendError(ctx.res, 409, "no_step_up_factor", "no verified email to send a code to");
return true;
}
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST account/reauth/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== MOCK_OTP_CODE) {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
markReauth(ctx);
return true;
}
case "GET account/migrate":
// No migration pending: the real API's answer until one is started in-game.
sendJSON(ctx.res, 200, { active: false });
return true;
case "GET account/sessions": {
const here = thisSessionHash(ctx.account);
const sessions = accountSessions(ctx.account)
.map((s) => (s.token_hash === here ? { ...s, last_seen_at: new Date().toISOString(), current: true } : s))
.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
sendJSON(ctx.res, 200, { sessions });
return true;
}
case "POST account/sessions/revoke-others": {
const here = thisSessionHash(ctx.account);
const before = accountSessions(ctx.account);
ctx.account.sessions = before.filter((s) => s.token_hash === here);
sendJSON(ctx.res, 200, { revoked: before.length - ctx.account.sessions.length });
return true;
}
default:
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "sessions" && ctx.parts[4]) {
const hash = ctx.parts[4];
const list = accountSessions(ctx.account);
if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or is not one of yours");
return true;
}
ctx.account.sessions = list.filter((s) => s.token_hash !== hash);
const signedOut = hash === thisSessionHash(ctx.account);
if (signedOut) clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true, signed_out: signedOut });
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "backups" && ctx.parts[3] && ctx.parts.length === 4) {
// Mirrors handleDeleteBackup: the scope that lists a backup deletes it and
// any other id is 404 no_backup; a restore on its server that may still be
// reading it is 409 restore_in_progress. The row turns expired at once.
const b = ctx.state.backups.find((x) => x.id === ctx.parts[3] && x.status === "present");
if (!b || !(isAdmin(ctx.account.role) || b.former_owner === ctx.account.id)) {
sendError(ctx.res, 404, "no_backup", "no matching backup exists");
return true;
}
const reading = (ctx.state.jobs[b.server_name] ?? []).some(
(j) => (j.kind === "restore" && j.state === "running") || (j.then_restore === "pending" && j.restore_backup_id === b.id),
);
if (reading) {
sendError(ctx.res, 409, "restore_in_progress", "a restore is running on this backup's server and may be reading it; delete it once the restore finishes");
return true;
}
b.status = "expired";
sendJSON(ctx.res, 200, { id: b.id, status: "expired" });
return true;
}
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
// The real API asks for the re-auth before it looks the passkey up.
if (refusedForReauth(ctx)) return true;
const id = ctx.parts[5];
const list = ctx.state.passkeys[ctx.account.id] ?? [];
const idx = list.findIndex((k) => k.id === id);
if (idx < 0) {
sendError(ctx.res, 404, "not_found", "passkey not found");
return true;
}
// Same rule as the real API: the only passkey of an account without a
// verified email stays, or the account would have no way to sign in.
if (list.length === 1 && !ctx.account.emailVerified) {
sendError(ctx.res, 409, "last_passkey", "this is your only passkey and your email is not verified");
return true;
}
list.splice(idx, 1);
// Like the real API, removing a passkey signs the other devices out.
const here = thisSessionHash(ctx.account);
ctx.account.sessions = accountSessions(ctx.account).filter((s) => s.token_hash === here);
ctx.res.statusCode = 204;
ctx.res.end();
return true;
}
if (await handleUserRoute(ctx)) return true;
if (await handleImageRoute(ctx)) return true;
if (await handleSubmissionRoute(ctx)) return true;
return await handleServerRoute(ctx);
}
}
async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
if (ctx.parts[2] !== "users") return false;
// Owner access check for user management routes
if (!isOwner(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "owner account required");
return true;
}
const userIdOrAction = ctx.parts[3];
// GET /api/v1/users
if (is("GET", ctx) && !userIdOrAction) {
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const search = url.searchParams.get("search")?.toLowerCase() || "";
const page = parseInt(url.searchParams.get("page") || "1", 10);
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
const allUsers = Object.values(ctx.state.accounts).map((acc) => {
// count active/owned servers
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
return {
id: `mock-${acc.id}`,
username: acc.id,
email: acc.email,
role: acc.role,
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
} as UserView;
});
const filtered = allUsers.filter((u) => {
return (
u.username.toLowerCase().includes(search) ||
(u.email ?? "").toLowerCase().includes(search)
);
});
const paginated = filtered.slice((page - 1) * limit, page * limit);
sendJSON(ctx.res, 200, {
users: paginated,
total: filtered.length,
});
return true;
}
// POST /api/v1/users
if (is("POST", ctx) && !userIdOrAction) {
const body = await readJSON<CreateUserRequest>(ctx.req);
const username = body.username?.trim().toLowerCase();
if (!username) {
sendError(ctx.res, 400, "bad_request", "username is required");
return true;
}
if (ctx.state.accounts[username]) {
sendError(ctx.res, 409, "already_exists", "username is already taken");
return true;
}
const newAcc: MockAccount = {
id: username,
role: body.role || "user",
email: body.email || `${username}@example.com`,
linked: false,
emailVerified: true,
disabled: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
quota: {
user_id: `mock-${username}`,
max_servers: 3,
max_cpu_milli: 4000,
max_memory_mb: 8192,
max_storage_gb: 50,
},
sessions: [],
};
ctx.state.accounts[username] = newAcc;
sendJSON(ctx.res, 201, {
id: `mock-${username}`,
username: username,
email: newAcc.email,
role: newAcc.role,
disabled: false,
email_verified: true,
server_count: 0,
created_at: newAcc.created_at,
updated_at: newAcc.updated_at,
} as UserView);
return true;
}
// Routes starting with /api/v1/users/{id}
if (userIdOrAction) {
const rawId = userIdOrAction;
const accountKey = rawId.startsWith("mock-") ? rawId.substring(5) : rawId;
const acc = ctx.state.accounts[accountKey];
if (!acc) {
sendError(ctx.res, 404, "not_found", "user not found");
return true;
}
const subAction = ctx.parts[4];
// GET /api/v1/users/{id}
if (is("GET", ctx) && !subAction) {
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
const linked_accounts = acc.linked ? [{
mc_uuid: MC_UUID,
auth_source: "mojang",
verified_at: new Date().toISOString()
}] : [];
const detail: UserDetail = {
id: `mock-${acc.id}`,
username: acc.id,
email: acc.email,
role: acc.role,
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at || new Date().toISOString(),
linked_accounts,
};
sendJSON(ctx.res, 200, detail);
return true;
}
// PATCH /api/v1/users/{id}
if (is("PATCH", ctx) && !subAction) {
const body = await readJSON<PatchUserRequest>(ctx.req);
if (body.role !== undefined) {
if (body.role !== "admin" && body.role !== "user") {
sendError(ctx.res, 400, "bad_request", `role must be 'admin' or 'user', got ${body.role}`);
return true;
}
if (ctx.account.id === acc.id && body.role !== ctx.account.role) {
sendError(ctx.res, 403, "self_protected", "cannot change your own role");
return true;
}
if (acc.role === "owner") {
sendError(
ctx.res,
403,
"owner_protected",
"the owner account's role cannot be changed from the panel"
);
return true;
}
acc.role = body.role;
}
if (body.email !== undefined) acc.email = body.email;
acc.updated_at = new Date().toISOString();
const serverCount = ctx.state.servers.filter((s) => s.owner === acc.id).length;
sendJSON(ctx.res, 200, {
id: `mock-${acc.id}`,
username: acc.id,
email: acc.email,
role: acc.role,
disabled: !!acc.disabled,
email_verified: acc.emailVerified,
server_count: serverCount,
created_at: acc.created_at || new Date().toISOString(),
updated_at: acc.updated_at,
} as UserView);
return true;
}
// DELETE /api/v1/users/{id}
if (is("DELETE", ctx) && !subAction) {
if (ctx.account.id === acc.id) {
sendError(ctx.res, 403, "self_protected", "cannot delete your own account");
return true;
}
if (acc.role === "owner") {
sendError(ctx.res, 403, "owner_protected", "the owner account cannot be deleted from the panel");
return true;
}
const activeServers = ctx.state.servers.filter(
(s) => s.owner === acc.id && s.phase !== "Stopped" && s.phase !== "Failed"
);
if (activeServers.length > 0) {
sendError(
ctx.res,
409,
"active_servers",
"cannot delete user with active servers"
);
return true;
}
ctx.state.servers.forEach((s) => {
if (s.owner === acc.id) {
s.owner = null;
}
});
delete ctx.state.accounts[accountKey];
sendJSON(ctx.res, 200, { deleted: true });
return true;
}
// POST /api/v1/users/{id}/disable
if (is("POST", ctx) && subAction === "disable") {
if (ctx.account.id === acc.id) {
sendError(ctx.res, 403, "self_protected", "cannot disable your own account");
return true;
}
const body = await readJSON<{ disabled: boolean }>(ctx.req);
if (acc.role === "owner" && body.disabled) {
sendError(ctx.res, 403, "owner_protected", "the owner account cannot be disabled from the panel");
return true;
}
acc.disabled = !!body.disabled;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled });
return true;
}
// GET /api/v1/users/{id}/quotas
if (is("GET", ctx) && subAction === "quotas") {
if (!acc.quota) {
acc.quota = {
user_id: `mock-${acc.id}`,
max_servers: 3,
max_cpu_milli: 4000,
max_memory_mb: 8192,
max_storage_gb: 50,
};
}
sendJSON(ctx.res, 200, acc.quota);
return true;
}
// PUT /api/v1/users/{id}/quotas
if (is("PUT", ctx) && subAction === "quotas") {
const body = await readJSON<QuotaInput>(ctx.req);
acc.quota = {
user_id: `mock-${acc.id}`,
max_servers: body.max_servers,
max_cpu_milli: body.max_cpu_milli,
max_memory_mb: body.max_memory_mb,
max_storage_gb: body.max_storage_gb,
};
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, acc.quota);
return true;
}
// GET /api/v1/users/{id}/sessions
if (is("GET", ctx) && subAction === "sessions") {
sendJSON(ctx.res, 200, { sessions: accountSessions(acc) });
return true;
}
// DELETE /api/v1/users/{id}/sessions/{hash} — revoke single session
if (is("DELETE", ctx) && subAction === "sessions" && ctx.parts[5]) {
const hash = ctx.parts[5];
const list = accountSessions(acc);
if (!list.some((s) => s.token_hash === hash)) {
sendError(ctx.res, 404, "session_not_found", "that session has already ended or does not belong to this user");
return true;
}
acc.sessions = list.filter((s) => s.token_hash !== hash);
sendJSON(ctx.res, 200, { ok: true });
return true;
}
// DELETE /api/v1/users/{id}/sessions
if (is("DELETE", ctx) && subAction === "sessions" && !ctx.parts[5]) {
acc.sessions = [];
sendJSON(ctx.res, 200, { ok: true });
return true;
}
// POST /api/v1/users/{id}/links — manual link
if (is("POST", ctx) && subAction === "links") {
const body = await readJSON<{ mc_uuid: string; auth_source?: string }>(ctx.req);
if (!body.mc_uuid) {
sendError(ctx.res, 400, "bad_request", "mc_uuid is required");
return true;
}
acc.linked = true;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { ok: true, mc_uuid: body.mc_uuid });
return true;
}
// DELETE /api/v1/users/{id}/links/{mc_uuid} — unlink
if (is("DELETE", ctx) && subAction === "links" && ctx.parts[5]) {
acc.linked = false;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { ok: true, mc_uuid: ctx.parts[5] });
return true;
}
}
return false;
}
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 (!isAdmin(ctx.account.role)) {
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 = whitelistImage(ref, "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 (!isAdmin(ctx.account.role)) {
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 (!isAdmin(ctx.account.role)) {
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({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 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 (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Same page and match as build.PGStore.ListBuilds: newest first, a query hits
// the id or status exactly or any part of the ref, and rows carry no Dockerfile.
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const q = (url.searchParams.get("query") ?? "").trim().toLowerCase();
const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
const matched = ctx.state.builds
.filter((b) => !q || b.id.toLowerCase() === q || b.status === q || b.image_ref.toLowerCase().includes(q))
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
sendJSON(ctx.res, 200, {
builds: matched.slice(offset, offset + limit).map(({ dockerfile: _, ...b }) => b),
total: matched.length,
});
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 (!isAdmin(ctx.account.role)) {
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;
}
// GET /api/v1/images/build/{id}/scan, /scan/report, /sbom (the kept scan). The
// seeded builds bld-1 (passed) and bld-2 (blocked) have one; the older history
// ran before builds kept their scans.
if (
is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] &&
((ctx.parts[5] === "scan" && (ctx.parts.length === 6 || (ctx.parts[6] === "report" && ctx.parts.length === 7))) ||
(ctx.parts[5] === "sbom" && ctx.parts.length === 6))
) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const build = ctx.state.builds.find((b) => b.id === ctx.parts[4]);
const scan = build ? mockScan(build) : null;
if (!build || !scan) {
sendError(ctx.res, 404, "scan_not_found",
"this build has no scan: it has not reached the scan step, or it ran before builds kept their scans");
return true;
}
if (ctx.parts.length === 6 && ctx.parts[5] === "scan") {
sendJSON(ctx.res, 200, scan);
return true;
}
const sbom = ctx.parts[5] === "sbom";
if (sbom && !scan.has_sbom) {
sendError(ctx.res, 404, "scan_document_not_kept",
"this build's scan kept no SBOM: it was too large to keep, or the step that writes it failed");
return true;
}
ctx.res.statusCode = 200;
ctx.res.setHeader("Content-Type", sbom ? "application/vnd.cyclonedx+json" : "application/json");
ctx.res.setHeader("Content-Disposition", `attachment; filename="${build.id}${sbom ? ".cdx.json" : "-trivy.json"}"`);
ctx.res.end(JSON.stringify(sbom
? { bomFormat: "CycloneDX", specVersion: "1.6", components: [] }
: { SchemaVersion: 2, ArtifactName: build.image_ref, Results: [] }, null, 2));
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 (!isAdmin(ctx.account.role)) {
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 (!isAdmin(ctx.account.role)) {
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;
}
// The same page as submit.PGStore.PageSubmissions: counts cover the whole scope,
// status and query (id, submitter or name, any part) narrow the rows, newest first.
function submissionPageOf(ctx: SessionContext, scope: Submission[]) {
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const status = url.searchParams.get("status") ?? "";
const q = (url.searchParams.get("query") ?? "").trim().toLowerCase();
const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
const counts = { pending_review: 0, approved: 0, rejected: 0 };
for (const s of scope) counts[s.status] += 1;
const matched = scope
.filter((s) => !status || s.status === status)
.filter((s) => !q || [s.id, s.submitted_by, s.display_name].some((f) => f.toLowerCase().includes(q)))
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
return { submissions: matched.slice(offset, offset + limit), total: matched.length, counts };
}
async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
const isAdminSubmissions = ctx.parts[2] === "submissions";
const isMeSubmissions = ctx.parts[2] === "me" && ctx.parts[3] === "submissions";
if (!isAdminSubmissions && !isMeSubmissions) return false;
// GET /api/v1/me/submissions
if (isMeSubmissions && is("GET", ctx) && ctx.parts.length === 4) {
const userSubs = ctx.state.submissions.filter((s) => s.submitted_by === ctx.account.email);
sendJSON(ctx.res, 200, submissionPageOf(ctx, userSubs));
return true;
}
// GET /api/v1/me/submissions/limits — the 1 GiB default.
if (isMeSubmissions && is("GET", ctx) && ctx.parts.length === 5 && ctx.parts[4] === "limits") {
sendJSON(ctx.res, 200, { max_context_bytes: MOCK_CONTEXT_MAX });
return true;
}
// DELETE /api/v1/me/submissions/{id} — withdraw a still-pending submission
// with its context and any staged upload.
if (isMeSubmissions && is("DELETE", ctx) && ctx.parts.length === 5) {
const id = ctx.parts[4];
const idx = ctx.state.submissions.findIndex((s) => s.id === id && s.submitted_by === ctx.account.email);
if (idx < 0) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
const sub = ctx.state.submissions[idx];
if (sub.status !== "pending_review") {
sendError(ctx.res, 409, "already_reviewed", "submission has already been reviewed");
return true;
}
ctx.state.submissions.splice(idx, 1);
contextBlobs.delete(id);
stagedParts.delete(id);
sendJSON(ctx.res, 200, sub);
return true;
}
// The chunked upload: GET/PUT /api/v1/me/submissions/{id}/context/upload and
// POST .../upload/complete. Parts are staged in memory the way PartStore
// stages them on disk: the staged length is the resume point.
if (isMeSubmissions && ctx.parts[5] === "context" && ctx.parts[6] === "upload") {
const id = ctx.parts[4];
const sub = ctx.state.submissions.find((s) => s.id === id && s.submitted_by === ctx.account.email);
if (!sub) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
if (sub.status !== "pending_review") {
sendError(ctx.res, 409, "already_reviewed", "submission has already been reviewed");
return true;
}
const staged = stagedParts.get(id) ?? Buffer.alloc(0);
const progress = (received: number) => ({
received,
part_max_bytes: MOCK_PART_MAX,
max_context_bytes: MOCK_CONTEXT_MAX,
});
if (is("GET", ctx) && ctx.parts.length === 7) {
sendJSON(ctx.res, 200, progress(staged.length));
return true;
}
if (is("PUT", ctx) && ctx.parts.length === 7) {
const raw = new URL(ctx.req.url ?? "/", "http://localhost").searchParams.get("offset");
const offset = raw === null || !/^\d+$/.test(raw) ? NaN : Number(raw);
if (Number.isNaN(offset)) {
sendError(ctx.res, 400, "bad_request", "offset must be the byte position the part starts at");
return true;
}
if (offset !== 0 && offset !== staged.length) {
sendError(ctx.res, 409, "upload_offset_mismatch", `the upload holds ${staged.length} bytes; send the part that starts there`);
return true;
}
const chunks: Buffer[] = [];
for await (const chunk of ctx.req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const part = Buffer.concat(chunks);
if (part.length > MOCK_PART_MAX) {
sendError(ctx.res, 413, "part_too_large", "an upload part exceeds the part size limit");
return true;
}
if (offset === 0 && (part[0] !== 0x1f || part[1] !== 0x8b)) {
sendError(ctx.res, 400, "bad_request", "context must be a gzip-compressed tarball");
return true;
}
const next = Buffer.concat([offset === 0 ? Buffer.alloc(0) : staged, part]);
stagedParts.set(id, next);
// A local network finishes a part at once; the pause lets the progress
// bar be seen and paused.
await new Promise((r) => setTimeout(r, 250));
sendJSON(ctx.res, 200, progress(next.length));
return true;
}
if (is("POST", ctx) && ctx.parts.length === 8 && ctx.parts[7] === "complete") {
if (!stagedParts.has(id)) {
sendError(ctx.res, 400, "bad_request", "no upload in progress for this submission; send its parts first");
return true;
}
contextBlobs.set(id, staged);
sub.context_sha256 = sha256Hex(staged);
stagedParts.delete(id);
sendJSON(ctx.res, 200, sub);
return true;
}
}
// POST /api/v1/me/submissions
if (isMeSubmissions && is("POST", ctx) && ctx.parts.length === 4) {
const body = await readJSON<{ display_name?: string }>(ctx.req);
const displayName = body.display_name?.trim();
if (!displayName) {
sendError(ctx.res, 400, "bad_request", "display_name is required");
return true;
}
const id = `sub-${Date.now()}`;
const newSub: Submission = {
id,
submitted_by: ctx.account.email,
display_name: displayName,
context_ref: `minio/contexts/${id}/context.tar.gz`,
status: "pending_review",
created_at: new Date().toISOString(),
};
ctx.state.submissions.unshift(newSub);
sendJSON(ctx.res, 201, newSub);
return true;
}
// POST /api/v1/me/submissions/{id}/context
if (isMeSubmissions && is("POST", ctx) && ctx.parts[5] === "context" && ctx.parts.length === 6) {
const id = ctx.parts[4];
const sub = ctx.state.submissions.find((s) => s.id === id);
if (!sub) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
if (sub.submitted_by !== ctx.account.email) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
if (sub.status !== "pending_review") {
sendError(ctx.res, 409, "already_reviewed", "submission has already been reviewed");
return true;
}
const chunks: Buffer[] = [];
for await (const chunk of ctx.req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const blob = Buffer.concat(chunks);
contextBlobs.set(id, blob);
sub.context_sha256 = sha256Hex(blob);
sendJSON(ctx.res, 200, sub);
return true;
}
// GET /api/v1/submissions/{id}/context — the reviewer's download, carrying the
// digest the approval must name.
if (isAdminSubmissions && is("GET", ctx) && ctx.parts[4] === "context" && ctx.parts.length === 5) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const id = ctx.parts[3];
const sub = ctx.state.submissions.find((s) => s.id === id);
if (!sub || !sub.context_sha256) {
sendError(ctx.res, 404, "not_found", "no context uploaded for this submission");
return true;
}
const blob = contextBlob(id);
ctx.res.writeHead(200, {
"Content-Type": "application/gzip",
"Content-Disposition": `attachment; filename="${id}-context.tar.gz"`,
"X-Felis-Context-Sha256": sha256Hex(blob),
});
ctx.res.end(blob);
return true;
}
// GET /api/v1/submissions
if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, submissionPageOf(ctx, ctx.state.submissions));
return true;
}
// POST /api/v1/submissions/{id}/approve
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "approve" && ctx.parts.length === 5) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const id = ctx.parts[3];
const sub = ctx.state.submissions.find((s) => s.id === id);
if (!sub) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
if (sub.status !== "pending_review") {
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
return true;
}
const { expected_digest: expected } = await readJSON<{ expected_digest?: string }>(ctx.req);
if (!/^[0-9a-f]{64}$/.test(expected?.trim().toLowerCase() ?? "")) {
sendError(ctx.res, 400, "bad_request", "expected_digest must be the sha256 of the context you reviewed (64 hex characters)");
return true;
}
if (!sub.context_sha256) {
sendError(ctx.res, 400, "bad_request", "this context was uploaded before digests were recorded; the submitter must upload it again");
return true;
}
if (expected!.trim().toLowerCase() !== sub.context_sha256) {
sendError(ctx.res, 409, "context_changed", "the build context was uploaded again after it was reviewed; review the new upload before approving");
return true;
}
sub.status = "approved";
sub.reviewed_by = ctx.account.email;
sub.reviewed_at = new Date().toISOString();
sub.image_ref = `registry.felis.svc:5000/user-uploads/${id}:latest`;
// Trigger mock build
const newBuild: Build = {
id: `bld-${Date.now()}`,
image_ref: sub.image_ref,
status: "building",
dockerfile: `# felis user-modpack submission ${id}\n# The executed Dockerfile is provided by the uploaded build context:\n# ${sub.context_ref}\n`,
context_ref: sub.context_ref,
requested_by: ctx.account.email,
created_at: new Date().toISOString(),
};
ctx.state.builds.unshift(newBuild);
sub.build_id = newBuild.id;
// Succeeded after 15 seconds
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();
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
ctx.state.images.unshift({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 15000);
sendJSON(ctx.res, 200, sub);
return true;
}
// POST /api/v1/submissions/{id}/reject
if (isAdminSubmissions && is("POST", ctx) && ctx.parts[4] === "reject" && ctx.parts.length === 5) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const id = ctx.parts[3];
const sub = ctx.state.submissions.find((s) => s.id === id);
if (!sub) {
sendError(ctx.res, 404, "not_found", "submission not found");
return true;
}
if (sub.status !== "pending_review") {
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
return true;
}
const body = await readJSON<{ reason?: string }>(ctx.req);
const reason = body.reason?.trim();
if (!reason) {
sendError(ctx.res, 400, "bad_request", "reason is required");
return true;
}
sub.status = "rejected";
sub.reviewed_by = ctx.account.email;
sub.reviewed_at = new Date().toISOString();
sub.reject_reason = reason;
sendJSON(ctx.res, 200, sub);
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");
// A modpack boot floods the console; the burst exercises the frame batching.
for (let n = 0; n < 400; n++) {
res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\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 (!isAdmin(ctx.account.role)) {
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 (serverInfo.reaperExempt && !isAdmin(ctx.account.role)) {
sendError(ctx.res, 400, "bad_name", "system service is reserved for staff");
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, statusView(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" || ctx.parts[4] === "restart")) {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.retiring) {
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true;
}
if (ctx.parts[4] === "restart" && serverInfo.phase !== "Running") {
sendError(ctx.res, 409, "not_running", "start the server before restarting it");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "emergency-stop") {
if (!ctx.account || !isOwner(ctx.account.role)) { sendError(ctx.res, 403, "forbidden", "owner access required"); return true; }
const body = await readJSON(ctx.req) as { confirm?: string };
if (body.confirm !== serverInfo.name) { sendError(ctx.res, 400, "bad_request", "type the server name"); return true; }
setPhase(serverInfo, "Stopped");
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Stopped" });
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 (ctx.parts[4] === "retirement" && (is("PUT", ctx) || is("DELETE", ctx))) {
await handleRetirementMock(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 (is("POST", ctx) && ctx.parts[4] === "backup") {
handleBackupNowMock(ctx, serverInfo);
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "jobs") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
sendJSON(ctx.res, 200, { server: serverInfo.name, jobs: ctx.state.jobs[serverInfo.name] ?? [] });
return true;
}
if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo);
}
if (ctx.parts[4] === "files" || ctx.parts[4] === "file") {
return handleFilesMock(ctx, serverInfo);
}
return false;
}
// ---- server files (handlers_files.go) ----
interface MockFileNode {
is_dir: boolean;
data: Buffer;
mod_time: string;
}
type MockTree = Map<string, MockFileNode>;
const MOCK_MAX_WRITE = 256 * 1024;
const MOCK_MAX_READ = 1024 * 1024;
const MOCK_MAX_UPLOAD = 64 * 1024 * 1024;
const MOCK_SECRET_CONFIG = "config/paper-global.yml";
const MOCK_MANAGED = new Set([MOCK_SECRET_CONFIG, "config", "server.properties"]);
// seedFiles is a Paper server's volume after a few days of play: the configs an
// owner edits, plugin jars, a world and its logs. Sizes are real-looking, the
// binary content is zeros.
function seedFiles(serverName: string): MockTree {
const tree: MockTree = new Map();
const ago = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString();
const dir = (p: string, hours: number) => tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: ago(hours) });
const file = (p: string, content: string | number, hours: number) =>
tree.set(p, {
is_dir: false,
data: typeof content === "string" ? Buffer.from(content) : Buffer.alloc(content),
mod_time: ago(hours),
});
dir("", 0);
file(
"server.properties",
`#Minecraft server properties\nmotd=${serverName}\nmax-players=20\nonline-mode=false\ndifficulty=normal\ngamemode=survival\npvp=true\nview-distance=10\nspawn-protection=16\nenable-rcon=true\nrcon.password=[redacted by felis]\n`,
30,
);
file("eula.txt", "eula=true\n", 400);
file("ops.json", "[]\n", 50);
file("whitelist.json", '[\n {\n "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",\n "name": "Notch"\n }\n]\n', 50);
file("banned-players.json", "[]\n", 50);
file("bukkit.yml", "settings:\n allow-end: true\n warn-on-overload: true\nspawn-limits:\n monsters: 70\n", 300);
file("spigot.yml", "settings:\n debug: false\n restart-on-crash: true\n", 300);
dir("config", 300);
file(MOCK_SECRET_CONFIG, "# Do not hand-edit: felis rewrites this file on every boot.\n", 30);
file("config/paper-world-defaults.yml", "entities:\n spawning:\n per-player-mob-spawns: true\n", 300);
dir("plugins", 72);
file("plugins/LuckPerms-Bukkit-5.4.141.jar", 1_380_214, 72);
file("plugins/EssentialsX-2.20.1.jar", 3_120_876, 70);
file("plugins/Chunky-Bukkit-1.4.28.jar", 412_553, 12);
dir("plugins/LuckPerms", 72);
file("plugins/LuckPerms/config.yml", "server: global\nstorage-method: h2\n", 72);
dir("plugins/Essentials", 70);
file("plugins/Essentials/config.yml", "ops-name-color: '4'\nnickname-prefix: '~'\n", 70);
dir("world", 2);
file("world/level.dat", 2_431, 2);
file("world/session.lock", 3, 2);
dir("world/region", 2);
file("world/region/r.0.0.mca", 8_392_704, 2);
file("world/region/r.-1.0.mca", 5_246_976, 3);
file("world/region/r.0.-1.mca", 6_295_552, 26);
dir("world_nether", 26);
dir("world_the_end", 140);
dir("logs", 2);
file("logs/latest.log", `[12:00:00] [Server thread/INFO]: Starting minecraft server version 1.21.1\n[12:00:04] [Server thread/INFO]: Done (3.912s)! For help, type "help"\n`, 2);
return tree;
}
function filesOf(state: MockState, serverName: string): MockTree {
let tree = state.files[serverName];
if (!tree) {
tree = seedFiles(serverName);
state.files[serverName] = tree;
}
return tree;
}
function parentPath(p: string): string {
const i = p.lastIndexOf("/");
return i === -1 ? "" : p.slice(0, i);
}
// cleanFilePath is os.Root's containment in miniature: "." segments and empty
// ones collapse, and ".." or an absolute path is 400 bad_path. null means the
// response has been sent.
function cleanFilePath(ctx: SessionContext, raw: string | null, allowRoot = false): string | null {
if (raw === null || (raw === "" && !allowRoot)) {
sendError(ctx.res, 400, "bad_request", "path is required");
return null;
}
if (raw.startsWith("/") || raw.split("/").includes("..")) {
sendError(ctx.res, 400, "bad_path", `${raw} escapes the world root`);
return null;
}
const p = raw.split("/").filter((s) => s !== "" && s !== ".").join("/");
if (p === "" && !allowRoot) {
sendError(ctx.res, 400, "bad_path", "the world root itself cannot be changed");
return null;
}
return p;
}
function mockSha(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
async function readRawBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
// handleFilesMock mirrors the file routes: owner-or-admin, the stopped gate, then
// the op. Each real call is a Job that takes seconds; the pause stands in for it.
async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Promise<boolean> {
const route = ctx.parts.slice(4).join("/");
const known = ["GET files", "GET file", "PUT file", "DELETE file", "POST files/mkdir", "POST files/rename", "PUT files/upload"];
const key = `${ctx.method} ${route}`;
const fail = (status: number, code: string, message: string): true => {
sendError(ctx.res, status, code, message);
return true;
};
if (!known.includes(key)) return false;
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const liveConfig = ["login", "lobby"].includes(serverInfo.name) &&
["GET file", "PUT file"].includes(key) && url.searchParams.get("path") === "felis-experience.json";
if (!liveConfig && serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
return true;
}
const tree = filesOf(ctx.state, serverInfo.name);
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
if (p === null) return true;
await new Promise((r) => setTimeout(r, 350));
const now = new Date().toISOString();
const node = tree.get(p);
const parent = tree.get(parentPath(p));
switch (key) {
case "GET files": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (!node.is_dir) return fail(400, "bad_path", `${p} is not a directory`);
const entries = [...tree.entries()]
.filter(([k]) => k !== "" && parentPath(k) === p)
.map(([k, n]) => ({ name: k.slice(p === "" ? 0 : p.length + 1), size: n.is_dir ? 4096 : n.data.length, is_dir: n.is_dir, mod_time: n.mod_time }))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
sendJSON(ctx.res, 200, { path: p, entries, truncated: false });
return true;
}
case "GET file": {
if (p === MOCK_SECRET_CONFIG) return fail(400, "bad_path", `${p} holds the proxy forwarding secret`);
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data), content_sha256: mockSha(node.data) });
return true;
}
case "PUT file": {
const body = await readJSON<{ content?: string; expect_sha256?: string; create_only?: boolean }>(ctx.req);
if (typeof body.content !== "string") return fail(400, "bad_request", "content is required");
const data = Buffer.from(body.content, "base64");
if (data.length > MOCK_MAX_WRITE) return fail(413, "too_large", "the content is larger than the editor writes");
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (body.create_only && node) return fail(409, "file_exists", `${p} already exists`);
if (body.expect_sha256 && (!node || mockSha(node.data) !== body.expect_sha256)) {
return fail(409, "file_changed", `${p} changed since it was read`);
}
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "written", sha256: mockSha(data) });
return true;
}
case "DELETE file": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
for (const k of [...tree.keys()]) if (k === p || k.startsWith(`${p}/`)) tree.delete(k);
sendJSON(ctx.res, 200, { path: p, status: "deleted" });
return true;
}
case "POST files/mkdir": {
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node) return fail(409, "file_exists", `${p} already exists`);
tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "created" });
return true;
}
case "POST files/rename": {
const body = await readJSON<{ to?: string }>(ctx.req);
const to = cleanFilePath(ctx, body.to ?? "");
if (to === null) return true;
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (MOCK_MANAGED.has(p)) return fail(400, "bad_path", `${p} is managed by felis and cannot be moved or renamed`);
if (tree.has(to)) return fail(409, "file_exists", `${to} already exists`);
if (!tree.get(parentPath(to))?.is_dir) return fail(404, "not_found", `folder ${parentPath(to)} does not exist`);
for (const [k, n] of [...tree.entries()]) {
if (k === p || k.startsWith(`${p}/`)) {
tree.delete(k);
tree.set(to + k.slice(p.length), n);
}
}
sendJSON(ctx.res, 200, { path: p, to, status: "renamed" });
return true;
}
case "PUT files/upload": {
const declared = Number(ctx.req.headers["content-length"] ?? NaN);
if (Number.isNaN(declared)) return fail(411, "length_required", "an upload needs a Content-Length");
if (declared > MOCK_MAX_UPLOAD) return fail(413, "too_large", "uploads are at most 64 MiB");
const data = await readRawBody(ctx.req);
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (node && url.searchParams.get("overwrite") !== "true") return fail(409, "file_exists", `${p} already exists`);
// Landing takes the Job a moment after the body is in.
await new Promise((r) => setTimeout(r, 900));
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "uploaded", sha256: mockSha(data), size: data.length });
return true;
}
}
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;
let safety = true;
try {
const body = await readJSON<{ backup_id?: string; safety_snapshot?: boolean }>(ctx.req);
backupId = body.backup_id;
safety = body.safety_snapshot !== false;
} 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 (!isAdmin(ctx.account.role) && 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;
}
const now = new Date().toISOString();
const done = { state: "succeeded", started_at: now, finished_at: now };
const jobs: ServerJob[] = [{ name: `restore-${serverInfo.name}-${Date.now()}`, kind: "restore", ...done }];
if (safety) {
ctx.state.backups.unshift(backup(serverInfo.name, 0, 0.9 * GiB, ctx.account.id, "pre_restore"));
jobs.push({
name: `backup-${serverInfo.name}-${Date.now()}`,
kind: "backup",
...done,
then_restore: "started",
restore_backup_id: backup.id,
});
}
ctx.state.jobs[serverInfo.name] = [...jobs, ...(ctx.state.jobs[serverInfo.name] ?? [])];
sendJSON(ctx.res, 202, {
name: serverInfo.name,
status: "restoring",
backup_id: backup.id,
safety_snapshot: safety,
});
return true;
}
// handleBackupNowMock mirrors POST /servers/{name}/backup: owner-or-admin, then
// the stopped gate (the world volume is RWO), then 202. The Job finishes at once.
function handleBackupNowMock(ctx: SessionContext, serverInfo: MockServer): void {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
if (serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before backing it up");
return;
}
const now = new Date().toISOString();
ctx.state.backups.unshift(backup(serverInfo.name, 0, 0.9 * GiB, ctx.account.id, "manual"));
const job: ServerJob = {
name: `backup-${serverInfo.name}-${Date.now()}`,
kind: "backup",
state: "succeeded",
started_at: now,
finished_at: now,
};
ctx.state.jobs[serverInfo.name] = [job, ...(ctx.state.jobs[serverInfo.name] ?? [])];
sendJSON(ctx.res, 202, { name: serverInfo.name, status: "backing_up" });
}
function accessFor(state: MockState, name: string): AccessState {
let entry = state.access[name];
if (!entry) {
entry = { whitelist: [], banned: [], online: [], luckperms: {} };
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 === "luckperms") {
const player = ctx.parts[6];
if (!player || !PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return true;
}
const lpData = access.luckperms[player] || { groups: ["default"], permissions: [] };
sendJSON(ctx.res, 200, {
player,
groups: lpData.groups,
permissions: lpData.permissions,
});
return true;
}
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.playersMax ?? 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;
}
if (is("POST", ctx) && sub === "group") {
void handleGroupMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && sub === "permission") {
void handlePermissionMock(ctx, serverInfo);
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}`,
});
}
async function handleGroupMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
const body = await readJSON<{ action?: string; player?: string; group?: string }>(ctx.req);
const player = body.player?.trim() ?? "";
const group = body.group?.trim() ?? "";
const action = body.action?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
if (!LP_CTX.test(group)) {
sendError(ctx.res, 400, "bad_request", "invalid group name");
return;
}
if (action !== "add" && action !== "remove") {
sendError(ctx.res, 400, "bad_request", "invalid action");
return;
}
const access = accessFor(ctx.state, serverInfo.name);
if (!access.luckperms[player]) {
access.luckperms[player] = { groups: ["default"], permissions: [] };
}
const lp = access.luckperms[player];
if (action === "add") {
if (!lp.groups.includes(group)) lp.groups.push(group);
} else if (action === "remove") {
lp.groups = lp.groups.filter((g) => g !== group);
}
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action,
player,
group,
output: `[mock-luckperms] Added parent group '${group}' for user ${player} in context global`,
});
}
async function handlePermissionMock(
ctx: SessionContext,
serverInfo: MockServer,
): Promise<void> {
const body = await readJSON<{
action?: string;
player?: string;
node?: string;
value?: boolean;
world?: string;
}>(ctx.req);
const player = body.player?.trim() ?? "";
const node = body.node?.trim() ?? "";
const action = body.action?.trim() ?? "";
const value = body.value ?? true;
const world = body.world?.trim() ?? "";
if (!PLAYER_NAME.test(player)) {
sendError(ctx.res, 400, "bad_request", "invalid player name");
return;
}
if (!LP_NODE.test(node)) {
sendError(ctx.res, 400, "bad_request", "invalid permission node");
return;
}
if (world !== "" && !LP_CTX.test(world)) {
sendError(ctx.res, 400, "bad_request", "invalid world context");
return;
}
if (action !== "set" && action !== "unset") {
sendError(ctx.res, 400, "bad_request", "invalid action");
return;
}
const access = accessFor(ctx.state, serverInfo.name);
if (!access.luckperms[player]) {
access.luckperms[player] = { groups: ["default"], permissions: [] };
}
const lp = access.luckperms[player];
if (action === "set") {
lp.permissions = lp.permissions.filter((p) => !(p.node === node && p.world === world));
lp.permissions.push({ node, value, world: world || undefined });
} else if (action === "unset") {
lp.permissions = lp.permissions.filter((p) => !(p.node === node && p.world === world));
}
const worldSuffix = world ? ` (world=${world})` : "";
const outputMsg = action === "set"
? `[mock-luckperms] Set permission ${node} to ${value} for ${player}${worldSuffix}`
: `[mock-luckperms] Unset permission ${node} for ${player}${worldSuffix}`;
sendJSON(ctx.res, 200, {
name: serverInfo.name,
action,
player,
node,
value,
world: world || undefined,
output: outputMsg,
});
}
// 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`;
}
// handleRetirementMock mirrors handlers_retire.go: owner-or-admin, delete is an
// admin's, the typed name must match, a system server is refused. The mock has no
// reaper, so the request just sits until cancelled; the cancel of a deletion is an
// admin's too.
async function handleRetirementMock(ctx: SessionContext, serverInfo: MockServer): Promise<void> {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
const admin = isAdmin(ctx.account.role);
if (is("DELETE", ctx)) {
if (serverInfo.retiring?.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can cancel the deletion of a server");
return;
}
serverInfo.retiring = undefined;
ctx.res.statusCode = 204;
ctx.res.end();
return;
}
const body = await readJSON<{ confirm?: string; delete?: boolean }>(ctx.req);
if (body.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can delete a server");
return;
}
if (body.confirm !== serverInfo.name) {
sendError(ctx.res, 400, "confirm_mismatch", "type the server's name to confirm");
return;
}
if (serverInfo.reaperExempt) {
sendError(ctx.res, 409, "system_server", "a system server cannot be given up or deleted");
return;
}
setPhase(serverInfo, "Stopped");
serverInfo.retiring = {
requested_at: serverInfo.retiring?.requested_at ?? new Date().toISOString(),
delete: (serverInfo.retiring?.delete ?? false) || body.delete === true,
};
sendJSON(ctx.res, 202, { name: serverInfo.name, retiring: serverInfo.retiring });
}
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;
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\"",
"[12:00:05] [Server thread/INFO]: [Essentials] §aReady§r — §6§lwelcome§r to §x§5§5§c§c§f§fFelis",
"[12:00:06] [Server thread/WARN]: \x1b[33mTPS dropped to 18.2\x1b[0m",
];
let i = 0;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(": connected\n\n");
// A modpack boot floods the console; the burst exercises the frame batching.
for (let n = 0; n < 400; n++) {
res.write(`data: [11:59:59] [main/INFO]: Loading mod ${n + 1}/400 (mock-mod-${n})\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,
build: { version: "v0.4.0+g1a2b3c4", release: "v0.4.0", commit: "1a2b3c4", dev: true },
});
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");
}
});
},
};
}