Harden gameplay timing and map loading

This commit is contained in:
2026-10-05 15:15:35 +05:00
parent 8b31891776
commit dcc3779262
36 changed files with 1050 additions and 205 deletions

View File

@@ -0,0 +1,63 @@
/** Validate before launching either companion process; never print key values. */
export function validateRuntime(env, { production = false } = {}) {
const missing = [
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
"SUPABASE_SECRET_KEY",
].filter((key) => !env[key]);
if (missing.length)
throw new Error(
`Missing configuration: ${missing.join(", ")}. Configure .env.local or your host's environment.`,
);
const backend = new URL(env.NEXT_PUBLIC_SUPABASE_URL);
if (
!["http:", "https:"].includes(backend.protocol) ||
backend.username ||
backend.password
)
throw new Error(
"NEXT_PUBLIC_SUPABASE_URL must be an HTTP(S) URL without credentials.",
);
if (
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.startsWith("sb_secret_") ||
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY === env.SUPABASE_SECRET_KEY
)
throw new Error(
"The public Supabase key must not contain the server secret.",
);
if (production) {
if (env.MINEGEO_ALLOW_ANY_ORIGIN === "true")
throw new Error("MINEGEO_ALLOW_ANY_ORIGIN must be false in production.");
if (!env.MINEGEO_URL)
throw new Error(
"Set MINEGEO_URL to the website's public origin before starting production.",
);
const website = new URL(env.MINEGEO_URL);
const local = ["localhost", "127.0.0.1", "[::1]"].includes(
website.hostname,
);
if (
(!local && website.protocol !== "https:") ||
!["https:", "http:"].includes(website.protocol) ||
website.username ||
website.password ||
website.pathname !== "/" ||
website.search ||
website.hash
)
throw new Error(
"MINEGEO_URL must be an HTTPS origin (HTTP is allowed only on loopback).",
);
}
const workerHeap = Number(env.MINEGEO_WORKER_HEAP_MB || 4096);
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768)
throw new Error(
"MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.",
);
const faceSize = Number(env.MINEGEO_FACE_SIZE || 1024);
if (!Number.isInteger(faceSize) || faceSize < 256 || faceSize > 2048)
throw new Error(
"MINEGEO_FACE_SIZE must be an integer between 256 and 2048.",
);
return { workerHeap };
}

View File

