Files
EventSnap/frontend/src/lib/auth.ts
fabi a53729a704 fix(frontend): park uploads that cannot succeed, and stop two false signals
The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on
its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which
purged the blob and moved the row to `blocked` — a terminal state with no retry button — so
lifting a ban restored everything except the photo actually in flight. Ban and release are
now distinct codes that keep the blob, charge no attempt, and tell the guest what has to
happen. `releaseResolvedParks` drains them at boot from /me/context, because the live
`user-shown` / `event-opened` events only reach a tab that was open when the host acted,
and the usual sequence is the other way round.

Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without
deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds
`lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop
polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could
never clear, each tap costing a full filtered refetch. It now dedupes in both branches.

The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id
AND user_id from every payload, so by the time anything was deleted or anyone banned, their
ids were already marked delivered from ordinary traffic about live content. The
`deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a
half-open socket undetected while a host moderated into a feed nobody was listening to.
Each event now records only the id its own clause tests.

Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin`
prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:44:26 +02:00

184 lines
6.4 KiB
TypeScript

import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin';
const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
export const isAuthenticated = writable(false);
/**
* Reactive mirror of `localStorage[PIN_KEY]`. Subscribers (the My Account page) see
* the PIN change immediately when an SSE `pin-reset` event invalidates it from any
* route — keeps the displayed PIN consistent with the server hash.
*/
export const currentPin = writable<string | null>(null);
// Guest auth lives in localStorage (persists across sessions — a guest returning to the
// event days later stays signed in). The ADMIN token lives in sessionStorage instead
// (USER_JOURNEYS §11.1): its tighter 1-day lifetime is meant to bound exposure on a shared
// "event laptop", and sessionStorage clears on tab/browser close, which localStorage defeats.
// Reads check sessionStorage FIRST so an active admin token wins over any leftover guest
// token on the same device.
function readAuth(key: string): string | null {
if (!browser) return null;
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
}
export function getToken(): string | null {
return readAuth(TOKEN_KEY);
}
export function getPin(): string | null {
if (!browser) return null;
return localStorage.getItem(PIN_KEY);
}
/**
* Clear the locally-cached recovery PIN. Called when the server resets it (host
* action) — the cached plaintext no longer matches the bcrypt hash, so showing it
* would mislead the user.
*/
export function clearPin(): void {
if (!browser) return;
localStorage.removeItem(PIN_KEY);
currentPin.set(null);
}
export function getUserId(): string | null {
return readAuth(USER_ID_KEY);
}
export function getDisplayName(): string | null {
return readAuth(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp ? new Date(payload.exp * 1000) : null;
} catch {
return null;
}
}
export function setAuth(
jwt: string,
pin: string | null,
userId: string,
displayName?: string
): void {
if (!browser) return;
// DISPLACE any resident admin session: reads are sessionStorage-first, so a leftover
// admin token there would otherwise shadow this guest login and hand the guest admin
// rights on a shared device. Clear the sessionStorage identity so exactly one is resident.
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(USER_ID_KEY);
sessionStorage.removeItem(DISPLAY_NAME_KEY);
localStorage.setItem(TOKEN_KEY, jwt);
if (pin) {
localStorage.setItem(PIN_KEY, pin);
currentPin.set(pin);
}
localStorage.setItem(USER_ID_KEY, userId);
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
fireSetAuthHooks();
}
/**
* Persist an ADMIN session in sessionStorage (USER_JOURNEYS §11.1) rather than localStorage,
* so the elevated credential does not survive a tab/browser close on a shared device — the
* point of the tighter 1-day admin token. Admins have no recovery PIN. `getToken` reads
* sessionStorage first, so this wins over any leftover guest token on the same device.
*/
export function setAdminAuth(jwt: string, userId: string, displayName?: string): void {
if (!browser) return;
// DISPLACE any resident guest session so exactly one identity is resident (symmetric with
// setAuth). Keep the guest PIN — it's deliberately preserved for later recovery, and the
// admin has none. Reads are sessionStorage-first, so the admin token now wins cleanly.
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_ID_KEY);
localStorage.removeItem(DISPLAY_NAME_KEY);
sessionStorage.setItem(TOKEN_KEY, jwt);
sessionStorage.setItem(USER_ID_KEY, userId);
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
fireSetAuthHooks();
}
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
// here at import-time so they get reset on every clearAuth path — both the
// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps
// clearAuth the single source of truth without baking dependencies on every
// downstream store into this module (which would create circular imports).
const clearAuthHooks: Array<() => void> = [];
export function onClearAuth(fn: () => void): void {
clearAuthHooks.push(fn);
}
// The mirror of `onClearAuth`, for stores that must be RE-SEEDED when a new identity
// arrives rather than merely cleared. Without it, anything derived from the token
// survives a logout→login in the same tab: `goto()` is a client-side navigation, so no
// module is re-imported and no `onMount` re-runs, and the previous user's value simply
// stays. Fires after the new token is resident, so hooks can read it.
const setAuthHooks: Array<() => void> = [];
export function onSetAuth(fn: () => void): void {
setAuthHooks.push(fn);
}
function fireSetAuthHooks(): void {
for (const fn of setAuthHooks) {
try {
fn();
} catch {
/* hook failure is non-fatal */
}
}
}
export function clearAuth(): void {
if (!browser) return;
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in
// sessionStorage; either could be present, and a stale one must not linger.
for (const store of [localStorage, sessionStorage]) {
store.removeItem(TOKEN_KEY);
store.removeItem(USER_ID_KEY);
// Clear the display name too — on a shared device the next user shouldn't see
// the previous guest's name pre-filled on /join.
store.removeItem(DISPLAY_NAME_KEY);
}
// PIN is intentionally kept so the user can recover
isAuthenticated.set(false);
// Hooks fire in registration order. Keep them dependency-free of each other —
// if you ever need ordering, introduce a priority field rather than relying
// on import-load timing, which is fragile across refactors.
for (const fn of clearAuthHooks) {
try {
fn();
} catch {
/* hook failure is non-fatal */
}
}
}
export function getRole(): 'guest' | 'host' | 'admin' | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.role ?? null;
} catch {
return null;
}
}
export function initAuth(): void {
if (!browser) return;
isAuthenticated.set(!!getToken());
currentPin.set(getPin());
}