Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ node_modules
.env
logs
*.sqlite
*.sqlite-shm
*.sqlite-wal
.DS_Store
.cursor
.idea

# Dev/utility files not needed in production
bulk-test.ts
check-db.ts
quipslop.tsx
scripts/
*.md
todo.md
CLAUDE.md
README.md
LICENSE
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Required
OPENROUTER_API_KEY=your_openrouter_api_key_here

# Admin panel authentication (required for admin features)
ADMIN_SECRET=your_secure_admin_passcode_here

# Server
PORT=5109
NODE_ENV=production

# Database
DATABASE_PATH=quipslop.sqlite

# Rate limiting (requests per minute)
WS_UPGRADE_LIMIT_PER_MIN=20
HISTORY_LIMIT_PER_MIN=120
ADMIN_LIMIT_PER_MIN=10

# WebSocket limits
MAX_WS_GLOBAL=100000
MAX_WS_PER_IP=8

# History pagination
MAX_HISTORY_PAGE=100000
MAX_HISTORY_LIMIT=50
HISTORY_CACHE_TTL_MS=5000
MAX_HISTORY_CACHE_KEYS=500

# Streaming (optional, for scripts/stream-browser.ts)
TWITCH_STREAM_KEY=your_twitch_stream_key_here
STREAM_TARGET_SIZE=1920x1080
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ coverage

# logs
logs
_.log
*.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# dotenv environment variable files
Expand All @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
*.sqlite
*.sqlite-shm
*.sqlite-wal
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ RUN bun install --frozen-lockfile --production
COPY . .

ENV NODE_ENV=production
EXPOSE ${PORT:-5109}
EXPOSE 5109

# Run as non-root user
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
USER app
Comment on lines +15 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Dockerfile:15

The app user won't have write permissions to /app since COPY runs as root. SQLite database creation will fail. Consider adding RUN chown -R app:app /app before USER app.

Suggested change
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
USER app
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
RUN chown -R app:app /app
USER app
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file Dockerfile around lines 15-16:

The `app` user won't have write permissions to `/app` since `COPY` runs as root. SQLite database creation will fail. Consider adding `RUN chown -R app:app /app` before `USER app`.

Comment on lines +14 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing chown: the app user cannot write to /app, breaking SQLite database creation.

All files under /app are copied as root and owned by root (permissions 755/644). After USER app, the server tries to create quipslop.sqlite (default DATABASE_PATH) inside /app — a directory where the non-root user has no write permission — resulting in EACCES at startup.

Add chown -R app:app /app in the same RUN layer before switching users:

🔒 Proposed fix
-RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
+RUN addgroup --system --gid 1001 app && \
+    adduser --system --uid 1001 --ingroup app app && \
+    chown -R app:app /app
 USER app

Alternatively, use COPY --chown=app:app instead of a separate chown layer to keep image size minimal:

+RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
 COPY package.json bun.lock ./
 RUN bun install --frozen-lockfile --production
-COPY . .
+COPY --chown=app:app . .
+USER app
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Run as non-root user
RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app
USER app
# Run as non-root user
RUN addgroup --system --gid 1001 app && \
adduser --system --uid 1001 --ingroup app app && \
chown -R app:app /app
USER app
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 14 - 16, The non-root user creation
(addgroup/adduser) and USER app switch leave /app owned by root so the app
cannot write the SQLite DB; fix by ensuring ownership is changed to app before
switching users — add a chown -R app:app /app in the same RUN layer where
addgroup/adduser are executed (before USER app), or prefer using COPY
--chown=app:app when copying files so /app is owned by app and writable by the
process that runs under USER app.


HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 5109) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["bun", "server.ts"]
51 changes: 47 additions & 4 deletions admin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./admin.css";

Expand Down Expand Up @@ -82,7 +82,50 @@ function App() {
};
}, []);

const busy = useMemo(() => pending !== null, [pending]);
// Poll admin status every 10 seconds when authenticated
useEffect(() => {
if (mode !== "ready") return;
const interval = setInterval(() => {
requestAdminJson("/api/admin/status")
.then((data) => setSnapshot(data))
.catch(() => {});
}, 10_000);
return () => clearInterval(interval);
}, [mode]);

// Modal escape key handler
useEffect(() => {
if (!isResetOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsResetOpen(false);
setResetText("");
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isResetOpen]);

// Modal focus trap
const modalRef = useRef<HTMLDivElement>(null);
const handleModalKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== "Tab" || !modalRef.current) return;
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
'button, input, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, []);

const busy = pending !== null;

