Skip to content

Commit 221e162

Browse files
committed
fix(sync): auth-gated sync state machine v3.3.7
Auth failure is now a STOP condition, not a retry condition. - isAuthError/isPermissionError/isNetworkError error classifiers - authedJson tags auth errors with __isAuthError=true - __isoSyncAuthBlocked global flag; block() and unblock() helpers - 30-min timer cleared on auth failure, restarted on session recovery - All sync triggers check __isoSyncAuthBlocked before running - Token intercept calls __isoSyncAuthUnblock() on new valid session - Login success calls __isoSyncAuthUnblock() - Online event re-validates JWT before unblocking - Smart-sync catch re-throws auth errors instead of swallowing - All catch blocks write paused_auth (not failed) on auth errors - Permission errors get distinct failed_permission status (no retry) - CHANGELOG, VERSION bumped to 3.3.7
1 parent 4e2b2c0 commit 221e162

3 files changed

Lines changed: 189 additions & 17 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
---
77

8+
## [3.3.7] β€” 2026-06-08 β€” Fix: auth-gated sync state machine; stop infinite retry on auth failure
9+
10+
### Fixed (sync state machine β€” complete rebuild)
11+
12+
- **Auth failure is now a STOP condition, not a retry condition** β€” Previously any auth error (expired session, no session, 401) caused the sync to be written as `failed` and retried on the next timer tick, visibility change, or online event. The same 2009 KB payload would upload infinitely. Now any auth error immediately sets `__isoSyncAuthBlocked = true` and the entire sync pipeline halts.
13+
- **New `isAuthError` / `isPermissionError` / `isNetworkError` classifiers** β€” Errors are classified before deciding to stop vs. retry. Network errors still retry; auth and permission errors do not.
14+
- **`authedJson` now throws tagged `AuthError` objects** β€” When JWT is null or refresh fails, the thrown `Error` has `__isAuthError = true`. When the server returns 401/auth message, the thrown error is also tagged. All callers can now distinguish the error type.
15+
- **30-min timer stops on auth failure, restarts on recovery** β€” `__isoSyncAuthBlock()` calls `clearInterval(_autoSyncTimer)`. `__isoSyncAuthUnblock()` restarts it and schedules one sync attempt.
16+
- **All sync triggers check auth-blocked state** β€” `__isoAutoSync`, `__isoStartupSync`, the 30-min timer interval, the visibility-change handler, and the online-event handler all check `window.__isoSyncAuthBlocked` and return `{ reason: 'paused_auth' }` without running any upload/download.
17+
- **Token intercept unblocks sync on new valid session** β€” When Supabase returns a new `access_token` (login, token refresh), the fetch interceptor calls `window.__isoSyncAuthUnblock()`, which clears the blocked flag, restarts the timer, and queues one sync attempt 2 s later.
18+
- **Login (`__isoLogin`) unblocks sync on success** β€” After a successful username/password login and profile sync, `__isoSyncAuthUnblock()` is called so sync resumes without waiting for the next Supabase token intercept.
19+
- **Online event re-validates session before unblocking** β€” When the network comes back and `__isoSyncAuthBlocked` is true, the handler calls `getValidJwt()` first; if a valid JWT exists, it unblocks and syncs. It does not blindly retry the upload.
20+
- **Smart-sync catch re-throws auth errors** β€” The `try/catch` around `/__auth/backup/latest` in `__isoRunManualCloudSync` previously swallowed all errors as "non-fatal". Now auth errors are rethrown so they propagate to the outer catch and trigger the block.
21+
- **Permission errors get a distinct `failed_permission` status** β€” These are written to sync metadata and history separately; they never trigger a retry.
22+
- **All sync operations (`snapshot`, `upload`, `download_import`, `manual_sync`) handle auth errors uniformly** β€” Each catch block calls `__isoSyncAuthBlock()` and writes `paused_auth` to sync history instead of `failed`.
23+
24+
### Audit (v3.3.7 β€” 2026-06-08)
25+
26+
| # | Check | Result |
27+
|---|-------|--------|
28+
| 1 | Auth failure stops all scheduled sync | βœ… fixed |
29+
| 2 | Same payload never uploads infinitely on auth error | βœ… fixed |
30+
| 3 | 30-min timer cleared on auth failure | βœ… fixed |
31+
| 4 | Timer restarted on new valid session | βœ… fixed |
32+
| 5 | All sync triggers check `__isoSyncAuthBlocked` | βœ… fixed |
33+
| 6 | Token intercept calls `__isoSyncAuthUnblock()` | βœ… fixed |
34+
| 7 | Login success calls `__isoSyncAuthUnblock()` | βœ… fixed |
35+
| 8 | Auth vs network vs permission errors classified | βœ… fixed |
36+
| 9 | Smart-sync auth errors propagate instead of being swallowed | βœ… fixed |
37+
38+
---
39+
840
## [3.3.6] β€” 2026-06-08 β€” Fix: cloud sync download on new device; storage cleanup; setup improvements
941

