fix(spa): don't log user out on permalink reload

App.svelte's onMount used to call api.logout() whenever it detected an
existing server session at boot, on the theory that "we can't decrypt
without the DEK so the session is half-broken anyway." That destroyed
the user's session on every full-page load — including clicking the
plain <a href="/a/<id>"> permalink in ActivityRow, which navigates
the browser instead of routing client-side.

Symptom reported by the user: clicking a permalink for a private
activity returned "fant ikke aktiviteten" (because the now-anonymous
caller can't read private rows), and the back button left them logged
out (because session.user was never re-hydrated).

Fix: keep the server session on reload and re-hydrate session.user
from /me. The DEK is still intentionally absent (it never persists),
so private rows that the SPA can't decrypt now show a clear
"logg inn på nytt med passordet ditt for å vise det" message
instead of a stuck "Dekrypterer …" spinner. Public / semi / friends
content keeps working without re-authentication.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-05-25 17:35:27 +02:00
commit 03ac99e555
3 changed files with 28 additions and 6 deletions

View file

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { ready } from './lib/crypto';
import { api, ApiError } from './lib/api';
import { session } from './lib/session.svelte';
import { session, setSessionUserOnly } from './lib/session.svelte';
import { logout } from './lib/auth';
import Login from './components/Login.svelte';
import Signup from './components/Signup.svelte';
@ -112,14 +112,19 @@
.then((s) => { selfRegistryEnabled = s.self_registry_enabled; })
.catch(() => { /* default open if the call fails */ });
// Probe the server for an existing session, but it doesn't change which
// URL we're rendering — only what content we'd show on /home.
// Probe the server for an existing session and rehydrate client-side
// state. The DEK is intentionally NOT persisted (it lives only in
// memory), so after a reload private rows can't be decrypted until the
// user re-enters their password. We used to log out here to avoid the
// "logged in but can't decrypt" state, but that broke permalink
// navigation: clicking `<a href="/a/<id>">` causes a full reload, this
// handler ran, and the session was destroyed — kicking the user out
// mid-click. Now: keep the server session (so non-private content,
// hearts, bookmarks, etc. all keep working) and just leave dek=null.
try {
const me = await api.me();
defaultEmail = me.email;
// Reloaded with a server session but no DEK. Drop the server session;
// we can't decrypt anything without the password anyway.
await api.logout();
setSessionUserOnly(me);
} catch {
// No session — fine.
}

View file

@ -224,6 +224,13 @@
{@render locationLine(decrypted.loc_label, null, null)}
{/if}
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
{:else if !session.dek}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
<span class="vis-badge private">Privat</span>
</h3>
<p class="muted">
Innholdet er kryptert. Logg inn på nytt med passordet ditt for å vise det.
</p>
{:else}
<p class="muted">Dekrypterer …</p>
{/if}

View file

@ -19,6 +19,16 @@ export function setSession(user: MeResponse, dek: Uint8Array): void {
session.dek = dek;
}
/**
* Rehydrate the user identity from /me on page load. The DEK is intentionally
* left null it never persists across reloads. Callers needing private
* decryption must run the full login flow to re-derive it.
*/
export function setSessionUserOnly(user: MeResponse): void {
session.user = user;
session.dek = null;
}
export function clearSession(): void {
if (session.dek) {
// Best-effort zeroisation. The buffer may have been copied internally by