diff --git a/.env.example b/.env.example
index eb32c1d..8da27d1 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/README.md b/README.md
index a493d22..e55ae36 100644
--- a/README.md
+++ b/README.md
@@ -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 -- ` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).
diff --git a/app/api/v1/[...path]/route.ts b/app/api/v1/[...path]/route.ts
index 639baf6..b3df3d4 100644
--- a/app/api/v1/[...path]/route.ts
+++ b/app/api/v1/[...path]/route.ts
@@ -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 };
diff --git a/app/error.tsx b/app/error.tsx
index 2fa4da0..3fce087 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -1,5 +1,5 @@
"use client";
-export default function ErrorPage({ reset }: { reset: () => void }) {
+export default function ErrorPage({ retry }: { retry: () => void }) {
return (
Lost connection.
@@ -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.
-
+
Try again
diff --git a/app/map-theme.css b/app/map-theme.css
index feef5a8..67aa34a 100644
--- a/app/map-theme.css
+++ b/app/map-theme.css
@@ -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;
}
diff --git a/app/page.tsx b/app/page.tsx
index 6206057..709024e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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
/>
diff --git a/app/servers/[id]/page.tsx b/app/servers/[id]/page.tsx
index 518c1e2..3c3932d 100644
--- a/app/servers/[id]/page.tsx
+++ b/app/servers/[id]/page.tsx
@@ -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 (
<>
@@ -34,7 +30,7 @@ export default async function ServerPage({
(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[] }) {
setSource(id)}
>
{label}
diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx
index 2edbc70..edaac3a 100644
--- a/components/Dashboard.tsx
+++ b/components/Dashboard.tsx
@@ -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 (
diff --git a/components/Game.tsx b/components/Game.tsx
index 684fcf2..2dc067d 100644
--- a/components/Game.tsx
+++ b/components/Game.tsx
@@ -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(null),
- [remaining, setRemaining] = useState(0),
- [progress, setProgress] = useState(0);
- const offset = useRef(0),
+ [remaining, setRemaining] = useState(0);
+ const clock = useRef | 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("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 }) {
Preparing your expedition.
-
Loading compressed panoramas · {progress}%
+
Loading panorama and surface map…
)}
{error && (
@@ -267,7 +282,7 @@ export function Game({ id }: { id: string }) {
)}
- {result && (
+ {result && !error && (
ROUND {(session?.round || 0) + 1} COMPLETE
diff --git a/components/GuessMap.tsx b/components/GuessMap.tsx
index 11e9365..db6c0c7 100644
--- a/components/GuessMap.tsx
+++ b/components/GuessMap.tsx
@@ -26,12 +26,21 @@ export function GuessMap({
const root = useRef(null),
markers = useRef(null),
lib = useRef(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 | 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 && (
+
+
{error}
+
setAttempt((value) => value + 1)}
+ >
+ Retry map
+
+
+ )}
);
}
diff --git a/components/Panorama.tsx b/components/Panorama.tsx
index 2c43505..f1a129f 100644
--- a/components/Panorama.tsx
+++ b/components/Panorama.tsx
@@ -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
>();
-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(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 (
<>
setFov(Math.max(35, view.current.fov - 10))}
+ onClick={() => {
+ view.current.fov = Math.max(35, view.current.fov - 10);
+ }}
>
setFov(Math.min(100, view.current.fov + 10))}
+ onClick={() => {
+ view.current.fov = Math.min(100, view.current.fov + 10);
+ }}
>
@@ -193,7 +189,6 @@ export function Panorama({
aria-label="Reset view"
onClick={() => {
view.current = { yaw: 0, pitch: 0, fov: 80 };
- setFov(80);
}}
>
diff --git a/components/Results.tsx b/components/Results.tsx
index 30cf39c..e4aab1b 100644
--- a/components/Results.tsx
+++ b/components/Results.tsx
@@ -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
("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 {error}
;
if (!session) return Unpacking your expedition…
;
@@ -52,6 +62,7 @@ export function Results({ id }: { id: string }) {
setSelected(i)}
>
Round {i + 1}
@@ -85,7 +96,7 @@ export function Results({ id }: { id: string }) {
Another adventure →
- My expeditions
+ My maps
diff --git a/components/StartGame.tsx b/components/StartGame.tsx
index 7a5e506..020b221 100644
--- a/components/StartGame.tsx
+++ b/components/StartGame.tsx
@@ -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>(
- path: string,
- body?: unknown,
- method = body ? "POST" : "GET",
-): Promise {
- 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("normal"),
[loading, setLoading] = useState(false),
diff --git a/docs/local-network.md b/docs/local-network.md
index 8aa5de7..3655952 100644
--- a/docs/local-network.md
+++ b/docs/local-network.md
@@ -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.
diff --git a/docs/production-audit.md b/docs/production-audit.md
new file mode 100644
index 0000000..b81e4be
--- /dev/null
+++ b/docs/production-audit.md
@@ -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.
diff --git a/docs/production.md b/docs/production.md
new file mode 100644
index 0000000..02ebebb
--- /dev/null
+++ b/docs/production.md
@@ -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.
diff --git a/lib/api.ts b/lib/api.ts
index b6b20c0..7acd65d 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -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 });
}
}
diff --git a/lib/catalog.ts b/lib/catalog.ts
index b247364..9424801 100644
--- a/lib/catalog.ts
+++ b/lib/catalog.ts
@@ -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],
};
}
diff --git a/lib/client-api.ts b/lib/client-api.ts
new file mode 100644
index 0000000..93eca17
--- /dev/null
+++ b/lib/client-api.ts
@@ -0,0 +1,41 @@
+export async function api>(
+ path: string,
+ body?: unknown,
+ method = body === undefined ? "GET" : "POST",
+): Promise {
+ 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;
+}
diff --git a/lib/panorama-textures.ts b/lib/panorama-textures.ts
new file mode 100644
index 0000000..3fe22b1
--- /dev/null
+++ b/lib/panorama-textures.ts
@@ -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 = new THREE.TextureLoader(),
+ timeoutMs = 30000,
+): Promise {
+ const textures: THREE.Texture[] = [];
+ let failed = false;
+ let timer: ReturnType | 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((_, 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);
+ }
+}
diff --git a/lib/request-origin.ts b/lib/request-origin.ts
index 55d5d37..10486d4 100644
--- a/lib/request-origin.ts
+++ b/lib/request-origin.ts
@@ -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;
diff --git a/lib/service-error.ts b/lib/service-error.ts
new file mode 100644
index 0000000..ec99dac
--- /dev/null
+++ b/lib/service-error.ts
@@ -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.");
+}
diff --git a/lib/supabase.ts b/lib/supabase.ts
index a70cd37..42ab364 100644
--- a/lib/supabase.ts
+++ b/lib/supabase.ts
@@ -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> | 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(
process.env.NEXT_PUBLIC_SUPABASE_URL,
@@ -51,9 +52,9 @@ export const user = cache(async function user() {
});
export function required(result: {
data: T;
- error: { message: string } | null;
+ error: { message: string; code?: string } | null;
}): NonNullable {
- 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;
diff --git a/next.config.ts b/next.config.ts
index e6915f8..0c2f150 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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;
diff --git a/package.json b/package.json
index 1e85216..a896771 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/scripts/runtime-config.mjs b/scripts/runtime-config.mjs
new file mode 100644
index 0000000..84ff48e
--- /dev/null
+++ b/scripts/runtime-config.mjs
@@ -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 };
+}
diff --git a/scripts/supervise.mjs b/scripts/supervise.mjs
index db45a8f..e528838 100644
--- a/scripts/supervise.mjs
+++ b/scripts/supervise.mjs
@@ -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",
+]);
diff --git a/scripts/test-production.mjs b/scripts/test-production.mjs
new file mode 100644
index 0000000..aa11747
--- /dev/null
+++ b/scripts/test-production.mjs
@@ -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);
+ }
+}
diff --git a/shared/round-clock.ts b/shared/round-clock.ts
new file mode 100644
index 0000000..0a71721
--- /dev/null
+++ b/shared/round-clock.ts
@@ -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,
+ ),
+ );
+ },
+ };
+}
diff --git a/tests/client-api.test.ts b/tests/client-api.test.ts
new file mode 100644
index 0000000..c8dcaa8
--- /dev/null
+++ b/tests/client-api.test.ts
@@ -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/);
+});
diff --git a/tests/panorama-textures.test.ts b/tests/panorama-textures.test.ts
new file mode 100644
index 0000000..90ecf77
--- /dev/null
+++ b/tests/panorama-textures.test.ts
@@ -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((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((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);
+});
diff --git a/tests/request-origin.test.ts b/tests/request-origin.test.ts
index 982be68..cac5842 100644
--- a/tests/request-origin.test.ts
+++ b/tests/request-origin.test.ts
@@ -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;
+ 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");
diff --git a/tests/round-clock.test.ts b/tests/round-clock.test.ts
new file mode 100644
index 0000000..35a6c1d
--- /dev/null
+++ b/tests/round-clock.test.ts
@@ -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);
+});
diff --git a/tests/runtime-config.test.ts b/tests/runtime-config.test.ts
new file mode 100644
index 0000000..a4c94df
--- /dev/null
+++ b/tests/runtime-config.test.ts
@@ -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,
+ );
+});
diff --git a/tests/service-error.test.ts b/tests/service-error.test.ts
new file mode 100644
index 0000000..08b5371
--- /dev/null
+++ b/tests/service-error.test.ts
@@ -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));
+});