From ae12fcd6fd6b551e5d87dc9046d1a22a2060c4d7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 11 Sep 2026 13:31:25 -0400 Subject: [PATCH] Auto-retry once after CSRF token resync in lt.api (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lt.api's fetch wrapper (_apiFetchAuth in base.js — the live implementation lt.api.* resolves to) already resynced window.CSRF_TOKEN from a 403 response's csrf_token field, but still threw immediately — every caller saw a raw "Invalid CSRF token" error on the FIRST attempt, with no transparent retry. Since CsrfMiddleware's token lifetime (1h) is shorter than the session idle timeout (5h), this was a routine, fully recoverable case (an hour of page inactivity, or a write in another tab rotating the shared token), not a real rejection. After resyncing the token from a 403 body that carries one, now retries the original request exactly once with the fresh token before surfacing an error — transparent to the caller on the common case, with a `retried` flag preventing more than one retry so a genuinely broken session still fails cleanly instead of looping. Verified via jsdom with a mocked fetch: a 403-then-succeeds sequence resolves successfully with exactly 2 network calls and the correct final token; a persistently-403 sequence still throws after exactly 2 calls (no infinite retry). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv --- assets/js/base.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/assets/js/base.js b/assets/js/base.js index 3547921..397c58f 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -2801,7 +2801,7 @@ }; // Patch lt.api — auth-aware wrapper (renamed to avoid strict-mode duplicate declaration) - async function _apiFetchAuth(method, url, body) { + async function _apiFetchAuth(method, url, body, retried) { if (_authAccess && auth.isExpiringSoon()) await auth.refresh(); const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) }; if (_authAccess) opts.headers['Authorization'] = 'Bearer ' + _authAccess; @@ -2821,6 +2821,15 @@ // Resync CSRF token from any response body that carries a fresh one // (bootstrap rotates on success and returns the current token on rejection). if (data && data.csrf_token) global.CSRF_TOKEN = data.csrf_token; + // Auto-retry once on a stale-CSRF-token 403: the token lifetime (1h) is + // shorter than the session idle timeout (5h), so this is a routine, + // recoverable case (an hour of inactivity, or a write in another tab + // rotating the shared token) rather than a real rejection — resyncing + // above already has the fresh token, so silently resending once succeeds + // transparently instead of surfacing a confusing error on the first try. + if (resp.status === 403 && !retried && data && data.csrf_token) { + return _apiFetchAuth(method, url, body, true); + } if (!resp.ok) { const err = new Error(data.error || data.message || 'HTTP ' + resp.status); err.data = data;