Preserve native sessions during temporary server outages
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:51:10 +01:00
parent 58df8d06e1
commit 7aef4171af
5 changed files with 79 additions and 6 deletions
+11
View File
@@ -9,3 +9,14 @@ calls Twitch directly, executes plugin code, or treats local UI state as
authoritative. Local overlay drafts and display preferences are recoverable
presentation data only; the server remains authoritative for publication,
authorization, Twitch state, and durable history.
# Saved-session recovery
Native startup verifies the keyring session before loading authenticated data.
HTTP 401/403 clears the saved session and requests login. Server failures retain
the credential and retry immediately, then after 3, 10, 15, and repeatedly 30
seconds. Manual login and logout cancel pending restoration. Dashboard-loading
failures do not invalidate a successfully verified session.
Run `node --test src/session-recovery.test.js` for response classification and
retry-schedule coverage. This is not a substitute for native AppImage acceptance.
+36 -6
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
let sessionRestoreTimer: ReturnType<typeof setTimeout> | undefined;
let sessionRestoreAttempt = 0;
let sessionRestoreGeneration = 0;
type ClientSettings = { server_address: string; username: string };
@@ -63,30 +68,51 @@
async function restoreNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress) return;
const generation = ++sessionRestoreGeneration;
clearTimeout(sessionRestoreTimer);
let address = serverAddress;
const account = username;
const isCurrentRestore = () => generation === sessionRestoreGeneration && address === serverAddress && account === username;
try {
const safeServerAddress = validatedServerAddress(serverAddress);
serverAddress = safeServerAddress;
address = safeServerAddress;
const settings: ClientSettings = { server_address: safeServerAddress, username };
const storedSession = await invoke<string | null>('load_server_session', { settings });
if (!storedSession) return;
if (!storedSession || !isCurrentRestore()) return;
const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, {
headers: { authorization: `Bearer ${storedSession}` },
});
if (!response.ok) {
if (!isCurrentRestore()) return;
const state = sessionResponseState(response.status);
if (state === 'revoked') {
await invoke('clear_server_session', { settings });
if (!isCurrentRestore()) return;
sessionToken = '';
authenticated = false;
authError = 'Authentication revoked. Log in again.';
return;
}
if (state !== 'valid') throw new Error('Session verification unavailable');
const result: { must_replace_credentials: boolean } = await response.json();
if (!isCurrentRestore()) return;
sessionRestoreAttempt = 0;
authError = '';
sessionToken = storedSession;
authenticated = true;
mustReplaceCredentials = result.must_replace_credentials;
connectionState = 'CONNECTED';
await loadAuthenticatedData();
await loadAuthenticatedData().catch(() => {
authError = 'Authenticated, but dashboard data could not be loaded.';
});
if (mustReplaceCredentials) newUsername = username;
} catch {
// A stale keyring session must not prevent the normal login form from opening.
sessionToken = '';
authenticated = false;
if (!isCurrentRestore()) return;
// Transport/server failures are not proof of revocation: preserve the keyring.
authError = 'Server unavailable. Retrying saved session.';
sessionRestoreTimer = setTimeout(() => {
if (isCurrentRestore()) void restoreNativeSession();
}, sessionRetryDelay(sessionRestoreAttempt++));
}
}
@@ -336,6 +362,8 @@
}
async function login() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
if (connectionState === 'INCOMPATIBLE') {
authError = 'This client version is not compatible with the server.';
return;
@@ -449,6 +477,8 @@
}
async function logout() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
realtimeSocket?.close();
realtimeSocket = undefined;
realtimeState = 'DISCONNECTED';
+14
View File
@@ -0,0 +1,14 @@
/** Classify authoritative rejection separately from temporary server failures.
* @param {number} status HTTP response status.
*/
export function sessionResponseState(status) {
if (status === 401 || status === 403) return 'revoked';
return status >= 200 && status < 300 ? 'valid' : 'unavailable';
}
/** Milliseconds before each retry; attempt zero is the immediate first retry.
* @param {number} attempt Zero-based retry index.
*/
export function sessionRetryDelay(attempt) {
return [0, 3000, 10000, 15000][attempt] ?? 30000;
}
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
test('only explicit authentication rejection invalidates a saved session', () => {
for (const status of [401, 403]) assert.equal(sessionResponseState(status), 'revoked');
for (const status of [404, 429, 500, 502, 503])
assert.equal(sessionResponseState(status), 'unavailable');
assert.equal(sessionResponseState(200), 'valid');
});
test('saved session retries follow the shared recovery schedule', () => {
assert.deepEqual(
Array.from({ length: 7 }, (_, attempt) => sessionRetryDelay(attempt)),
[0, 3000, 10000, 15000, 30000, 30000, 30000],
);
});
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,