feat: auth session refresh overhaul — all 7 phases

Fixes two root-cause bugs: browser tabs racing the same single-use refresh
token, and Electron never refreshing after its one-shot startup call.

Browser:
- TokenUpdater: periodic 5-min + activity-triggered pings to /api/auth/me,
  EventSlot-driven valid/refreshing/expired/network-error state.
  Web Locks leader election (one tab runs the updater; others follow via
  BroadcastChannel). Falls back to leader-always without Locks API support.
- GuardedCall: three-tier wrapper (user/editor/silent) with pre-flight state
  check and tier-specific retry. Replaces ad-hoc fetch calls in Editor and
  editor-shell layout save/load.
- auth-connector: jwtMiddleware proactively rotates access-token cookie
  within 15 min of real expiry (server clock, no client-side exp needed).

Electron:
- Periodic token updater in main process: reads JWT exp directly, uses
  server-corrected clock (Date response header offset) for comparisons,
  refreshes within 15 min of expiry. Replaces one-shot startup refresh.
- Session heartbeat: running instance writes accessToken + timestamp every
  10 s. New instance mints its own session via POST /api/auth/new-session
  if heartbeat is ≤30 s old — skips login screen transparently.
- Retired plaintext last-password.txt auto-login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-08-02 22:39:56 +02:00
parent 6766f6d467
commit 7c754e4256
11 changed files with 553 additions and 44 deletions

View File