@@ -1,13 +1,16 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { validateRuntime } from "./runtime-config.mjs";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const dev = process.argv[2] === "dev";
const { workerHeap } = validateRuntime(process.env, { production: !dev });
const children = [];
let stopping = false;
function run(args) {
const c = spawn(process.execPath, args, {
stdio: "inherit",
env: process.env,
windowsHide: true,
});
children.push(c);
c.on("exit", (code) => {
@@ -34,10 +37,9 @@ run([
"--hostname",
process.env.MINEGEO_HOST || "127.0.0.1",
]);
const workerHeap = Number(process.env.MINEGEO_WORKER_HEAP_MB || 4096);
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768) {
console.error("MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.");
stop(1);
} else {
run([`--max-old-space-size=${workerHeap}`, "--import", "tsx", "worker/main.ts"]);
}
run([
`--max-old-space-size=${workerHeap}`,
"--import",
"tsx",
"worker/main.ts",
]);

167
scripts/test-production.mjs Normal file
View File

@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { createClient } from "@supabase/supabase-js";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const origin = new URL(
process.argv[2] || process.env.MINEGEO_URL || "http://localhost:3000",
).origin;
const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SECRET_KEY,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
let sessionId, cookie;
async function request(path, body, options = {}) {
return fetch(origin + path, {
method: body === undefined ? "GET" : "POST",
headers: {
...(body === undefined
? {}
: { "content-type": "application/json", origin }),
...(cookie ? { cookie } : {}),
...options.headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
}
async function json(path, body) {
const response = await request("/api/v1/" + path, body);
assert.equal(
response.status,
200,
`${path}: ${await response.clone().text()}`,
);
assert.match(response.headers.get("cache-control"), /no-store/);
return response.json();
}
try {
for (const path of [
"/",
"/servers",
"/downloads",
"/about",
"/log-in",
"/fonts/VT323-Regular.ttf",
"/images/example-map.webp",
"/tools/plugin/minegeo-plugin-1.21.11.jar",
"/tools/mod/minegeo-fabric-1.21.11.jar",
]) {
const response = await request(path);
assert.equal(response.status, 200, path);
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
assert.equal(response.headers.get("x-frame-options"), "DENY");
assert.ok((await response.arrayBuffer()).byteLength > 0, path);
}
assert.equal((await json("health")).ok, true);
for (const path of [
"/servers/not-a-uuid",
"/servers/00000000-0000-4000-8000-000000000000",
"/api/v1/catalog/extra",
]) {
assert.equal((await request(path)).status, 404, path);
}
for (const path of ["datasets", "tokens", "profile"]) {
assert.equal(
(await request("/api/v1/" + path)).status,
401,
`guest ${path}`,
);
}
const forbidden = await request(
"/api/v1/sessions",
{},
{ headers: { origin: "https://foreign.example" } },
);
assert.equal(forbidden.status, 403);
const malformed = await fetch(origin + "/api/v1/sessions", {
method: "POST",
headers: { "content-type": "application/json", origin },
body: "{",
signal: AbortSignal.timeout(30000),
});
assert.equal(malformed.status, 400);
assert.equal((await malformed.json()).error, "Invalid JSON request");
const catalog = await json("catalog");
assert.ok(
catalog.maps.length,
"Publish at least one map before verifying gameplay",
);
const map = catalog.maps[0];
const created = await request("/api/v1/sessions", {
datasetId: map.id,
difficulty: "hard",
});
assert.equal(created.status, 200, await created.clone().text());
sessionId = (await created.json()).id;
cookie = created.headers.get("set-cookie").split(";")[0];
const session = await json("sessions/" + sessionId);
assert.equal(session.rounds.length, 5);
assert.ok(
session.rounds.every(
(round) => round.faces.length === 6 && round.faces.every(Boolean),
),
);
assert.equal(
(await fetch(origin + "/api/v1/sessions/" + sessionId)).status,
404,
"another guest cannot read the session",
);
// Exercise actual signed Storage reads, not just the URL response shape.
for (const url of [session.map, ...session.rounds[0].faces]) {
const image = await fetch(url, { signal: AbortSignal.timeout(30000) });
assert.equal(image.status, 200);
assert.match(image.headers.get("content-type"), /image\/webp/);
assert.ok((await image.arrayBuffer()).byteLength > 0);
}
for (let round = 0; round < 5; round++) {
const first = await json(`sessions/${sessionId}/activate`, { round });
const duplicate = await json(`sessions/${sessionId}/activate`, { round });
assert.equal(
first.deadline,
duplicate.deadline,
"activation does not reset the timer",
);
assert.equal(
(
await request(`/api/v1/sessions/${sessionId}/guess`, {
round,
x: map.bounds.maxX + 1,
z: map.bounds.minZ,
})
).status,
400,
);
const guess = {
round,
x: (map.bounds.minX + map.bounds.maxX) / 2,
z: (map.bounds.minZ + map.bounds.maxZ) / 2,
};
const result = await json(`sessions/${sessionId}/guess`, guess);
assert.ok(result.score >= 0 && result.score <= 5000);
assert.equal(result.timedOut, false);
assert.deepEqual(
await json(`sessions/${sessionId}/guess`, guess),
result,
"submission retries are idempotent",
);
}
assert.equal((await json("sessions/" + sessionId)).round, 5);
console.log(
"Production HTTP/API/Storage smoke passed: pages, headers, privacy, origin checks, signed media and all five rounds.",
);
} finally {
if (sessionId && cookie) {
const secret = cookie.slice(cookie.indexOf("=") + 1);
const { error } = await db
.from("game_sessions")
.delete()
.eq("id", sessionId)
.eq("access_hash", createHash("sha256").update(secret).digest("hex"))
.is("owner_id", null);
if (error)
throw new Error("Smoke session cleanup failed: " + error.message);
}
}