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
|
authoritative. Local overlay drafts and display preferences are recoverable
|
||||||
presentation data only; the server remains authoritative for publication,
|
presentation data only; the server remains authoritative for publication,
|
||||||
authorization, Twitch state, and durable history.
|
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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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 };
|
type ClientSettings = { server_address: string; username: string };
|
||||||
|
|
||||||
@@ -63,30 +68,51 @@
|
|||||||
|
|
||||||
async function restoreNativeSession() {
|
async function restoreNativeSession() {
|
||||||
if (!nativeClientAvailable() || !username || !serverAddress) return;
|
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 {
|
try {
|
||||||
const safeServerAddress = validatedServerAddress(serverAddress);
|
const safeServerAddress = validatedServerAddress(serverAddress);
|
||||||
serverAddress = safeServerAddress;
|
serverAddress = safeServerAddress;
|
||||||
|
address = safeServerAddress;
|
||||||
const settings: ClientSettings = { server_address: safeServerAddress, username };
|
const settings: ClientSettings = { server_address: safeServerAddress, username };
|
||||||
const storedSession = await invoke<string | null>('load_server_session', { settings });
|
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`, {
|
const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, {
|
||||||
headers: { authorization: `Bearer ${storedSession}` },
|
headers: { authorization: `Bearer ${storedSession}` },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!isCurrentRestore()) return;
|
||||||
|
const state = sessionResponseState(response.status);
|
||||||
|
if (state === 'revoked') {
|
||||||
await invoke('clear_server_session', { settings });
|
await invoke('clear_server_session', { settings });
|
||||||
|
if (!isCurrentRestore()) return;
|
||||||
|
sessionToken = '';
|
||||||
|
authenticated = false;
|
||||||
|
authError = 'Authentication revoked. Log in again.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state !== 'valid') throw new Error('Session verification unavailable');
|
||||||
const result: { must_replace_credentials: boolean } = await response.json();
|
const result: { must_replace_credentials: boolean } = await response.json();
|
||||||
|
if (!isCurrentRestore()) return;
|
||||||
|
sessionRestoreAttempt = 0;
|
||||||
|
authError = '';
|
||||||
sessionToken = storedSession;
|
sessionToken = storedSession;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
mustReplaceCredentials = result.must_replace_credentials;
|
mustReplaceCredentials = result.must_replace_credentials;
|
||||||
connectionState = 'CONNECTED';
|
connectionState = 'CONNECTED';
|
||||||
await loadAuthenticatedData();
|
await loadAuthenticatedData().catch(() => {
|
||||||
|
authError = 'Authenticated, but dashboard data could not be loaded.';
|
||||||
|
});
|
||||||
if (mustReplaceCredentials) newUsername = username;
|
if (mustReplaceCredentials) newUsername = username;
|
||||||
} catch {
|
} catch {
|
||||||
// A stale keyring session must not prevent the normal login form from opening.
|
if (!isCurrentRestore()) return;
|
||||||
sessionToken = '';
|
// Transport/server failures are not proof of revocation: preserve the keyring.
|
||||||
authenticated = false;
|
authError = 'Server unavailable. Retrying saved session.';
|
||||||
|
sessionRestoreTimer = setTimeout(() => {
|
||||||
|
if (isCurrentRestore()) void restoreNativeSession();
|
||||||
|
}, sessionRetryDelay(sessionRestoreAttempt++));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +362,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
sessionRestoreGeneration += 1;
|
||||||
|
clearTimeout(sessionRestoreTimer);
|
||||||
if (connectionState === 'INCOMPATIBLE') {
|
if (connectionState === 'INCOMPATIBLE') {
|
||||||
authError = 'This client version is not compatible with the server.';
|
authError = 'This client version is not compatible with the server.';
|
||||||
return;
|
return;
|
||||||
@@ -449,6 +477,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
|
sessionRestoreGeneration += 1;
|
||||||
|
clearTimeout(sessionRestoreTimer);
|
||||||
realtimeSocket?.close();
|
realtimeSocket?.close();
|
||||||
realtimeSocket = undefined;
|
realtimeSocket = undefined;
|
||||||
realtimeState = 'DISCONNECTED';
|
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",
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user