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

@@ -4,7 +4,7 @@ MINEGEO_URL=http://localhost:3000
MINEGEO_HOST=127.0.0.1
# Optional comma-separated exact public origins for a reverse proxy or aliases.
MINEGEO_ALLOWED_ORIGINS=
# Testing only: true bypasses browser origin checks (including CSRF origin protection).
# Development testing only; production rejects this bypass of CSRF protection.
MINEGEO_ALLOW_ANY_ORIGIN=false
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

View File

@@ -20,6 +20,13 @@ npm run build
npm start
```
Before public deployment, follow the [production launch guide](docs/production.md).
`npm run check` runs unit tests, TypeScript checking and the production build.
With the compiled site running, `npm run test:production -- http://localhost:3000`
checks HTTP routes, privacy, signed images and five-round gameplay against the
configured Supabase project. It creates and removes one temporary guest game;
at least one map must be published.
`npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker.
Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- <dataset UUID>` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).

View File

@@ -6,6 +6,9 @@ async function route(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return handle(request, (await params).path);
const response = await handle(request, (await params).path);
// Includes private drafts, pairing credentials, token creation and errors.
response.headers.set("Cache-Control", "private, no-store");
return response;
}
export { route as GET, route as POST, route as PATCH, route as DELETE };

View File

@@ -1,5 +1,5 @@
"use client";
export default function ErrorPage({ reset }: { reset: () => void }) {
export default function ErrorPage({ retry }: { retry: () => void }) {
return (
<main className="section page-content">
<h1>Lost connection.</h1>
@@ -7,7 +7,7 @@ export default function ErrorPage({ reset }: { reset: () => void }) {
We couldn’t load this part of the world. Check your connection and try
again.
</p>
<button className="button primary" onClick={reset}>
<button className="button primary" onClick={retry}>
Try again
</button>
</main>

View File

@@ -1,11 +1,24 @@
/* The same parchment surrounds the editor preview and interactive player map. */
.detail-map,
.map-surface {
position: relative;
border: 22px solid transparent;
border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch;
background: #ddc79f;
box-shadow: 4px 6px 0 #0005;
}
.map-load-error {
position: absolute;
inset: 0;
z-index: 1001;
display: grid;
place-content: center;
gap: 12px;
padding: 16px;
text-align: center;
background: #ddc79f;
color: #493a28;
}
.detail-map {
margin-top: 12px;
}

View File

@@ -59,7 +59,7 @@ export default async function Home() {
alt="A Minecraft surface map framed in parchment"
width={1024}
height={1024}
priority
loading="eager"
unoptimized
/>
<span className="square-pin pin-green" />

View File

@@ -10,12 +10,8 @@ export default async function ServerPage({
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let map;
try {
map = await getMap(id);
} catch {
notFound();
}
const map = await getMap(id);
if (!map) notFound();
return (
<>
<Header />
@@ -34,7 +30,7 @@ export default async function ServerPage({
<div>
<div className="detail-map">
<Image
src={map.mapImage || map.image || ""}
src={map.mapImage || map.image || "/images/example-map.webp"}
alt={`${map.name} surface map`}
width={1024}
height={1024}

View File

@@ -8,9 +8,9 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
const shown = maps.filter(
(m) =>
(source === "all" || m.source === source) &&
(m.name + " " + m.description + " " + m.server?.name)
(m.name + " " + m.description + " " + (m.server?.name ?? ""))
.toLowerCase()
.includes(query.toLowerCase()),
.includes(query.trim().toLowerCase()),
);
return (
<>
@@ -33,6 +33,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
<button
key={id}
className={source === id ? "active" : ""}
aria-pressed={source === id}
onClick={() => setSource(id)}
>
{label}

View File

@@ -29,6 +29,7 @@ export function Dashboard() {
]);
setMaps(m.maps);
setTokens(t.tokens);
setError("");
} catch (e) {
setError((e as Error).message);
}
@@ -65,7 +66,11 @@ export function Dashboard() {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
await client.auth.signOut();
const { error: signOutError } = await client.auth.signOut();
if (signOutError) {
setError("Could not sign out. Please try again.");
return;
}
router.refresh();
}
return (

View File

@@ -9,10 +9,11 @@ import {
MapPin,
} from "lucide-react";
import { Brand } from "./Brand";
import { Panorama, preloadFaces } from "./Panorama";
import { Panorama } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame";
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
import { roundClock } from "@/shared/round-clock";
export type RoundResult = {
score: number;
distance: number | null;
@@ -44,15 +45,19 @@ export function Game({ id }: { id: string }) {
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[result, setResult] = useState<RoundResult | null>(null),
[remaining, setRemaining] = useState(0),
[progress, setProgress] = useState(0);
const offset = useRef(0),
[remaining, setRemaining] = useState(0);
const clock = useRef<ReturnType<typeof roundClock> | null>(null),
loadVersion = useRef(0),
guessing = useRef(false),
activating = useRef(false),
timingOut = useRef(false);
const load = useCallback(async () => {
const version = ++loadVersion.current;
const sentAt = performance.now();
try {
const s = await api<SessionView>("sessions/" + id);
offset.current = s.serverNow - Date.now();
if (version !== loadVersion.current) return;
clock.current = roundClock(s.serverNow, sentAt, performance.now());
if (s.round >= 5) {
router.replace("/results/" + id);
return;
@@ -60,31 +65,28 @@ export function Game({ id }: { id: string }) {
setSession(s);
setRemaining(
s.rounds[s.round].deadline
? Math.max(
0,
Math.ceil(
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
),
? clock.current.remaining(
s.rounds[s.round].deadline!,
performance.now(),
)
: difficulties[s.difficulty].seconds,
);
s.rounds.forEach((r) =>
preloadFaces(r.faces)
.then(() => setProgress((p) => Math.min(100, p + 20)))
.catch(() => {}),
);
} catch (e) {
setError((e as Error).message);
if (version === loadVersion.current) setError((e as Error).message);
}
}, [id, router]);
useEffect(() => {
load();
return () => {
loadVersion.current++;
};
}, [load]);
useEffect(() => {
if (
!session ||
!loaded.panorama ||
!loaded.map ||
error ||
result ||
activating.current
)
@@ -92,28 +94,31 @@ export function Game({ id }: { id: string }) {
const r = session.rounds[session.round];
if (r.deadline) return;
activating.current = true;
const round = session.round;
const sentAt = performance.now();
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
round: session.round,
})
.then((d) => {
offset.current = d.serverNow - Date.now();
clock.current = roundClock(d.serverNow, sentAt, performance.now());
setSession((s) =>
s
s && s.round === round
? {
...s,
rounds: s.rounds.map((r) =>
r.number === s.round ? { ...r, deadline: d.deadline } : r,
),
}
: null,
: s,
);
})
.catch((e) => setError(e.message))
.finally(() => (activating.current = false));
}, [loaded, session, id, result]);
}, [loaded, session, id, result, error]);
const guess = useCallback(
async (timeout = false) => {
if (!session || busy || result || (!pin && !timeout)) return;
if (!session || guessing.current || result || (!pin && !timeout)) return;
guessing.current = true;
setBusy(true);
setError("");
try {
@@ -122,26 +127,36 @@ export function Game({ id }: { id: string }) {
...(!timeout && pin ? pin : {}),
});
setResult(r);
setSession((s) =>
s
? {
...s,
rounds: s.rounds.map((round) =>
round.number === session.round
? { ...round, result: r }
: round,
),
}
: s,
);
} catch (e) {
setError((e as Error).message);
timingOut.current = false;
} finally {
guessing.current = false;
setBusy(false);
}
},
[session, busy, result, pin, id],
[session, result, pin, id],
);
useEffect(() => {
if (!session || result) return;
if (!session || result || error) return;
const deadline = session.rounds[session.round].deadline;
if (!deadline) return;
const tick = () => {
const left = Math.max(
0,
Math.ceil((Date.parse(deadline) - Date.now() - offset.current) / 1000),
);
const left = clock.current?.remaining(deadline, performance.now()) ?? 0;
setRemaining(left);
if (left === 0 && !timingOut.current) {
if (left === 0 && !timingOut.current && !guessing.current) {
timingOut.current = true;
guess(true);
}
@@ -149,7 +164,7 @@ export function Game({ id }: { id: string }) {
tick();
const timer = setInterval(tick, 250);
return () => clearInterval(timer);
}, [session, result, guess]);
}, [session, result, guess, error]);
async function next() {
if (!session) return;
setSession(null);
@@ -157,7 +172,7 @@ export function Game({ id }: { id: string }) {
setPin(null);
setLoaded({ panorama: false, map: false });
timingOut.current = false;
setProgress(0);
setError("");
await load();
}
const ready = useCallback(
@@ -215,7 +230,7 @@ export function Game({ id }: { id: string }) {
key={"map-" + session.round}
bounds={session.bounds}
image={session.map}
pin={result?.guess || pin}
pin={result ? result.guess : pin}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
@@ -252,7 +267,7 @@ export function Game({ id }: { id: string }) {
<div className="game-loading">
<div className="loading-cube" />
<h2>Preparing your expedition.</h2>
<p>Loading compressed panoramas · {progress}%</p>
<p>Loading panorama and surface map…</p>
</div>
)}
{error && (
@@ -267,7 +282,7 @@ export function Game({ id }: { id: string }) {
</button>
</div>
)}
{result && (
{result && !error && (
<div className="round-result">
<span className="eyebrow">
ROUND {(session?.round || 0) + 1} COMPLETE

View File

@@ -26,12 +26,21 @@ export function GuessMap({
const root = useRef<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(null),
[generation, setGeneration] = useState(0);
[generation, setGeneration] = useState(0),
[error, setError] = useState(""),
[attempt, setAttempt] = useState(0);
const callbacks = useRef({ onPin, onReady, onError, disabled });
callbacks.current = { onPin, onReady, onError, disabled };
useEffect(() => {
let disposed = false;
let cleanup = () => {};
setError("");
const fail = (message: string) => {
if (disposed) return;
setError(message);
callbacks.current.onError?.(message);
};
let loadTimer: ReturnType<typeof setTimeout> | undefined;
import("leaflet")
.then((L) => {
if (disposed || !root.current) return;
@@ -48,13 +57,31 @@ export function GuessMap({
maxBounds: b.pad(0.15),
attributionControl: false,
});
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
observer?.disconnect();
markers.current = null;
lib.current = null;
m.remove();
};
const overlay = L.imageOverlay(image, b);
overlay.on("load", () => callbacks.current.onReady?.());
overlay.on("error", () =>
callbacks.current.onError?.(
"The surface map could not load. Reconnect to retry.",
),
loadTimer = setTimeout(
() =>
fail("The surface map took too long to load. Reconnect to retry."),
30000,
);
overlay.on("load", () => {
clearTimeout(loadTimer);
if (!disposed) {
setError("");
callbacks.current.onReady?.();
}
});
overlay.on("error", () => {
clearTimeout(loadTimer);
fail("The surface map could not load. Reconnect to retry.");
});
overlay.addTo(m);
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
m.fitBounds(b, { padding: [0, 0], animate: false });
@@ -74,12 +101,33 @@ export function GuessMap({
)
callbacks.current.onPin?.({ x, z });
});
const keydown = (event: KeyboardEvent) => {
if (
event.key !== "Enter" ||
event.target !== m.getContainer() ||
callbacks.current.disabled
)
return;
event.preventDefault();
const center = m.getCenter();
callbacks.current.onPin?.({
x: Math.max(bounds.minX, Math.min(bounds.maxX, center.lng)),
z: Math.max(bounds.minZ, Math.min(bounds.maxZ, -center.lat)),
});
};
m.getContainer().addEventListener("keydown", keydown);
const removeMap = cleanup;
cleanup = () => {
m.getContainer().removeEventListener("keydown", keydown);
removeMap();
};
let fittedZoom = fitZoom;
const observer = new ResizeObserver(() => {
observer = new ResizeObserver(() => {
if (!root.current?.clientWidth || !root.current.clientHeight) return;
// Expanding the map should reveal the whole survey at its new size.
// Preserve a player's chosen zoom and center while inspecting terrain.
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
m.invalidateSize();
m.invalidateSize({ pan: false });
m.setMinZoom(-5);
fittedZoom = m.getBoundsZoom(b);
m.setMaxZoom(Math.max(3, fittedZoom + 3));
@@ -88,20 +136,14 @@ export function GuessMap({
m.setMinZoom(fittedZoom);
});
observer.observe(root.current);
cleanup = () => {
observer.disconnect();
markers.current = null;
m.remove();
};
})
.catch(() =>
callbacks.current.onError?.("The map viewer could not initialize."),
);
.catch(() => fail("The map viewer could not initialize."));
return () => {
disposed = true;
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
useEffect(() => {
const L = lib.current,
group = markers.current;
@@ -133,8 +175,23 @@ export function GuessMap({
ref={root}
style={{ height: "100%", width: "100%" }}
role="application"
aria-label="Minecraft map. North is up. Click to place a guess."
aria-label={
disabled
? "Minecraft map. North is up."
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
}
/>
{error && (
<div className="map-load-error" role="alert">
<p>{error}</p>
<button
className="button"
onClick={() => setAttempt((value) => value + 1)}
>
Retry map
</button>
</div>
)}
</div>
);
}

View File

@@ -1,29 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { Plus, Minus, RotateCcw } from "lucide-react";
import * as THREE from "three";
const textureCache = new Map<string, Promise<THREE.Texture>>();
export function preloadFaces(urls: string[]) {
return Promise.all(
urls.map((url) => {
if (!textureCache.has(url))
textureCache.set(
url,
new THREE.TextureLoader()
.loadAsync(url)
.then((t) => {
t.colorSpace = THREE.SRGBColorSpace;
return t;
})
.catch((e) => {
textureCache.delete(url);
throw e;
}),
);
return textureCache.get(url)!;
}),
);
}
import { loadPanoramaTextures } from "@/lib/panorama-textures";
// Explicit projection matches the six positive-FOV camera images emitted by
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection;
@@ -43,8 +22,7 @@ export function Panorama({
onError: (message: string) => void;
}) {
const mount = useRef<HTMLDivElement>(null),
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
[fov, setFov] = useState(80);
view = useRef({ yaw: 0, pitch: 0, fov: 80 });
const callbacks = useRef({ onReady, onError });
callbacks.current = { onReady, onError };
useEffect(() => {
@@ -53,7 +31,8 @@ export function Panorama({
frame = 0,
observer: ResizeObserver | undefined;
let material: THREE.ShaderMaterial | undefined,
geometry: THREE.SphereGeometry | undefined;
geometry: THREE.SphereGeometry | undefined,
ownedTextures: THREE.Texture[] = [];
const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => {
@@ -81,6 +60,13 @@ export function Panorama({
Math.min(100, view.current.fov + e.deltaY * 0.04),
);
};
const contextLost = (event: Event) => {
event.preventDefault();
cancelAnimationFrame(frame);
callbacks.current.onError(
"The panorama graphics context was lost. Reconnect to restore the view.",
);
};
let drag: { x: number; y: number } | null = null;
if (urls.length !== 6) {
callbacks.current.onError(
@@ -88,13 +74,18 @@ export function Panorama({
);
return;
}
preloadFaces(urls)
loadPanoramaTextures(urls)
.then((textures) => {
if (disposed) return;
if (disposed) {
textures.forEach((texture) => texture.dispose());
return;
}
ownedTextures = textures;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
element.appendChild(renderer.domElement);
renderer.domElement.addEventListener("webglcontextlost", contextLost);
const scene = new THREE.Scene(),
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
geometry = new THREE.SphereGeometry(5, 32, 16);
@@ -112,9 +103,11 @@ export function Panorama({
});
scene.add(new THREE.Mesh(geometry, material));
const resize = () => {
camera.aspect = element.clientWidth / element.clientHeight;
const width = Math.max(1, element.clientWidth),
height = Math.max(1, element.clientHeight);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer!.setSize(element.clientWidth, element.clientHeight);
renderer!.setSize(width, height);
};
observer = new ResizeObserver(resize);
observer.observe(element);
@@ -157,13 +150,12 @@ export function Panorama({
element.removeEventListener("wheel", wheel);
geometry?.dispose();
material?.dispose();
ownedTextures.forEach((texture) => texture.dispose());
renderer?.domElement.removeEventListener("webglcontextlost", contextLost);
renderer?.dispose();
renderer?.domElement.remove();
};
}, [urls, zoom]);
useEffect(() => {
view.current.fov = fov;
}, [fov]);
return (
<>
<div
@@ -177,13 +169,17 @@ export function Panorama({
<>
<button
aria-label="Zoom in"
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
onClick={() => {
view.current.fov = Math.max(35, view.current.fov - 10);
}}
>
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
onClick={() => {
view.current.fov = Math.min(100, view.current.fov + 10);
}}
>
<Minus size={17} />
</button>
@@ -193,7 +189,6 @@ export function Panorama({
aria-label="Reset view"
onClick={() => {
view.current = { yaw: 0, pitch: 0, fov: 80 };
setFov(80);
}}
>
<RotateCcw size={17} />

View File

@@ -11,12 +11,22 @@ export function Results({ id }: { id: string }) {
[error, setError] = useState("");
const router = useRouter();
useEffect(() => {
let disposed = false;
setSession(null);
setSelected(0);
setError("");
api<SessionView>("sessions/" + id)
.then((s) => {
if (disposed) return;
if (s.round < 5) router.replace("/play/" + id);
else setSession(s);
})
.catch((e) => setError(e.message));
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [id, router]);
if (error) return <p role="alert">{error}</p>;
if (!session) return <p className="muted">Unpacking your expedition…</p>;
@@ -52,6 +62,7 @@ export function Results({ id }: { id: string }) {
<button
key={i}
className={i === selected ? "active" : ""}
aria-pressed={i === selected}
onClick={() => setSelected(i)}
>
Round {i + 1}
@@ -85,7 +96,7 @@ export function Results({ id }: { id: string }) {
Another adventure →
</Link>
<Link className="button" href="/dashboard">
My expeditions
My maps
</Link>
</div>
</div>

View File

@@ -3,21 +3,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
export async function api<T = Record<string, unknown>>(
path: string,
body?: unknown,
method = body ? "POST" : "GET",
): Promise<T> {
const res = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000),
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[loading, setLoading] = useState(false),

View File

@@ -36,6 +36,9 @@ MINEGEO_ALLOW_ANY_ORIGIN=true
This bypasses MineGeo's browser origin checks, including their CSRF protection.
The default is `false`; only the exact value `true` enables the bypass.
Use `npm run dev` for this testing mode. Production ignores the bypass, and
`npm start` refuses to launch when it is enabled. Public production URLs must use
HTTPS; loopback HTTP remains available for testing a production build locally.
Authentication, permissions, and JSON request requirements still apply. This
setting controls MineGeo's API checks; Supabase email sign-in still needs allowed
redirect URLs, and `MINEGEO_URL` still controls capture-tool pairing links.

57
docs/production-audit.md Normal file
View File

@@ -0,0 +1,57 @@
# Map and production audit — October 5, 2026
Tracked in [Linear DUN-30](https://linear.app/dungeons-and-projects/issue/DUN-30/fix-map-and-gameplay-bugs-and-prepare-minegeo-for-production).
## Fixes
- Panorama viewers own and release their textures on unmount. Failed and stalled
loads release completed and late-arriving faces. There is no unbounded global
cache of every panorama and rotating signed URL.
- Map-image failures and stalled loads show a retry control, including results.
Map cleanup is installed before image initialization. Resizing keeps the map
fitted or preserves the player's view. Keyboard users can pan with arrow keys
and press Enter to place a pin at the center.
- Countdown synchronization accounts for request latency and uses a monotonic
clock. Submission has an immediate lock, failed timeout requests stop retrying
behind an error dialog, current-round points update immediately, and timeout
results do not retain an unsubmitted pin.
- Session media signing uses a single batch for the map and 30 faces. Catalog
signing deduplicates images. Existing revision/media authorization is retained.
- Missing/invalid map IDs get a 404; backend failures reach the retryable error
boundary. The error page now uses Next.js 16.3's `retry()` to fetch fresh data.
Database transport/schema errors return 503 without exposing their internals.
- All API responses prevent caching, guest HTTPS cookies respect the configured
proxy origin, production disables the origin bypass, and baseline browser
security headers are configured. Startup validates environment, heap and
panorama size before launching either process.
- Client requests explain offline, timeout and non-JSON gateway errors. Catalog
searches trim whitespace, source/result selection exposes pressed state,
successful dashboard refreshes clear stale errors, and sign-out failures stay
visible. Results now label the dashboard link accurately as My maps.
## Validation
- 63 Node regression tests passed, including new client-error, texture-disposal,
clock, startup-config, backend-error and production-origin tests.
- TypeScript checking and the optimized Next.js 16.3.8 production build passed.
- The HTTP/API/Storage smoke passed against both development and compiled
production servers with the configured backend. It exercised pages/JARs,
headers, privacy, signed map/cubemap downloads, five rounds, duplicate requests,
out-of-bounds rejection and foreign-origin denial, then deleted its test games.
- Codex's built-in browser verified the live homepage/catalog, trimmed search,
hard-mode panorama and timeout, game reload/results and result-round selection.
A separate local fixture verified failed map images, successful retry, keyboard
guessing and rectangular-map resizing. Browser checks found no uncaught errors.
- The production npm dependency audit reported zero known vulnerabilities.
- The Supabase security advisor and a permissions query confirmed public workflow
RPCs use invoker security and deny anon/authenticated execution. Informational
no-policy notices concern deliberately server-only RLS tables.
## Launch requirements and limits
Leaked-password protection remains disabled in the configured Supabase project;
it must be enabled in provider settings where the plan supports it. Production
SMTP, emailed login/recovery delivery, public HTTPS/proxy configuration and
restoration from backups were not exercised in this local audit. No deployment,
new Minecraft client capture, or cross-platform worker benchmark was performed.
Follow [the production guide](production.md) before public launch.

55
docs/production.md Normal file
View File

@@ -0,0 +1,55 @@
# Production launch
The website requires a Node.js host and a persistent rendering worker. Deploy
this checkout with Node.js 22 or later, run `npm ci`, `npm run setup`, and
`npm run check`, then start it with `npm start` under your host's process manager.
The supervisor runs the website and worker together and stops the companion if
either exits. A host that runs only Next.js must run `npm run worker` separately.
Configure these before starting:
- `MINEGEO_URL`: the public HTTPS origin, without a path, query or credentials.
Loopback HTTP is supported for local production checks.
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, and the
server-only `SUPABASE_SECRET_KEY`. The supervisor validates missing keys and
rejects a server secret in the publishable-key field before starting children.
- Keep `MINEGEO_ALLOW_ANY_ORIGIN=false`. Production ignores the bypass; the
supervisor also rejects it at startup. Use `MINEGEO_ALLOWED_ORIGINS` for exact
aliases and configure your reverse proxy to retain the public Host header.
- Keep the default loopback binding behind a local HTTPS reverse proxy. Set
`MINEGEO_HOST=0.0.0.0` only when your hosting/container network requires it.
- Apply the existing `supabase/migrations` in filename order. This audit adds no
database migration. Keep the media and raw buckets private.
Configure Supabase Auth's Site URL and allowed `/auth/callback` redirects for the
public origin, email confirmation, working production SMTP, and a minimum
password length of 12. Enable leaked-password protection when the plan supports
it; the October 5 audit found it disabled. See the [account guide](account-settings.md)
and [Supabase password security](https://supabase.com/docs/guides/auth/password-security).
The site sends no-store headers for all application API responses, including
drafts, pairing credentials, tokens and errors. Guest cookies are HttpOnly,
SameSite=Lax, and Secure for the configured HTTPS public host behind a proxy.
The response headers prevent framing and MIME sniffing, limit referrers, and
disable unused camera, microphone and location permissions. Configure HTTPS
certificates and HSTS at the public reverse proxy.
Monitor `/api/v1/health`: HTTP 200 means its database query succeeded; 503 means
configuration or the data service is unavailable. Also retain worker logs and
monitor failed/queued jobs; website health does not assert worker liveness.
Keep Supabase backups enabled and test restoration before storing irreplaceable
community uploads.
After deployment, run:
```sh
npm run test:production -- https://your-public-origin.example
```
Run this from the configured checkout for the same Supabase project. The test
checks pages/downloads, security headers, invalid map IDs, guest access denial,
CSRF denial, malformed JSON, session isolation, real signed WebP downloads, and
all five rounds with idempotent activation/submission and out-of-bounds rejection.
It removes only its generated guest session. A published map is required.
See [the audit record](production-audit.md) for evidence and remaining limits.

View File

@@ -11,7 +11,8 @@ import {
panoramaPath,
viewpointSchema,
} from "../shared/protocol";
import { mediaUrl } from "./catalog";
import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api";
const hash = (value: string) =>
@@ -107,7 +108,7 @@ export async function handle(request: NextRequest, path: string[]) {
return await accountApi(request, path, { jsonBody, limit });
const client = admin(),
method = request.method;
if (path[0] === "health") {
if (path[0] === "health" && path.length === 1 && method === "GET") {
const { error } = await client
.from("catalog_servers")
.select("id")
@@ -117,7 +118,7 @@ export async function handle(request: NextRequest, path: string[]) {
{ status: error ? 503 : 200 },
);
}
if (method === "GET" && path[0] === "catalog") {
if (method === "GET" && path[0] === "catalog" && path.length === 1) {
const rows = required(
await client.from("datasets").select("*").eq("state", "published"),
);
@@ -153,14 +154,16 @@ export async function handle(request: NextRequest, path: string[]) {
response.cookies.set("mg_guest", secret, {
httpOnly: true,
sameSite: "lax",
secure: new URL(request.url).protocol === "https:",
secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
"https:",
),
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
}
const s = await session(request, path[1]);
if (method === "GET") {
if (method === "GET" && path.length === 2) {
const revision = required(
await client
.from("revisions")
@@ -176,6 +179,18 @@ export async function handle(request: NextRequest, path: string[]) {
.eq("session_id", s.id)
.order("round_number"),
);
const signed = await mediaUrls([
`${revision.asset_root}/map.webp`,
...rounds.flatMap((r) =>
faces.map((f) =>
panoramaPath(
revision.asset_root,
viewpointSchema.parse(r.viewpoint),
`${f}.webp`,
),
),
),
]);
return NextResponse.json(
{
id: s.id,
@@ -183,31 +198,22 @@ export async function handle(request: NextRequest, path: string[]) {
round: s.current_round,
bounds: manifest.bounds,
name: manifest.name,
map: await mediaUrl(`${revision.asset_root}/map.webp`),
rounds: await Promise.all(
rounds.map(async (r) => ({
number: r.round_number,
deadline: r.deadline,
result: r.result,
faces: await Promise.all(
faces.map((f) =>
mediaUrl(
panoramaPath(
revision.asset_root,
viewpointSchema.parse(r.viewpoint),
`${f}.webp`,
),
),
),
),
})),
),
map: signed[0],
rounds: rounds.map((r, index) => ({
number: r.round_number,
deadline: r.deadline,
result: r.result,
faces: signed.slice(
1 + index * faces.length,
1 + (index + 1) * faces.length,
),
})),
serverNow: Date.now(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
if (method === "POST" && path[2] === "activate") {
if (method === "POST" && path[2] === "activate" && path.length === 3) {
const body = z
.object({ round: z.number().int().min(0).max(4) })
.parse(await jsonBody(request));
@@ -220,7 +226,7 @@ export async function handle(request: NextRequest, path: string[]) {
);
return NextResponse.json({ deadline, serverNow: Date.now() });
}
if (method === "POST" && path[2] === "guess") {
if (method === "POST" && path[2] === "guess" && path.length === 3) {
const body = z
.object({
round: z.number().int().min(0).max(4),
@@ -272,7 +278,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_dataset: d.id,
p_owner: owner,
});
if (serverCheck.error) throw new Error(serverCheck.error.message);
checkServiceError(serverCheck.error);
const input = required(
await client
.from("dataset_inputs")
@@ -371,7 +377,7 @@ export async function handle(request: NextRequest, path: string[]) {
.select("state,progress,error,updated_at,lease_until")
.eq("dataset_id", d.id)
.maybeSingle();
if (jobResult.error) throw new Error(jobResult.error.message);
checkServiceError(jobResult.error);
return NextResponse.json({
...d,
upload_jobs: jobResult.data,
@@ -420,7 +426,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_owner: d.owner_id,
p_address: body.contributionServerAddress ?? null,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
if (method === "PATCH") {
@@ -438,7 +444,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_description: body.description,
p_ids: body.selectedIds,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "submit") {
@@ -446,7 +452,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_dataset: d.id,
p_owner: d.owner_id,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "cancel") {
@@ -455,7 +461,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_dataset: d.id,
p_owner: d.owner_id,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
if (!["uploading", "processing"].includes(d.state))
@@ -467,7 +473,7 @@ export async function handle(request: NextRequest, path: string[]) {
.in("state", ["queued", "running"])
.select("id")
.maybeSingle();
if (cancelled.error) throw new Error(cancelled.error.message);
checkServiceError(cancelled.error);
required(
await client
.from("datasets")
@@ -571,7 +577,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_decision: body.decision,
p_reason: body.reason ?? undefined,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
}
@@ -657,7 +663,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_token: token,
p_hash: hash(token),
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[1] === "poll") {
@@ -781,7 +787,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_decision: b.decision,
p_reason: b.reason ?? null,
});
if (error) throw new Error(error.message);
checkServiceError(error);
return NextResponse.json({ ok: true });
}
}
@@ -824,16 +830,25 @@ export async function handle(request: NextRequest, path: string[]) {
const message =
error instanceof z.ZodError
? "Invalid request: " + error.issues.map((i) => i.message).join("; ")
: error instanceof Error
? error.message
: "Request failed";
const status = message.includes("Sign in")
? 401
: message.includes("permission") || message.includes("origin")
? 403
: message.includes("Too many")
? 429
: 400;
: error instanceof SyntaxError
? "Invalid JSON request"
: error instanceof Error
? error.message
: "Request failed";
const status =
path[0] === "health"
? 503
: error instanceof ServiceError
? error.status
: message.includes("Sign in")
? 401
: message.includes("permission") || message.includes("origin")
? 403
: message.includes("Too many")
? 429
: message.includes("not found")
? 404
: 400;
return NextResponse.json({ error: message }, { status });
}
}

View File

@@ -1,5 +1,7 @@
import "server-only";
import { admin, required } from "./supabase";
import { z } from "zod";
import { ServiceError } from "./service-error";
export type MapListing = {
id: string;
name: string;
@@ -23,12 +25,22 @@ export type MapListing = {
credit?: string;
};
export async function mediaUrl(path: string | null, expires = 3600) {
if (!path) return null;
return (await mediaUrls([path], expires))[0];
}
export async function mediaUrls(paths: (string | null)[], expires = 3600) {
const unique = [...new Set(paths.filter((path): path is string => !!path))];
if (!unique.length) return paths.map(() => null);
const { data, error } = await admin()
.storage.from("minegeo-media")
.createSignedUrl(path, expires);
if (error) throw new Error(error.message);
return data.signedUrl;
.createSignedUrls(unique, expires);
if (error || !data || data.some((item) => item.error || !item.signedUrl))
throw new ServiceError(
"Map images are temporarily unavailable. Please try again.",
);
const signed = new Map(
data.map((item, index) => [unique[index], item.signedUrl]),
);
return paths.map((path) => (path ? (signed.get(path) ?? null) : null));
}
export async function catalog() {
const client = admin();
@@ -50,35 +62,40 @@ export async function catalog() {
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
)
: [];
return Promise.all(
maps.map(async (m) => ({
...m,
credit:
owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
image: (await mediaUrl(m.map_path || m.cover_path)) ?? undefined,
mapImage: (await mediaUrl(m.map_path)) ?? undefined,
server: servers.find((s) => s.id === m.server_id),
})),
const signed = await mediaUrls(
maps.flatMap((map) => [map.map_path || map.cover_path, map.map_path]),
);
return maps.map((m, index) => ({
...m,
credit: owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
image: signed[index * 2] ?? undefined,
mapImage: signed[index * 2 + 1] ?? undefined,
server: servers.find((s) => s.id === m.server_id),
}));
}
export async function getMap(id: string) {
const map = required(
await admin()
.from("datasets")
.select("*")
.eq("id", id)
.eq("state", "published")
.single(),
) as MapListing;
const owner = await admin()
.from("profiles")
.select("display_name")
.eq("id", map.owner_id)
if (!z.string().uuid().safeParse(id).success) return null;
const result = await admin()
.from("datasets")
.select("*")
.eq("id", id)
.eq("state", "published")
.maybeSingle();
if (result.error) required(result);
if (!result.data) return null;
const map = result.data as unknown as MapListing;
const [owner, signed] = await Promise.all([
admin()
.from("profiles")
.select("display_name")
.eq("id", map.owner_id)
.maybeSingle(),
mediaUrls([map.cover_path, map.map_path]),
]);
return {
...map,
credit: owner.data?.display_name ?? "Explorer",
image: await mediaUrl(map.cover_path),
mapImage: await mediaUrl(map.map_path),
image: signed[0],
mapImage: signed[1],
};
}

41
lib/client-api.ts Normal file
View File

@@ -0,0 +1,41 @@
export async function api<T = Record<string, unknown>>(
path: string,
body?: unknown,
method = body === undefined ? "GET" : "POST",
): Promise<T> {
let response: Response;
try {
response = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000),
cache: "no-store",
method,
headers:
body === undefined ? undefined : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
if (
error instanceof Error &&
["TimeoutError", "AbortError"].includes(error.name)
)
throw new Error("The request timed out. Reconnect and try again.");
throw new Error(
"Could not reach MineGeo. Check your connection and try again.",
);
}
let data: T & { error?: string };
try {
data = await response.json();
} catch {
throw new Error(
"MineGeo returned an unreadable response. Please try again.",
);
}
if (!response.ok)
throw new Error(
typeof data?.error === "string"
? data.error
: "Request failed. Please try again.",
);
return data;
}

44
lib/panorama-textures.ts Normal file
View File

@@ -0,0 +1,44 @@
import * as THREE from "three";
// Each viewer owns its textures. Browser HTTP caching handles repeat downloads;
// keeping GPU resources in a global cache leaks every visited map and signed URL.
export async function loadPanoramaTextures(
urls: string[],
loader: Pick<THREE.TextureLoader, "loadAsync"> = new THREE.TextureLoader(),
timeoutMs = 30000,
): Promise<THREE.Texture[]> {
const textures: THREE.Texture[] = [];
let failed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.all(
urls.map(async (url) => {
const texture = await loader.loadAsync(url);
if (failed) {
texture.dispose();
throw new Error("Panorama loading cancelled");
}
texture.colorSpace = THREE.SRGBColorSpace;
textures.push(texture);
return texture;
}),
),
new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error("Panorama loading timed out. Reconnect to retry."),
),
timeoutMs,
);
}),
]);
} catch (error) {
failed = true;
textures.forEach((texture) => texture.dispose());
throw error;
} finally {
clearTimeout(timer);
}
}

View File

@@ -50,7 +50,9 @@ export function verifyOrigin(
configuration: OriginConfiguration = {
websiteUrl: process.env.MINEGEO_URL,
additionalOrigins: process.env.MINEGEO_ALLOWED_ORIGINS,
allowAnyOrigin: process.env.MINEGEO_ALLOW_ANY_ORIGIN === "true",
allowAnyOrigin:
process.env.NODE_ENV !== "production" &&
process.env.MINEGEO_ALLOW_ANY_ORIGIN === "true",
},
) {
if (request.method === "GET" || request.method === "HEAD") return;

16
lib/service-error.ts Normal file
View File

@@ -0,0 +1,16 @@
export class ServiceError extends Error {
constructor(
message: string,
public readonly status = 503,
) {
super(message);
}
}
export function checkServiceError(error: { message: string; code?: string } | null) {
if (!error) return;
if (error.code === "P0001") throw new Error(error.message);
if (error.code === "PGRST116") throw new ServiceError("Record not found", 404);
console.error("Supabase request failed:", error.code, error.message);
throw new ServiceError("MineGeo could not reach its data service. Please try again.");
}

View File

@@ -4,11 +4,12 @@ import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { cache } from "react";
import type { Database } from "@/shared/database";
import { ServiceError, checkServiceError } from "./service-error";
let singleton: ReturnType<typeof createClient<Database>> | undefined;
export function admin() {
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SECRET_KEY)
throw new Error(
"Supabase configuration missing. Run npm run setup and configure .env.local.",
throw new ServiceError(
"MineGeo is not configured yet. Please try again later.",
);
return (singleton ??= createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL,
@@ -51,9 +52,9 @@ export const user = cache(async function user() {
});
export function required<T>(result: {
data: T;
error: { message: string } | null;
error: { message: string; code?: string } | null;
}): NonNullable<T> {
if (result.error) throw new Error(result.error.message);
checkServiceError(result.error);
if (result.data === null || result.data === undefined)
throw new Error("Record not found");
return result.data as NonNullable<T>;

View File

@@ -3,5 +3,25 @@ const config: NextConfig = {
poweredByHeader: false,
serverExternalPackages: ["pg"],
turbopack: { root: process.cwd() },
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "same-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
},
],
},
];
},
};
export default config;

View File

@@ -16,7 +16,8 @@
"test": "tsx --test tests/*.test.ts",
"test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs",
"test:e2e": "playwright test",
"test:production": "node scripts/test-production.mjs",
"check": "npm test && npm run typecheck -- --incremental false && npm run build",
"typecheck": "tsc --noEmit",
"renderer:proof": "node scripts/proof.mjs",
"java:build": "node scripts/java-build.mjs"

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);
}
}

19
shared/round-clock.ts Normal file
View File

@@ -0,0 +1,19 @@
// Synchronize at receipt using a bounded estimate of the return journey. The
// countdown uses performance.now(), so changing the OS clock cannot extend it.
export function roundClock(
serverNow: number,
sentAt: number,
receivedAt: number,
) {
const serverAtReceipt = serverNow + Math.max(0, receivedAt - sentAt) / 2;
return {
remaining(deadline: string, now: number) {
return Math.max(
0,
Math.ceil(
(Date.parse(deadline) - serverAtReceipt - (now - receivedAt)) / 1000,
),
);
},
};
}

46
tests/client-api.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { api } from "../lib/client-api";
test("API sends explicit falsy JSON bodies and prevents cached private responses", async (t) => {
const fetch = t.mock.method(
globalThis,
"fetch",
async (_url: unknown, options: RequestInit) => {
assert.equal(options.method, "POST");
assert.equal(options.body, "false");
assert.equal(options.cache, "no-store");
return Response.json({ ok: true });
},
);
assert.deepEqual(await api("test", false), { ok: true });
assert.equal(fetch.mock.callCount(), 1);
});
test("API explains HTML gateway failures, JSON errors, and empty error responses", async (t) => {
const fetch = t.mock.method(
globalThis,
"fetch",
async () => new Response("Bad gateway", { status: 502 }),
);
await assert.rejects(api("test"), /unreadable response/);
fetch.mock.mockImplementation(async () =>
Response.json({ error: "Map unavailable" }, { status: 404 }),
);
await assert.rejects(api("test"), /Map unavailable/);
fetch.mock.mockImplementation(async () =>
Response.json(null, { status: 503 }),
);
await assert.rejects(api("test"), /Request failed/);
});
test("API reports timeouts and offline connections with a recovery action", async (t) => {
const fetch = t.mock.method(globalThis, "fetch", async () => {
throw new DOMException("timeout", "TimeoutError");
});
await assert.rejects(api("test"), /timed out.*Reconnect/);
fetch.mock.mockImplementation(async () => {
throw new TypeError("Failed to fetch");
});
await assert.rejects(api("test"), /Check your connection/);
});

View File

@@ -0,0 +1,59 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import * as THREE from "three";
import { loadPanoramaTextures } from "../lib/panorama-textures";
function texture() {
const value = new THREE.Texture();
let disposals = 0;
value.addEventListener("dispose", () => disposals++);
return { value, disposals: () => disposals };
}
test("panorama textures retain face order and are owned separately by each viewer", async () => {
const loader = { loadAsync: async () => new THREE.Texture() };
const first = await loadPanoramaTextures(["px", "nx"], loader);
const second = await loadPanoramaTextures(["px", "nx"], loader);
assert.equal(first.length, 2);
assert.equal(first[0].colorSpace, THREE.SRGBColorSpace);
assert.notEqual(first[0], second[0]);
[...first, ...second].forEach((value) => value.dispose());
});
test("a failed panorama frees both loaded faces and faces that complete later", async () => {
const early = texture(),
late = texture();
let finish!: (value: THREE.Texture) => void;
const loader = {
loadAsync: (url: string) =>
url === "early"
? Promise.resolve(early.value)
: url === "late"
? new Promise<THREE.Texture>((resolve) => {
finish = resolve;
})
: Promise.reject(new Error("missing face")),
};
await assert.rejects(
loadPanoramaTextures(["early", "late", "missing"], loader),
/missing face/,
);
assert.equal(early.disposals(), 1);
finish(late.value);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(late.disposals(), 1);
});
test("stalled panoramas time out and release textures that arrive afterward", async () => {
const late = texture();
let finish!: (value: THREE.Texture) => void;
const loader = {
loadAsync: () =>
new Promise<THREE.Texture>((resolve) => {
finish = resolve;
}),
};
await assert.rejects(loadPanoramaTextures(["late"], loader, 5), /timed out/);
finish(late.value);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(late.disposals(), 1);
});

View File

@@ -58,6 +58,26 @@ test("the testing switch retains JSON checks for requests without an origin", ()
);
});
test("production never enables the environment origin bypass", () => {
const env = process.env as Record<string, string | undefined>;
const previousNode = env.NODE_ENV,
previousBypass = env.MINEGEO_ALLOW_ANY_ORIGIN;
try {
env.NODE_ENV = "production";
env.MINEGEO_ALLOW_ANY_ORIGIN = "true";
assert.throws(
() =>
verifyOrigin(makeRequest("localhost:3000", "https://foreign.example")),
/Invalid request origin/,
);
} finally {
if (previousNode === undefined) delete env.NODE_ENV;
else env.NODE_ENV = previousNode;
if (previousBypass === undefined) delete env.MINEGEO_ALLOW_ANY_ORIGIN;
else env.MINEGEO_ALLOW_ANY_ORIGIN = previousBypass;
}
});
test("accepts a Mac LAN origin when Next uses localhost internally", () => {
const request = makeRequest("192.168.1.50:3000", "http://192.168.1.50:3000");
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");

20
tests/round-clock.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { roundClock } from "../shared/round-clock";
test("round timer accounts for return latency and stops at the server deadline", () => {
const clock = roundClock(100000, 1000, 5000);
const deadline = new Date(105000).toISOString();
assert.equal(clock.remaining(deadline, 5000), 3);
assert.equal(clock.remaining(deadline, 7001), 1);
assert.equal(clock.remaining(deadline, 8000), 0);
assert.equal(clock.remaining(deadline, 9000), 0);
});
test("round timer relies on elapsed time even if the device wall clock changes", (t) => {
const clock = roundClock(100000, 1000, 1000);
t.mock.method(Date, "now", () => -10000000);
assert.equal(clock.remaining(new Date(110000).toISOString(), 5000), 6);
t.mock.method(Date, "now", () => 10000000);
assert.equal(clock.remaining(new Date(110000).toISOString(), 11000), 0);
});

View File

@@ -0,0 +1,69 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateRuntime } from "../scripts/runtime-config.mjs";
const env = {
NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co",
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_example",
SUPABASE_SECRET_KEY: "sb_secret_example",
MINEGEO_URL: "https://minegeo.example",
};
test("production startup rejects CSRF bypass, secret exposure and missing config", () => {
assert.throws(
() =>
validateRuntime(
{ ...env, MINEGEO_ALLOW_ANY_ORIGIN: "true" },
{ production: true },
),
/must be false/,
);
assert.throws(
() =>
validateRuntime({
...env,
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: env.SUPABASE_SECRET_KEY,
}),
/server secret/,
);
assert.throws(
() => validateRuntime({ ...env, SUPABASE_SECRET_KEY: "" }),
/Missing configuration.*SUPABASE_SECRET_KEY/,
);
});
test("production requires a secure public origin while allowing local production checks", () => {
for (const url of [
"http://minegeo.example",
"https://user:pass@minegeo.example",
"https://minegeo.example/path",
"https://minegeo.example?x=1",
])
assert.throws(
() => validateRuntime({ ...env, MINEGEO_URL: url }, { production: true }),
/HTTPS origin/,
);
assert.equal(
validateRuntime(
{ ...env, MINEGEO_URL: "http://localhost:3000" },
{ production: true },
).workerHeap,
4096,
);
assert.equal(validateRuntime(env, { production: true }).workerHeap, 4096);
});
test("startup validates worker heap and panorama size before spawning processes", () => {
assert.throws(
() => validateRuntime({ ...env, MINEGEO_WORKER_HEAP_MB: "NaN" }),
/WORKER_HEAP/,
);
assert.throws(
() => validateRuntime({ ...env, MINEGEO_FACE_SIZE: "8192" }),
/FACE_SIZE/,
);
assert.equal(
validateRuntime({
...env,
MINEGEO_WORKER_HEAP_MB: "2048",
MINEGEO_FACE_SIZE: "256",
}).workerHeap,
2048,
);
});

View File

@@ -0,0 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkServiceError, ServiceError } from "../lib/service-error";
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
t.mock.method(console, "error", () => {});
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
assert.ok(error instanceof ServiceError);
assert.equal(error.status, 503);
assert.doesNotMatch(error.message, /private_table|42P01/);
return true;
});
});
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404);
assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
assert.doesNotThrow(() => checkServiceError(null));
});