async function onLogin(event: React.FormEvent) {
event.preventDefault();
Expand Down Expand Up @@ -328,8 +371,8 @@ function App() {
</main>

{isResetOpen && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="modal">
<div className="modal-backdrop" role="dialog" aria-modal="true" onKeyDown={handleModalKeyDown}>
<div className="modal" ref={modalRef}>
<h2>Reset all data?</h2>
<p>
This permanently deletes every saved round and resets scores.
Expand Down
20 changes: 20 additions & 0 deletions broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,26 @@ function startCanvasCaptureSink() {
const sink = params.get("sink");
if (!sink) return;

// Only allow WebSocket connections to localhost to prevent data exfiltration
try {
const sinkUrl = new URL(sink);
const isLocalhost =
sinkUrl.hostname === "localhost" ||
sinkUrl.hostname === "127.0.0.1" ||
sinkUrl.hostname === "::1";
if (!isLocalhost) {
setStatus("Sink must be localhost");
return;
}
if (sinkUrl.protocol !== "ws:" && sinkUrl.protocol !== "wss:") {
setStatus("Sink must use ws:// or wss://");
return;
}
} catch {
setStatus("Invalid sink URL");
return;
}

if (!("MediaRecorder" in window)) {
setStatus("MediaRecorder unavailable");
return;
Expand Down
3 changes: 2 additions & 1 deletion bulk-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appendFileSync } from "node:fs";
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import {
MODELS,
Expand All @@ -21,6 +21,7 @@ const CONCURRENCY = 100;

const startTime = Date.now();
const LOGS_DIR = join(import.meta.dir, "logs");
mkdirSync(LOGS_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const LOG_FILE = join(LOGS_DIR, `bulk-test-${timestamp}.log`);

Expand Down
3 changes: 2 additions & 1 deletion check-db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Database } from "bun:sqlite";
const db = new Database("quipslop.sqlite");
const dbPath = process.env.DATABASE_PATH ?? "quipslop.sqlite";
const db = new Database(dbPath);
const rows = db.query("SELECT id, num FROM rounds ORDER BY id DESC LIMIT 20").all();
console.log(rows);
16 changes: 10 additions & 6 deletions db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ db.exec(`
data TEXT
);
`);
db.exec("PRAGMA journal_mode = WAL;");

const insertRound = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
const countRounds = db.query("SELECT COUNT(*) as count FROM rounds");
const selectRoundsPage = db.prepare("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset");
const selectAllRounds = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC");

export function saveRound(round: RoundState) {
const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
insert.run({ $num: round.num, $data: JSON.stringify(round) });
insertRound.run({ $num: round.num, $data: JSON.stringify(round) });
}

export function getRounds(page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
const rows = db.query("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset")
.all({ $limit: limit, $offset: offset }) as { data: string }[];
const countQuery = countRounds.get() as { count: number };
const rows = selectRoundsPage.all({ $limit: limit, $offset: offset }) as { data: string }[];
return {
rounds: rows.map(r => JSON.parse(r.data) as RoundState),
total: countQuery.count,
Expand All @@ -33,7 +37,7 @@ export function getRounds(page: number = 1, limit: number = 10) {
}

export function getAllRounds() {
const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[];
const rows = selectAllRounds.all() as { data: string }[];
return rows.map(r => JSON.parse(r.data) as RoundState);
}

Expand Down
16 changes: 11 additions & 5 deletions frontend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,19 +199,19 @@ function ContestantCard({
vote{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots">
{voters.map((v, i) => {
{voters.map((v) => {
const logo = getLogo(v.voter.name);
return logo ? (
<img
key={i}
key={v.voter.name}
src={logo}
alt={v.voter.name}
title={v.voter.name}
className="voter-dot"
/>
) : (
<span
key={i}
key={v.voter.name}
className="voter-dot voter-dot--letter"
style={{ color: getColor(v.voter.name) }}
title={v.voter.name}
Expand Down Expand Up @@ -410,14 +410,20 @@ function App() {
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
let reconnectAttempt = 0;

let knownVersion: string | null = null;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => setConnected(true);
ws.onopen = () => {
setConnected(true);
reconnectAttempt = 0;
};
ws.onclose = () => {
setConnected(false);
reconnectTimer = setTimeout(connect, 2000);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt), 30000) + Math.random() * 1000;
reconnectAttempt++;
reconnectTimer = setTimeout(connect, delay);
};
Comment on lines +418 to 427
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

WebSocket cleanup leaves an uncancellable reconnect timer.

ws?.close() in the cleanup triggers the onclose handler asynchronously — after the cleanup function has already returned. When onclose fires, it sets a brand-new reconnectTimer via setTimeout(connect, delay). Because clearTimeout(reconnectTimer) already ran, this new timer is never cleared. connect() then fires, creates a fresh WebSocket, and calls setState on the (already unmounted) component.

This is especially visible in React StrictMode (effects are mounted → cleaned up → re-mounted): the async onclose from the first cleanup races with the second mount, potentially creating two live WebSocket connections.

Fix: null out ws.onclose before closing so the handler doesn't fire on an intentional cleanup close.

🔒 Proposed fix
     return () => {
       clearTimeout(reconnectTimer);
+      ws.onclose = null; // prevent async onclose from scheduling a reconnect after cleanup
       ws?.close();
     };

Also applies to: 443-446

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend.tsx` around lines 418 - 427, The cleanup currently calls ws?.close()
which asynchronously triggers the ws.onclose handler and creates a new
reconnectTimer that clearTimeout already cleared; to fix, before calling
ws?.close() null out the onclose handler (e.g., set ws.onclose = null) so the
intentional close doesn't schedule a reconnection, and keep the existing logic
that clears reconnectTimer and resets reconnectAttempt; apply the same change
for the other cleanup spot that mirrors the onclose/reconnect logic (the block
that assigns ws.onopen/ws.onclose and calls connect/reconnectTimer).

ws.onmessage = (e) => {
const msg: ServerMessage = JSON.parse(e.data);
Expand Down
24 changes: 13 additions & 11 deletions game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export async function withRetry<T>(
}

export function isRealString(s: string, minLength = 5): boolean {
return s.length >= minLength;
return s.trim().length >= minLength;
}

export function cleanResponse(text: string): string {
Expand Down Expand Up @@ -192,7 +192,7 @@ Come up with something ORIGINAL — don't copy these examples.`;
export async function callGeneratePrompt(model: Model): Promise<string> {
log("INFO", `prompt:${model.name}`, "Calling API", { modelId: model.id });
const system = buildPromptSystem();
const { text, usage, reasoning } = await generateText({
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system,
prompt:
Expand All @@ -214,7 +214,7 @@ export async function callGenerateAnswer(
modelId: model.id,
prompt,
});
const { text, usage, reasoning } = await generateText({
const { text, usage } = await generateText({
model: openrouter.chat(model.id),
system: `You are playing Quiplash! You'll be given a fill-in-the-blank prompt. Give the FUNNIEST possible answer. Be creative, edgy, unexpected, and concise. Reply with ONLY your answer — no quotes, no explanation, no preamble. Keep it short (under 12 words). Keep it concise and witty.`,
prompt: `Fill in the blank: ${prompt}`,
Expand All @@ -239,18 +239,18 @@ export async function callVote(
answerA: a.answer,
answerB: b.answer,
});
const { text, usage, reasoning } = await generateText({
const { text, usage } = await generateText({
model: openrouter.chat(voter.id),
system: `You are a judge in a comedy game. You'll see a fill-in-the-blank prompt and two answers. Pick which answer is FUNNIER. You MUST respond with exactly "A" or "B" — nothing else.`,
prompt: `Prompt: "${prompt}"\n\nAnswer A: "${a.answer}"\nAnswer B: "${b.answer}"\n\nWhich is funnier? Reply with just A or B.`,
});

log("INFO", `vote:${voter.name}`, "Raw response", { rawText: text, usage });
const cleaned = text.trim().toUpperCase();
if (!cleaned.startsWith("A") && !cleaned.startsWith("B")) {
const cleaned = text.trim().toUpperCase().charAt(0);
if (cleaned !== "A" && cleaned !== "B") {
throw new Error(`Invalid vote: "${text.trim()}"`);
}
return cleaned.startsWith("A") ? "A" : "B";
return cleaned as "A" | "B";
}

import { saveRound } from "./db.ts";
Expand Down Expand Up @@ -280,8 +280,9 @@ export async function runGame(
const latest = state.completed.at(-1);
const expectedR = latest ? latest.num + 1 : 1;
if (r !== expectedR) {
r = expectedR;
endRound = r + runs - 1;
r = expectedR - 1; // subtract 1 because the for-loop increments r
endRound = expectedR + runs - 1;
continue;
}

const shuffled = shuffle([...MODELS]);
Expand Down Expand Up @@ -451,13 +452,14 @@ export async function runGame(
}
rerender();

// Archive round before the display delay to prevent data loss on crash
saveRound(round);

await new Promise((r) => setTimeout(r, 5000));
if (state.generation !== roundGeneration) {
continue;
}

// Archive round
saveRound(round);
state.completed = [...state.completed, round];
state.active = null;
rerender();
Expand Down
Loading