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;