@ -38,10 +38,6 @@ function clearTokens(): void {
try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ }
}
function passwordFile(): string {
return path.join( app.getPath( 'userData' ), 'last-password.txt' );
}
function loadLastEmail(): string {
try { return fs.readFileSync( emailFile(), 'utf-8' ).trim(); } catch { return ''; }
}
@ -50,17 +46,8 @@ function saveLastEmail( email: string ): void {
fs.writeFileSync( emailFile(), email, 'utf-8' );
}
function loadLastPassword(): string {
try { return fs.readFileSync( passwordFile(), 'utf-8' ).trim(); } catch { return ''; }
}
function saveLastPassword( password: string ): void {
fs.writeFileSync( passwordFile(), password, 'utf-8' );
}
function clearCredentials(): void {
try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ }
try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ }
try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ }
}
function localRecentsFile(): string {
@ -92,9 +79,27 @@ function removeLocalRecent( folderPath: string ): string[] {
return recents;
}
// ── Clock offset ───────────────────────────────────────────────────────────────
// Electron holds the access token directly, so expiry comparisons must use
// server-authoritative time, not the local clock (which can be significantly wrong).
// We read the Date header from the first auth-server response and cache the offset.
let _serverClockOffsetMs: number | null = null;
function updateClockOffset( dateHeader: string | undefined ): void {
if ( _serverClockOffsetMs !== null || !dateHeader ) return;
const serverMs = new Date( dateHeader ).getTime();
if ( isNaN( serverMs ) ) return;
_serverClockOffsetMs = serverMs - Date.now();
}
function serverNow(): number {
return Date.now() + ( _serverClockOffsetMs ?? 0 );
}
// ── Network ────────────────────────────────────────────────────────────────────
function postJson( url: string, body: unknown ): Promise<unknown> {
function postJson( url: string, body: unknown, extraHeaders?: Record<string, string> ): Promise<unknown> {
return new Promise( ( resolve, reject ) => {
const data = JSON.stringify( body );
const parsed = new URL( url );
@ -106,9 +111,11 @@ function postJson( url: string, body: unknown ): Promise<unknown> {
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength( data ),
...( extraHeaders ?? {} ),
},
},
( res ) => {
updateClockOffset( res.headers.date );
let raw = '';
res.on( 'data', ( chunk: string ) => { raw += chunk; } );
res.on( 'end', () => {
@ -123,6 +130,127 @@ function postJson( url: string, body: unknown ): Promise<unknown> {
} );
}
// ── Electron token updater ─────────────────────────────────────────────────────
// Checks the access token's expiry (decoded from the JWT payload) against
// server-corrected time every 5 minutes. Refreshes proactively when within
// 15 minutes of expiry — matching the server-side PROACTIVE_REFRESH_MARGIN_SEC.
// On refresh failure: network errors are silently retried next tick;
// auth failures (revoked / expired refresh token) close the main window and
// show the login screen.
const REFRESH_MARGIN_MS = 15 * 60 * 1000;
const UPDATER_INTERVAL_MS = 5 * 60 * 1000;
function decodeJwtPayload( token: string ): Record<string, unknown> | null {
try {
const parts = token.split( '.' );
if ( parts.length !== 3 ) return null;
const json = Buffer.from(
parts[ 1 ].replace( /-/g, '+' ).replace( /_/g, '/' ),
'base64'
).toString( 'utf-8' );
return JSON.parse( json ) as Record<string, unknown>;
} catch { return null; }
}
function tokenExpMs( token: string ): number | null {
const payload = decodeJwtPayload( token );
if ( !payload || typeof payload.exp !== 'number' ) return null;
return payload.exp * 1000;
}
async function refreshCurrentTokens(): Promise<void> {
const tokens = currentTokens;
if ( !tokens ) return;
try {
const result = await postJson(
`${ AUTH_HOST }/api/auth/refresh`,
{ refreshToken: tokens.refreshToken }
) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
currentTokens = {
accessToken: result.accessToken as string,
refreshToken: result.refreshToken as string,
};
saveTokens( currentTokens );
} else {
clearTokens();
currentTokens = null;
mainWindow?.close();
createLoginWindow();
}
} catch {
// network error — keep current tokens and retry next tick
}
}
async function checkAndRefreshIfDue(): Promise<void> {
const tokens = currentTokens;
if ( !tokens ) return;
const expMs = tokenExpMs( tokens.accessToken );
if ( expMs === null ) return;
if ( expMs - serverNow() < REFRESH_MARGIN_MS ) {
await refreshCurrentTokens();
}
}
function startElectronTokenUpdater(): void {
setInterval( () => void checkAndRefreshIfDue(), UPDATER_INTERVAL_MS );
setInterval( () => writeHeartbeat(), HEARTBEAT_WRITE_INTERVAL_MS );
}
// ── Session heartbeat ───────────────────────────────────────────────────────────
// A running instance writes its current access token + timestamp every 10 s.
// A newly-starting instance reads this file on launch: if ≤ 30 s old it calls
// POST /api/auth/new-session (requireAuth-guarded) to mint its own independent
// token pair, avoiding the login screen when at least one other instance is live.
// This replaces the plaintext last-password.txt auto-login that was here before.
const HEARTBEAT_WRITE_INTERVAL_MS = 10_000;
const HEARTBEAT_MAX_AGE_MS = 30_000;
interface Heartbeat { accessToken: string; timestamp: number; }
function heartbeatFile(): string {
return path.join( app.getPath( 'userData' ), 'session-heartbeat.json' );
}
function writeHeartbeat(): void {
const tokens = currentTokens;
if ( !tokens ) return;
const hb: Heartbeat = { accessToken: tokens.accessToken, timestamp: Date.now() };
try { fs.writeFileSync( heartbeatFile(), JSON.stringify( hb ), 'utf-8' ); } catch { /* ignore */ }
}
function loadHeartbeat(): Heartbeat | null {
try {
const raw = fs.readFileSync( heartbeatFile(), 'utf-8' );
return JSON.parse( raw ) as Heartbeat;
} catch { return null; }
}
async function tryHeartbeatLogin(): Promise<boolean> {
const hb = loadHeartbeat();
if ( !hb ) return false;
if ( Date.now() - hb.timestamp > HEARTBEAT_MAX_AGE_MS ) return false;
try {
const result = await postJson(
`${ AUTH_HOST }/api/auth/new-session`,
{},
{ Authorization: `Bearer ${ hb.accessToken }` }
) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
currentTokens = {
accessToken: result.accessToken as string,
refreshToken: result.refreshToken as string,
};
saveTokens( currentTokens );
return true;
}
} catch { /* network error — fall through to login screen */ }
return false;
}
// ── Header injection ───────────────────────────────────────────────────────────
function registerHeaderInjector( getToken: () => string | null ): void {
@ -222,20 +350,17 @@ function startExpressServer(): void {
// ── App lifecycle ──────────────────────────────────────────────────────────────
app.whenReady().then( () => {
startElectronTokenUpdater();
registerHeaderInjector( () => currentTokens?.accessToken ?? null );
ipcMain.handle( 'auth:login', async ( _event, email: string, password: string, remember: boolean ) => {
try {
const result = await postJson( `${AUTH_HOST}/api/auth/login`, { email, password } ) as Record<string, unknown>;
const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string };
saveTokens( currentTokens );
if ( remember ) {
saveLastEmail( email );
saveLastPassword( password );
} else {
clearCredentials();
}
if ( remember ) saveLastEmail( email );
else clearCredentials();
return { ok: true };
}
return { ok: false, error: ( result.error as string ) ?? 'Login failed' };
@ -244,8 +369,7 @@ app.whenReady().then( () => {
}
} );
ipcMain.handle( 'auth:last-email', () => loadLastEmail() );
ipcMain.handle( 'auth:last-password', () => loadLastPassword() );
ipcMain.handle( 'auth:last-email', () => loadLastEmail() );
ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } );
ipcMain.handle( 'local:open-folder', async () => {
@ -277,7 +401,7 @@ app.whenReady().then( () => {
if ( currentTokens ) {
try {
const result = await postJson(
`${AUTH_HOST}/api/auth/refresh`,
`${ AUTH_HOST }/api/auth/refresh`,
{ refreshToken: currentTokens.refreshToken }
) as Record<string, unknown>;
if ( result.accessToken && result.refreshToken ) {
@ -295,7 +419,11 @@ app.whenReady().then( () => {
createLoginWindow();
}
} else {
createLoginWindow();
// No saved tokens — try to mint a new independent session from a running instance's
// heartbeat before falling through to the login screen.
const gotSession = await tryHeartbeatLogin();
if ( gotSession ) createMainWindow();
else createLoginWindow();
}
}, 500 );

View File

@ -7,8 +7,6 @@ contextBridge.exposeInMainWorld( 'electronAuth', {
ipcRenderer.send( 'auth:login-success' ),
lastEmail: () =>
ipcRenderer.invoke( 'auth:last-email' ),
lastPassword: () =>
ipcRenderer.invoke( 'auth:last-password' ),
clearCredentials: () =>
ipcRenderer.invoke( 'auth:clear-credentials' ),
} );

@ -1 +1 @@
Subproject commit cb2fba6e8b4a7c8ed236113cd40084e100df4021
Subproject commit 7fa88118960df0fa876594cf306b3a1766634245

118
source/auth/GuardedCall.ts Normal file
View File

@ -0,0 +1,118 @@
import { TokenUpdater, AuthState } from './TokenUpdater.js';
import { sleep } from '../library-ts/browser/animation/sleep.js';
export type CallTier = 'user' | 'editor' | 'silent';
const REFRESHING_WAIT_MS = 6_000;
const RETRY_DELAYS_MS: Record<'editor' | 'silent', number[]> = {
editor: [ 1_000, 3_000, 8_000 ],
silent: [ 1_000, 3_000, 8_000 ],
};
export class GuardedCallError extends Error
{
constructor( message: string, readonly tier: CallTier )
{
super( message );
this.name = 'GuardedCallError';
}
}
export class GuardedCall
{
private static _instance: GuardedCall | null = null;
static init( updater: TokenUpdater ): GuardedCall
{
this._instance = new GuardedCall( updater );
return this._instance;
}
static get(): GuardedCall
{
if ( !this._instance ) throw new Error( 'GuardedCall not initialized' );
return this._instance;
}
constructor( private readonly _updater: TokenUpdater ) {}
async call( tier: CallTier, fn: () => Promise<Response> ): Promise<Response>
{
try { await this._preflight( tier ); }
catch ( e )
{
if ( tier !== 'silent' ) throw e;
console.warn( '[auth] guarded call skipped — session expired', e );
return new Response( null, { status: 0 } );
}
return this._executeWithRetry( tier, fn );
}
private async _preflight( tier: CallTier ): Promise<void>
{
const state = this._updater.state;
if ( state === 'expired' ) throw new GuardedCallError( 'Session expired', tier );
if ( state === 'refreshing' ) await this._waitForNotRefreshing( tier );
}
private _waitForNotRefreshing( tier: CallTier ): Promise<void>
{
return new Promise( ( resolve, reject ) =>
{
const timer = setTimeout( () =>
{
this._updater.onStateChanged.removeListener( handler );
resolve();
}, REFRESHING_WAIT_MS );
const handler = ( state: AuthState ) =>
{
if ( state === 'refreshing' ) return;
clearTimeout( timer );
this._updater.onStateChanged.removeListener( handler );
if ( state === 'expired' ) reject( new GuardedCallError( 'Session expired', tier ) );
else resolve();
};
this._updater.onStateChanged.addListener( handler );
} );
}
private async _executeWithRetry( tier: CallTier, fn: () => Promise<Response> ): Promise<Response>
{
const delays: number[] = tier === 'user' ? [] : RETRY_DELAYS_MS[ tier ];
let lastError: unknown;
for ( let attempt = 0; attempt <= delays.length; attempt++ )
{
if ( attempt > 0 ) await sleep( delays[ attempt - 1 ] );
try
{
const res = await fn();
if ( res.ok || res.status === 401 ) return res;
throw new GuardedCallError( `HTTP ${ res.status }`, tier );
}
catch ( e )
{
lastError = e;
const sessionDead = this._updater.state === 'expired';
if ( tier === 'user' ) throw e;
if ( tier === 'editor' && sessionDead ) throw e;
if ( sessionDead ) break;
if ( tier === 'silent' ) console.warn( '[auth] guarded call failed (attempt', attempt + 1, ')', e );
}
}
if ( tier === 'silent' )
{
console.warn( '[auth] guarded call giving up after retries' );
return new Response( null, { status: 0 } );
}
throw lastError;
}
}

111
source/auth/TokenUpdater.ts Normal file
View File

@ -0,0 +1,111 @@
import { EventSlot } from '../library-ts/browser/events/EventSlot.js';
import { ActivityAnalyser } from '../library-ts/browser/dom/ActivityAnalyser.js';
// Central token-lifecycle owner for the browser session. Runs periodically and on
// user activity, keeping the session's cookies fresh via a cheap authenticated ping.
// Uses Web Locks to elect exactly one leader tab; other tabs follow state via
// BroadcastChannel. The actual refresh decision is server-authoritative (see
// PROACTIVE_REFRESH_MARGIN_SEC in auth-connector's jwtMiddleware) — this class
// never inspects or compares token expiry itself.
export type AuthState = 'valid' | 'refreshing' | 'expired' | 'network-error';
type AuthChannelMessage =
| { type: 'state'; value: AuthState }
| { type: 'request-state' };
const CHANNEL_NAME = 'roject-auth';
const LOCK_NAME = 'roject-token-updater-leader';
export class TokenUpdater
{
static readonly CHECK_INTERVAL_MS = 5 * 60 * 1000;
readonly onStateChanged = new EventSlot<AuthState>();
private _state: AuthState = 'valid';
get state(): AuthState { return this._state; }
private readonly _activity = new ActivityAnalyser();
private _checking = false;
private _isLeader = false;
private readonly _channel = new BroadcastChannel( CHANNEL_NAME );
start(): void
{
this._channel.addEventListener( 'message', ( e: MessageEvent ) =>
{
const msg = e.data as AuthChannelMessage;
if ( this._isLeader )
{
if ( msg.type === 'request-state' )
this._channel.postMessage( { type: 'state', value: this._state } );
return;
}
if ( msg.type === 'state' ) this._setState( msg.value );
} );
if ( !( 'locks' in navigator ) )
{
this._becomeLeader();
return;
}
// Ask the current leader (if any) for its state so this tab syncs immediately.
this._channel.postMessage( { type: 'request-state' } );
// Queue for the exclusive lock. The first tab gets it immediately; subsequent
// tabs wait silently (listening via BroadcastChannel) until the current holder
// closes, then automatically become the new leader.
void navigator.locks.request( LOCK_NAME, async () =>
{
this._becomeLeader();
await new Promise<void>( () => {} ); // hold the lock until tab closes
} );
}
private _becomeLeader(): void
{
this._isLeader = true;
this._activity.start();
this._activity.onActive.addListener( () => this._check() );
setInterval( () => this._check(), TokenUpdater.CHECK_INTERVAL_MS );
void this._check();
}
private async _check(): Promise<void>
{
if ( this._checking ) return;
this._checking = true;
this._setState( 'refreshing' );
try
{
const res = await fetch( '/api/auth/me' );
if ( res.ok ) this._setState( 'valid' );
else if ( res.status === 401 ) this._setState( 'expired' );
else this._setState( 'network-error' );
}
catch
{
this._setState( 'network-error' );
}
finally
{
this._checking = false;
}
}
private _setState( state: AuthState ): void
{
if ( this._state === state ) return;
this._state = state;
this.onStateChanged.dispatch( state );
if ( this._isLeader )
this._channel.postMessage( { type: 'state', value: state } );
}
}

View File

@ -1,5 +1,7 @@
import { Editor } from '../../editor/Editor.js';
import { EditorConsole } from '../../editor/EditorConsole.js';
import { TokenUpdater } from '../../auth/TokenUpdater.js';
import { GuardedCall } from '../../auth/GuardedCall.js';
// ── Serialized layout types ───────────────────────────────────────────────────
@ -117,12 +119,21 @@ class EditorShell extends HTMLElement
private activePortraitPanel: string = 'center';
private _deviceId: string = '';
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
private readonly _tokenUpdater = new TokenUpdater();
async connectedCallback(): Promise<void>
{
const authRes = await fetch( '/api/auth/me' );
if ( !authRes.ok ) { location.href = '/'; return; }
this._tokenUpdater.onStateChanged.addListener( state =>
{
if ( state === 'expired' ) { location.href = '/'; return; }
if ( state === 'network-error' ) console.warn( '[auth] session check failed — network error' );
} );
this._tokenUpdater.start();
GuardedCall.init( this._tokenUpdater );
const params = new URLSearchParams( location.search );
const projectId = params.get( 'project' ) ?? '';
const localRoot = params.get( 'localRoot' ) ?? '';
@ -358,7 +369,7 @@ class EditorShell extends HTMLElement
{
try
{
const res = await fetch( this._layoutUrl() );
const res = await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl() ) );
if ( !res.ok ) return null;
const data = await res.json();
return data ?? null;
@ -375,15 +386,11 @@ class EditorShell extends HTMLElement
private async _saveLayout(): Promise<void>
{
const layout = this._serializeLayout();
try
{
await fetch( this._layoutUrl(), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( layout ),
} );
}
catch {}
await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl(), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( layout ),
} ) );
}
// ── Resize handles ──────────────────────────────────────────────────────────

View File

@ -1,5 +1,6 @@
import { EventSlot } from '../library-ts/browser/events/EventSlot.js';
import { FileEditorRegistry } from './FileEditorRegistry.js';
import { GuardedCall } from '../auth/GuardedCall.js';
export interface DocumentOpenedEvent
@ -57,7 +58,7 @@ export class Editor
if ( ! this.openDocs.has( filePath ) )
{
const res = await fetch( this._readUrl( filePath ) );
const res = await GuardedCall.get().call( 'editor', () => fetch( this._readUrl( filePath ) ) );
const content = await res.text();
this.openDocs.set( filePath, { content, dirty: false } );
}
@ -87,7 +88,7 @@ export class Editor
if ( !this.openDocs.has( filePath ) )
{
const res = await fetch( this._readUrl( filePath ) );
const res = await GuardedCall.get().call( 'editor', () => fetch( this._readUrl( filePath ) ) );
const content = await res.text();
this.openDocs.set( filePath, { content, dirty: false } );
}
@ -106,14 +107,14 @@ export class Editor
return;
}
await fetch(
await GuardedCall.get().call( 'user', () => fetch(
this._writeUrl( filePath ),
{
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: doc.content
}
);
) );
doc.dirty = false;
this.onDocumentSaved.dispatch( { path: filePath } );

@ -1 +1 @@
Subproject commit 98895de241f4f05e98ab46f9976e3756c2df5c7d
Subproject commit aac73efae0dda1b4f46de576e55e47ec835c9ffa

View File

@ -49,6 +49,18 @@
<div class="lane">
<div class="lane-header">Nice To Have</div>
<task-item class="purple hide-content">
<task-title>Reactive one-retry-on-401 fallback for token refresh</task-title>
<task-content>
Considered as part of the auth-update central Token Updater work and deliberately
not built: when a call 401s in the narrow window right after the access token
expired but before the next proactive refresh tick, retry it once after triggering
a refresh, instead of failing immediately. Skipped for now to keep the guarded-call
path simple (calls only care whether access is valid, nothing else); revisit if the
5-minute proactive refresh interval turns out to leave a real-world gap.
</task-content>
</task-item>
<task-item class="purple hide-content">
<task-title>Real-Time Multi-User Collaboration</task-title>
<task-content>

View File

@ -222,6 +222,55 @@
<div class="lane">
<div class="lane-header">Done</div>
<task-item class="green hide-content">
<task-title>Auth Update — session refresh overhaul</task-title>
<task-content>
Full plan and rationale: <code>workspace/history/2026/08-August/02-Saturday/auth-update.page</code>.
Root cause was two bugs: browser tabs racing the same single-use refresh token
(rokojori-auth), and Electron never refreshing after its one-shot startup call.
<b>Phase 1:</b> rokojori-auth refresh-token grace window. <code>db.ts</code>:
<code>RefreshToken</code> gained <code>usedAt</code>/<code>replacedBy</code>;
<code>markUsed()</code> marks-rotated instead of deleting; <code>create()</code> prunes
expired rows. <code>routes/auth.ts</code> <code>/api/auth/refresh</code>: first use rotates
+ calls <code>markUsed()</code>; same token reused within 10s (<code>REFRESH_GRACE_TTL</code>)
resolves to the same replacement pair instead of 401ing.
<b>Phase 2:</b> <code>ActivityAnalyser</code> gained <code>OnVisibilityChange</code>.
New <code>source/auth/TokenUpdater.ts</code>: periodic 5-min timer + activity-triggered
pings to <code>GET /api/auth/me</code>, <code>EventSlot</code>-driven
<code>valid | refreshing | expired | network-error</code> state. Wired into
<code>editor-shell.ts</code>. Browser access token is <code>httpOnly</code>, so proactive
margin decision moved server-side: <code>jwtMiddleware</code> rotates within 15 min of
real expiry.
<b>Phase 3:</b> <code>source/auth/GuardedCall.ts</code>: singleton wrapper with three
tiers &mdash; <b>user</b> (no retry, throw), <b>editor</b> (3 retries 1s/3s/8s, throw),
<b>silent</b> (same delays, never throws, <code>console.warn</code>). Pre-flight blocks
on <code>expired</code>; waits up to 6 s on <code>refreshing</code>. Wired into
<code>Editor.ts</code> and <code>editor-shell.ts</code> layout save/load.
<b>Phase 4:</b> Web Locks leader election in <code>TokenUpdater.ts</code>. First tab
acquires <code>roject-token-updater-leader</code>, runs checks, broadcasts state via
<code>BroadcastChannel</code>. Followers listen and mirror state. Leader handoff is
automatic when the holder tab closes.
<b>Phase 5:</b> Electron token updater in <code>electron/main.ts</code>. Server
clock offset from <code>Date</code> response header. JWT <code>exp</code> decoded
directly (no httpOnly cookie). <code>checkAndRefreshIfDue()</code> every 5 min,
refreshes within 15 min of expiry.
<b>Phase 6:</b> <code>POST /api/auth/new-session</code> in
<code>rokojori-auth/routes/auth.ts</code>. <code>requireAuth</code>-guarded; mints a
fresh independent token pair via <code>issueTokenPair</code>.
<b>Phase 7:</b> Session heartbeat in <code>electron/main.ts</code>. Running instance
writes <code>{ accessToken, timestamp }</code> every 10 s. New instance reads it on
startup; if &le;30 s old calls <code>POST /api/auth/new-session</code> to skip login.
Retired plaintext <code>last-password.txt</code> auto-login.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Per-project per-device layout persistence</task-title>
<task-content>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8">
<title>New Page</title>
<style>
@import url('https://styles.rokojori.com/get-font?family=Barlow&weights=100,400,700,900');
[data-theme="default-roject"] {
background: #0f1117;
color: #c8cce0;
font-family: 'Barlow', sans-serif;
font-size: 1rem;
line-height: 1.7;
}
[data-theme="default-roject"] h1 {
color: #7c8cff;
font-size: 2.5rem;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
margin: 1.5rem 0 0.75rem;
}
[data-theme="default-roject"] h2 {
color: #7c8cff;
font-size: 1.6rem;
font-weight: 700;
margin: 1.25rem 0 0.5rem;
}
[data-theme="default-roject"] h3 {
color: #9ba4c7;
font-size: 1.2rem;
font-weight: 700;
margin: 1rem 0 0.4rem;
}
[data-theme="default-roject"] p {
margin-bottom: 0.75rem;
}
[data-theme="default-roject"] b,
[data-theme="default-roject"] strong {
color: #e2e4ed;
font-weight: 700;
}
[data-theme="default-roject"] marked-text {
font-weight: 700;
color: hsl(190, 80%, 90%);
}
[data-theme="default-roject"] a {
color: hsl(200, 80%, 70%);
}
</style>
</head>
<body data-theme="default-roject">
<page-header></page-header>
<page-root data-theme="default-roject">
<page-block class="pep-block-full">
<page-area contenteditable="true" style="outline: none;"><h1>Auth Update</h1><div>Fixing session refresh for good: two concrete bugs found, plus a central token-lifecycle architecture to replace ad-hoc per-call refresh handling.</div></page-area>
</page-block>
<page-block class="pep-block-two-col">
<page-area contenteditable="true" style="outline: none;"><h2>Update Loop &amp; Activity Analyzer</h2><div>Token refreshing moves out of individual API calls and into one central unit that runs periodically and on user activity (via <code>ActivityAnalyser</code>), using server-authoritative time to decide when a refresh is actually due &mdash; not the browser's local clock.</div></page-area>
<page-area contenteditable="true" style="outline: none;"><h2>Call Refactoring</h2><div>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.</div></page-area>
</page-block><page-block class="pep-block-two-col">
<page-area contenteditable="true" style="outline: none;"><h2>Root Cause: Two Separate Bugs</h2><div>Investigation found two distinct causes behind &ldquo;auth fails after a while&rdquo;, not one. <b>Browser:</b> refresh tokens are single-use &mdash; <code>rokojori-auth</code>'s <code>/api/auth/refresh</code> deletes the used token then issues a new pair (<code>routes/auth.ts</code> &rarr; <code>issueTokenPair</code>). When Roject's editor fires several parallel API calls right as the access token expires, each one independently triggers <code>jwtMiddleware</code>'s silent refresh (<code>auth-connector/source/server/auth.ts</code> &rarr; <code>tryRefresh</code>) 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. <b>Electron:</b> <code>electron/main.ts</code> only ever refreshes once, in the startup <code>setTimeout</code> block. Nothing refreshes the Bearer token again for the rest of the running session, so once <code>ACCESS_TOKEN_TTL</code> (1h default) elapses, every subsequent request silently sends an expired token until the app is restarted.</div></page-area>
<page-area contenteditable="true" style="outline: none;"><h2>Central Token Updater <span style="opacity:0.6">(done, browser)</span></h2><div>Built as <code>source/auth/TokenUpdater.ts</code>: a periodic timer every <b>5 minutes</b> plus an immediate check on <code>ActivityAnalyser.onActive</code> (covering tab/window resume), each ping hitting the cheap <code>GET /api/auth/me</code>. Turned out the client-side exp/server-time-offset comparison originally planned here doesn't apply to the browser flow at all &mdash; the access token cookie is <code>httpOnly</code>, so client JS can never read its <code>exp</code> in the first place. The proactive decision moved server-side instead: <code>jwtMiddleware</code> (<code>auth-connector/source/server/auth.ts</code>) now rotates the cookie once the token is within <code>PROACTIVE_REFRESH_MARGIN_SEC</code> (15 min default) of its real, server-signed expiry &mdash; 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 <code>EventSlot</code>-driven <code>valid | refreshing | expired | network-error</code> state that <code>editor-shell</code> currently reacts to directly (redirect to login on <code>expired</code>, <code>console.warn</code> on <code>network-error</code>) 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 &mdash; to <b>Electron</b> (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 &mdash; deliberately skipped, tracked as a Nice To Have in the backlog if the 5-minute margin ever proves too wide.</div></page-area>
</page-block><page-block class="pep-block-two-col">
<page-area contenteditable="true" style="outline: none;"><h2>Multi-Session Coordination</h2><div><code>rokojori-auth</code> already supports multiple parallel sessions &mdash; every login creates an independent refresh-token row (<code>refreshTokens.create</code>) without invalidating others. The problem is that within one session, several writers can share the same refresh token and race each other. <b>Browser tabs</b> in the same profile share one cookie jar, so they're literally the same session; fix via leader election with the <b>Web Locks API</b> &mdash; confirmed as the approach &mdash; one tab holds an exclusive lock and runs the updater, others follow via <code>BroadcastChannel</code>. <b>Electron</b> deliberately will <i>not</i> get a single-instance lock &mdash; multiple projects need to run in parallel, each as its own instance, and each mints its own fully independent session (no shared <code>tokens.json</code>, 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 &mdash; if it was written within the last ~30s, it POSTs that token to a new <code>requireAuth</code>-guarded endpoint, <code>/api/auth/new-session</code>, which mints a brand-new independent token pair for the same user via <code>issueTokenPair</code> (the same call <code>/login</code> 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 &mdash; 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 <code>main.ts</code> (<code>saveLastPassword</code>/<code>loadLastPassword</code>) with something safer &mdash; proof of a live session instead of a stored secret.</div></page-area>
<page-area contenteditable="true" style="outline: none;"><h2>Server-Side Refresh Tolerance</h2><div>Client-side coordination can't reach across process/app/device boundaries &mdash; 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 <code>rokojori-auth/routes/auth.ts</code>, 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.</div></page-area>
</page-block><page-block class="pep-block-two-col">
<page-area contenteditable="true" style="outline: none;"><h2>Guarded Calls &amp; Retry Policy</h2><div>A single wrapper function classifies every call by retry tier: <b>user</b> actions (save, delete) never auto-retry &mdash; the user gets a warning and repeats manually; <b>editor</b> actions (autosave, sync) retry with backoff then surface failure; <b>silent</b> actions (layout persistence, telemetry) retry-or-not with no UI, but always log to the console as a <code>console.warn</code> so failures stay debuggable instead of vanishing silently. None of the three tiers retry against a confirmed-dead session (<code>expired</code> state) &mdash; only against transient <code>refreshing</code>/<code>network-error</code> states. Calls should also check the shared auth/network state proactively before firing, not just react to a failed response.</div></page-area>
<page-area contenteditable="true" style="outline: none;"><h2>Phases</h2><div><b>1.</b> Add a short grace window to refresh-token rotation in <code>rokojori-auth</code> so concurrent refresh calls stop hard-failing &mdash; fixes the browser race outright, independent of any client changes. <b>2.</b> Build the central Token Updater (periodic loop + <code>ActivityAnalyser.onActive</code> + server-time offset), replacing <code>jwtMiddleware</code>'s silent per-request refresh and <code>electron/main.ts</code>'s one-shot startup refresh. <b>3.</b> Refactor <code>apiFetch</code> and Electron's request path into the shared guarded-call function with the three-tier retry policy. <b>4.</b> Add Web Locks-based leader election across browser tabs, with <code>BroadcastChannel</code> token sharing. <b>5.</b> Move the Electron updater into the main process, no single-instance lock &mdash; each project runs as its own instance with its own independent session. <b>6.</b> Add <code>POST /api/auth/new-session</code> to <code>rokojori-auth</code> (mints a fresh token pair from an existing valid access token). <b>7.</b> Electron: write a heartbeat file (current access token + timestamp) every 10s from the updater tick; on startup, if the heartbeat is &le; 30s old, silently mint a new session from it instead of showing the login screen; retire the plaintext <code>last-password.txt</code> auto-login in favour of this.</div></page-area>
</page-block><page-block class="pep-block-two-col">
<page-area contenteditable="true" style="outline: none;"><h2>Technical Details</h2><div>Server time offset: read the <code>Date</code> response header once, diff against local <code>Date.now()</code>, cache the offset, use <code>localNow + offset</code> for all expiry comparisons &mdash; ties directly into the existing clock-skew task instead of duplicating it. <code>ActivityAnalyser</code> (<code>library-ts/browser/dom/ActivityAnalyser.ts</code>) needs an <code>OnVisibilityChange</code> 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 <code>EventSlot</code>-driven enum (<code>valid | refreshing | expired | network-error</code>) that both the updater and the guarded-call wrapper read and write, following existing project convention &mdash; no ad-hoc event buses.</div></page-area>
<page-area contenteditable="true" style="outline: none;"><h2>Open Questions</h2><div>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 <code>console.warn</code>; Electron runs multiple concurrent instances with no single-instance lock, each minting its own independent session transparently via the heartbeat + <code>/api/auth/new-session</code> 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 &mdash; acceptable for now since it's no weaker than the existing on-disk <code>tokens.json</code>, but worth revisiting if the security surface needs tightening later.</div></page-area>
</page-block></page-root>
<page-footer></page-footer>
</body></html>