From 4e1453fd90aaceb549769dc8659b96191263647f Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Fri, 2 Oct 2026 22:40:13 -0400 Subject: [PATCH 1/5] =?UTF-8?q?fix(cloud):=20an=20expired=20session=20is?= =?UTF-8?q?=20a=20sign-in=20card,=20found=20before=20the=20first=20message?= =?UTF-8?q?=20=E2=80=94=20not=20a=20401=20in=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user back from a week away typed a goal and got "LevelCode Cloud API 401: Signature has expired" in the transcript, while the account popover said they were signed in. Three things made that possible, and each is closed here. The session could die on a schedule nobody could see. The editor stored the access token the refresh endpoint returned and kept its ORIGINAL refresh token forever, so the 30 days ran from the last sign-in. The server now rotates the refresh token (thin.ly #438) and the editor stores it, so the window slides with use. "Signed in" meant "a secret exists". cloudSignedIn was the presence of a token in SecretStorage, never its validity, so dead credentials kept the footer on "Gateway · Max" and the popover on "Manage account". refreshCloudToken now classifies the refresh reply (providers/session.js): only an explicit 401 ends the session — offline and 5xx keep the tokens, because clearing credentials on a network blip would log someone out for closing their laptop on the train. When it IS over, sessionExpired() forgets the tokens, flips the flag, resyncs the popover, and tells the webview. The failure was discovered by the user's first message. The access token's own `exp` is now read locally at the webview's ready and on window focus (throttled), and the network is touched only when it is expired or within five minutes of it — so an expiry found on launch is a sign-in card at the top of an empty chat, not the reply to a goal they just typed. A send that still finds the session dead — chat, agent, or a prep failure in gateway mode with no token — posts code 'session_expired', which the webview routes to the card ahead of the cap and service cards and the red-text fallback. Gateway mode with no token is named for what it is, "signed out", rather than "No API key set for OpenAI". Verified: session 12 tests, sessionExpiredUi 10; full suite 42 suites, 621 cases, 0 failing. Each guard reverted in turn fails only its own assertion: the session check moved behind the cap card -> the ordering guard; agent.js no longer naming the dead session -> the agent assertion; the ready check removed -> the startup guard. No tsconfig exists, so node --check is the syntax gate. --- extensions/levelcode-ai/agent.js | 6 + extensions/levelcode-ai/extension.js | 112 ++++++++++++++++-- extensions/levelcode-ai/media/chat.html | 32 ++++- extensions/levelcode-ai/providers/session.js | 82 +++++++++++++ extensions/levelcode-ai/test/session.test.js | 93 +++++++++++++++ .../test/sessionExpiredUi.test.js | 92 ++++++++++++++ 6 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 extensions/levelcode-ai/providers/session.js create mode 100644 extensions/levelcode-ai/test/session.test.js create mode 100644 extensions/levelcode-ai/test/sessionExpiredUi.test.js diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index a763cd1..1bc308e 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -1048,6 +1048,12 @@ async function runAgent(ctx) { ctx.post({ type: 'agentError', message: 'You’ve hit the model’s context window (the conversation got too long). Start a New chat to reset it, switch to a larger-context model, or pin fewer files — then continue.', kind: 'context' }); reason = 'error'; } + else if (typeof ctx.isSessionExpired === 'function' && ctx.isSessionExpired(e)) { + // The gateway 401 that refreshAuth could not recover: the session is over. A sign-in card, + // not the adapter's raw message — the user needs a button, not a status code. + ctx.post({ type: 'agentError', message: ctx.sessionExpiredMessage || msg, code: 'session_expired' }); + reason = 'error'; + } else { ctx.post({ type: 'agentError', message: msg, code }); reason = 'error'; } } finally { dbg('agent.done', { reason, steps: step - 1, edits: ctx.editCount || 0, costMicros: runCostMicros, creditsLeftMicros: ctx.credits != null ? ctx.credits : null }); diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 91ddd15..060fe33 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -17,6 +17,7 @@ const crypto = require('crypto'); const providers = require('./providers/index'); const catalog = require('./providers/catalog'); const { resolveGateway } = require('./providers/gateway'); +const session = require('./providers/session'); const { registerAiEdit } = require('./aiEdit'); const { registerLmProvider } = require('./lmProvider'); const { registerInlineComplete } = require('./inlineComplete'); @@ -202,6 +203,7 @@ function providerErrorMessage(req) { if (req.reason === 'baseURL') { return 'Set a base URL for the custom OpenAI-compatible provider first (levelcode.ai.baseURL).'; } if (req.reason === 'insecureBaseURL') { return 'Refusing to send your API key over plain http to a non-local host. Use an https base URL (or a localhost endpoint) for the custom provider.'; } if (req.reason === 'insecureGateway') { return 'Refusing to send your LevelCode Cloud token over plain http. Set "levelcode.cloud.endpoint" to an https URL to use gateway mode.'; } + if (req.reason === 'signedOut') { return session.SESSION_EXPIRED_MESSAGE; } return 'No API key set for ' + req.label + '. Use the key button or “LevelCode: AI: Set API Key”.'; } @@ -347,26 +349,89 @@ function isAuthError(e) { return /\bAPI 401\b|\b401\b.*unauthor/i.test(String((e * {apiUrl}/api/levelcode/v1/auth/refresh (the Rails backend), store the new access token, return true. * No-op (returns false) when not applicable (byok, signed out, no refresh token, or non-https apiUrl). */ +/** + * Renew the access token from the refresh token. Returns true on success. + * + * Two things this now does that it did not before, both for the same incident — a user back from a + * week away was shown "LevelCode Cloud API 401: Signature has expired" in the transcript while the + * account popover still said they were signed in: + * + * 1. It STORES the rotated refresh token the server now returns. Before, the editor kept the + * refresh token from sign-in for its whole life, so the 30 days ran from the last sign-in rather + * than the last use, and an active user was logged out on a schedule they could not see. + * 2. It knows the difference between "this attempt failed" and "the session is over". Only an + * explicit 401 from the refresh endpoint is the latter; that ends the session (sessionExpired). + * Offline, a 5xx, a malformed reply: nothing is known yet, so the tokens stay. + */ async function refreshCloudToken() { if (!ctx) { return false; } const endpoint = cloudApiUrl(); if (!/^https:\/\//i.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(endpoint)) { return false; } const refresh = await ctx.secrets.get(ACCOUNT_REFRESH_KEY); if (!refresh) { return false; } + let outcome = 'retry'; try { const res = await fetch(endpoint + '/api/levelcode/v1/auth/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ refresh }) }); - if (!res.ok) { dbg('cloud.refresh', { ok: false, status: res.status }); return false; } const data = await res.json().catch(() => null); - const access = data && (data.access || data.token); - if (!access) { return false; } - await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access); - dbg('cloud.refresh', { ok: true }); - return true; - } catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); return false; } + outcome = session.classifyRefresh({ status: res.status, body: data }); + dbg('cloud.refresh', { outcome, status: res.status, code: data && data.error && data.error.code }); + if (outcome === 'ok') { + await ctx.secrets.store(ACCOUNT_TOKEN_KEY, data.access || data.token); + if (data.refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, data.refresh); } + return true; + } + } catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); } + if (outcome === 'expired') { await sessionExpired(); } + return false; +} + +/** + * The cloud session is over and cannot be renewed: forget the dead credentials, tell the webview, + * and resync the account popover so it stops claiming the user is signed in. + * + * The cached profile is deliberately KEPT — the sign-in card can say who it is talking to, and the + * next sign-in overwrites it anyway. What must go is anything the editor would otherwise keep + * presenting as a live session: the tokens, and the `cloudSignedIn` flag the footer and model gate + * read. Idempotent, so every path that discovers the expiry can call it without coordination. + */ +let sessionExpiredAnnounced = false; +async function sessionExpired() { + const hadToken = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); + cloudSignedIn = false; + if (ctx) { + await ctx.secrets.delete(ACCOUNT_TOKEN_KEY); + await ctx.secrets.delete(ACCOUNT_REFRESH_KEY); + } + if (!hadToken && sessionExpiredAnnounced) { return; } + sessionExpiredAnnounced = true; + const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {}; + dbg('cloud.sessionExpired', { name: p.name || p.email || '' }); + post({ type: 'sessionExpired', name: p.name || '', message: session.SESSION_EXPIRED_MESSAGE }); + await postAccount(false); + sendConfigToWebview(); +} + +/** + * Check the session BEFORE the user types anything, so an expiry found on launch shows up as a + * sign-in card at the top of the chat rather than as the reply to their first message. + * + * Cheap by design: the access token's own `exp` is read locally and the network is only touched + * when it is expired or about to be. A session with hours left costs nothing here. Called on the + * webview's `ready` and again when the window regains focus after a while away. + */ +let lastSessionCheck = 0; +async function checkCloudSession(reason) { + if (!ctx || providerMode() !== 'gateway') { return; } + const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); + if (!token) { return; } + lastSessionCheck = Date.now(); + if (!session.accessNeedsRefresh(token)) { return; } + dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) }); + await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside } /** Gateway-mode token refresh (the streaming 401 retry path). Delegates to refreshCloudToken. */ @@ -387,6 +452,12 @@ async function prepProviderRequest(opts) { // LevelCode Cloud metered gateway (an openai-kind endpoint) instead of the user's own provider/key. Falls // back to the BYOK path below when signed out or in byok mode — the default is untouched. const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null; + // Gateway mode with no token at all is a signed-out gateway user — most often one whose session + // just expired — not a BYOK user who forgot a key. Say so, with a sign-in card, instead of "No API + // key set for OpenAI", which sends them hunting for a key they never needed. + if (providerMode() === 'gateway' && !token && cloudEndpoint()) { + return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: 'signedOut', gateway: true }; + } const gw = resolveGateway({ mode: providerMode(), endpoint: cloudApiUrl(), token: token || '' }); if (gw.use) { if (!gw.ok) { return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: gw.reason }; } @@ -1633,7 +1704,7 @@ async function agentFlow(text, imageBlocks) { return; } const req = await prepProviderRequest({ prompt: true }); - if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req) }); post({ type: 'agentDone', reason: 'error' }); return; } + if (!req.ok) { post({ type: 'agentError', message: providerErrorMessage(req), code: req.reason === 'signedOut' ? 'session_expired' : undefined }); post({ type: 'agentDone', reason: 'error' }); return; } post({ type: 'agentStart' }); const epoch = conversationEpoch; // this turn belongs to the conversation as it is RIGHT NOW @@ -1702,6 +1773,11 @@ async function agentFlow(text, imageBlocks) { if (!req.gateway) { return null; } return (await refreshGatewayToken()) ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null; }, + // After refreshAuth has failed on a gateway 401, agent.js asks whether that was the session + // ending (→ it posts a sign-in card) or just an error. The refresh itself already ran + // sessionExpired() when the server said the refresh token was dead. + isSessionExpired: (e) => !!req.gateway && !cloudSignedIn && session.isSessionExpiredError(e), + sessionExpiredMessage: session.SESSION_EXPIRED_MESSAGE, skills: skillsObj, // M6.5: implicit skills (name+desc menu in SYSTEM + use_skill resolver) projectMemory: projectMemoryMarkdown(), // cross-session memory: a verify-first digest of past sessions, injected like project rules // The recall_sessions tool: search past-session outcomes on demand. Off → the tool isn't offered at all. @@ -1984,7 +2060,7 @@ async function handleSend(text, images) { const req = await prepProviderRequest({ prompt: true }); if (!req.ok) { conversation.pop(); - post({ type: 'assistantError', message: providerErrorMessage(req) }); + post({ type: 'assistantError', message: providerErrorMessage(req), code: req.reason === 'signedOut' ? 'session_expired' : undefined }); return; } const doStream = (r) => providers.streamChat({ @@ -2014,7 +2090,14 @@ async function handleSend(text, images) { post({ type: 'assistantDone' }); } else { conversation.pop(); - post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code }); + // A gateway 401 the refresh above could not recover is a dead session, not an error to read: + // name it so the webview shows the sign-in card instead of the adapter's raw message. + if (req.gateway && session.isSessionExpiredError(e)) { + await sessionExpired(); + post({ type: 'assistantError', message: session.SESSION_EXPIRED_MESSAGE, code: 'session_expired' }); + } else { + post({ type: 'assistantError', message: String((e && e.message) || e), code: e && e.code }); + } } } finally { // Only if this turn still owns it. A new turn may already have installed its own controller, and @@ -2497,7 +2580,7 @@ class ChatViewProvider { switch (msg.type) { // `ready` is the earliest a freshly-loaded webview can hear anything, so it is also where a // surface that just took over replays the conversation it inherited (openChatInEditor). - case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break; + case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); await checkCloudSession('ready'); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break; case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break; case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break; case 'send': await handleSend(msg.text, msg.images); break; @@ -2528,6 +2611,8 @@ class ChatViewProvider { case 'accountSignOut': await accountSignOut(); break; case 'accountManage': await accountManage(); break; case 'accountUpgrade': await openUpgrade(); break; + // The sign-in card's second button: the same setting the model picker's BYOK row opens. + case 'byokSettings': vscode.commands.executeCommand('workbench.action.openSettings', 'levelcode.ai.providerMode'); break; case 'openExternal': await openLegal(msg.target); break; case 'copy': try { await vscode.env.clipboard.writeText(String(msg.text || '')); } catch (e) { /* clipboard unavailable */ } break; case 'retry': if (lastAgentGoal && !abort) { dbg('retry', { goalChars: lastAgentGoal.length }); await agentFlow(lastAgentGoal); } break; @@ -2994,6 +3079,11 @@ async function openWorkspaceFile(rel) { } function activate(context) { + // A window that comes back after a while away may have outlived its access token (8 h). Re-check + // on focus, throttled, so the expiry is found before the next message rather than by it. + context.subscriptions.push(vscode.window.onDidChangeWindowState((st) => { + if (st.focused && Date.now() - lastSessionCheck > 10 * 60 * 1000) { checkCloudSession('focus').catch(() => {}); } + })); ctx = context; // Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a // contributed view, but the panel still needs the one object that owns wire()/makeLive(). diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 06884cc..9c5adda 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -362,6 +362,8 @@ .upgradecard .ucbtn.primary:hover { filter: brightness(1.1); } .upgradecard .ucbtn.ghost { background: transparent; border: 1px solid var(--border); color: var(--vscode-foreground); } .upgradecard .ucbtn.ghost:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.14)); } + /* session expired — the upgrade card's shape, because it too ends in one button the user wants to press */ + .sessioncard .uchead .ci { color: var(--accent); } /* our-side outage notice — neutral, NO accent, NO CTA (it is not the user's account/usage) */ .noticecard { border: 1px solid var(--border); border-radius: 12px; margin: 8px 2px; padding: 13px 15px 14px; background: var(--field-bg); } .noticecard .uchead { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; margin-bottom: 6px; } @@ -2733,6 +2735,27 @@ log.appendChild(card); scrollIfStuck(); card.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => vscode.postMessage({ type: 'accountUpgrade' }); }); } + // The cloud session is over (the refresh token expired — 30 days without use, or a sign-out + // elsewhere). This is the ONE failure the user can fix in a click, so it gets a button, not red + // text: the raw "API 401: Signature has expired" this replaces told them nothing about what to do, + // while the account popover beside it still said they were signed in. + function isSessionExpired(m){ return !!(m && m.code === 'session_expired'); } + let sessionCard = null; + function addSignInCard(m){ + clearStatus(); finishAgentBubble(); closeGroup(); + if (sessionCard && sessionCard.isConnected){ sessionCard.remove(); } // one card, however many paths find the expiry + const who = m && m.name ? 'Welcome back, ' + esc(m.name) + '.' : ''; + const card = document.createElement('div'); card.className = 'upgradecard sessioncard'; + card.innerHTML = + '
' + codicon('shield') + 'Your session has expired
' + + '
' + (who ? who + ' ' : '') + 'Sign in again to keep using LevelCode Cloud — your chat and files here are untouched.
' + + '
' + + '
'; + log.appendChild(card); scrollIfStuck(); + card.querySelector('[data-act="signin"]').onclick = () => vscode.postMessage({ type: 'accountSignIn' }); + card.querySelector('[data-act="byok"]').onclick = () => vscode.postMessage({ type: 'byokSettings' }); + sessionCard = card; + } // Our-side outage / transient issue → a neutral "service" notice, NOT the red error and NOT the // upgrade card (the gateway already sanitized the message; this just picks a calmer presentation). function isServiceIssue(m){ @@ -4121,7 +4144,8 @@ else if (m.type === 'assistantError'){ pending = ''; flushAll = false; doneSignaled = false; const cap = capReachedInfo(m.message); - if (cap){ current = null; addUpgradeCard(cap); } + if (isSessionExpired(m)){ current = null; addSignInCard(m); } + else if (cap){ current = null; addUpgradeCard(cap); } else if (isServiceIssue(m)){ current = null; addServiceCard(m); } else { const html = '' + esc(m.message) + ''; @@ -4130,6 +4154,7 @@ } setStreaming(false); } + else if (m.type === 'sessionExpired'){ addSignInCard(m); } else if (m.type === 'activeFile'){ activeFileLabel = m.label; renderChips(); } else if (m.type === 'contextFiles'){ ctxFiles = m.files || []; renderChips(); } else if (m.type === 'autoContext'){ addAutoCtx(m.names); } @@ -4167,9 +4192,10 @@ else if (m.type === 'compactStart'){ ctxCompact = 'busy'; renderCtxCard(); } else if (m.type === 'compactResult'){ onCompactResult(m); } else if (m.type === 'debug'){ addDebug(m); } - else if (m.type === 'account'){ renderAccount(m); if (m.open) openAccount(); } + else if (m.type === 'account'){ + if (m.signedIn && sessionCard && sessionCard.isConnected){ sessionCard.remove(); sessionCard = null; } renderAccount(m); if (m.open) openAccount(); } else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); } - else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '' + esc(m.message) + ''); } } + else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (isSessionExpired(m)){ addSignInCard(m); } else if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '' + esc(m.message) + ''); } } else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); } else if (m.type === 'context'){ selLabel = m.label; renderChips(); } else if (m.type === 'clearContext'){ selLabel = null; renderChips(); } diff --git a/extensions/levelcode-ai/providers/session.js b/extensions/levelcode-ai/providers/session.js new file mode 100644 index 0000000..78bdff8 --- /dev/null +++ b/extensions/levelcode-ai/providers/session.js @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · LevelCode Cloud session state (pure) + * + * The editor keeps two credentials for the cloud: a short-lived access token (8 h) and a refresh + * token (30 days, rotated on use). Everything here is the arithmetic and classification around + * them — no VS Code, no IO — so it can be unit-tested (test/session.test.js) and so the host can + * answer "is this session still alive?" WITHOUT a network round-trip, by reading the access + * token's own `exp` claim. A JWT's payload is plain base64url JSON; reading it is not verifying it + * (the server does that), it is only asking the token when it says it dies. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +/** Refresh this far ahead of expiry, so a request issued right now cannot land after the deadline. */ +const EXPIRY_MARGIN_MS = 5 * 60 * 1000; + +/** The sentence shown when the session is gone. Mirrors the server's own wording. */ +const SESSION_EXPIRED_MESSAGE = 'Your LevelCode Cloud session has expired. Sign in again to continue.'; + +/** + * The `exp` claim of a JWT as epoch milliseconds, or null when the token is not a JWT, carries no + * `exp`, or is unreadable. Never throws: a malformed token is a reason to re-check with the server, + * not a reason to crash the editor. + * @param {string|null|undefined} token + * @returns {number|null} + */ +function jwtExpiresAt(token) { + try { + const parts = String(token || '').split('.'); + if (parts.length !== 3) { return null; } + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(Buffer.from(b64 + '='.repeat((4 - b64.length % 4) % 4), 'base64').toString('utf8')); + const exp = Number(payload && payload.exp); + return Number.isFinite(exp) && exp > 0 ? exp * 1000 : null; + } catch { return null; } +} + +/** + * Whether an access token should be refreshed before use: it expires within the margin, has + * already expired, or cannot be read at all (an unreadable token is treated as expired — the + * server would reject it anyway, and asking first is cheaper than a failed request). + * @param {string|null|undefined} token + * @param {number} [nowMs] + */ +function accessNeedsRefresh(token, nowMs = Date.now()) { + const exp = jwtExpiresAt(token); + return exp === null || exp - nowMs <= EXPIRY_MARGIN_MS; +} + +/** + * Classify the outcome of POST /auth/refresh so the caller knows whether the SESSION is over, or + * only this attempt failed. + * + * 'ok' — a new access token was issued + * 'expired' — the server rejected the refresh token itself (401): the session is over, sign in again + * 'retry' — anything else: offline, 5xx, a malformed reply. Keep the tokens; nothing is known yet. + * + * Only an explicit 401 ends the session. Clearing credentials on a network blip would log a user + * out for closing their laptop on the train. + * @param {{status?:number, body?:any}|null|undefined} res + * @returns {'ok'|'expired'|'retry'} + */ +function classifyRefresh(res) { + if (!res) { return 'retry'; } + if (res.status === 401) { return 'expired'; } + if (res.status >= 200 && res.status < 300 && res.body && (res.body.access || res.body.token)) { return 'ok'; } + return 'retry'; +} + +/** + * True when a provider error means the cloud session is dead — a gateway 401 that a refresh could + * not recover. The adapter formats failures as `