@@ -40,6 +40,7 @@ import {
4040} from "@wolfathon/api/twitch" ;
4141import {
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" ;
5457import { createDb , type Db } from "@wolfathon/db" ;
5558import { env } from "@wolfathon/env/server" ;
@@ -66,10 +69,28 @@ import { logger } from "hono/logger";
6669 */
6770const 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) ;
7495app . use (
7596 "/*" ,
@@ -100,12 +121,23 @@ app.use(
100121app . 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 */
440479async 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 }
0 commit comments