Files
Skald-Circle/web/lib/session-expiry.js
T
Daniele 5fb5854ff2
Nightly Build / build (push) Successful in 8m11s
fix(auth): stop the re-login dialog hijacking the login screen
On a cold load with no session, both shells mount every component before
their boot auth check resolves, so a dozen gated /api calls 401 in
parallel and the fetch watch raised the re-login dialog over the login
screen the boot check was about to show (.relogin-backdrop is z-10000,
.login-page z-9999). The user typed their password into the modal, which
only closes itself on success — revealing the login page still up with
the app hidden, so they were asked a second time and only a manual
reload got them in.

The dialog is for a session that dies under an open tab, so gate it on
one having ever been established. Recognising that is passive, in the
same fetch wrapper: mobile.html probes /api/auth/me from a classic
inline script that runs before this module exists, so an explicit marker
per shell would never fire there and the dialog would be dead on mobile.
Any 2xx from a gated endpoint proves a session; only the routes
guard.rs::is_public lets through unauthenticated are excluded.

Knock-on: with the report now a no-op on a cold load, the chat's
reconnect loop no longer stopped on it. Retry only in the native shell,
which authenticates on its own — everywhere else something is already
asking for a password.
2026-08-10 12:23:45 +01:00

146 lines
6.2 KiB
JavaScript

/**
* Session-expiry detection for an already-open tab.
*
* A session lives in the server's RAM (blueprint §9), so a restart invalidates
* every token while the browser keeps happily sending its cookie. From that
* moment every gated `/api` call answers 401 and the chat socket is refused at
* the upgrade — and a refused upgrade is indistinguishable, in `onclose`, from a
* dropped wifi. That is why the chat used to sit forever behind "Not connected —
* reconnecting": it was reconnecting, correctly, to a server that will never
* accept it again.
*
* This module is the single place that turns "the server says I am nobody" into
* a fact the app can act on: the `auth-expired` event, answered by the re-login
* dialog (`components/session-relogin.js`), and `auth-restored` once a new
* session is in hand. Nothing here touches the DOM — reporting and reacting stay
* apart, so a second reactor (a banner, a mobile-specific screen) costs nothing.
*/
let expired = false;
/**
* Whether this page has ever held a session.
*
* The dialog answers "your session died **while you were using the app**", and
* that premise is not free: on a cold load with no session at all, the shells
* mount every component before their boot auth check resolves, so a dozen `/api`
* calls 401 in parallel and used to raise the dialog *over* the login screen the
* boot check was about to show (`.relogin-backdrop` is z-10000, `.login-page`
* z-9999). Logging in through that modal only closes the modal — the login page
* underneath stayed up with the app hidden, so the user was asked for their
* password a second time and a manual reload was the only way through.
*
* So a 401 is only an *expiry* once something proved we had a session; before
* that it is the ordinary "not logged in yet", which the boot check owns.
*/
let established = false;
// The native mobile shell authenticates in the background and must never be
// gated by a web login form (the rule `mobile.html`'s bootstrap already states).
// Guarding the report rather than each producer means no future caller can
// reintroduce the dialog there.
const NATIVE_SHELL = new URLSearchParams(location.search).get('native') === 'true';
/**
* True in the native mobile shell, which authenticates on its own and gets no
* dialog. Exported because "no dialog is coming" is not the same fact as "the
* session is fine": a caller deciding whether to keep retrying needs to tell the
* two apart (see `chat-session.js::_scheduleReconnect`).
*/
export function isNativeShell() {
return NATIVE_SHELL;
}
/** True once the server has told us this browser has no session anymore. */
export function isSessionExpired() {
return expired;
}
/**
* Report a lost session. Idempotent and one-way: the first call fires the
* `auth-expired` window event, every later one is a no-op — several components
* discover the same 401 at once, and the dialog must be raised once.
*
* A no-op while no session was ever established (see [`established`]): there is
* nothing to renew, and the shell's own boot check is already showing the login
* screen.
*/
export function notifySessionExpired() {
if (expired || !established || NATIVE_SHELL) return;
expired = true;
window.dispatchEvent(new CustomEvent('auth-expired'));
}
/**
* Report that a fresh session has been obtained (the re-login dialog succeeded).
* Re-arms the detector and fires `auth-restored`, on which the live connections
* that gave up — the chat socket above all — pick themselves back up.
*/
export function notifySessionRestored() {
if (!expired) return;
expired = false;
window.dispatchEvent(new CustomEvent('auth-restored'));
}
/**
* Ask the server whether this browser still has a session.
*
* Returns `'ok'`, `'expired'`, or `'unknown'` when the server could not be
* reached — the caller must treat that third case as "keep retrying", never as a
* logout: a box that is merely down comes back, and throwing the user at a login
* form they cannot submit would be strictly worse than waiting.
*/
export async function probeSession() {
try {
const res = await fetch('/api/auth/me');
if (res.status === 401) return 'expired';
return res.ok ? 'ok' : 'unknown';
} catch {
return 'unknown';
}
}
/**
* Wrap `window.fetch` so that a 401 from any gated `/api` endpoint reports an
* expired session, wherever in the app it happens.
*
* A wrapper rather than a helper every call site opts into: the components call
* `fetch` directly in dozens of places, and a seam that has to be remembered is
* one that will be forgotten by the next page. The auth endpoints are excluded
* because 401 is a *normal* answer there — `auth/me` is the "am I logged in?"
* probe and `auth/login` answers it to a wrong password; treating either as an
* expiry would raise the login screen from the login screen.
*
* The same wrapper is where a session is recognised as **established**, and it
* is deliberately passive rather than a call the two shells each make after
* their boot check: `mobile.html` probes `/api/auth/me` from a classic inline
* script that runs *before* this module exists, so an explicit marker would
* never fire there and the dialog would be dead on mobile. Any success from a
* gated endpoint proves a live session (the gate is deny-by-default), which is
* why only the routes `guard.rs::is_public` lets through unauthenticated are
* excluded — `auth/me` and `auth/login` answering 200 *do* prove one.
*/
export function installSessionExpiryWatch() {
const native = window.fetch.bind(window);
window.fetch = async (input, init) => {
const res = await native(input, init);
const url = typeof input === 'string' ? input : (input?.url ?? '');
const path = url.startsWith('http') ? new URL(url).pathname : url;
if (res.status === 401) {
if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !path.startsWith('/api/setup/')) {
notifySessionExpired();
}
} else if (res.ok && provesSession(path)) {
established = true;
}
return res;
};
}
/** Whether a 2xx on this path can only have come from an authenticated call. */
function provesSession(path) {
return path.startsWith('/api/')
&& !path.startsWith('/api/setup/')
&& path !== '/api/auth/logout';
}