admin and higher limits

This commit is contained in:
Theo Browne
2026-02-22 01:20:19 -08:00
parent 4786fcfedb
commit dc9ac4e7c8
11 changed files with 1057 additions and 24 deletions

330
admin.css Normal file
View File

@@ -0,0 +1,330 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #0a0a0a;
--surface: #111;
--border: #1c1c1c;
--text: #ededed;
--text-dim: #888;
--text-muted: #444;
--accent: #D97757;
--serif: 'DM Serif Display', Georgia, serif;
--sans: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--danger: #ef4444;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
min-height: 100dvh;
}
.admin {
max-width: 980px;
margin: 0 auto;
padding: 32px 24px 48px;
}
.admin--centered {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
gap: 16px;
}
.logo-link {
display: inline-flex;
text-decoration: none;
}
.logo-link img {
height: 20px;
width: auto;
}
.panel {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
}
.panel--login {
max-width: 460px;
padding: 32px;
display: grid;
gap: 18px;
}
.panel--login h1,
.panel-head h1 {
font-family: var(--serif);
font-size: clamp(36px, 5vw, 48px);
line-height: 1;
letter-spacing: -0.8px;
}
.panel--main {
padding: 28px;
}
.panel-head {
margin-bottom: 24px;
}
.panel-head p,
.muted {
color: var(--text-dim);
max-width: 64ch;
}
.login-form {
display: grid;
gap: 12px;
}
.field-label {
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 11px;
color: var(--text-muted);
}
.text-input {
width: 100%;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
padding: 10px 12px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.text-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.btn,
.link-button {
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.02);
color: var(--text);
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
padding: 10px 16px;
cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
}
.btn:hover:not(:disabled),
.link-button:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #3a3a3a;
}
.btn:disabled,
.link-button:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
font-weight: 700;
}
.btn--danger {
background: transparent;
border-color: var(--danger);
color: var(--danger);
}
.quick-links {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.quick-links a,
.link-button {
color: var(--text-dim);
text-decoration: none;
font-size: 12px;
font-family: var(--mono);
background: none;
border: none;
padding: 0;
text-transform: uppercase;
letter-spacing: 0.8px;
}
.quick-links a:hover,
.link-button:hover {
color: var(--text);
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.status-card {
border: 1px solid var(--border);
padding: 14px;
background: var(--bg);
}
.status-card__label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1px;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 6px;
}
.status-card__value {
font-size: 22px;
line-height: 1;
font-weight: 700;
}
.actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.error-banner {
border: 1px solid var(--danger);
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
padding: 10px 12px;
font-size: 14px;
font-family: var(--mono);
}
.loading {
border: 1px solid var(--border);
background: var(--surface);
padding: 14px 18px;
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: grid;
place-items: center;
padding: 20px;
backdrop-filter: blur(4px);
z-index: 10;
}
.modal {
width: min(540px, 100%);
border: 1px solid var(--border);
background: var(--surface);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
padding: 24px;
display: grid;
gap: 14px;
}
.modal h2 {
font-family: var(--serif);
font-size: 34px;
line-height: 1;
}
.modal p {
color: var(--text-dim);
}
.modal code {
font-family: var(--mono);
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 999px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
@media (max-width: 860px) {
.status-grid,
.actions {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 560px) {
.admin {
padding: 18px 14px 26px;
}
.panel--main,
.panel--login {
padding: 20px;
}
.status-grid,
.actions {
grid-template-columns: 1fr;
}
.admin-header {
flex-direction: column;
align-items: flex-start;
}
.modal {
padding: 18px;
}
.modal h2 {
font-size: 28px;
}
}

19
admin.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quipslop Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./admin.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./admin.tsx"></script>
</body>
</html>

378
admin.tsx Normal file
View File

@@ -0,0 +1,378 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import "./admin.css";
type AdminSnapshot = {
isPaused: boolean;
isRunningRound: boolean;
done: boolean;
completedInMemory: number;
persistedRounds: number;
viewerCount: number;
};
type AdminResponse = { ok: true } & AdminSnapshot;
type Mode = "checking" | "locked" | "ready";
const RESET_TOKEN = "RESET";
async function readErrorMessage(res: Response): Promise<string> {
const text = await res.text();
if (text) return text;
return `Request failed (${res.status})`;
}
async function requestAdminJson(
path: string,
init?: RequestInit,
): Promise<AdminResponse> {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(path, {
...init,
headers,
cache: "no-store",
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return (await response.json()) as AdminResponse;
}
function StatusCard({ label, value }: { label: string; value: string }) {
return (
<div className="status-card">
<div className="status-card__label">{label}</div>
<div className="status-card__value">{value}</div>
</div>
);
}
function App() {
const [mode, setMode] = useState<Mode>("checking");
const [snapshot, setSnapshot] = useState<AdminSnapshot | null>(null);
const [passcode, setPasscode] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<string | null>(null);
const [isResetOpen, setIsResetOpen] = useState(false);
const [resetText, setResetText] = useState("");
useEffect(() => {
let mounted = true;
requestAdminJson("/api/admin/status")
.then((data) => {
if (!mounted) return;
setSnapshot(data);
setMode("ready");
})
.catch(() => {
if (!mounted) return;
setSnapshot(null);
setMode("locked");
});
return () => {
mounted = false;
};
}, []);
const busy = useMemo(() => pending !== null, [pending]);
async function onLogin(event: React.FormEvent) {
event.preventDefault();
setError(null);
setPending("login");
try {
const data = await requestAdminJson("/api/admin/login", {
method: "POST",
body: JSON.stringify({ passcode }),
});
setSnapshot(data);
setPasscode("");
setMode("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to log in");
} finally {
setPending(null);
}
}
async function runControl(path: string, task: string) {
setError(null);
setPending(task);
try {
const data = await requestAdminJson(path, { method: "POST" });
setSnapshot(data);
} catch (err) {
const message = err instanceof Error ? err.message : "Admin action failed";
if (message.toLowerCase().includes("unauthorized")) {
setMode("locked");
setSnapshot(null);
}
setError(message);
} finally {
setPending(null);
}
}
async function onExport() {
setError(null);
setPending("export");
try {
const response = await fetch("/api/admin/export", { cache: "no-store" });
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const fileNameMatch = disposition.match(/filename="([^"]+)"/i);
const fileName = fileNameMatch?.[1] ?? `quipslop-export-${Date.now()}.json`;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (err) {
const message = err instanceof Error ? err.message : "Export failed";
if (message.toLowerCase().includes("unauthorized")) {
setMode("locked");
setSnapshot(null);
}
setError(message);
} finally {
setPending(null);
}
}
async function onReset() {
setError(null);
setPending("reset");
try {
const data = await requestAdminJson("/api/admin/reset", {
method: "POST",
body: JSON.stringify({ confirm: RESET_TOKEN }),
});
setSnapshot(data);
setResetText("");
setIsResetOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Reset failed");
} finally {
setPending(null);
}
}
async function onLogout() {
setError(null);
setPending("logout");
try {
await fetch("/api/admin/logout", {
method: "POST",
cache: "no-store",
});
setSnapshot(null);
setPasscode("");
setMode("locked");
} finally {
setPending(null);
}
}
if (mode === "checking") {
return (
<div className="admin admin--centered">
<div className="loading">Checking admin session...</div>
</div>
);
}
if (mode === "locked") {
return (
<div className="admin admin--centered">
<main className="panel panel--login">
<a href="/" className="logo-link">
<img src="/assets/logo.svg" alt="quipslop" />
</a>
<h1>Admin Access</h1>
<p className="muted">
Enter your passcode once. A secure cookie will keep this browser
logged in.
</p>
<form
onSubmit={onLogin}
className="login-form"
autoComplete="off"
data-1p-ignore
data-lpignore="true"
>
<label htmlFor="passcode" className="field-label">
Passcode
</label>
<input
id="passcode"
type="password"
value={passcode}
onChange={(e) => setPasscode(e.target.value)}
className="text-input"
autoFocus
autoComplete="off"
required
data-1p-ignore
data-lpignore="true"
/>
<button
type="submit"
className="btn btn--primary"
disabled={busy || !passcode.trim()}
data-1p-ignore
data-lpignore="true"
>
{pending === "login" ? "Checking..." : "Unlock Admin"}
</button>
</form>
{error && <div className="error-banner">{error}</div>}
<div className="quick-links">
<a href="/">Live Game</a>
<a href="/history">History</a>
</div>
</main>
</div>
);
}
return (
<div className="admin">
<header className="admin-header">
<a href="/" className="logo-link">
quipslop
</a>
<nav className="quick-links">
<a href="/">Live Game</a>
<a href="/history">History</a>
<button className="link-button" onClick={onLogout} disabled={busy}>
Logout
</button>
</nav>
</header>
<main className="panel panel--main">
<div className="panel-head">
<h1>Admin Console</h1>
<p>
Pause/resume the game loop, export all data as JSON, or wipe all
stored data.
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<section className="status-grid" aria-live="polite">
<StatusCard
label="Engine"
value={snapshot?.isPaused ? "Paused" : "Running"}
/>
<StatusCard
label="Active Round"
value={snapshot?.isRunningRound ? "In Progress" : "Idle"}
/>
<StatusCard
label="Persisted Rounds"
value={String(snapshot?.persistedRounds ?? 0)}
/>
<StatusCard label="Viewers" value={String(snapshot?.viewerCount ?? 0)} />
</section>
<section className="actions" aria-label="Admin actions">
<button
type="button"
className="btn btn--primary"
disabled={busy || Boolean(snapshot?.isPaused)}
onClick={() => runControl("/api/admin/pause", "pause")}
>
{pending === "pause" ? "Pausing..." : "Pause"}
</button>
<button
type="button"
className="btn"
disabled={busy || !snapshot?.isPaused}
onClick={() => runControl("/api/admin/resume", "resume")}
>
{pending === "resume" ? "Resuming..." : "Resume"}
</button>
<button type="button" className="btn" disabled={busy} onClick={onExport}>
{pending === "export" ? "Exporting..." : "Export JSON"}
</button>
<button
type="button"
className="btn btn--danger"
disabled={busy}
onClick={() => setIsResetOpen(true)}
>
Reset Data
</button>
</section>
</main>
{isResetOpen && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal">
<h2>Reset all data?</h2>
<p>
This permanently deletes every saved round and resets scores.
Current game flow is also paused.
</p>
<p>
Type <code>{RESET_TOKEN}</code> to continue.
</p>
<input
type="text"
value={resetText}
onChange={(e) => setResetText(e.target.value)}
className="text-input"
placeholder={RESET_TOKEN}
autoFocus
/>
<div className="modal-actions">
<button
type="button"
className="btn"
onClick={() => {
setIsResetOpen(false);
setResetText("");
}}
disabled={busy}
>
Cancel
</button>
<button
type="button"
className="btn btn--danger"
onClick={onReset}
disabled={busy || resetText !== RESET_TOKEN}
>
{pending === "reset" ? "Resetting..." : "Confirm Reset"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);

5
db.ts
View File

@@ -36,3 +36,8 @@ export function getAllRounds() {
const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[];
return rows.map(r => JSON.parse(r.data) as RoundState);
}
export function clearAllRounds() {
db.exec("DELETE FROM rounds;");
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
}

View File

@@ -352,6 +352,11 @@ body {
justify-content: space-between;
}
.standings__links {
display: flex;
gap: 10px;
}
.standings__title {
font-family: var(--mono);
font-size: 11px;

View File

@@ -36,6 +36,8 @@ type GameState = {
active: RoundState | null;
scores: Record<string, number>;
done: boolean;
isPaused: boolean;
generation: number;
};
type ServerMessage = {
type: "state";
@@ -340,9 +342,14 @@ function Standings({
<aside className="standings">
<div className="standings__head">
<span className="standings__title">Standings</span>
<a href="/history" className="standings__link">
History
</a>
<div className="standings__links">
<a href="/history" className="standings__link">
History
</a>
<a href="/admin" className="standings__link">
Admin
</a>
</div>
</div>
<div className="standings__list">
{sorted.map(([name, score], i) => {
@@ -443,9 +450,19 @@ function App() {
<a href="/" className="logo">
<img src="/assets/logo.svg" alt="quipslop" />
</a>
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{state.isPaused && (
<div
className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
Paused
</div>
)}
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching
</div>
</div>
</header>

46
game.ts
View File

@@ -72,6 +72,7 @@ export type GameState = {
scores: Record<string, number>;
done: boolean;
isPaused: boolean;
generation: number;
};
// ── OpenRouter ──────────────────────────────────────────────────────────────
@@ -254,8 +255,9 @@ export async function runGame(
rerender: () => void,
) {
let startRound = 1;
if (state.completed.length > 0) {
startRound = state.completed[state.completed.length - 1].num + 1;
const lastCompletedRound = state.completed.at(-1);
if (lastCompletedRound) {
startRound = lastCompletedRound.num + 1;
}
const endRound = startRound + runs - 1;
@@ -264,6 +266,7 @@ export async function runGame(
while (state.isPaused) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const roundGeneration = state.generation;
const shuffled = shuffle([...MODELS]);
const prompter = shuffled[0]!;
@@ -300,11 +303,17 @@ export async function runGame(
3,
`R${r}:prompt:${prompter.name}`,
);
if (state.generation !== roundGeneration) {
continue;
}
round.promptTask.finishedAt = Date.now();
round.promptTask.result = prompt;
round.prompt = prompt;
rerender();
} catch {
if (state.generation !== roundGeneration) {
continue;
}
round.promptTask.finishedAt = Date.now();
round.promptTask.error = "Failed after 3 attempts";
round.phase = "done";
@@ -323,6 +332,9 @@ export async function runGame(
await Promise.all(
round.answerTasks.map(async (task) => {
if (state.generation !== roundGeneration) {
return;
}
try {
const answer = await withRetry(
() => callGenerateAnswer(task.model, round.prompt!),
@@ -330,15 +342,27 @@ export async function runGame(
3,
`R${r}:answer:${task.model.name}`,
);
if (state.generation !== roundGeneration) {
return;
}
task.result = answer;
} catch {
if (state.generation !== roundGeneration) {
return;
}
task.error = "Failed to answer";
task.result = "[no answer]";
}
if (state.generation !== roundGeneration) {
return;
}
task.finishedAt = Date.now();
rerender();
}),
);
if (state.generation !== roundGeneration) {
continue;
}
// ── Vote phase ──
round.phase = "voting";
@@ -350,6 +374,9 @@ export async function runGame(
await Promise.all(
round.votes.map(async (vote) => {
if (state.generation !== roundGeneration) {
return;
}
try {
const showAFirst = Math.random() > 0.5;
const first = showAFirst ? { answer: answerA } : { answer: answerB };
@@ -361,6 +388,9 @@ export async function runGame(
3,
`R${r}:vote:${vote.voter.name}`,
);
if (state.generation !== roundGeneration) {
return;
}
const votedFor = showAFirst
? result === "A"
? contA
@@ -372,12 +402,21 @@ export async function runGame(
vote.finishedAt = Date.now();
vote.votedFor = votedFor;
} catch {
if (state.generation !== roundGeneration) {
return;
}
vote.finishedAt = Date.now();
vote.error = true;
}
if (state.generation !== roundGeneration) {
return;
}
rerender();
}),
);
if (state.generation !== roundGeneration) {
continue;
}
// ── Score ──
let votesA = 0;
@@ -397,6 +436,9 @@ export async function runGame(
rerender();
await new Promise((r) => setTimeout(r, 5000));
if (state.generation !== roundGeneration) {
continue;
}
// Archive round
saveRound(round);

View File

@@ -55,6 +55,12 @@ body {
margin-bottom: 16px;
}
.page-links {
display: flex;
align-items: center;
gap: 14px;
}
.page-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
@@ -280,4 +286,4 @@ body {
.history-card__showdown {
grid-template-columns: 1fr;
}
}
}

View File

@@ -266,9 +266,14 @@ function App() {
<main className="main">
<div className="page-header">
<div className="page-title">Past Rounds</div>
<a href="/" className="back-link">
Back to Game
</a>
<div className="page-links">
<a href="/" className="back-link">
Back to Game
</a>
<a href="/admin" className="back-link">
Admin
</a>
</div>
</div>
{loading ? (

View File

@@ -223,10 +223,11 @@ function Game({ runs }: { runs: number }) {
const stateRef = useRef<GameState>({
completed: [],
active: null,
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
isPaused: false,
});
scores: Object.fromEntries(MODELS.map((m) => [m.name, 0])),
done: false,
isPaused: false,
generation: 0,
});
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((t) => t + 1), []);

241
server.ts
View File

@@ -2,7 +2,8 @@ import type { ServerWebSocket } from "bun";
import { timingSafeEqual } from "node:crypto";
import indexHtml from "./index.html";
import historyHtml from "./history.html";
import { getRounds, getAllRounds } from "./db.ts";
import adminHtml from "./admin.html";
import { clearAllRounds, getRounds, getAllRounds } from "./db.ts";
import {
MODELS,
LOG_FILE,
@@ -52,6 +53,7 @@ const gameState: GameState = {
scores: initialScores,
done: false,
isPaused: false,
generation: 0,
};
// ── Guardrails ──────────────────────────────────────────────────────────────
@@ -71,7 +73,7 @@ const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
process.env.ADMIN_LIMIT_PER_MIN,
10,
);
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000);
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 100_000);
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
const MAX_HISTORY_PAGE = parsePositiveInt(
process.env.MAX_HISTORY_PAGE,
@@ -86,6 +88,8 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
process.env.MAX_HISTORY_CACHE_KEYS,
500,
);
const ADMIN_COOKIE = "quipslop_admin";
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const requestWindows = new Map<string, number[]>();
const wsByIp = new Map<string, number>();
@@ -136,11 +140,59 @@ function secureCompare(a: string, b: string): boolean {
return timingSafeEqual(aBuf, bBuf);
}
function parseCookies(req: Request): Record<string, string> {
const raw = req.headers.get("cookie");
if (!raw) return {};
const cookies: Record<string, string> = {};
for (const pair of raw.split(";")) {
const idx = pair.indexOf("=");
if (idx <= 0) continue;
const key = pair.slice(0, idx).trim();
const val = pair.slice(idx + 1).trim();
if (!key) continue;
try {
cookies[key] = decodeURIComponent(val);
} catch {
cookies[key] = val;
}
}
return cookies;
}
function buildAdminCookie(
passcode: string,
isSecure: boolean,
maxAgeSeconds = ADMIN_COOKIE_MAX_AGE_SECONDS,
): string {
const parts = [
`${ADMIN_COOKIE}=${encodeURIComponent(passcode)}`,
"Path=/",
"HttpOnly",
"SameSite=Strict",
`Max-Age=${maxAgeSeconds}`,
];
if (isSecure) {
parts.push("Secure");
}
return parts.join("; ");
}
function clearAdminCookie(isSecure: boolean): string {
return buildAdminCookie("", isSecure, 0);
}
function getProvidedAdminSecret(req: Request, url: URL): string {
const headerOrQuery =
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret");
if (headerOrQuery) return headerOrQuery;
const cookies = parseCookies(req);
return cookies[ADMIN_COOKIE] ?? "";
}
function isAdminAuthorized(req: Request, url: URL): boolean {
const expected = process.env.ADMIN_SECRET;
if (!expected) return false;
const provided =
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
const provided = getProvidedAdminSecret(req, url);
if (!provided) return false;
return secureCompare(provided, expected);
}
@@ -178,6 +230,17 @@ function broadcast() {
}
}
function getAdminSnapshot() {
return {
isPaused: gameState.isPaused,
isRunningRound: Boolean(gameState.active),
done: gameState.done,
completedInMemory: gameState.completed.length,
persistedRounds: getRounds(1, 1).total,
viewerCount: clients.size,
};
}
// ── Server ──────────────────────────────────────────────────────────────────
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
@@ -187,8 +250,9 @@ const server = Bun.serve<WsData>({
routes: {
"/": indexHtml,
"/history": historyHtml,
"/admin": adminHtml,
},
fetch(req, server) {
async fetch(req, server) {
const url = new URL(req.url);
const ip = getClientIp(req, server);
@@ -207,7 +271,108 @@ const server = Bun.serve<WsData>({
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
if (url.pathname === "/api/admin/login") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
const expected = process.env.ADMIN_SECRET;
if (!expected) {
return new Response("ADMIN_SECRET is not configured", { status: 503 });
}
let passcode = "";
try {
const body = await req.json();
passcode = String((body as Record<string, unknown>).passcode ?? "");
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
if (!passcode || !secureCompare(passcode, expected)) {
return new Response("Invalid passcode", { status: 401 });
}
const isSecure = url.protocol === "https:";
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": buildAdminCookie(passcode, isSecure),
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/admin/logout") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
const isSecure = url.protocol === "https:";
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": clearAdminCookie(isSecure),
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/admin/status") {
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
if (!isAdminAuthorized(req, url)) {
return new Response("Unauthorized", { status: 401 });
}
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
if (url.pathname === "/api/admin/export") {
if (req.method !== "GET") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET" },
});
}
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
if (!isAdminAuthorized(req, url)) {
return new Response("Unauthorized", { status: 401 });
}
const payload = {
exportedAt: new Date().toISOString(),
rounds: getAllRounds(),
state: gameState,
};
return new Response(JSON.stringify(payload, null, 2), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`,
},
});
}
if (url.pathname === "/api/admin/reset") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
@@ -221,16 +386,76 @@ const server = Bun.serve<WsData>({
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname === "/api/pause") {
let confirm = "";
try {
const body = await req.json();
confirm = String((body as Record<string, unknown>).confirm ?? "");
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
if (confirm !== "RESET") {
return new Response("Confirmation token must be RESET", {
status: 400,
});
}
clearAllRounds();
historyCache.clear();
gameState.completed = [];
gameState.active = null;
gameState.scores = Object.fromEntries(MODELS.map((m) => [m.name, 0]));
gameState.done = false;
gameState.isPaused = true;
gameState.generation += 1;
broadcast();
log("WARN", "admin", "Database reset requested", { ip });
return new Response(JSON.stringify({ ok: true, ...getAdminSnapshot() }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
if (
url.pathname === "/api/pause" ||
url.pathname === "/api/resume" ||
url.pathname === "/api/admin/pause" ||
url.pathname === "/api/admin/resume"
) {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
if (!isAdminAuthorized(req, url)) {
return new Response("Unauthorized", { status: 401 });
}
if (url.pathname.endsWith("/pause")) {
gameState.isPaused = true;
} else {
gameState.isPaused = false;
}
broadcast();
const action = url.pathname.endsWith("/pause") ? "Paused" : "Resumed";
if (url.pathname === "/api/pause" || url.pathname === "/api/resume") {
return new Response(action, { status: 200 });
}
return new Response(
url.pathname === "/api/pause" ? "Paused" : "Resumed",
JSON.stringify({ ok: true, action, ...getAdminSnapshot() }),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
},
);
}