Ask whose computer this is, and believe the answer

Sign-out never cleared local storage. It stopped push, flushed settings and
removed the subscription -- that last one reasoned explicitly that a browser
left holding someone's mail becomes somebody else's next -- and then left the
settings cache and the recently-addressed list on disk. That list is other
people's addresses, and nothing ever removed it.

Clearing it on sign-out is now unconditional, because lending a laptop is the
same exposure as a public machine, only quieter. The keep-list is short and
deliberate: lastUser, which only a trusted device writes; the trust flag; and
the random push device id. Everything else goes, so a key added later is
forgotten by default rather than by nobody having thought about it.

"Keep me signed in on this device" defaulted to true, which assumed the answer
most costly to get wrong -- someone on a library machine got a thirty-day
cookie unless they noticed a ticked box. It now asks whose computer this is,
defaults to not yours, and says what each answer does. Untrusted means a
session cookie, nothing written locally, no push subscription, and a five
minute idle sign-out.

The idle timer is there because the alternative does not work: custom
beforeunload text was removed from browsers years ago, and no event fires at
all for walking away from a signed-in screen, which is the case that matters.
A timer needs nobody's cooperation.

Reads are gated as well as writes, since a machine trusted once still has the
residue; an untrusted sign-in purges it outright. The wire keeps calling this
`remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate
every session file on upgrade for a change of vocabulary.

Verified in a browser against the mock, not only in tests: untrusted sign-in
leaves localStorage empty through a full session including folder expansion;
trusted writes settings, recent and lastUser as before; sign-out clears recent
and settings while keeping lastUser; an untrusted sign-in afterwards clears
even that.
This commit is contained in:
2026-08-28 14:15:15 -07:00
parent 045dda109b
commit 0b01956535
10 changed files with 361 additions and 9 deletions
+59
View File
@@ -0,0 +1,59 @@
/**
* Sign out an untrusted device after a few minutes of inactivity.
*
* This exists because the alternative does not work. Asking someone to
* remember to sign out relies on the person, which is the part you cannot rely
* on when the machine is not theirs — and a browser cannot help: custom
* `beforeunload` text was removed years ago, and no event fires at all for the
* case that actually matters, which is walking away from a signed-in screen.
*
* A timer needs nobody's cooperation, so that is what this is.
*
* Trusted devices are left alone entirely: the whole point of saying a machine
* is yours is not being signed out of it.
*/
const IDLE_MS = 5 * 60 * 1000;
/** Coarse enough not to fire constantly, broad enough to catch a person reading. */
const ACTIVITY = ["mousedown", "keydown", "touchstart", "scroll", "focus"] as const;
let timer: ReturnType<typeof setTimeout> | null = null;
let onExpire: (() => void) | null = null;
function arm(): void {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const fn = onExpire;
stopIdleLogout();
fn?.();
}, IDLE_MS);
}
/**
* Reading a long message is not idleness, but it produces no events either.
* Visibility is the honest signal available: a hidden tab is one nobody is
* looking at, so the clock keeps running; showing it again is activity.
*/
function onVisibility(): void {
if (document.visibilityState === "visible") arm();
}
export function startIdleLogout(expire: () => void): void {
stopIdleLogout();
onExpire = expire;
for (const ev of ACTIVITY) window.addEventListener(ev, arm, { passive: true, capture: true });
document.addEventListener("visibilitychange", onVisibility);
arm();
}
export function stopIdleLogout(): void {
if (timer) clearTimeout(timer);
timer = null;
onExpire = null;
for (const ev of ACTIVITY) window.removeEventListener(ev, arm, { capture: true });
document.removeEventListener("visibilitychange", onVisibility);
}
/** Exported for tests, which should not wait five real minutes. */
export const IDLE_TIMEOUT_MS = IDLE_MS;