Skip to content

Commit 63a263c

Browse files
Merge pull request #92 from MrDemonWolf/claude/subgoal-override-prevention-ac99d1
fix: stop sub-goals being overridden, plus repo audit and Workers perf
2 parents e264e69 + 1c8cb1d commit 63a263c

57 files changed

Lines changed: 2080 additions & 444 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ Keep the rewards flowing. Keep the clock ticking.
6868
weighted-random result or send the wheel to a specific slot. The token-gated
6969
OBS overlay stays hidden until you spin (then whirls a long, settling spin and
7070
reveals the result — a "Keep wheel on screen" toggle parks it permanently),
71-
with the centre logo spinning along. It can also **auto-spin every N counted
72-
subs** (default 10, configurable), announcing the dare in chat.
71+
with the centre logo spinning along.
7372
- **Chat bot** - Connect a separate bot account and it answers chat commands
7473
(`!wolfathon`, `!timer`, `!goals`, `!wheel`, `!giveaway`) from the server,
7574
reusing the EventSub webhook — no process to babysit. Live commands pick from
@@ -162,9 +161,11 @@ Each overlay is its own source — drag them where you want in OBS. Each renders
162161
at the fixed native size above and fills its source, so to fit a different scene
163162
just scale the **Browser** source in OBS (or size the source to match).
164163

165-
Both poll every 2 seconds, so control-panel edits and Twitch events appear on
166-
stream within about 2 seconds (the timer keeps counting smoothly between
167-
polls).
164+
Each overlay polls at its own cadence: the timer every 5 seconds (it counts
165+
down locally between polls, so it stays smooth), rewards every 10 seconds, and
166+
the wheel every 3 seconds so a triggered spin starts promptly. Control-panel
167+
edits and Twitch events show on stream within one tick of the relevant
168+
overlay.
168169

169170
### Wolfathon timer
170171

@@ -267,13 +268,21 @@ The timer half:
267268

268269
### What the rewards overlay shows
269270

270-
| Element | Shown on stream |
271-
| ----------------- | ------------------------------------------------- |
272-
| Current reward | The next locked goal's `reward` name, prominently |
273-
| Unlocked rewards | A dimmed row of already-unlocked `reward` names |
274-
| Future goals | Hidden entirely |
275-
| Numbers / amounts | Never shown |
276-
| `note` field | Never sent to the browser |
271+
| Element | Shown on stream |
272+
| ----------------- | ------------------------------------------------------------- |
273+
| Current reward | The next locked goal's `reward` name, prominently |
274+
| Coming up | The next few upcoming `reward` names, if "Next rewards" is on |
275+
| Further-off goals | Never shown, and never sent to the browser |
276+
| Hidden goals | Never shown, and never sent to the browser |
277+
| Numbers / amounts | Only the next goal's target (for the progress bar) |
278+
| `note` field | Never sent to the browser |
279+
280+
The overlay is only ever sent what it can draw: everything up to the current
281+
reward, plus the "Coming up" window when that toggle is on. Turning **Next
282+
rewards** off removes those names from the payload as well as the screen, so a
283+
reward further down the list can't be read out of the OBS source. To keep a
284+
reward secret even when it's the very next one, use the **eye** toggle — a
285+
hidden goal never reaches the browser at all.
277286

278287
### Wheel of dares
279288

@@ -292,11 +301,6 @@ screen** in the Customizer to park it permanently. It honours
292301
`prefers-reduced-motion` (lands without the whirl), shows only enabled slots,
293302
and never receives the token or any internal field.
294303

295-
The wheel can also **auto-spin every N counted subs** — set the cadence on the
296-
Wheel tab (default 10, or Off to spin only by hand). When a sub milestone is
297-
crossed the overlay plays the spin and, if the chat bot is connected, it
298-
announces the dare it landed on.
299-
300304
### Chat bot
301305

302306
Wolfathon can answer chat commands from a **separate bot account**. It runs on
@@ -370,7 +374,7 @@ it is operator-only behind Cloudflare Access.
370374

371375
**Settings → Customizer** tunes how the overlays paint: accent colours, font,
372376
corner radius, the eyebrow label, and per-overlay show/hide toggles (units,
373-
progress bar, unlocked row, status, and the rest), plus a **Keep wheel on
377+
progress bar, next rewards, status, and the rest), plus a **Keep wheel on
374378
screen** toggle (off by default — the wheel only appears when it spins). Each
375379
overlay renders at a fixed native size — scale the OBS source to fit your scene.
376380
A live preview renders the timer and rewards surfaces with sample data
@@ -569,7 +573,7 @@ route in the web app, where Cloudflare Access injects the verified identity.
569573
Twitch posts EventSub webhooks to the public server Worker, which verifies the
570574
HMAC and adds time. All three share one D1 database (rewards, timer, and
571575
Twitch secrets live in separate rows). For instant push instead of polling, a
572-
Durable Object plus WebSocket can replace the 2-second refetch later.
576+
Durable Object plus WebSocket can replace the polling later.
573577