1042
### Fixed

β€ŽVERSIONβ€Ž

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"version": "3.3.6",
3-
"sha": "v3.3.6",
4-
"message": "fix: cloud sync download on new device; storage cleanup; setup improvements",
5-
"updated_at": "2026-06-08T00:00:00.000Z"
2+
"version": "3.3.7",
3+
"sha": "v3.3.7",
4+
"message": "fix: auth-gated sync state machine β€” stop infinite retry on auth failure",
5+
"updated_at": "2026-06-08T01:00:00.000Z"
66
}

β€Žserver.mjsβ€Ž

Lines changed: 153 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -779,9 +779,69 @@ function buildUsernameAuthScript() {
779779
} catch(e) { return false; }
780780
}
781781
782+
// ── Sync auth state machine ──────────────────────────────────────────────
783+
// Auth failure is a STOP condition. Network failure is a RETRY condition.
784+
// When any sync call gets an auth error we block all scheduled syncs.
785+
// When a new valid session token arrives we unblock and queue one retry.
786+
787+
function isAuthError(e) {
788+
if (!e) return false;
789+
if (e.__isAuthError) return true;
790+
var msg = String(e.message || e || '').toLowerCase();
791+
return /authentication required|please log in|invalid token|token expired|jwt expired|not authenticated|invalid credentials|invalid claim|invalid jwt|session expired|no session|user not found/.test(msg) || (e.__httpStatus === 401);
792+
}
793+
794+
function isPermissionError(e) {
795+
if (!e) return false;
796+
var msg = String(e.message || e || '').toLowerCase();
797+
return /permission denied|policy|not authorized|forbidden|rls|row level security/.test(msg) || (e.__httpStatus === 403);
798+
}
799+
800+
function isNetworkError(e) {
801+
if (!e) return false;
802+
var msg = String(e.message || e || '').toLowerCase();
803+
return /network|fetch|timeout|timed out|econnrefused|econnreset|dns|no internet|failed to fetch|load failed/.test(msg) || e.name === 'AbortError' || e.name === 'TypeError';
804+
}
805+
806+
window.__isoSyncAuthBlocked = false;
807+
808+
// Block all scheduled syncs (auth failure). Stops the 30-min timer.
809+
window.__isoSyncAuthBlock = function(reason) {
810+
if (!window.__isoSyncAuthBlocked) {
811+
window.__isoSyncAuthBlocked = true;
812+
writeSyncMetadata({ last_sync_status: 'paused_auth', last_error: reason || 'Authentication required β€” please log in' });
813+
writeSyncHistory({ op: 'auth_block', status: 'paused_auth', detail: reason || 'Authentication required' });
814+
// Stop the 30-min timer so it cannot fire while auth is broken
815+
if (typeof _autoSyncTimer !== 'undefined' && _autoSyncTimer) {
816+
clearInterval(_autoSyncTimer);
817+
_autoSyncTimer = null;
818+
}
819+
}
820+
};
821+
822+
// Unblock syncs (new session token received). Restarts the timer + queues one sync.
823+
window.__isoSyncAuthUnblock = function() {
824+
var wasBlocked = window.__isoSyncAuthBlocked;
825+
window.__isoSyncAuthBlocked = false;
826+
if (wasBlocked) {
827+
writeSyncHistory({ op: 'auth_unblock', status: 'ok', detail: 'Session restored β€” sync resuming' });
828+
// Restart the recurring timer and schedule one sync attempt shortly
829+
if (typeof startAutoSyncTimer === 'function') {
830+
try { startAutoSyncTimer(); } catch(e) {}
831+
}
832+
setTimeout(function() {
833+
try { window.__isoAutoSync('auth_recovered').catch(function() {}); } catch(e) {}
834+
}, 2000);
835+
}
836+
};
837+
782838
async function authedJson(url, options) {
783839
var jwt = await getValidJwt();
784-
if (!jwt) throw new Error('Authentication required β€” please log in');
840+
if (!jwt) {
841+
var _noJwtErr = new Error('Authentication required β€” please log in');
842+
_noJwtErr.__isAuthError = true;
843+
throw _noJwtErr;
844+
}
785845
var headers = Object.assign({ 'Accept': 'application/json', 'Authorization': 'Bearer ' + jwt }, (options && options.headers) || {});
786846
var timeoutMs = options && options.timeoutMs ? options.timeoutMs : 45000;
787847
var r = await withTimeout(function(signal) {
@@ -801,10 +861,26 @@ function buildUsernameAuthScript() {
801861
if (signal) init.signal = signal;
802862
return fetch(url, init);
803863
}, timeoutMs, 'Cloud request timed out');
864+
} else {
865+
// Refresh failed β€” this is a genuine auth error
866+
var _authErr = new Error('Authentication required β€” session could not be refreshed');
867+
_authErr.__isAuthError = true;
868+
_authErr.__httpStatus = 401;
869+
throw _authErr;
804870
}
805871
}
806872
var d = await r.json().catch(function(){ return {}; });
807-
if (!r.ok || !d.ok) throw new Error(d.error || ('Request failed: ' + r.status));
873+
if (!r.ok || !d.ok) {
874+
var errMsg = d.error || ('Request failed: ' + r.status);
875+
var err = new Error(errMsg);
876+
err.__httpStatus = r.status;
877+
if (r.status === 401 || /authentication required|please log in|invalid token|jwt|not authenticated|session/i.test(errMsg)) {
878+
err.__isAuthError = true;
879+
} else if (r.status === 403 || /permission|policy|forbidden|rls/i.test(errMsg)) {
880+
err.__isPermissionError = true;
881+
}
882+
throw err;
883+
}
808884
if (d.cloud_snapshot && d.user_id) cacheCloudSnapshot(d.cloud_snapshot, d.user_id);
809885
return d;
810886
}
@@ -910,8 +986,13 @@ function buildUsernameAuthScript() {
910986
writeSyncHistory({ op: 'snapshot', status: 'ok', source: _src, at: snapshotAt });
911987
return { ok: true, snapshot_storage: d.snapshot_storage || null };
912988
} catch(e) {
913-
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Cloud snapshot upload failed' });
914-
writeSyncHistory({ op: 'snapshot', status: 'failed', error: e.message || 'Cloud snapshot upload failed', source: _src });
989+
if (isAuthError(e)) {
990+
try { window.__isoSyncAuthBlock(e.message); } catch(_ae) {}
991+
writeSyncHistory({ op: 'snapshot', status: 'paused_auth', error: e.message, source: _src });
992+
} else {
993+
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Cloud snapshot upload failed' });
994+
writeSyncHistory({ op: 'snapshot', status: 'failed', error: e.message || 'Cloud snapshot upload failed', source: _src });
995+
}
915996
throw e;
916997
}
917998
});
@@ -930,8 +1011,13 @@ function buildUsernameAuthScript() {
9301011
writeSyncHistory({ op: 'upload', status: result.skipped ? 'skipped' : 'ok', source: source, bytes: bytes, hash: hash, detail: result.reason || null });
9311012
return result;
9321013
} catch(e) {
933-
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Backup upload failed' });
934-
writeSyncHistory({ op: 'upload', status: 'failed', source: source, error: e.message || 'Backup upload failed', bytes: bytes, hash: hash });
1014+
if (isAuthError(e)) {
1015+
try { window.__isoSyncAuthBlock(e.message); } catch(_ae) {}
1016+
writeSyncHistory({ op: 'upload', status: 'paused_auth', source: source, error: e.message, bytes: bytes, hash: hash });
1017+
} else {
1018+
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Backup upload failed' });
1019+
writeSyncHistory({ op: 'upload', status: 'failed', source: source, error: e.message || 'Backup upload failed', bytes: bytes, hash: hash });
1020+
}
9351021
throw e;
9361022
}
9371023
});
@@ -999,6 +1085,9 @@ function buildUsernameAuthScript() {
9991085
cloudIsNewer = cloudTs > 0 && localTs > 0 ? (cloudTs - localTs > 10000) : false;
10001086
if (!localTs && cloudTs > 0) cloudIsNewer = true; // first sync: always download first
10011087
} catch(fetchErr) {
1088+
// Auth errors must propagate β€” they are a STOP condition, not non-fatal
1089+
if (isAuthError(fetchErr)) throw fetchErr;
1090+
// Network/other errors are non-fatal for smart-sync direction check
10021091
console.warn('[SmartSync] Could not fetch cloud metadata (non-fatal):', fetchErr && fetchErr.message);
10031092
}
10041093
@@ -1059,8 +1148,17 @@ function buildUsernameAuthScript() {
10591148
writeSyncHistory({ op: 'manual_sync', status: 'ok', source: _src, bytes: bytes, hash: hash, uploaded: uploaded, upload_skipped: uploadSkipped, downloaded: downloaded, imported: imported });
10601149
return { ok: true, uploaded: uploaded, upload_skipped: uploadSkipped, downloaded: downloaded, imported: imported, hash: hash, bytes: bytes };
10611150
} catch(e) {
1062-
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Cloud sync failed' });
1063-
writeSyncHistory({ op: 'manual_sync', status: 'failed', source: _src, bytes: bytes, hash: hash, error: e.message || 'Cloud sync failed' });
1151+
// Auth errors are a STOP condition β€” block scheduled syncs immediately
1152+
if (isAuthError(e)) {
1153+
try { window.__isoSyncAuthBlock(e.message); } catch(_ae) {}
1154+
writeSyncHistory({ op: 'manual_sync', status: 'paused_auth', source: _src, bytes: bytes, hash: hash, error: e.message });
1155+
} else if (isPermissionError(e)) {
1156+
writeSyncMetadata({ last_sync_status: 'failed_permission', last_error: e.message || 'Storage permission error' });
1157+
writeSyncHistory({ op: 'manual_sync', status: 'failed_permission', source: _src, bytes: bytes, hash: hash, error: e.message });
1158+
} else {
1159+
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Cloud sync failed' });
1160+
writeSyncHistory({ op: 'manual_sync', status: 'failed', source: _src, bytes: bytes, hash: hash, error: e.message || 'Cloud sync failed' });
1161+
}
10641162
throw e;
10651163
}
10661164
});
@@ -1085,8 +1183,13 @@ function buildUsernameAuthScript() {
10851183
writeSyncHistory({ op: 'download_import', status: result && result.skipped ? 'skipped' : 'ok', source: _src, bytes: result && result.bytes || 0, hash: result && result.hash || null, imported: imported });
10861184
return { ok: true, imported: imported, downloaded: !!(result && result.backup_json), hash: result && result.hash || null };
10871185
} catch(e) {
1088-
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Download/import failed' });
1089-
writeSyncHistory({ op: 'download_import', status: 'failed', source: _src, error: e.message || 'Download/import failed' });
1186+
if (isAuthError(e)) {
1187+
try { window.__isoSyncAuthBlock(e.message); } catch(_ae) {}
1188+
writeSyncHistory({ op: 'download_import', status: 'paused_auth', source: _src, error: e.message });
1189+
} else {
1190+
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Download/import failed' });
1191+
writeSyncHistory({ op: 'download_import', status: 'failed', source: _src, error: e.message || 'Download/import failed' });
1192+
}
10901193
throw e;
10911194
}
10921195
});
@@ -1282,6 +1385,8 @@ function buildUsernameAuthScript() {
12821385
console.warn('[Auth] Profile sync after login failed (non-fatal):', syncErr && syncErr.message);
12831386
// Still succeed β€” the session is valid, the app will retry sync later.
12841387
}
1388+
// AUTH GATE: new valid session β†’ unblock sync immediately
1389+
try { if (window.__isoSyncAuthUnblock) window.__isoSyncAuthUnblock(); } catch(_ue) {}
12851390
}
12861391
return {ok: true, onboarding_completed: onboarding_completed};
12871392
} catch(e) {
@@ -1541,9 +1646,17 @@ function buildUsernameAuthScript() {
15411646
writeSyncHistory({ op: 'auto_sync', status: 'skipped', source: _src, detail: 'offline' });
15421647
return { ok: false, skipped: true, reason: 'offline' };
15431648
}
1649+
// AUTH GATE: never run sync when auth is blocked (previous auth failure)
1650+
if (window.__isoSyncAuthBlocked) {
1651+
return { ok: false, skipped: true, reason: 'paused_auth' };
1652+
}
15441653
var jwt = null;
15451654
try { jwt = await getValidJwt(); } catch(e) {}
1546-
if (!jwt) return { ok: false, skipped: true, reason: 'no_session' };
1655+
if (!jwt) {
1656+
// No session β€” block immediately to prevent repeat attempts
1657+
try { window.__isoSyncAuthBlock('No active session'); } catch(_ae) {}
1658+
return { ok: false, skipped: true, reason: 'no_session' };
1659+
}
15471660
15481661
var speed = null;
15491662
try { speed = await measureNetSpeed(); } catch(e) {}
@@ -1603,6 +1716,16 @@ function buildUsernameAuthScript() {
16031716
return { ok: true, snapshot: true };
16041717
}
16051718
} catch(e) {
1719+
if (isAuthError(e)) {
1720+
// Auth failure: block all future scheduled syncs until session is restored
1721+
try { window.__isoSyncAuthBlock(e.message); } catch(_ae) {}
1722+
writeSyncHistory({ op: 'auto_sync', status: 'paused_auth', source: _src, error: e.message });
1723+
return { ok: false, reason: 'paused_auth', error: e.message };
1724+
} else if (isPermissionError(e)) {
1725+
writeSyncMetadata({ last_sync_status: 'failed_permission', last_error: e.message || 'Permission error' });
1726+
writeSyncHistory({ op: 'auto_sync', status: 'failed_permission', source: _src, error: e.message });
1727+
return { ok: false, reason: 'failed_permission', error: e.message };
1728+
}
16061729
writeSyncMetadata({ last_sync_status: 'failed', last_error: e.message || 'Auto-sync failed' });
16071730
writeSyncHistory({ op: 'auto_sync', status: 'failed', source: _src, error: e.message || 'Auto-sync failed' });
16081731
return { ok: false, error: e.message };
@@ -1617,9 +1740,14 @@ function buildUsernameAuthScript() {
16171740
window.__isoStartupSync = async function() {
16181741
var _src = 'startup_sync';
16191742
if (!navigator.onLine) return { ok: false, reason: 'offline' };
1743+
// AUTH GATE: if a previous sync already blocked due to auth failure, skip
1744+
if (window.__isoSyncAuthBlocked) return { ok: false, reason: 'paused_auth' };
16201745
var jwt = null;
16211746
try { jwt = await getValidJwt(); } catch(e) {}
1622-
if (!jwt) return { ok: false, reason: 'no_session' };
1747+
if (!jwt) {
1748+
try { window.__isoSyncAuthBlock('No active session on startup'); } catch(_ae) {}
1749+
return { ok: false, reason: 'no_session' };
1750+
}
16231751
var _isFirstSync = false;
16241752
try {
16251753
var meta = readSyncMetadata();
@@ -1651,6 +1779,8 @@ function buildUsernameAuthScript() {
16511779
function startAutoSyncTimer() {
16521780
if (_autoSyncTimer) clearInterval(_autoSyncTimer);
16531781
_autoSyncTimer = setInterval(function() {
1782+
// AUTH GATE: timer must not fire while auth is blocked
1783+
if (window.__isoSyncAuthBlocked) return;
16541784
window.__isoAutoSync('auto_30min').catch(function() {});
16551785
}, AUTO_SYNC_INTERVAL);
16561786
}
@@ -1661,7 +1791,7 @@ function buildUsernameAuthScript() {
16611791
document.addEventListener('visibilitychange', function() {
16621792
if (document.visibilityState === 'visible') {
16631793
var away = Date.now() - _lastVisibleAt;
1664-
if (away >= 5 * 60 * 1000) {
1794+
if (away >= 5 * 60 * 1000 && !window.__isoSyncAuthBlocked) {
16651795
setTimeout(function() {
16661796
window.__isoAutoSync('visibility_sync').catch(function() {});
16671797
}, 2000);
@@ -1674,6 +1804,14 @@ function buildUsernameAuthScript() {
16741804
// ── Online-event sync ─────────────────────────────────────────────────────
16751805
window.addEventListener('online', function() {
16761806
setTimeout(function() {
1807+
// Auth-blocked: re-validate session instead of retrying upload
1808+
if (window.__isoSyncAuthBlocked) {
1809+
// Try to get a valid JWT β€” if it succeeds, unblock and sync
1810+
getValidJwt().then(function(jwt) {
1811+
if (jwt) { try { window.__isoSyncAuthUnblock(); } catch(e) {} }
1812+
}).catch(function() {});
1813+
return;
1814+
}
16771815
window.__isoAutoSync('online_sync').catch(function() {});
16781816
}, 3000);
16791817
});
@@ -2440,6 +2578,8 @@ const PREMIUM_SCRIPT = `<script>
24402578
localStorage.setItem('isotope-auth-token', _s);
24412579
} catch(_e) {}
24422580
upgradeProfile(jwt, userId);
2581+
// AUTH GATE: new valid session β†’ unblock sync (resumes timer + queues one sync)
2582+
try { if (window.__isoSyncAuthUnblock) window.__isoSyncAuthUnblock(); } catch(_ue) {}
24432583
}
24442584
}).catch(function(){});
24452585
}

0 commit comments

Comments
Β (0)