fix(frontend): three dead ends a guest cannot get out of

**1. The cached PIN could never be cleared after a host reset.**
`/recover` clears a rejected cached PIN only when the submitted name is the one
this device belongs to — narrowed on this branch so a guest who mistypes their
own name does not lose the only copy of their PIN (the server keeps just the
bcrypt). But it compared against `DISPLAY_NAME_KEY`, which `clearAuth` deletes
for shared-device privacy — one step BEFORE the guest ever reaches that screen:

  host taps "PIN zurücksetzen" -> the backend also revokes every session for that
  user -> the guest's next request 401s -> clearAuth -> redirect to /join -> they
  go to /recover, where the field is pre-filled with the dead PIN and the guard
  can never fire again

Since a 4-digit value auto-submits, every correction burns another of the four
wrong-PIN attempts the shared venue IP allows per 15 minutes. The PIN's owner is
now stored WITH the PIN and survives alongside it, with a fallback to the auth
display name for devices that cached a PIN before this key existed.

**2. Every layout-level SSE handler waited on the `/me/context` retry.**
The retry was awaited inside the same `onMount` that registers `pin-reset`,
`user-hidden`/`user-shown`, `event-closed`/`event-opened` and `event-updated`.
Worst case is a 20s timeout + 2s backoff + a second 20s timeout: ~42s with an
empty handler list, on exactly the wifi the retry exists for. Five of the six
self-heal; `pin-reset` does not, and a missed one leaves a dead PIN displayed in
"Mein Konto" and pre-filling /recover — the same state as (1), reached from the
other end. Detached, since nothing below reads its result.

**3. `crypto.randomUUID` was on the join critical path.**
It needs Safari >= 15.4 / Chrome >= 92 AND a secure context. The queue already
depended on it, so an old phone previously joined and browsed and only failed at
upload — degraded but survivable. Minting an idempotency key at join turned that
into a `TypeError` caught by the generic handler and rendered as "Ein Fehler ist
aufgetreten." on every retry: cannot join, cannot browse, and /recover is no help
because there is no account yet. The one screen where a hard failure has no way
out at all. Falls back to `crypto.getRandomValues` with the RFC 4122 version and
variant bits set; `Math.random` is deliberately NOT a further fallback, since a
collision between two guests would replay one guest's join or upload onto
another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-13 19:45:46 +02:00
parent 9bae5d77ed
commit ee70eec094
8 changed files with 196 additions and 20 deletions

View File

@@ -3,6 +3,23 @@ import { browser } from '$app/environment';
const TOKEN_KEY = 'eventsnap_jwt';
const PIN_KEY = 'eventsnap_pin';
/**
* Whose PIN `PIN_KEY` holds — and it is a SEPARATE key from `DISPLAY_NAME_KEY` on purpose.
*
* `/recover` only clears a rejected cached PIN when the name submitted is the one this device
* belongs to, so that a guest who mistypes their own name does not lose the only copy of their PIN
* (the server keeps just the bcrypt). That check read `DISPLAY_NAME_KEY` — which `clearAuth`
* deletes, for shared-device privacy, one step BEFORE the guest ever reaches `/recover`:
*
* host taps "PIN zurücksetzen" → the backend also revokes every session for that user
* (`host.rs`, `Session::delete_all_for_user`) → the guest's next request 401s → `clearAuth`
* → redirect to /join → the guest goes to /recover, where the field is PRE-FILLED with the
* dead PIN and can never be cleared, because the name it would be compared against is gone
*
* Since `clearAuth` deliberately keeps the PIN so the guest can recover, it must keep the PIN's
* owner too, or the pair is inconsistent and the guard is unreachable exactly when it is needed.
*/
const PIN_OWNER_KEY = 'eventsnap_pin_owner';
const USER_ID_KEY = 'eventsnap_user_id';
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
@@ -43,9 +60,22 @@ export function getPin(): string | null {
export function clearPin(): void {
if (!browser) return;
localStorage.removeItem(PIN_KEY);
localStorage.removeItem(PIN_OWNER_KEY);
currentPin.set(null);
}
/**
* The display name the cached PIN belongs to, or `null` if there is no cached PIN.
*
* Survives `clearAuth` alongside the PIN itself — see [`PIN_OWNER_KEY`]. Falls back to the auth
* display name for devices that cached a PIN before this key existed.
*/
export function getPinOwner(): string | null {
if (!browser) return null;
if (localStorage.getItem(PIN_KEY) === null) return null;
return localStorage.getItem(PIN_OWNER_KEY) ?? readAuth(DISPLAY_NAME_KEY);
}
export function getUserId(): string | null {
return readAuth(USER_ID_KEY);
}
@@ -81,6 +111,8 @@ export function setAuth(
localStorage.setItem(TOKEN_KEY, jwt);
if (pin) {
localStorage.setItem(PIN_KEY, pin);
// Stored with the PIN, not derived from it later — see `PIN_OWNER_KEY`.
if (displayName) localStorage.setItem(PIN_OWNER_KEY, displayName);
currentPin.set(pin);
}
localStorage.setItem(USER_ID_KEY, userId);