Harden gameplay timing and map loading
This commit is contained in:
@@ -4,7 +4,7 @@ MINEGEO_URL=http://localhost:3000
|
|||||||
MINEGEO_HOST=127.0.0.1
|
MINEGEO_HOST=127.0.0.1
|
||||||
# Optional comma-separated exact public origins for a reverse proxy or aliases.
|
# Optional comma-separated exact public origins for a reverse proxy or aliases.
|
||||||
MINEGEO_ALLOWED_ORIGINS=
|
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
|
MINEGEO_ALLOW_ANY_ORIGIN=false
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ npm run build
|
|||||||
npm start
|
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.
|
`npm start` and `npm run dev` supervise both the website and processing worker. The worker is required to turn an upload into a previewable draft. `npm run worker` also loads `.env.local` when run separately. Setup downloads the pinned vanilla rendering assets and the Chromium runtime used by the existing terrain-rendering worker. Browser UI checks are separate from this worker.
|
||||||
|
|
||||||
Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- <dataset UUID>` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).
|
Missing vanilla assets are also prepared automatically when a plugin export is rendered. For an existing failed or empty draft, stop the worker and use `npm run reprocess -- <dataset UUID>` before restarting. This retains the raw upload. See [empty-render recovery](docs/server-plugin.md#recover-an-empty-render).
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ async function route(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ 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 };
|
export { route as GET, route as POST, route as PATCH, route as DELETE };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
export default function ErrorPage({ reset }: { reset: () => void }) {
|
export default function ErrorPage({ retry }: { retry: () => void }) {
|
||||||
return (
|
return (
|
||||||
<main className="section page-content">
|
<main className="section page-content">
|
||||||
<h1>Lost connection.</h1>
|
<h1>Lost connection.</h1>
|
||||||
@@ -7,7 +7,7 @@ export default function ErrorPage({ reset }: { reset: () => void }) {
|
|||||||
We couldn’t load this part of the world. Check your connection and try
|
We couldn’t load this part of the world. Check your connection and try
|
||||||
again.
|
again.
|
||||||
</p>
|
</p>
|
||||||
<button className="button primary" onClick={reset}>
|
<button className="button primary" onClick={retry}>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
/* The same parchment surrounds the editor preview and interactive player map. */
|
/* The same parchment surrounds the editor preview and interactive player map. */
|
||||||
.detail-map,
|
.detail-map,
|
||||||
.map-surface {
|
.map-surface {
|
||||||
|
position: relative;
|
||||||
border: 22px solid transparent;
|
border: 22px solid transparent;
|
||||||
border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch;
|
border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch;
|
||||||
background: #ddc79f;
|
background: #ddc79f;
|
||||||
box-shadow: 4px 6px 0 #0005;
|
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 {
|
.detail-map {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function Home() {
|
|||||||
alt="A Minecraft surface map framed in parchment"
|
alt="A Minecraft surface map framed in parchment"
|
||||||
width={1024}
|
width={1024}
|
||||||
height={1024}
|
height={1024}
|
||||||
priority
|
loading="eager"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
<span className="square-pin pin-green" />
|
<span className="square-pin pin-green" />
|
||||||
|
|||||||
@@ -10,12 +10,8 @@ export default async function ServerPage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
let map;
|
const map = await getMap(id);
|
||||||
try {
|
if (!map) notFound();
|
||||||
map = await getMap(id);
|
|
||||||
} catch {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -34,7 +30,7 @@ export default async function ServerPage({
|
|||||||
<div>
|
<div>
|
||||||
<div className="detail-map">
|
<div className="detail-map">
|
||||||
<Image
|
<Image
|
||||||
src={map.mapImage || map.image || ""}
|
src={map.mapImage || map.image || "/images/example-map.webp"}
|
||||||
alt={`${map.name} surface map`}
|
alt={`${map.name} surface map`}
|
||||||
width={1024}
|
width={1024}
|
||||||
height={1024}
|
height={1024}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
|||||||
const shown = maps.filter(
|
const shown = maps.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
(source === "all" || m.source === source) &&
|
(source === "all" || m.source === source) &&
|
||||||
(m.name + " " + m.description + " " + m.server?.name)
|
(m.name + " " + m.description + " " + (m.server?.name ?? ""))
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query.toLowerCase()),
|
.includes(query.trim().toLowerCase()),
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -33,6 +33,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
|
|||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
className={source === id ? "active" : ""}
|
className={source === id ? "active" : ""}
|
||||||
|
aria-pressed={source === id}
|
||||||
onClick={() => setSource(id)}
|
onClick={() => setSource(id)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function Dashboard() {
|
|||||||
]);
|
]);
|
||||||
setMaps(m.maps);
|
setMaps(m.maps);
|
||||||
setTokens(t.tokens);
|
setTokens(t.tokens);
|
||||||
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,11 @@ export function Dashboard() {
|
|||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
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();
|
router.refresh();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { Panorama, preloadFaces } from "./Panorama";
|
import { Panorama } from "./Panorama";
|
||||||
import { GuessMap, type Pin } from "./GuessMap";
|
import { GuessMap, type Pin } from "./GuessMap";
|
||||||
import { api } from "./StartGame";
|
import { api } from "./StartGame";
|
||||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||||
|
import { roundClock } from "@/shared/round-clock";
|
||||||
export type RoundResult = {
|
export type RoundResult = {
|
||||||
score: number;
|
score: number;
|
||||||
distance: number | null;
|
distance: number | null;
|
||||||
@@ -44,15 +45,19 @@ export function Game({ id }: { id: string }) {
|
|||||||
[error, setError] = useState(""),
|
[error, setError] = useState(""),
|
||||||
[busy, setBusy] = useState(false),
|
[busy, setBusy] = useState(false),
|
||||||
[result, setResult] = useState<RoundResult | null>(null),
|
[result, setResult] = useState<RoundResult | null>(null),
|
||||||
[remaining, setRemaining] = useState(0),
|
[remaining, setRemaining] = useState(0);
|
||||||
[progress, setProgress] = useState(0);
|
const clock = useRef<ReturnType<typeof roundClock> | null>(null),
|
||||||
const offset = useRef(0),
|
loadVersion = useRef(0),
|
||||||
|
guessing = useRef(false),
|
||||||
activating = useRef(false),
|
activating = useRef(false),
|
||||||
timingOut = useRef(false);
|
timingOut = useRef(false);
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const version = ++loadVersion.current;
|
||||||
|
const sentAt = performance.now();
|
||||||
try {
|
try {
|
||||||
const s = await api<SessionView>("sessions/" + id);
|
const s = await api<SessionView>("sessions/" + id);
|
||||||
offset.current = s.serverNow - Date.now();
|
if (version !== loadVersion.current) return;
|
||||||
|
clock.current = roundClock(s.serverNow, sentAt, performance.now());
|
||||||
if (s.round >= 5) {
|
if (s.round >= 5) {
|
||||||
router.replace("/results/" + id);
|
router.replace("/results/" + id);
|
||||||
return;
|
return;
|
||||||
@@ -60,31 +65,28 @@ export function Game({ id }: { id: string }) {
|
|||||||
setSession(s);
|
setSession(s);
|
||||||
setRemaining(
|
setRemaining(
|
||||||
s.rounds[s.round].deadline
|
s.rounds[s.round].deadline
|
||||||
? Math.max(
|
? clock.current.remaining(
|
||||||
0,
|
s.rounds[s.round].deadline!,
|
||||||
Math.ceil(
|
performance.now(),
|
||||||
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: difficulties[s.difficulty].seconds,
|
: difficulties[s.difficulty].seconds,
|
||||||
);
|
);
|
||||||
s.rounds.forEach((r) =>
|
|
||||||
preloadFaces(r.faces)
|
|
||||||
.then(() => setProgress((p) => Math.min(100, p + 20)))
|
|
||||||
.catch(() => {}),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
if (version === loadVersion.current) setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}, [id, router]);
|
}, [id, router]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
return () => {
|
||||||
|
loadVersion.current++;
|
||||||
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!session ||
|
!session ||
|
||||||
!loaded.panorama ||
|
!loaded.panorama ||
|
||||||
!loaded.map ||
|
!loaded.map ||
|
||||||
|
error ||
|
||||||
result ||
|
result ||
|
||||||
activating.current
|
activating.current
|
||||||
)
|
)
|
||||||
@@ -92,28 +94,31 @@ export function Game({ id }: { id: string }) {
|
|||||||
const r = session.rounds[session.round];
|
const r = session.rounds[session.round];
|
||||||
if (r.deadline) return;
|
if (r.deadline) return;
|
||||||
activating.current = true;
|
activating.current = true;
|
||||||
|
const round = session.round;
|
||||||
|
const sentAt = performance.now();
|
||||||
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
|
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
|
||||||
round: session.round,
|
round: session.round,
|
||||||
})
|
})
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
offset.current = d.serverNow - Date.now();
|
clock.current = roundClock(d.serverNow, sentAt, performance.now());
|
||||||
setSession((s) =>
|
setSession((s) =>
|
||||||
s
|
s && s.round === round
|
||||||
? {
|
? {
|
||||||
...s,
|
...s,
|
||||||
rounds: s.rounds.map((r) =>
|
rounds: s.rounds.map((r) =>
|
||||||
r.number === s.round ? { ...r, deadline: d.deadline } : r,
|
r.number === s.round ? { ...r, deadline: d.deadline } : r,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: s,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => (activating.current = false));
|
.finally(() => (activating.current = false));
|
||||||
}, [loaded, session, id, result]);
|
}, [loaded, session, id, result, error]);
|
||||||
const guess = useCallback(
|
const guess = useCallback(
|
||||||
async (timeout = false) => {
|
async (timeout = false) => {
|
||||||
if (!session || busy || result || (!pin && !timeout)) return;
|
if (!session || guessing.current || result || (!pin && !timeout)) return;
|
||||||
|
guessing.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -122,26 +127,36 @@ export function Game({ id }: { id: string }) {
|
|||||||
...(!timeout && pin ? pin : {}),
|
...(!timeout && pin ? pin : {}),
|
||||||
});
|
});
|
||||||
setResult(r);
|
setResult(r);
|
||||||
|
setSession((s) =>
|
||||||
|
s
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
rounds: s.rounds.map((round) =>
|
||||||
|
round.number === session.round
|
||||||
|
? { ...round, result: r }
|
||||||
|
: round,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
timingOut.current = false;
|
timingOut.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
|
guessing.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[session, busy, result, pin, id],
|
[session, result, pin, id],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || result) return;
|
if (!session || result || error) return;
|
||||||
const deadline = session.rounds[session.round].deadline;
|
const deadline = session.rounds[session.round].deadline;
|
||||||
if (!deadline) return;
|
if (!deadline) return;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const left = Math.max(
|
const left = clock.current?.remaining(deadline, performance.now()) ?? 0;
|
||||||
0,
|
|
||||||
Math.ceil((Date.parse(deadline) - Date.now() - offset.current) / 1000),
|
|
||||||
);
|
|
||||||
setRemaining(left);
|
setRemaining(left);
|
||||||
if (left === 0 && !timingOut.current) {
|
if (left === 0 && !timingOut.current && !guessing.current) {
|
||||||
timingOut.current = true;
|
timingOut.current = true;
|
||||||
guess(true);
|
guess(true);
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
tick();
|
tick();
|
||||||
const timer = setInterval(tick, 250);
|
const timer = setInterval(tick, 250);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [session, result, guess]);
|
}, [session, result, guess, error]);
|
||||||
async function next() {
|
async function next() {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
@@ -157,7 +172,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
setPin(null);
|
setPin(null);
|
||||||
setLoaded({ panorama: false, map: false });
|
setLoaded({ panorama: false, map: false });
|
||||||
timingOut.current = false;
|
timingOut.current = false;
|
||||||
setProgress(0);
|
setError("");
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
const ready = useCallback(
|
const ready = useCallback(
|
||||||
@@ -215,7 +230,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
key={"map-" + session.round}
|
key={"map-" + session.round}
|
||||||
bounds={session.bounds}
|
bounds={session.bounds}
|
||||||
image={session.map}
|
image={session.map}
|
||||||
pin={result?.guess || pin}
|
pin={result ? result.guess : pin}
|
||||||
actual={result?.actual}
|
actual={result?.actual}
|
||||||
onPin={setPin}
|
onPin={setPin}
|
||||||
onReady={mapReady}
|
onReady={mapReady}
|
||||||
@@ -252,7 +267,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
<div className="game-loading">
|
<div className="game-loading">
|
||||||
<div className="loading-cube" />
|
<div className="loading-cube" />
|
||||||
<h2>Preparing your expedition.</h2>
|
<h2>Preparing your expedition.</h2>
|
||||||
<p>Loading compressed panoramas · {progress}%</p>
|
<p>Loading panorama and surface map…</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
@@ -267,7 +282,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{result && !error && (
|
||||||
<div className="round-result">
|
<div className="round-result">
|
||||||
<span className="eyebrow">
|
<span className="eyebrow">
|
||||||
ROUND {(session?.round || 0) + 1} COMPLETE
|
ROUND {(session?.round || 0) + 1} COMPLETE
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ export function GuessMap({
|
|||||||
const root = useRef<HTMLDivElement>(null),
|
const root = useRef<HTMLDivElement>(null),
|
||||||
markers = useRef<Leaflet.LayerGroup | null>(null),
|
markers = useRef<Leaflet.LayerGroup | null>(null),
|
||||||
lib = useRef<typeof Leaflet | null>(null),
|
lib = useRef<typeof Leaflet | null>(null),
|
||||||
[generation, setGeneration] = useState(0);
|
[generation, setGeneration] = useState(0),
|
||||||
|
[error, setError] = useState(""),
|
||||||
|
[attempt, setAttempt] = useState(0);
|
||||||
const callbacks = useRef({ onPin, onReady, onError, disabled });
|
const callbacks = useRef({ onPin, onReady, onError, disabled });
|
||||||
callbacks.current = { onPin, onReady, onError, disabled };
|
callbacks.current = { onPin, onReady, onError, disabled };
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let cleanup = () => {};
|
let cleanup = () => {};
|
||||||
|
setError("");
|
||||||
|
const fail = (message: string) => {
|
||||||
|
if (disposed) return;
|
||||||
|
setError(message);
|
||||||
|
callbacks.current.onError?.(message);
|
||||||
|
};
|
||||||
|
let loadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
import("leaflet")
|
import("leaflet")
|
||||||
.then((L) => {
|
.then((L) => {
|
||||||
if (disposed || !root.current) return;
|
if (disposed || !root.current) return;
|
||||||
@@ -48,13 +57,31 @@ export function GuessMap({
|
|||||||
maxBounds: b.pad(0.15),
|
maxBounds: b.pad(0.15),
|
||||||
attributionControl: false,
|
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);
|
const overlay = L.imageOverlay(image, b);
|
||||||
overlay.on("load", () => callbacks.current.onReady?.());
|
loadTimer = setTimeout(
|
||||||
overlay.on("error", () =>
|
() =>
|
||||||
callbacks.current.onError?.(
|
fail("The surface map took too long to load. Reconnect to retry."),
|
||||||
"The surface map could not 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);
|
overlay.addTo(m);
|
||||||
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
|
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
|
||||||
m.fitBounds(b, { padding: [0, 0], animate: false });
|
m.fitBounds(b, { padding: [0, 0], animate: false });
|
||||||
@@ -74,12 +101,33 @@ export function GuessMap({
|
|||||||
)
|
)
|
||||||
callbacks.current.onPin?.({ x, z });
|
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;
|
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.
|
// Expanding the map should reveal the whole survey at its new size.
|
||||||
// Preserve a player's chosen zoom and center while inspecting terrain.
|
// Preserve a player's chosen zoom and center while inspecting terrain.
|
||||||
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
|
||||||
m.invalidateSize();
|
m.invalidateSize({ pan: false });
|
||||||
m.setMinZoom(-5);
|
m.setMinZoom(-5);
|
||||||
fittedZoom = m.getBoundsZoom(b);
|
fittedZoom = m.getBoundsZoom(b);
|
||||||
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
m.setMaxZoom(Math.max(3, fittedZoom + 3));
|
||||||
@@ -88,20 +136,14 @@ export function GuessMap({
|
|||||||
m.setMinZoom(fittedZoom);
|
m.setMinZoom(fittedZoom);
|
||||||
});
|
});
|
||||||
observer.observe(root.current);
|
observer.observe(root.current);
|
||||||
cleanup = () => {
|
|
||||||
observer.disconnect();
|
|
||||||
markers.current = null;
|
|
||||||
m.remove();
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() => fail("The map viewer could not initialize."));
|
||||||
callbacks.current.onError?.("The map viewer could not initialize."),
|
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
clearTimeout(loadTimer);
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
|
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const L = lib.current,
|
const L = lib.current,
|
||||||
group = markers.current;
|
group = markers.current;
|
||||||
@@ -133,8 +175,23 @@ export function GuessMap({
|
|||||||
ref={root}
|
ref={root}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
role="application"
|
role="application"
|
||||||
aria-label="Minecraft map. North is up. Click to place a guess."
|
aria-label={
|
||||||
|
disabled
|
||||||
|
? "Minecraft map. North is up."
|
||||||
|
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<div className="map-load-error" role="alert">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => setAttempt((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Retry map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Plus, Minus, RotateCcw } from "lucide-react";
|
import { Plus, Minus, RotateCcw } from "lucide-react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
const textureCache = new Map<string, Promise<THREE.Texture>>();
|
import { loadPanoramaTextures } from "@/lib/panorama-textures";
|
||||||
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)!;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Explicit projection matches the six positive-FOV camera images emitted by
|
// Explicit projection matches the six positive-FOV camera images emitted by
|
||||||
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
|
// 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;
|
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;
|
onError: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const mount = useRef<HTMLDivElement>(null),
|
const mount = useRef<HTMLDivElement>(null),
|
||||||
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
|
view = useRef({ yaw: 0, pitch: 0, fov: 80 });
|
||||||
[fov, setFov] = useState(80);
|
|
||||||
const callbacks = useRef({ onReady, onError });
|
const callbacks = useRef({ onReady, onError });
|
||||||
callbacks.current = { onReady, onError };
|
callbacks.current = { onReady, onError };
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +31,8 @@ export function Panorama({
|
|||||||
frame = 0,
|
frame = 0,
|
||||||
observer: ResizeObserver | undefined;
|
observer: ResizeObserver | undefined;
|
||||||
let material: THREE.ShaderMaterial | undefined,
|
let material: THREE.ShaderMaterial | undefined,
|
||||||
geometry: THREE.SphereGeometry | undefined;
|
geometry: THREE.SphereGeometry | undefined,
|
||||||
|
ownedTextures: THREE.Texture[] = [];
|
||||||
const element = mount.current!;
|
const element = mount.current!;
|
||||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||||
const down = (e: PointerEvent) => {
|
const down = (e: PointerEvent) => {
|
||||||
@@ -81,6 +60,13 @@ export function Panorama({
|
|||||||
Math.min(100, view.current.fov + e.deltaY * 0.04),
|
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;
|
let drag: { x: number; y: number } | null = null;
|
||||||
if (urls.length !== 6) {
|
if (urls.length !== 6) {
|
||||||
callbacks.current.onError(
|
callbacks.current.onError(
|
||||||
@@ -88,13 +74,18 @@ export function Panorama({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preloadFaces(urls)
|
loadPanoramaTextures(urls)
|
||||||
.then((textures) => {
|
.then((textures) => {
|
||||||
if (disposed) return;
|
if (disposed) {
|
||||||
|
textures.forEach((texture) => texture.dispose());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ownedTextures = textures;
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
element.appendChild(renderer.domElement);
|
element.appendChild(renderer.domElement);
|
||||||
|
renderer.domElement.addEventListener("webglcontextlost", contextLost);
|
||||||
const scene = new THREE.Scene(),
|
const scene = new THREE.Scene(),
|
||||||
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
|
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
|
||||||
geometry = new THREE.SphereGeometry(5, 32, 16);
|
geometry = new THREE.SphereGeometry(5, 32, 16);
|
||||||
@@ -112,9 +103,11 @@ export function Panorama({
|
|||||||
});
|
});
|
||||||
scene.add(new THREE.Mesh(geometry, material));
|
scene.add(new THREE.Mesh(geometry, material));
|
||||||
const resize = () => {
|
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();
|
camera.updateProjectionMatrix();
|
||||||
renderer!.setSize(element.clientWidth, element.clientHeight);
|
renderer!.setSize(width, height);
|
||||||
};
|
};
|
||||||
observer = new ResizeObserver(resize);
|
observer = new ResizeObserver(resize);
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
@@ -157,13 +150,12 @@ export function Panorama({
|
|||||||
element.removeEventListener("wheel", wheel);
|
element.removeEventListener("wheel", wheel);
|
||||||
geometry?.dispose();
|
geometry?.dispose();
|
||||||
material?.dispose();
|
material?.dispose();
|
||||||
|
ownedTextures.forEach((texture) => texture.dispose());
|
||||||
|
renderer?.domElement.removeEventListener("webglcontextlost", contextLost);
|
||||||
renderer?.dispose();
|
renderer?.dispose();
|
||||||
renderer?.domElement.remove();
|
renderer?.domElement.remove();
|
||||||
};
|
};
|
||||||
}, [urls, zoom]);
|
}, [urls, zoom]);
|
||||||
useEffect(() => {
|
|
||||||
view.current.fov = fov;
|
|
||||||
}, [fov]);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -177,13 +169,17 @@ export function Panorama({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
|
onClick={() => {
|
||||||
|
view.current.fov = Math.max(35, view.current.fov - 10);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={17} />
|
<Plus size={17} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
|
onClick={() => {
|
||||||
|
view.current.fov = Math.min(100, view.current.fov + 10);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Minus size={17} />
|
<Minus size={17} />
|
||||||
</button>
|
</button>
|
||||||
@@ -193,7 +189,6 @@ export function Panorama({
|
|||||||
aria-label="Reset view"
|
aria-label="Reset view"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||||
setFov(80);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw size={17} />
|
<RotateCcw size={17} />
|
||||||
|
|||||||
@@ -11,12 +11,22 @@ export function Results({ id }: { id: string }) {
|
|||||||
[error, setError] = useState("");
|
[error, setError] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
setSession(null);
|
||||||
|
setSelected(0);
|
||||||
|
setError("");
|
||||||
api<SessionView>("sessions/" + id)
|
api<SessionView>("sessions/" + id)
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
|
if (disposed) return;
|
||||||
if (s.round < 5) router.replace("/play/" + id);
|
if (s.round < 5) router.replace("/play/" + id);
|
||||||
else setSession(s);
|
else setSession(s);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message));
|
.catch((e) => {
|
||||||
|
if (!disposed) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
}, [id, router]);
|
}, [id, router]);
|
||||||
if (error) return <p role="alert">{error}</p>;
|
if (error) return <p role="alert">{error}</p>;
|
||||||
if (!session) return <p className="muted">Unpacking your expedition…</p>;
|
if (!session) return <p className="muted">Unpacking your expedition…</p>;
|
||||||
@@ -52,6 +62,7 @@ export function Results({ id }: { id: string }) {
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={i === selected ? "active" : ""}
|
className={i === selected ? "active" : ""}
|
||||||
|
aria-pressed={i === selected}
|
||||||
onClick={() => setSelected(i)}
|
onClick={() => setSelected(i)}
|
||||||
>
|
>
|
||||||
Round {i + 1}
|
Round {i + 1}
|
||||||
@@ -85,7 +96,7 @@ export function Results({ id }: { id: string }) {
|
|||||||
Another adventure →
|
Another adventure →
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="button" href="/dashboard">
|
<Link className="button" href="/dashboard">
|
||||||
My expeditions
|
My maps
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,8 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
|
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
|
||||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||||
export async function api<T = Record<string, unknown>>(
|
import { api } from "@/lib/client-api";
|
||||||
path: string,
|
export { api } from "@/lib/client-api";
|
||||||
body?: unknown,
|
|
||||||
method = body ? "POST" : "GET",
|
|
||||||
): Promise<T> {
|
|
||||||
const res = await fetch("/api/v1/" + path, {
|
|
||||||
signal: AbortSignal.timeout(30000),
|
|
||||||
method,
|
|
||||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
export function StartGame({ datasetId }: { datasetId: string }) {
|
export function StartGame({ datasetId }: { datasetId: string }) {
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||||
[loading, setLoading] = useState(false),
|
[loading, setLoading] = useState(false),
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ MINEGEO_ALLOW_ANY_ORIGIN=true
|
|||||||
|
|
||||||
This bypasses MineGeo's browser origin checks, including their CSRF protection.
|
This bypasses MineGeo's browser origin checks, including their CSRF protection.
|
||||||
The default is `false`; only the exact value `true` enables the bypass.
|
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
|
Authentication, permissions, and JSON request requirements still apply. This
|
||||||
setting controls MineGeo's API checks; Supabase email sign-in still needs allowed
|
setting controls MineGeo's API checks; Supabase email sign-in still needs allowed
|
||||||
redirect URLs, and `MINEGEO_URL` still controls capture-tool pairing links.
|
redirect URLs, and `MINEGEO_URL` still controls capture-tool pairing links.
|
||||||
|
|||||||
57
docs/production-audit.md
Normal file
57
docs/production-audit.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Map and production audit — October 5, 2026
|
||||||
|
|
||||||
|
Tracked in [Linear DUN-30](https://linear.app/dungeons-and-projects/issue/DUN-30/fix-map-and-gameplay-bugs-and-prepare-minegeo-for-production).
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Panorama viewers own and release their textures on unmount. Failed and stalled
|
||||||
|
loads release completed and late-arriving faces. There is no unbounded global
|
||||||
|
cache of every panorama and rotating signed URL.
|
||||||
|
- Map-image failures and stalled loads show a retry control, including results.
|
||||||
|
Map cleanup is installed before image initialization. Resizing keeps the map
|
||||||
|
fitted or preserves the player's view. Keyboard users can pan with arrow keys
|
||||||
|
and press Enter to place a pin at the center.
|
||||||
|
- Countdown synchronization accounts for request latency and uses a monotonic
|
||||||
|
clock. Submission has an immediate lock, failed timeout requests stop retrying
|
||||||
|
behind an error dialog, current-round points update immediately, and timeout
|
||||||
|
results do not retain an unsubmitted pin.
|
||||||
|
- Session media signing uses a single batch for the map and 30 faces. Catalog
|
||||||
|
signing deduplicates images. Existing revision/media authorization is retained.
|
||||||
|
- Missing/invalid map IDs get a 404; backend failures reach the retryable error
|
||||||
|
boundary. The error page now uses Next.js 16.3's `retry()` to fetch fresh data.
|
||||||
|
Database transport/schema errors return 503 without exposing their internals.
|
||||||
|
- All API responses prevent caching, guest HTTPS cookies respect the configured
|
||||||
|
proxy origin, production disables the origin bypass, and baseline browser
|
||||||
|
security headers are configured. Startup validates environment, heap and
|
||||||
|
panorama size before launching either process.
|
||||||
|
- Client requests explain offline, timeout and non-JSON gateway errors. Catalog
|
||||||
|
searches trim whitespace, source/result selection exposes pressed state,
|
||||||
|
successful dashboard refreshes clear stale errors, and sign-out failures stay
|
||||||
|
visible. Results now label the dashboard link accurately as My maps.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- 63 Node regression tests passed, including new client-error, texture-disposal,
|
||||||
|
clock, startup-config, backend-error and production-origin tests.
|
||||||
|
- TypeScript checking and the optimized Next.js 16.3.8 production build passed.
|
||||||
|
- The HTTP/API/Storage smoke passed against both development and compiled
|
||||||
|
production servers with the configured backend. It exercised pages/JARs,
|
||||||
|
headers, privacy, signed map/cubemap downloads, five rounds, duplicate requests,
|
||||||
|
out-of-bounds rejection and foreign-origin denial, then deleted its test games.
|
||||||
|
- Codex's built-in browser verified the live homepage/catalog, trimmed search,
|
||||||
|
hard-mode panorama and timeout, game reload/results and result-round selection.
|
||||||
|
A separate local fixture verified failed map images, successful retry, keyboard
|
||||||
|
guessing and rectangular-map resizing. Browser checks found no uncaught errors.
|
||||||
|
- The production npm dependency audit reported zero known vulnerabilities.
|
||||||
|
- The Supabase security advisor and a permissions query confirmed public workflow
|
||||||
|
RPCs use invoker security and deny anon/authenticated execution. Informational
|
||||||
|
no-policy notices concern deliberately server-only RLS tables.
|
||||||
|
|
||||||
|
## Launch requirements and limits
|
||||||
|
|
||||||
|
Leaked-password protection remains disabled in the configured Supabase project;
|
||||||
|
it must be enabled in provider settings where the plan supports it. Production
|
||||||
|
SMTP, emailed login/recovery delivery, public HTTPS/proxy configuration and
|
||||||
|
restoration from backups were not exercised in this local audit. No deployment,
|
||||||
|
new Minecraft client capture, or cross-platform worker benchmark was performed.
|
||||||
|
Follow [the production guide](production.md) before public launch.
|
||||||
55
docs/production.md
Normal file
55
docs/production.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Production launch
|
||||||
|
|
||||||
|
The website requires a Node.js host and a persistent rendering worker. Deploy
|
||||||
|
this checkout with Node.js 22 or later, run `npm ci`, `npm run setup`, and
|
||||||
|
`npm run check`, then start it with `npm start` under your host's process manager.
|
||||||
|
The supervisor runs the website and worker together and stops the companion if
|
||||||
|
either exits. A host that runs only Next.js must run `npm run worker` separately.
|
||||||
|
|
||||||
|
Configure these before starting:
|
||||||
|
|
||||||
|
- `MINEGEO_URL`: the public HTTPS origin, without a path, query or credentials.
|
||||||
|
Loopback HTTP is supported for local production checks.
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, and the
|
||||||
|
server-only `SUPABASE_SECRET_KEY`. The supervisor validates missing keys and
|
||||||
|
rejects a server secret in the publishable-key field before starting children.
|
||||||
|
- Keep `MINEGEO_ALLOW_ANY_ORIGIN=false`. Production ignores the bypass; the
|
||||||
|
supervisor also rejects it at startup. Use `MINEGEO_ALLOWED_ORIGINS` for exact
|
||||||
|
aliases and configure your reverse proxy to retain the public Host header.
|
||||||
|
- Keep the default loopback binding behind a local HTTPS reverse proxy. Set
|
||||||
|
`MINEGEO_HOST=0.0.0.0` only when your hosting/container network requires it.
|
||||||
|
- Apply the existing `supabase/migrations` in filename order. This audit adds no
|
||||||
|
database migration. Keep the media and raw buckets private.
|
||||||
|
|
||||||
|
Configure Supabase Auth's Site URL and allowed `/auth/callback` redirects for the
|
||||||
|
public origin, email confirmation, working production SMTP, and a minimum
|
||||||
|
password length of 12. Enable leaked-password protection when the plan supports
|
||||||
|
it; the October 5 audit found it disabled. See the [account guide](account-settings.md)
|
||||||
|
and [Supabase password security](https://supabase.com/docs/guides/auth/password-security).
|
||||||
|
|
||||||
|
The site sends no-store headers for all application API responses, including
|
||||||
|
drafts, pairing credentials, tokens and errors. Guest cookies are HttpOnly,
|
||||||
|
SameSite=Lax, and Secure for the configured HTTPS public host behind a proxy.
|
||||||
|
The response headers prevent framing and MIME sniffing, limit referrers, and
|
||||||
|
disable unused camera, microphone and location permissions. Configure HTTPS
|
||||||
|
certificates and HSTS at the public reverse proxy.
|
||||||
|
|
||||||
|
Monitor `/api/v1/health`: HTTP 200 means its database query succeeded; 503 means
|
||||||
|
configuration or the data service is unavailable. Also retain worker logs and
|
||||||
|
monitor failed/queued jobs; website health does not assert worker liveness.
|
||||||
|
Keep Supabase backups enabled and test restoration before storing irreplaceable
|
||||||
|
community uploads.
|
||||||
|
|
||||||
|
After deployment, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:production -- https://your-public-origin.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this from the configured checkout for the same Supabase project. The test
|
||||||
|
checks pages/downloads, security headers, invalid map IDs, guest access denial,
|
||||||
|
CSRF denial, malformed JSON, session isolation, real signed WebP downloads, and
|
||||||
|
all five rounds with idempotent activation/submission and out-of-bounds rejection.
|
||||||
|
It removes only its generated guest session. A published map is required.
|
||||||
|
|
||||||
|
See [the audit record](production-audit.md) for evidence and remaining limits.
|
||||||
83
lib/api.ts
83
lib/api.ts
@@ -11,7 +11,8 @@ import {
|
|||||||
panoramaPath,
|
panoramaPath,
|
||||||
viewpointSchema,
|
viewpointSchema,
|
||||||
} from "../shared/protocol";
|
} 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 { requestOrigin, verifyOrigin } from "./request-origin";
|
||||||
import { accountApi } from "./account-api";
|
import { accountApi } from "./account-api";
|
||||||
const hash = (value: string) =>
|
const hash = (value: string) =>
|
||||||
@@ -107,7 +108,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
return await accountApi(request, path, { jsonBody, limit });
|
return await accountApi(request, path, { jsonBody, limit });
|
||||||
const client = admin(),
|
const client = admin(),
|
||||||
method = request.method;
|
method = request.method;
|
||||||
if (path[0] === "health") {
|
if (path[0] === "health" && path.length === 1 && method === "GET") {
|
||||||
const { error } = await client
|
const { error } = await client
|
||||||
.from("catalog_servers")
|
.from("catalog_servers")
|
||||||
.select("id")
|
.select("id")
|
||||||
@@ -117,7 +118,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
{ status: error ? 503 : 200 },
|
{ status: error ? 503 : 200 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (method === "GET" && path[0] === "catalog") {
|
if (method === "GET" && path[0] === "catalog" && path.length === 1) {
|
||||||
const rows = required(
|
const rows = required(
|
||||||
await client.from("datasets").select("*").eq("state", "published"),
|
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, {
|
response.cookies.set("mg_guest", secret, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: new URL(request.url).protocol === "https:",
|
secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
|
||||||
|
"https:",
|
||||||
|
),
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const s = await session(request, path[1]);
|
const s = await session(request, path[1]);
|
||||||
if (method === "GET") {
|
if (method === "GET" && path.length === 2) {
|
||||||
const revision = required(
|
const revision = required(
|
||||||
await client
|
await client
|
||||||
.from("revisions")
|
.from("revisions")
|
||||||
@@ -176,22 +179,10 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
.eq("session_id", s.id)
|
.eq("session_id", s.id)
|
||||||
.order("round_number"),
|
.order("round_number"),
|
||||||
);
|
);
|
||||||
return NextResponse.json(
|
const signed = await mediaUrls([
|
||||||
{
|
`${revision.asset_root}/map.webp`,
|
||||||
id: s.id,
|
...rounds.flatMap((r) =>
|
||||||
difficulty: s.difficulty,
|
|
||||||
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) =>
|
faces.map((f) =>
|
||||||
mediaUrl(
|
|
||||||
panoramaPath(
|
panoramaPath(
|
||||||
revision.asset_root,
|
revision.asset_root,
|
||||||
viewpointSchema.parse(r.viewpoint),
|
viewpointSchema.parse(r.viewpoint),
|
||||||
@@ -199,15 +190,30 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
id: s.id,
|
||||||
|
difficulty: s.difficulty,
|
||||||
|
round: s.current_round,
|
||||||
|
bounds: manifest.bounds,
|
||||||
|
name: manifest.name,
|
||||||
|
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(),
|
serverNow: Date.now(),
|
||||||
},
|
},
|
||||||
{ headers: { "Cache-Control": "no-store" } },
|
{ headers: { "Cache-Control": "no-store" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (method === "POST" && path[2] === "activate") {
|
if (method === "POST" && path[2] === "activate" && path.length === 3) {
|
||||||
const body = z
|
const body = z
|
||||||
.object({ round: z.number().int().min(0).max(4) })
|
.object({ round: z.number().int().min(0).max(4) })
|
||||||
.parse(await jsonBody(request));
|
.parse(await jsonBody(request));
|
||||||
@@ -220,7 +226,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
);
|
);
|
||||||
return NextResponse.json({ deadline, serverNow: Date.now() });
|
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
|
const body = z
|
||||||
.object({
|
.object({
|
||||||
round: z.number().int().min(0).max(4),
|
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_dataset: d.id,
|
||||||
p_owner: owner,
|
p_owner: owner,
|
||||||
});
|
});
|
||||||
if (serverCheck.error) throw new Error(serverCheck.error.message);
|
checkServiceError(serverCheck.error);
|
||||||
const input = required(
|
const input = required(
|
||||||
await client
|
await client
|
||||||
.from("dataset_inputs")
|
.from("dataset_inputs")
|
||||||
@@ -371,7 +377,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
.select("state,progress,error,updated_at,lease_until")
|
.select("state,progress,error,updated_at,lease_until")
|
||||||
.eq("dataset_id", d.id)
|
.eq("dataset_id", d.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (jobResult.error) throw new Error(jobResult.error.message);
|
checkServiceError(jobResult.error);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
...d,
|
...d,
|
||||||
upload_jobs: jobResult.data,
|
upload_jobs: jobResult.data,
|
||||||
@@ -420,7 +426,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_owner: d.owner_id,
|
p_owner: d.owner_id,
|
||||||
p_address: body.contributionServerAddress ?? null,
|
p_address: body.contributionServerAddress ?? null,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
if (method === "PATCH") {
|
if (method === "PATCH") {
|
||||||
@@ -438,7 +444,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_description: body.description,
|
p_description: body.description,
|
||||||
p_ids: body.selectedIds,
|
p_ids: body.selectedIds,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
if (method === "POST" && path[2] === "submit") {
|
if (method === "POST" && path[2] === "submit") {
|
||||||
@@ -446,7 +452,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_dataset: d.id,
|
p_dataset: d.id,
|
||||||
p_owner: d.owner_id,
|
p_owner: d.owner_id,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
if (method === "POST" && path[2] === "cancel") {
|
if (method === "POST" && path[2] === "cancel") {
|
||||||
@@ -455,7 +461,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_dataset: d.id,
|
p_dataset: d.id,
|
||||||
p_owner: d.owner_id,
|
p_owner: d.owner_id,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
if (!["uploading", "processing"].includes(d.state))
|
if (!["uploading", "processing"].includes(d.state))
|
||||||
@@ -467,7 +473,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
.in("state", ["queued", "running"])
|
.in("state", ["queued", "running"])
|
||||||
.select("id")
|
.select("id")
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
if (cancelled.error) throw new Error(cancelled.error.message);
|
checkServiceError(cancelled.error);
|
||||||
required(
|
required(
|
||||||
await client
|
await client
|
||||||
.from("datasets")
|
.from("datasets")
|
||||||
@@ -571,7 +577,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_decision: body.decision,
|
p_decision: body.decision,
|
||||||
p_reason: body.reason ?? undefined,
|
p_reason: body.reason ?? undefined,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,7 +663,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_token: token,
|
p_token: token,
|
||||||
p_hash: hash(token),
|
p_hash: hash(token),
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
if (method === "POST" && path[1] === "poll") {
|
if (method === "POST" && path[1] === "poll") {
|
||||||
@@ -781,7 +787,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
p_decision: b.decision,
|
p_decision: b.decision,
|
||||||
p_reason: b.reason ?? null,
|
p_reason: b.reason ?? null,
|
||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
checkServiceError(error);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,15 +830,24 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof z.ZodError
|
error instanceof z.ZodError
|
||||||
? "Invalid request: " + error.issues.map((i) => i.message).join("; ")
|
? "Invalid request: " + error.issues.map((i) => i.message).join("; ")
|
||||||
|
: error instanceof SyntaxError
|
||||||
|
? "Invalid JSON request"
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "Request failed";
|
: "Request failed";
|
||||||
const status = message.includes("Sign in")
|
const status =
|
||||||
|
path[0] === "health"
|
||||||
|
? 503
|
||||||
|
: error instanceof ServiceError
|
||||||
|
? error.status
|
||||||
|
: message.includes("Sign in")
|
||||||
? 401
|
? 401
|
||||||
: message.includes("permission") || message.includes("origin")
|
: message.includes("permission") || message.includes("origin")
|
||||||
? 403
|
? 403
|
||||||
: message.includes("Too many")
|
: message.includes("Too many")
|
||||||
? 429
|
? 429
|
||||||
|
: message.includes("not found")
|
||||||
|
? 404
|
||||||
: 400;
|
: 400;
|
||||||
return NextResponse.json({ error: message }, { status });
|
return NextResponse.json({ error: message }, { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { admin, required } from "./supabase";
|
import { admin, required } from "./supabase";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ServiceError } from "./service-error";
|
||||||
export type MapListing = {
|
export type MapListing = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,12 +25,22 @@ export type MapListing = {
|
|||||||
credit?: string;
|
credit?: string;
|
||||||
};
|
};
|
||||||
export async function mediaUrl(path: string | null, expires = 3600) {
|
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()
|
const { data, error } = await admin()
|
||||||
.storage.from("minegeo-media")
|
.storage.from("minegeo-media")
|
||||||
.createSignedUrl(path, expires);
|
.createSignedUrls(unique, expires);
|
||||||
if (error) throw new Error(error.message);
|
if (error || !data || data.some((item) => item.error || !item.signedUrl))
|
||||||
return data.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() {
|
export async function catalog() {
|
||||||
const client = admin();
|
const client = admin();
|
||||||
@@ -50,35 +62,40 @@ export async function catalog() {
|
|||||||
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
return Promise.all(
|
const signed = await mediaUrls(
|
||||||
maps.map(async (m) => ({
|
maps.flatMap((map) => [map.map_path || map.cover_path, map.map_path]),
|
||||||
...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),
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
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) {
|
export async function getMap(id: string) {
|
||||||
const map = required(
|
if (!z.string().uuid().safeParse(id).success) return null;
|
||||||
await admin()
|
const result = await admin()
|
||||||
.from("datasets")
|
.from("datasets")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
.eq("state", "published")
|
.eq("state", "published")
|
||||||
.single(),
|
.maybeSingle();
|
||||||
) as MapListing;
|
if (result.error) required(result);
|
||||||
const owner = await admin()
|
if (!result.data) return null;
|
||||||
|
const map = result.data as unknown as MapListing;
|
||||||
|
const [owner, signed] = await Promise.all([
|
||||||
|
admin()
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("display_name")
|
.select("display_name")
|
||||||
.eq("id", map.owner_id)
|
.eq("id", map.owner_id)
|
||||||
.maybeSingle();
|
.maybeSingle(),
|
||||||
|
mediaUrls([map.cover_path, map.map_path]),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
credit: owner.data?.display_name ?? "Explorer",
|
credit: owner.data?.display_name ?? "Explorer",
|
||||||
image: await mediaUrl(map.cover_path),
|
image: signed[0],
|
||||||
mapImage: await mediaUrl(map.map_path),
|
mapImage: signed[1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
41
lib/client-api.ts
Normal file
41
lib/client-api.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export async function api<T = Record<string, unknown>>(
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
method = body === undefined ? "GET" : "POST",
|
||||||
|
): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch("/api/v1/" + path, {
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
cache: "no-store",
|
||||||
|
method,
|
||||||
|
headers:
|
||||||
|
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
["TimeoutError", "AbortError"].includes(error.name)
|
||||||
|
)
|
||||||
|
throw new Error("The request timed out. Reconnect and try again.");
|
||||||
|
throw new Error(
|
||||||
|
"Could not reach MineGeo. Check your connection and try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let data: T & { error?: string };
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"MineGeo returned an unreadable response. Please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(
|
||||||
|
typeof data?.error === "string"
|
||||||
|
? data.error
|
||||||
|
: "Request failed. Please try again.",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
44
lib/panorama-textures.ts
Normal file
44
lib/panorama-textures.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
// Each viewer owns its textures. Browser HTTP caching handles repeat downloads;
|
||||||
|
// keeping GPU resources in a global cache leaks every visited map and signed URL.
|
||||||
|
export async function loadPanoramaTextures(
|
||||||
|
urls: string[],
|
||||||
|
loader: Pick<THREE.TextureLoader, "loadAsync"> = new THREE.TextureLoader(),
|
||||||
|
timeoutMs = 30000,
|
||||||
|
): Promise<THREE.Texture[]> {
|
||||||
|
const textures: THREE.Texture[] = [];
|
||||||
|
let failed = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
const texture = await loader.loadAsync(url);
|
||||||
|
if (failed) {
|
||||||
|
texture.dispose();
|
||||||
|
throw new Error("Panorama loading cancelled");
|
||||||
|
}
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
textures.push(texture);
|
||||||
|
return texture;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
reject(
|
||||||
|
new Error("Panorama loading timed out. Reconnect to retry."),
|
||||||
|
),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
textures.forEach((texture) => texture.dispose());
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,9 @@ export function verifyOrigin(
|
|||||||
configuration: OriginConfiguration = {
|
configuration: OriginConfiguration = {
|
||||||
websiteUrl: process.env.MINEGEO_URL,
|
websiteUrl: process.env.MINEGEO_URL,
|
||||||
additionalOrigins: process.env.MINEGEO_ALLOWED_ORIGINS,
|
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;
|
if (request.method === "GET" || request.method === "HEAD") return;
|
||||||
|
|||||||
16
lib/service-error.ts
Normal file
16
lib/service-error.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export class ServiceError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status = 503,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkServiceError(error: { message: string; code?: string } | null) {
|
||||||
|
if (!error) return;
|
||||||
|
if (error.code === "P0001") throw new Error(error.message);
|
||||||
|
if (error.code === "PGRST116") throw new ServiceError("Record not found", 404);
|
||||||
|
console.error("Supabase request failed:", error.code, error.message);
|
||||||
|
throw new ServiceError("MineGeo could not reach its data service. Please try again.");
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { createServerClient } from "@supabase/ssr";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import type { Database } from "@/shared/database";
|
import type { Database } from "@/shared/database";
|
||||||
|
import { ServiceError, checkServiceError } from "./service-error";
|
||||||
let singleton: ReturnType<typeof createClient<Database>> | undefined;
|
let singleton: ReturnType<typeof createClient<Database>> | undefined;
|
||||||
export function admin() {
|
export function admin() {
|
||||||
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SECRET_KEY)
|
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SECRET_KEY)
|
||||||
throw new Error(
|
throw new ServiceError(
|
||||||
"Supabase configuration missing. Run npm run setup and configure .env.local.",
|
"MineGeo is not configured yet. Please try again later.",
|
||||||
);
|
);
|
||||||
return (singleton ??= createClient<Database>(
|
return (singleton ??= createClient<Database>(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
@@ -51,9 +52,9 @@ export const user = cache(async function user() {
|
|||||||
});
|
});
|
||||||
export function required<T>(result: {
|
export function required<T>(result: {
|
||||||
data: T;
|
data: T;
|
||||||
error: { message: string } | null;
|
error: { message: string; code?: string } | null;
|
||||||
}): NonNullable<T> {
|
}): NonNullable<T> {
|
||||||
if (result.error) throw new Error(result.error.message);
|
checkServiceError(result.error);
|
||||||
if (result.data === null || result.data === undefined)
|
if (result.data === null || result.data === undefined)
|
||||||
throw new Error("Record not found");
|
throw new Error("Record not found");
|
||||||
return result.data as NonNullable<T>;
|
return result.data as NonNullable<T>;
|
||||||
|
|||||||
@@ -3,5 +3,25 @@ const config: NextConfig = {
|
|||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
serverExternalPackages: ["pg"],
|
serverExternalPackages: ["pg"],
|
||||||
turbopack: { root: process.cwd() },
|
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;
|
export default config;
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"test": "tsx --test tests/*.test.ts",
|
"test": "tsx --test tests/*.test.ts",
|
||||||
"test:renderer": "node scripts/test-renderer.mjs",
|
"test:renderer": "node scripts/test-renderer.mjs",
|
||||||
"render:map": "node scripts/render-map.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",
|
"typecheck": "tsc --noEmit",
|
||||||
"renderer:proof": "node scripts/proof.mjs",
|
"renderer:proof": "node scripts/proof.mjs",
|
||||||
"java:build": "node scripts/java-build.mjs"
|
"java:build": "node scripts/java-build.mjs"
|
||||||
|
|||||||
63
scripts/runtime-config.mjs
Normal file
63
scripts/runtime-config.mjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/** Validate before launching either companion process; never print key values. */
|
||||||
|
export function validateRuntime(env, { production = false } = {}) {
|
||||||
|
const missing = [
|
||||||
|
"NEXT_PUBLIC_SUPABASE_URL",
|
||||||
|
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
|
||||||
|
"SUPABASE_SECRET_KEY",
|
||||||
|
].filter((key) => !env[key]);
|
||||||
|
if (missing.length)
|
||||||
|
throw new Error(
|
||||||
|
`Missing configuration: ${missing.join(", ")}. Configure .env.local or your host's environment.`,
|
||||||
|
);
|
||||||
|
const backend = new URL(env.NEXT_PUBLIC_SUPABASE_URL);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(backend.protocol) ||
|
||||||
|
backend.username ||
|
||||||
|
backend.password
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"NEXT_PUBLIC_SUPABASE_URL must be an HTTP(S) URL without credentials.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY.startsWith("sb_secret_") ||
|
||||||
|
env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY === env.SUPABASE_SECRET_KEY
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"The public Supabase key must not contain the server secret.",
|
||||||
|
);
|
||||||
|
if (production) {
|
||||||
|
if (env.MINEGEO_ALLOW_ANY_ORIGIN === "true")
|
||||||
|
throw new Error("MINEGEO_ALLOW_ANY_ORIGIN must be false in production.");
|
||||||
|
if (!env.MINEGEO_URL)
|
||||||
|
throw new Error(
|
||||||
|
"Set MINEGEO_URL to the website's public origin before starting production.",
|
||||||
|
);
|
||||||
|
const website = new URL(env.MINEGEO_URL);
|
||||||
|
const local = ["localhost", "127.0.0.1", "[::1]"].includes(
|
||||||
|
website.hostname,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(!local && website.protocol !== "https:") ||
|
||||||
|
!["https:", "http:"].includes(website.protocol) ||
|
||||||
|
website.username ||
|
||||||
|
website.password ||
|
||||||
|
website.pathname !== "/" ||
|
||||||
|
website.search ||
|
||||||
|
website.hash
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"MINEGEO_URL must be an HTTPS origin (HTTP is allowed only on loopback).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const workerHeap = Number(env.MINEGEO_WORKER_HEAP_MB || 4096);
|
||||||
|
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768)
|
||||||
|
throw new Error(
|
||||||
|
"MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.",
|
||||||
|
);
|
||||||
|
const faceSize = Number(env.MINEGEO_FACE_SIZE || 1024);
|
||||||
|
if (!Number.isInteger(faceSize) || faceSize < 256 || faceSize > 2048)
|
||||||
|
throw new Error(
|
||||||
|
"MINEGEO_FACE_SIZE must be an integer between 256 and 2048.",
|
||||||
|
);
|
||||||
|
return { workerHeap };
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { validateRuntime } from "./runtime-config.mjs";
|
||||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||||
const dev = process.argv[2] === "dev";
|
const dev = process.argv[2] === "dev";
|
||||||
|
const { workerHeap } = validateRuntime(process.env, { production: !dev });
|
||||||
const children = [];
|
const children = [];
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
function run(args) {
|
function run(args) {
|
||||||
const c = spawn(process.execPath, args, {
|
const c = spawn(process.execPath, args, {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
children.push(c);
|
children.push(c);
|
||||||
c.on("exit", (code) => {
|
c.on("exit", (code) => {
|
||||||
@@ -34,10 +37,9 @@ run([
|
|||||||
"--hostname",
|
"--hostname",
|
||||||
process.env.MINEGEO_HOST || "127.0.0.1",
|
process.env.MINEGEO_HOST || "127.0.0.1",
|
||||||
]);
|
]);
|
||||||
const workerHeap = Number(process.env.MINEGEO_WORKER_HEAP_MB || 4096);
|
run([
|
||||||
if (!Number.isInteger(workerHeap) || workerHeap < 512 || workerHeap > 32768) {
|
`--max-old-space-size=${workerHeap}`,
|
||||||
console.error("MINEGEO_WORKER_HEAP_MB must be an integer between 512 and 32768.");
|
"--import",
|
||||||
stop(1);
|
"tsx",
|
||||||
} else {
|
"worker/main.ts",
|
||||||
run([`--max-old-space-size=${workerHeap}`, "--import", "tsx", "worker/main.ts"]);
|
]);
|
||||||
}
|
|
||||||
|
|||||||
167
scripts/test-production.mjs
Normal file
167
scripts/test-production.mjs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||||
|
const origin = new URL(
|
||||||
|
process.argv[2] || process.env.MINEGEO_URL || "http://localhost:3000",
|
||||||
|
).origin;
|
||||||
|
const db = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SECRET_KEY,
|
||||||
|
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||||
|
);
|
||||||
|
let sessionId, cookie;
|
||||||
|
async function request(path, body, options = {}) {
|
||||||
|
return fetch(origin + path, {
|
||||||
|
method: body === undefined ? "GET" : "POST",
|
||||||
|
headers: {
|
||||||
|
...(body === undefined
|
||||||
|
? {}
|
||||||
|
: { "content-type": "application/json", origin }),
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function json(path, body) {
|
||||||
|
const response = await request("/api/v1/" + path, body);
|
||||||
|
assert.equal(
|
||||||
|
response.status,
|
||||||
|
200,
|
||||||
|
`${path}: ${await response.clone().text()}`,
|
||||||
|
);
|
||||||
|
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const path of [
|
||||||
|
"/",
|
||||||
|
"/servers",
|
||||||
|
"/downloads",
|
||||||
|
"/about",
|
||||||
|
"/log-in",
|
||||||
|
"/fonts/VT323-Regular.ttf",
|
||||||
|
"/images/example-map.webp",
|
||||||
|
"/tools/plugin/minegeo-plugin-1.21.11.jar",
|
||||||
|
"/tools/mod/minegeo-fabric-1.21.11.jar",
|
||||||
|
]) {
|
||||||
|
const response = await request(path);
|
||||||
|
assert.equal(response.status, 200, path);
|
||||||
|
assert.equal(response.headers.get("x-content-type-options"), "nosniff");
|
||||||
|
assert.equal(response.headers.get("x-frame-options"), "DENY");
|
||||||
|
assert.ok((await response.arrayBuffer()).byteLength > 0, path);
|
||||||
|
}
|
||||||
|
assert.equal((await json("health")).ok, true);
|
||||||
|
for (const path of [
|
||||||
|
"/servers/not-a-uuid",
|
||||||
|
"/servers/00000000-0000-4000-8000-000000000000",
|
||||||
|
"/api/v1/catalog/extra",
|
||||||
|
]) {
|
||||||
|
assert.equal((await request(path)).status, 404, path);
|
||||||
|
}
|
||||||
|
for (const path of ["datasets", "tokens", "profile"]) {
|
||||||
|
assert.equal(
|
||||||
|
(await request("/api/v1/" + path)).status,
|
||||||
|
401,
|
||||||
|
`guest ${path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const forbidden = await request(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
{},
|
||||||
|
{ headers: { origin: "https://foreign.example" } },
|
||||||
|
);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const malformed = await fetch(origin + "/api/v1/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin },
|
||||||
|
body: "{",
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
assert.equal(malformed.status, 400);
|
||||||
|
assert.equal((await malformed.json()).error, "Invalid JSON request");
|
||||||
|
const catalog = await json("catalog");
|
||||||
|
assert.ok(
|
||||||
|
catalog.maps.length,
|
||||||
|
"Publish at least one map before verifying gameplay",
|
||||||
|
);
|
||||||
|
const map = catalog.maps[0];
|
||||||
|
const created = await request("/api/v1/sessions", {
|
||||||
|
datasetId: map.id,
|
||||||
|
difficulty: "hard",
|
||||||
|
});
|
||||||
|
assert.equal(created.status, 200, await created.clone().text());
|
||||||
|
sessionId = (await created.json()).id;
|
||||||
|
cookie = created.headers.get("set-cookie").split(";")[0];
|
||||||
|
const session = await json("sessions/" + sessionId);
|
||||||
|
assert.equal(session.rounds.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
session.rounds.every(
|
||||||
|
(round) => round.faces.length === 6 && round.faces.every(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await fetch(origin + "/api/v1/sessions/" + sessionId)).status,
|
||||||
|
404,
|
||||||
|
"another guest cannot read the session",
|
||||||
|
);
|
||||||
|
// Exercise actual signed Storage reads, not just the URL response shape.
|
||||||
|
for (const url of [session.map, ...session.rounds[0].faces]) {
|
||||||
|
const image = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||||
|
assert.equal(image.status, 200);
|
||||||
|
assert.match(image.headers.get("content-type"), /image\/webp/);
|
||||||
|
assert.ok((await image.arrayBuffer()).byteLength > 0);
|
||||||
|
}
|
||||||
|
for (let round = 0; round < 5; round++) {
|
||||||
|
const first = await json(`sessions/${sessionId}/activate`, { round });
|
||||||
|
const duplicate = await json(`sessions/${sessionId}/activate`, { round });
|
||||||
|
assert.equal(
|
||||||
|
first.deadline,
|
||||||
|
duplicate.deadline,
|
||||||
|
"activation does not reset the timer",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await request(`/api/v1/sessions/${sessionId}/guess`, {
|
||||||
|
round,
|
||||||
|
x: map.bounds.maxX + 1,
|
||||||
|
z: map.bounds.minZ,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
const guess = {
|
||||||
|
round,
|
||||||
|
x: (map.bounds.minX + map.bounds.maxX) / 2,
|
||||||
|
z: (map.bounds.minZ + map.bounds.maxZ) / 2,
|
||||||
|
};
|
||||||
|
const result = await json(`sessions/${sessionId}/guess`, guess);
|
||||||
|
assert.ok(result.score >= 0 && result.score <= 5000);
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
await json(`sessions/${sessionId}/guess`, guess),
|
||||||
|
result,
|
||||||
|
"submission retries are idempotent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal((await json("sessions/" + sessionId)).round, 5);
|
||||||
|
console.log(
|
||||||
|
"Production HTTP/API/Storage smoke passed: pages, headers, privacy, origin checks, signed media and all five rounds.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (sessionId && cookie) {
|
||||||
|
const secret = cookie.slice(cookie.indexOf("=") + 1);
|
||||||
|
const { error } = await db
|
||||||
|
.from("game_sessions")
|
||||||
|
.delete()
|
||||||
|
.eq("id", sessionId)
|
||||||
|
.eq("access_hash", createHash("sha256").update(secret).digest("hex"))
|
||||||
|
.is("owner_id", null);
|
||||||
|
if (error)
|
||||||
|
throw new Error("Smoke session cleanup failed: " + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
shared/round-clock.ts
Normal file
19
shared/round-clock.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Synchronize at receipt using a bounded estimate of the return journey. The
|
||||||
|
// countdown uses performance.now(), so changing the OS clock cannot extend it.
|
||||||
|
export function roundClock(
|
||||||
|
serverNow: number,
|
||||||
|
sentAt: number,
|
||||||
|
receivedAt: number,
|
||||||
|
) {
|
||||||
|
const serverAtReceipt = serverNow + Math.max(0, receivedAt - sentAt) / 2;
|
||||||
|
return {
|
||||||
|
remaining(deadline: string, now: number) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil(
|
||||||
|
(Date.parse(deadline) - serverAtReceipt - (now - receivedAt)) / 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
46
tests/client-api.test.ts
Normal file
46
tests/client-api.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { api } from "../lib/client-api";
|
||||||
|
|
||||||
|
test("API sends explicit falsy JSON bodies and prevents cached private responses", async (t) => {
|
||||||
|
const fetch = t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
"fetch",
|
||||||
|
async (_url: unknown, options: RequestInit) => {
|
||||||
|
assert.equal(options.method, "POST");
|
||||||
|
assert.equal(options.body, "false");
|
||||||
|
assert.equal(options.cache, "no-store");
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(await api("test", false), { ok: true });
|
||||||
|
assert.equal(fetch.mock.callCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("API explains HTML gateway failures, JSON errors, and empty error responses", async (t) => {
|
||||||
|
const fetch = t.mock.method(
|
||||||
|
globalThis,
|
||||||
|
"fetch",
|
||||||
|
async () => new Response("Bad gateway", { status: 502 }),
|
||||||
|
);
|
||||||
|
await assert.rejects(api("test"), /unreadable response/);
|
||||||
|
fetch.mock.mockImplementation(async () =>
|
||||||
|
Response.json({ error: "Map unavailable" }, { status: 404 }),
|
||||||
|
);
|
||||||
|
await assert.rejects(api("test"), /Map unavailable/);
|
||||||
|
fetch.mock.mockImplementation(async () =>
|
||||||
|
Response.json(null, { status: 503 }),
|
||||||
|
);
|
||||||
|
await assert.rejects(api("test"), /Request failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("API reports timeouts and offline connections with a recovery action", async (t) => {
|
||||||
|
const fetch = t.mock.method(globalThis, "fetch", async () => {
|
||||||
|
throw new DOMException("timeout", "TimeoutError");
|
||||||
|
});
|
||||||
|
await assert.rejects(api("test"), /timed out.*Reconnect/);
|
||||||
|
fetch.mock.mockImplementation(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
});
|
||||||
|
await assert.rejects(api("test"), /Check your connection/);
|
||||||
|
});
|
||||||
59
tests/panorama-textures.test.ts
Normal file
59
tests/panorama-textures.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { loadPanoramaTextures } from "../lib/panorama-textures";
|
||||||
|
|
||||||
|
function texture() {
|
||||||
|
const value = new THREE.Texture();
|
||||||
|
let disposals = 0;
|
||||||
|
value.addEventListener("dispose", () => disposals++);
|
||||||
|
return { value, disposals: () => disposals };
|
||||||
|
}
|
||||||
|
test("panorama textures retain face order and are owned separately by each viewer", async () => {
|
||||||
|
const loader = { loadAsync: async () => new THREE.Texture() };
|
||||||
|
const first = await loadPanoramaTextures(["px", "nx"], loader);
|
||||||
|
const second = await loadPanoramaTextures(["px", "nx"], loader);
|
||||||
|
assert.equal(first.length, 2);
|
||||||
|
assert.equal(first[0].colorSpace, THREE.SRGBColorSpace);
|
||||||
|
assert.notEqual(first[0], second[0]);
|
||||||
|
[...first, ...second].forEach((value) => value.dispose());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed panorama frees both loaded faces and faces that complete later", async () => {
|
||||||
|
const early = texture(),
|
||||||
|
late = texture();
|
||||||
|
let finish!: (value: THREE.Texture) => void;
|
||||||
|
const loader = {
|
||||||
|
loadAsync: (url: string) =>
|
||||||
|
url === "early"
|
||||||
|
? Promise.resolve(early.value)
|
||||||
|
: url === "late"
|
||||||
|
? new Promise<THREE.Texture>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
})
|
||||||
|
: Promise.reject(new Error("missing face")),
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
loadPanoramaTextures(["early", "late", "missing"], loader),
|
||||||
|
/missing face/,
|
||||||
|
);
|
||||||
|
assert.equal(early.disposals(), 1);
|
||||||
|
finish(late.value);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(late.disposals(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stalled panoramas time out and release textures that arrive afterward", async () => {
|
||||||
|
const late = texture();
|
||||||
|
let finish!: (value: THREE.Texture) => void;
|
||||||
|
const loader = {
|
||||||
|
loadAsync: () =>
|
||||||
|
new Promise<THREE.Texture>((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
await assert.rejects(loadPanoramaTextures(["late"], loader, 5), /timed out/);
|
||||||
|
finish(late.value);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(late.disposals(), 1);
|
||||||
|
});
|
||||||
@@ -58,6 +58,26 @@ test("the testing switch retains JSON checks for requests without an origin", ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production never enables the environment origin bypass", () => {
|
||||||
|
const env = process.env as Record<string, string | undefined>;
|
||||||
|
const previousNode = env.NODE_ENV,
|
||||||
|
previousBypass = env.MINEGEO_ALLOW_ANY_ORIGIN;
|
||||||
|
try {
|
||||||
|
env.NODE_ENV = "production";
|
||||||
|
env.MINEGEO_ALLOW_ANY_ORIGIN = "true";
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
verifyOrigin(makeRequest("localhost:3000", "https://foreign.example")),
|
||||||
|
/Invalid request origin/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousNode === undefined) delete env.NODE_ENV;
|
||||||
|
else env.NODE_ENV = previousNode;
|
||||||
|
if (previousBypass === undefined) delete env.MINEGEO_ALLOW_ANY_ORIGIN;
|
||||||
|
else env.MINEGEO_ALLOW_ANY_ORIGIN = previousBypass;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts a Mac LAN origin when Next uses localhost internally", () => {
|
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");
|
const request = makeRequest("192.168.1.50:3000", "http://192.168.1.50:3000");
|
||||||
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");
|
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");
|
||||||
|
|||||||
20
tests/round-clock.test.ts
Normal file
20
tests/round-clock.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { roundClock } from "../shared/round-clock";
|
||||||
|
|
||||||
|
test("round timer accounts for return latency and stops at the server deadline", () => {
|
||||||
|
const clock = roundClock(100000, 1000, 5000);
|
||||||
|
const deadline = new Date(105000).toISOString();
|
||||||
|
assert.equal(clock.remaining(deadline, 5000), 3);
|
||||||
|
assert.equal(clock.remaining(deadline, 7001), 1);
|
||||||
|
assert.equal(clock.remaining(deadline, 8000), 0);
|
||||||
|
assert.equal(clock.remaining(deadline, 9000), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round timer relies on elapsed time even if the device wall clock changes", (t) => {
|
||||||
|
const clock = roundClock(100000, 1000, 1000);
|
||||||
|
t.mock.method(Date, "now", () => -10000000);
|
||||||
|
assert.equal(clock.remaining(new Date(110000).toISOString(), 5000), 6);
|
||||||
|
t.mock.method(Date, "now", () => 10000000);
|
||||||
|
assert.equal(clock.remaining(new Date(110000).toISOString(), 11000), 0);
|
||||||
|
});
|
||||||
69
tests/runtime-config.test.ts
Normal file
69
tests/runtime-config.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { validateRuntime } from "../scripts/runtime-config.mjs";
|
||||||
|
const env = {
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: "https://project.supabase.co",
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "sb_publishable_example",
|
||||||
|
SUPABASE_SECRET_KEY: "sb_secret_example",
|
||||||
|
MINEGEO_URL: "https://minegeo.example",
|
||||||
|
};
|
||||||
|
test("production startup rejects CSRF bypass, secret exposure and missing config", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateRuntime(
|
||||||
|
{ ...env, MINEGEO_ALLOW_ANY_ORIGIN: "true" },
|
||||||
|
{ production: true },
|
||||||
|
),
|
||||||
|
/must be false/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateRuntime({
|
||||||
|
...env,
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: env.SUPABASE_SECRET_KEY,
|
||||||
|
}),
|
||||||
|
/server secret/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateRuntime({ ...env, SUPABASE_SECRET_KEY: "" }),
|
||||||
|
/Missing configuration.*SUPABASE_SECRET_KEY/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("production requires a secure public origin while allowing local production checks", () => {
|
||||||
|
for (const url of [
|
||||||
|
"http://minegeo.example",
|
||||||
|
"https://user:pass@minegeo.example",
|
||||||
|
"https://minegeo.example/path",
|
||||||
|
"https://minegeo.example?x=1",
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() => validateRuntime({ ...env, MINEGEO_URL: url }, { production: true }),
|
||||||
|
/HTTPS origin/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validateRuntime(
|
||||||
|
{ ...env, MINEGEO_URL: "http://localhost:3000" },
|
||||||
|
{ production: true },
|
||||||
|
).workerHeap,
|
||||||
|
4096,
|
||||||
|
);
|
||||||
|
assert.equal(validateRuntime(env, { production: true }).workerHeap, 4096);
|
||||||
|
});
|
||||||
|
test("startup validates worker heap and panorama size before spawning processes", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateRuntime({ ...env, MINEGEO_WORKER_HEAP_MB: "NaN" }),
|
||||||
|
/WORKER_HEAP/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateRuntime({ ...env, MINEGEO_FACE_SIZE: "8192" }),
|
||||||
|
/FACE_SIZE/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validateRuntime({
|
||||||
|
...env,
|
||||||
|
MINEGEO_WORKER_HEAP_MB: "2048",
|
||||||
|
MINEGEO_FACE_SIZE: "256",
|
||||||
|
}).workerHeap,
|
||||||
|
2048,
|
||||||
|
);
|
||||||
|
});
|
||||||
18
tests/service-error.test.ts
Normal file
18
tests/service-error.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { checkServiceError, ServiceError } from "../lib/service-error";
|
||||||
|
|
||||||
|
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
|
||||||
|
t.mock.method(console, "error", () => {});
|
||||||
|
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
|
||||||
|
assert.ok(error instanceof ServiceError);
|
||||||
|
assert.equal(error.status, 503);
|
||||||
|
assert.doesNotMatch(error.message, /private_table|42P01/);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
|
||||||
|
assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404);
|
||||||
|
assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
|
||||||
|
assert.doesNotThrow(() => checkServiceError(null));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user