Auth Update

Fixing session refresh for good: two concrete bugs found, plus a central token-lifecycle architecture to replace ad-hoc per-call refresh handling.

Update Loop & Activity Analyzer

Token refreshing moves out of individual API calls and into one central unit that runs periodically and on user activity (via ActivityAnalyser), using server-authoritative time to decide when a refresh is actually due — not the browser's local clock.

Call Refactoring

API calls stop managing auth and retries themselves. A central guarded-call function checks whether it's safe to call before firing, and applies a configurable retry policy depending on whether the call was user-triggered, a normal editor action, or a silent background action.

Root Cause: Two Separate Bugs

Investigation found two distinct causes behind “auth fails after a while”, not one. Browser: refresh tokens are single-use — rokojori-auth's /api/auth/refresh deletes the used token then issues a new pair (routes/auth.tsissueTokenPair). When Roject's editor fires several parallel API calls right as the access token expires, each one independently triggers jwtMiddleware's silent refresh (auth-connector/source/server/auth.tstryRefresh) using the same refresh-token cookie; the first call wins and rotates it, every other call already in flight gets a 401 against the now-deleted token and force-redirects to login even though the session is fine. Electron: electron/main.ts only ever refreshes once, in the startup setTimeout block. Nothing refreshes the Bearer token again for the rest of the running session, so once ACCESS_TOKEN_TTL (1h default) elapses, every subsequent request silently sends an expired token until the app is restarted.

Central Token Updater (done, browser)

Built as source/auth/TokenUpdater.ts: a periodic timer every 5 minutes plus an immediate check on ActivityAnalyser.onActive (covering tab/window resume), each ping hitting the cheap GET /api/auth/me. Turned out the client-side exp/server-time-offset comparison originally planned here doesn't apply to the browser flow at all — the access token cookie is httpOnly, so client JS can never read its exp in the first place. The proactive decision moved server-side instead: jwtMiddleware (auth-connector/source/server/auth.ts) now rotates the cookie once the token is within PROACTIVE_REFRESH_MARGIN_SEC (15 min default) of its real, server-signed expiry — using the server's own clock, trivially authoritative, no offset math needed. The Updater's only job on the browser side is making sure a request happens often enough for the server to act on; it exposes an EventSlot-driven valid | refreshing | expired | network-error state that editor-shell currently reacts to directly (redirect to login on expired, console.warn on network-error) until Phase 3 routes this through the shared guarded-call wrapper instead. The exp/server-time-offset comparison as originally described still applies as designed — to Electron (Phase 5/7), where the token is a Bearer value actually held in the main process, not hidden behind a cookie. No reactive retry-on-401 fallback — deliberately skipped, tracked as a Nice To Have in the backlog if the 5-minute margin ever proves too wide.

Multi-Session Coordination

rokojori-auth already supports multiple parallel sessions — every login creates an independent refresh-token row (refreshTokens.create) without invalidating others. The problem is that within one session, several writers can share the same refresh token and race each other. Browser tabs in the same profile share one cookie jar, so they're literally the same session; fix via leader election with the Web Locks API — confirmed as the approach — one tab holds an exclusive lock and runs the updater, others follow via BroadcastChannel. Electron deliberately will not get a single-instance lock — multiple projects need to run in parallel, each as its own instance, and each mints its own fully independent session (no shared tokens.json, nothing to race over). To keep this transparent instead of showing a login screen per instance: every running instance writes its current access token plus a timestamp to a shared heartbeat file every 10s (piggybacking on the updater's tick). A newly-starting instance checks that file on launch — if it was written within the last ~30s, it POSTs that token to a new requireAuth-guarded endpoint, /api/auth/new-session, which mints a brand-new independent token pair for the same user via issueTokenPair (the same call /login already uses, just triggered by an existing valid access token instead of a password). The new instance now owns its own refresh token from the start — never shared, never racing. If the heartbeat is stale or the mint call fails, it falls through to the normal login screen. This also replaces the plaintext-password auto-login currently in main.ts (saveLastPassword/loadLastPassword) with something safer — proof of a live session instead of a stored secret.

Server-Side Refresh Tolerance

Client-side coordination can't reach across process/app/device boundaries — a browser tab, an Electron instance, and a second device can all share the same refresh-token record with no way to elect one leader across them. The real fix has to live on the server: give a just-rotated refresh token a short grace window instead of deleting it immediately in rokojori-auth/routes/auth.ts, so a near-simultaneous second refresh call still succeeds instead of hard-401ing. This is the baseline correctness guarantee; client-side leader election (Web Locks) is only an optimization on top to reduce how often that grace window gets exercised.

Guarded Calls & Retry Policy

A single wrapper function classifies every call by retry tier: user actions (save, delete) never auto-retry — the user gets a warning and repeats manually; editor actions (autosave, sync) retry with backoff then surface failure; silent actions (layout persistence, telemetry) retry-or-not with no UI, but always log to the console as a console.warn so failures stay debuggable instead of vanishing silently. None of the three tiers retry against a confirmed-dead session (expired state) — only against transient refreshing/network-error states. Calls should also check the shared auth/network state proactively before firing, not just react to a failed response.

Phases

1. Add a short grace window to refresh-token rotation in rokojori-auth so concurrent refresh calls stop hard-failing — fixes the browser race outright, independent of any client changes. 2. Build the central Token Updater (periodic loop + ActivityAnalyser.onActive + server-time offset), replacing jwtMiddleware's silent per-request refresh and electron/main.ts's one-shot startup refresh. 3. Refactor apiFetch and Electron's request path into the shared guarded-call function with the three-tier retry policy. 4. Add Web Locks-based leader election across browser tabs, with BroadcastChannel token sharing. 5. Move the Electron updater into the main process, no single-instance lock — each project runs as its own instance with its own independent session. 6. Add POST /api/auth/new-session to rokojori-auth (mints a fresh token pair from an existing valid access token). 7. Electron: write a heartbeat file (current access token + timestamp) every 10s from the updater tick; on startup, if the heartbeat is ≤ 30s old, silently mint a new session from it instead of showing the login screen; retire the plaintext last-password.txt auto-login in favour of this.

Technical Details

Server time offset: read the Date response header once, diff against local Date.now(), cache the offset, use localNow + offset for all expiry comparisons — ties directly into the existing clock-skew task instead of duplicating it. ActivityAnalyser (library-ts/browser/dom/ActivityAnalyser.ts) needs an OnVisibilityChange listener added alongside its existing focus/blur/mouse/touch set, since switching tabs within one window doesn't fire window focus/blur at all. Shared auth state should be an EventSlot-driven enum (valid | refreshing | expired | network-error) that both the updater and the guarded-call wrapper read and write, following existing project convention — no ad-hoc event buses.

Open Questions

All resolved. Proactive refresh runs every 5 minutes; Web Locks API confirmed for browser tab leader election; no reactive retry-on-401 (tracked as a Nice To Have in the backlog instead); silent-tier failures log via console.warn; Electron runs multiple concurrent instances with no single-instance lock, each minting its own independent session transparently via the heartbeat + /api/auth/new-session mechanism described under Multi-Session Coordination. One deliberately deferred hardening note for later: the heartbeat bootstrap currently reuses the general-purpose access token rather than a narrow-scope, short-lived bootstrap token — acceptable for now since it's no weaker than the existing on-disk tokens.json, but worth revisiting if the security surface needs tightening later.