574578
## Project Structure
575579

apps/server/src/index.ts

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "@wolfathon/api/twitch";
4141
import {
4242
applyTimerEventAndBumpSubs,
43+
claimEventId,
4344
mutateBot,
4445
mutateGiveaway,
4546
mutateTimer,
@@ -50,6 +51,8 @@ import {
5051
readTimer,
5152
readWheel,
5253
readTwitch,
54+
seenInLegacyRing,
55+
sweepSeenEventIds,
5356
} from "@wolfathon/api/store";
5457
import { createDb, type Db } from "@wolfathon/db";
5558
import { env } from "@wolfathon/env/server";
@@ -66,10 +69,28 @@ import { logger } from "hono/logger";
6669
*/
6770
const app = new Hono();
6871

72+
/**
73+
* The EventSub webhook secret, cached for the life of the isolate.
74+
*
75+
* Twitch delivers every chat message to this endpoint, so reading the twitch doc
76+
* from D1 before the signature check made the entire chat firehose cost one D1 read
77+
* per message — and made an unsigned-POST flood cost the same. With the secret
78+
* cached, an ordinary chat line costs zero reads unless it turns out to be
79+
* actionable. Fail-closed is unchanged: a failed verify re-reads once before the
80+
* 403, so rotating the secret self-heals rather than blackholing deliveries.
81+
*/
82+
let cachedWebhookSecret: string | undefined;
83+
6984
// Redact the overlay token (`?t=...`) from request logs — the public Worker logs
7085
// every path, and the token is the overlays' only credential (see sec audit).
71-
app.use(
72-
logger((message, ...rest) => console.log(message.replace(/\?\S+/, "?[redacted]"), ...rest)),
86+
const redactedLogger = logger((message, ...rest) =>
87+
console.log(message.replace(/\?\S+/, "?[redacted]"), ...rest),
88+
);
89+
app.use((c, next) =>
90+
// Twitch delivers EVERY chat message to the webhook, so at raid volume logging it
91+
// is thousands of lines a minute for deliveries that do nothing and 204. The
92+
// handler logs what actually matters itself.
93+
c.req.path === "/twitch/eventsub" ? next() : redactedLogger(c, next),
7394
);
7495
app.use(
7596
"/*",
@@ -100,12 +121,23 @@ app.use(
100121
app.post("/twitch/eventsub", async (c) => {
101122
const raw = await c.req.text();
102123
const db = createDb(env.DB);
103-
const twitch = await readTwitch(db);
104124

105-
// No secret = not connected; reject so nothing can be spoofed in.
106-
if (!twitch.webhookSecret) return c.text("not configured", 404);
107-
108-
const valid = await verifyEventsubSignature(c.req.raw.headers, raw, twitch.webhookSecret);
125+
// The twitch doc, read at most once per delivery — and not at all for the chat
126+
// firehose, which never gets past the pre-filter below.
127+
let twitchDoc: Awaited<ReturnType<typeof readTwitch>> | undefined;
128+
const loadTwitch = async () => (twitchDoc ??= await readTwitch(db));
129+
130+
let secret = cachedWebhookSecret;
131+
let valid = secret ? await verifyEventsubSignature(c.req.raw.headers, raw, secret) : false;
132+
if (!valid) {
133+
// Cache miss, or the secret rotated out from under this isolate — re-read once
134+
// and retry, so a rotation self-heals instead of hard-failing every delivery.
135+
secret = (await loadTwitch()).webhookSecret;
136+
// No secret = not connected; reject so nothing can be spoofed in.
137+
if (!secret) return c.text("not configured", 404);
138+
cachedWebhookSecret = secret;
139+
valid = await verifyEventsubSignature(c.req.raw.headers, raw, secret);
140+
}
109141
if (!valid) return c.text("invalid signature", 403);
110142

111143
const messageType = c.req.header("twitch-eventsub-message-type");
@@ -149,24 +181,27 @@ app.post("/twitch/eventsub", async (c) => {
149181
// Nothing actionable → skip dedup write + all giveaway/timer reads.
150182
if (!timerEvent && !maybeGiveaway && !isStreamState) return c.body(null, 204);
151183

152-
const recent = twitch.recentEventIds ?? [];
153-
if (messageId && recent.includes(messageId)) return c.body(null, 204); // already processed
154-
155-
// Idempotency: record the message id BEFORE applying side effects, so a
156-
// retried delivery short-circuits the dedup check above. Trade-off: if the
157-
// handler crashes mid-apply, the event is dropped (lost time) rather than
158-
// double-counted on retry — the safer failure mode, since over-counting
159-
// silently inflates the timer and is unrecoverable, and Twitch's
160-
// at-least-once delivery already tolerates the occasional loss. mutateTwitch
161-
// is compare-and-swap, so concurrent deliveries can't clobber each other's ids.
184+
// Actionable — now the twitch doc is genuinely needed (the bot's and
185+
// gift-announcement's credentials). Ordinary chat never reaches here.
186+
const twitch = await loadTwitch();
187+
188+
// Idempotency: claim the message id BEFORE applying side effects, so a retried
189+
// delivery short-circuits here. Trade-off: if the handler crashes mid-apply the
190+
// event is dropped (lost time) rather than double-counted on retry — the safer
191+
// failure mode, since over-counting silently inflates the timer and is
192+
// unrecoverable, and Twitch's at-least-once delivery already tolerates the
193+
// occasional loss. The claim is an INSERT on a primary key, so concurrent
194+
// deliveries can't both win and distinct ids never contend.
195+
const now = Date.now();
162196
if (messageId) {
163-
await mutateTwitch(db, (doc) => ({
164-
...doc,
165-
recentEventIds: [messageId, ...(doc.recentEventIds ?? [])].slice(0, 50),
166-
}));
197+
// One-release shim: ids written by the previous deploy live in the twitch
198+
// doc's old ring buffer, so a retry spanning the deploy is still recognised.
199+
if (seenInLegacyRing(twitch, messageId)) return c.body(null, 204);
200+
if (!(await claimEventId(db, messageId))) return c.body(null, 204); // already processed
201+
// Housekeeping, off the response path and only occasionally — the table is
202+
// correct whether or not this ever runs.
203+
if (Math.random() < 0.01) c.executionCtx.waitUntil(sweepSeenEventIds(db, now));
167204
}
168-
169-
const now = Date.now();
170205
if (isStreamState) {
171206
// Stream went down / came back — auto-pause so an outage doesn't burn
172207
// Wolfathon time, then auto-resume on return. Opt-in (default on); resume
@@ -433,9 +468,13 @@ async function flushGiftBatch(db: Db, twitch: TwitchDoc): Promise<void> {
433468
/**
434469
* A valid bot user token, refreshing (and persisting the rotated tokens) when
435470
* within a minute of expiry. Null if refresh fails — the caller skips the reply
436-
* rather than send with a dead token. ponytail: a thundering herd of refreshes
437-
* right at expiry would leave all-but-one failing; rare given the cooldown + low
438-
* command volume, and the next command reads the persisted fresh token.
471+
* rather than send with a dead token.
472+
*
473+
* Concurrent `waitUntil` sends share one `bot` snapshot and so refresh with the
474+
* SAME refresh token. Twitch rotates it on use, so all but one get a 4xx: those
475+
* are lost races, not dead grants, and must not raise `tokenInvalid` (see below).
476+
* The losers return null and skip that one reply; the next command reads the
477+
* freshly persisted token.
439478
*/
440479
async function ensureBotToken(db: Db, bot: NonNullable<TwitchDoc["bot"]>): Promise<string | null> {
441480
if (tokenFresh(bot.expiresAt)) return bot.accessToken;
@@ -457,7 +496,14 @@ async function ensureBotToken(db: Db, bot: NonNullable<TwitchDoc["bot"]>): Promi
457496
// so the next command just retries.
458497
const status = err instanceof TwitchAuthError ? err.status : 0;
459498
if (status >= 400 && status < 500) {
460-
await mutateTwitch(db, (d) => (d.bot ? { ...d, bot: { ...d.bot, tokenInvalid: true } } : d));
499+
await mutateTwitch(db, (d) => {
500+
// Only flag if the STORED refresh token is still the one we just tried. If
501+
// it has moved, a concurrent refresh already succeeded and rotated it —
502+
// our 4xx is that race, not a revoked grant. Flagging here would show
503+
// "Bot token expired — reconnect" for a bot that is working fine.
504+
if (!d.bot || d.bot.refreshToken !== bot.refreshToken) return d;
505+
return { ...d, bot: { ...d.bot, tokenInvalid: true } };
506+
});
461507
}
462508
return null;
463509
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { RewardsTab } from "@/components/control/rewards-tab";
22

3-
/** Root = the control panel's default section (rewards). The panel layout's
4-
* navbar tabs switch between this and the timer/twitch/overlays sections. */
3+
/** The operator panel's default section (rewards). The dashboard layout's nav
4+
* switches between this, the timer, the wheel, the giveaway and Settings. */
55
export default function ControlRewardsPage() {
66
return <RewardsTab />;
77
}

apps/web/src/app/manifest.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import type { MetadataRoute } from "next";
22

33
/**
4-
* PWA manifest. The installable surface is the control panel, which now lives
5-
* at the root, so `start_url` opens `/` (which sits behind Cloudflare Access).
4+
* PWA manifest. The installable surface is the operator panel at `/dashboard`
5+
* (behind Cloudflare Access) — NOT `/`, which is the public landing page. An
6+
* installed app opening marketing instead of the panel is the whole point of
7+
* pinning `start_url` here.
68
*/
79
export default function manifest(): MetadataRoute.Manifest {
810
return {
911
name: "Wolfathon Control",
1012
short_name: "Wolfathon",
1113
description: "Wolfathon reward tracker control panel",
12-
start_url: "/",
14+
start_url: "/dashboard",
1315
display: "standalone",
1416
background_color: "#091533",
1517
theme_color: "#091533",

apps/web/src/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const FEATURES = [
4646
{
4747
icon: Disc3,
4848
title: "Wheel of dares",
49-
body: "Spin Howlwheel on stream for a random dare — on demand, or auto-spun every few subs. Hidden until it lands so nobody sees it coming.",
49+
body: "Spin Howlwheel on stream for a random dare. Hidden until it lands so nobody sees it coming.",
5050
},
5151
{
5252
icon: Ticket,

apps/web/src/components/control/backup-tab.tsx

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,11 @@ import { EXAMPLE_DOC, REWARDS_SCHEMA_BULLETS } from "./example";
2222
import { type IEError, type IEConfig, ImportExportPanel } from "./import-export-panel";
2323
import { TIMER_EXAMPLE, TIMER_SCHEMA_BULLETS } from "./timer-example";
2424
import { guard } from "./use-draft";
25-
import { nowStamp } from "./util";
25+
import { downloadFile, nowStamp } from "./util";
2626

2727
const rewardsLabel = (index: number) => (index < 0 ? "Document" : `Goal #${index + 1}`);
2828

2929
/** Trigger a client-side file download of arbitrary text. */
30-
function downloadText(filename: string, text: string, mime: string) {
31-
const url = URL.createObjectURL(new Blob([text], { type: mime }));
32-
const a = document.createElement("a");
33-
a.href = url;
34-
a.download = filename;
35-
a.click();
36-
URL.revokeObjectURL(url);
37-
}
38-
3930
/** One combined backup file: the rewards doc and the timer config in one document. */
4031
const BACKUP_EXAMPLE_JSON = JSON.stringify(buildBackupDoc(EXAMPLE_DOC, TIMER_EXAMPLE), null, 2);
4132

@@ -210,7 +201,7 @@ export function BackupTab() {
210201
disabled={!ready}
211202
onClick={() =>
212203
ready &&
213-
downloadText(
204+
downloadFile(
214205
`wolfathon-recap-${nowStamp()}.md`,
215206
buildRecapMarkdown(rewards, timer, giveaway),
216207
"text/markdown",

apps/web/src/components/control/bot-panel.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,11 @@ function CommandRow({ cmd }: { cmd: BotCommand }) {
303303

304304
{/* Triggers (aliases) */}
305305
<div className="mt-3">
306-
<div className="mb-1 text-xs text-muted-foreground">
306+
<label htmlFor={`${cmd.id}-triggers`} className="mb-1 block text-xs text-muted-foreground">
307307
Triggers (space-separated, each starts with !)
308-
</div>
308+
</label>
309309
<Input
310+
id={`${cmd.id}-triggers`}
310311
value={triggers}
311312
onChange={(e) => setTriggers(e.target.value)}
312313
onBlur={commitTriggers}
@@ -366,8 +367,14 @@ function CommandRow({ cmd }: { cmd: BotCommand }) {
366367
</div>
367368
) : (
368369
<div className="mt-3">
369-
<div className="mb-1 text-xs text-muted-foreground">Reply text</div>
370+
<label
371+
htmlFor={`${cmd.id}-response`}
372+
className="mb-1 block text-xs text-muted-foreground"
373+
>
374+
Reply text
375+
</label>
370376
<textarea
377+
id={`${cmd.id}-response`}
371378
value={response}
372379
onChange={(e) => setResponse(e.target.value)}
373380
onBlur={commitResponse}

0 commit comments

Comments
 (0)