Preserve native sessions during temporary server outages
This commit is contained in:
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user