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.

-
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({
{`${map.name} (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[] }) {
)} - {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}

+ +
+ )}
); } 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 ( <>
@@ -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 }) {
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)); +});