fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session

Follows the perf + security + user-flow work with a role/persona audit (guest, host,
admin, projector) and fixes across three review rounds. Highlights:

HIGH
- Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has
  no replay, so a client that missed it (esp. the unattended diashow) kept cycling a
  banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in
  /feed/delta; feed + diashow evict those users. Applied even on a truncated delta.

MEDIUM
- Locked-upload data loss: a photo staged offline during a lock/release was purged as a
  terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code;
  the queue keeps the blob and auto-resumes on the `event-opened` SSE.
- Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake.
- Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never
  in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host
  race can't hand out a conflicting PIN.
- Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren
  hidden on peer-host rows for non-admins (they always 403'd).
- Host dashboard shows live keepsake generation progress / ready state + link to /export.
- Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices.

Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq`
(migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation
temp/final paths, download follows `file_path`, prune only strictly-older generations.

LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles
galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401;
delta `>=` tie-break + 429 retry; misc copy/labels.

Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a
data-completeness test. Reconciles USER_JOURNEYS §9/§11.

Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit
tests, 155 e2e passing on chromium-desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-13 21:06:28 +02:00
parent 641174717c
commit 36fe59caa5
28 changed files with 1185 additions and 302 deletions

View File

@@ -15,9 +15,19 @@ export const isAuthenticated = writable(false);
*/
export const currentPin = writable<string | null>(null);
export function getToken(): string | 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 localStorage.getItem(TOKEN_KEY);
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
}
export function getToken(): string | null {
return readAuth(TOKEN_KEY);
}
export function getPin(): string | null {
@@ -37,13 +47,11 @@ export function clearPin(): void {
}
export function getUserId(): string | null {
if (!browser) return null;
return localStorage.getItem(USER_ID_KEY);
return readAuth(USER_ID_KEY);
}
export function getDisplayName(): string | null {
if (!browser) return null;
return localStorage.getItem(DISPLAY_NAME_KEY);
return readAuth(DISPLAY_NAME_KEY);
}
export function getExpiry(): Date | null {
@@ -69,6 +77,20 @@ export function setAuth(jwt: string, pin: string | null, userId: string, display
isAuthenticated.set(true);
}
/**
* 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;
sessionStorage.setItem(TOKEN_KEY, jwt);
sessionStorage.setItem(USER_ID_KEY, userId);
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
isAuthenticated.set(true);
}
// 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 "Event verlassen" button and the api.ts 401 auto-clear. Keeps
@@ -81,11 +103,15 @@ export function onClearAuth(fn: () => void): void {
export function clearAuth(): void {
if (!browser) return;
localStorage.removeItem(TOKEN_KEY);
localStorage.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.
localStorage.removeItem(DISPLAY_NAME_KEY);
// 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 —

View File

@@ -12,7 +12,7 @@
// delta into its in-memory list.
import { getToken } from './auth';
import { api } from './api';
import { api, ApiError } from './api';
import type { DeltaResponse } from './types';
type StreamTicketResponse = { ticket: string; server_time: string };
@@ -45,7 +45,9 @@ const KNOWN_EVENTS = [
'event-updated',
'export-progress',
'export-available',
'pin-reset'
'pin-reset',
// A guest asked a host to reset their PIN — hosts refresh their pending-request badge.
'pin-reset-requested'
] as const;
/**
@@ -197,7 +199,7 @@ function extractCreatedAt(data: string): string | undefined {
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
* SSE event will keep the feed moving.
*/
async function deltaFetchAndFan(since: string): Promise<void> {
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
try {
const response = await api.get<DeltaResponse>(
`/feed/delta?since=${encodeURIComponent(since)}`
@@ -206,11 +208,24 @@ async function deltaFetchAndFan(since: string): Promise<void> {
// reconnect resumes exactly where the server left off (no browser-clock skew).
lastEventTime = response.server_time;
dispatch('feed-delta', JSON.stringify(response));
} catch {
// non-fatal
} catch (e) {
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
// lose it. Retry the SAME `since` with backoff so the gap [since, now] is still
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
}
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
}
}
/** Bounded backoff for a rate-limited reconnect delta (see `deltaFetchAndFan`). */
const MAX_DELTA_RETRIES = 4;
const DELTA_RETRY_BASE_MS = 2000;
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
// `onopen` runs the delta fetch.
function handleVisibilityChange() {

View File

@@ -27,6 +27,10 @@ export interface FeedResponse {
export interface DeltaResponse {
uploads: FeedUpload[];
deleted_ids: string[];
// Users hidden (banned / uploads_hidden) since the cursor. A ban is not a soft-delete so
// it's absent from deleted_ids; the client evicts all uploads from these users, which
// replays a ban the client missed while disconnected (esp. the unattended projector).
hidden_user_ids: string[];
// True when the delta hit the backend cap and is only the newest slice of the
// gap — the client must full-refresh rather than merge (see feed-delta handler).
truncated: boolean;

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { classifyUploadStatus } from './upload-queue';
/**
* Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out:
* every 4xx except 429 was classified terminal, and a terminal item has its blob PURGED
* from IndexedDB. A 401 (a sliding session that lapsed, or a host PIN-reset) is a 4xx, so a
* guest's queued photos were irrecoverably destroyed the moment the `online` auto-resume
* fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`.
*/
describe('classifyUploadStatus', () => {
it('2xx → success', () => {
expect(classifyUploadStatus(200)).toBe('success');
expect(classifyUploadStatus(201)).toBe('success');
expect(classifyUploadStatus(299)).toBe('success');
});
it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
expect(classifyUploadStatus(401)).toBe('auth');
});
it('429 → rate_limit (back off, auto-resume)', () => {
expect(classifyUploadStatus(429)).toBe('rate_limit');
});
it('408 → transient (request timeout is retryable, not terminal)', () => {
expect(classifyUploadStatus(408)).toBe('transient');
});
it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
expect(classifyUploadStatus(400)).toBe('terminal');
expect(classifyUploadStatus(404)).toBe('terminal');
});
it('5xx / unexpected → transient (retryable, blob kept)', () => {
expect(classifyUploadStatus(500)).toBe('transient');
expect(classifyUploadStatus(502)).toBe('transient');
expect(classifyUploadStatus(503)).toBe('transient');
});
});

View File

@@ -1,6 +1,7 @@
import { openDB, type IDBPDatabase } from 'idb';
import { writable, get } from 'svelte/store';
import { getToken, getUserId } from './auth';
import { getToken, getUserId, clearAuth } from './auth';
import { onSseEvent } from './sse';
import { refreshQuota } from './quota-store';
export interface QueueItem {
@@ -8,6 +9,9 @@ export interface QueueItem {
userId: string;
fileName: string;
fileSize: number;
/** Source file's last-modified ms; part of the dedup key so two distinct photos that
* happen to share a name and byte length don't collapse into one. */
lastModified?: number;
mimeType: string;
caption: string;
hashtags: string;
@@ -50,6 +54,23 @@ function bindOnline(): void {
}
bindOnline();
// Resume the queue when the host reopens the event. A queued upload that hit a locked/
// released event kept its blob and parked as a retryable `error` (LockedError); the
// `event-opened` SSE flips it back to pending and re-drains, so a photo staged during a
// lock isn't lost across a reopen. One-way import (sse.ts never imports this module).
let sseBound = false;
function bindSse(): void {
if (sseBound || typeof window === 'undefined') return;
onSseEvent('event-opened', () => {
void (async () => {
await requeueRetriable();
await processQueue();
})();
});
sseBound = true;
}
bindSse();
/**
* Flip transient `error` items (5xx / a network drop that got marked before we could
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
@@ -141,6 +162,47 @@ class TerminalError extends Error {
*/
class NetworkError extends Error {}
/**
* The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is
* KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the
* instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume
* fires). We mark the item retryable and route to re-auth; once the user signs back in with
* the same identity (via /recover), `loadQueue` re-associates these entries by userId and
* they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob.
*/
class AuthError extends Error {}
/**
* A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen
* it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden`
* like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened`
* SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged
* as terminal and lost the moment the host reopened.
*/
class LockedError extends Error {}
/** Retry policy for an upload response status. */
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
/**
* Classify an upload HTTP status into a retry policy. Pure + exported so the
* data-loss-critical rules are unit-testable without an XHR/IndexedDB harness:
* - 401 → `auth` (session gone: KEEP the blob, re-auth — NEVER purge it)
* - 408 → `transient` (request timeout: retryable)
* - 429 → `rate_limit`(back off, auto-resume)
* - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges)
* - 2xx → `success`; 5xx/other → `transient`
* The one rule that must never regress: a 401 is `auth`, not `terminal`.
*/
export function classifyUploadStatus(status: number): UploadOutcome {
if (status >= 200 && status < 300) return 'success';
if (status === 429) return 'rate_limit';
if (status === 401) return 'auth';
if (status === 408) return 'transient';
if (status >= 400 && status < 500) return 'terminal';
return 'transient';
}
export async function loadQueue(): Promise<void> {
const database = await getDb();
const myUserId = getUserId();
@@ -173,29 +235,40 @@ export async function loadQueue(): Promise<void> {
})();
}
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
export async function addToQueue(
file: File,
caption: string,
hashtags: string
): Promise<void> {
): Promise<EnqueueResult> {
const database = await getDb();
const userId = getUserId();
if (!userId) return; // not authenticated — nothing to do
// Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than
// 'full' so the caller doesn't show a misleading "queue full" toast. Practically
// unreachable: the upload view sits behind a token guard.
if (!userId) return 'duplicate';
// Dedup: don't queue the same file twice while an identical one is still unsent
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
// (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user
// — including lastModified so two genuinely different photos sharing a name and byte
// length aren't silently collapsed into one.
const mine = get(queueItems).filter((i) => i.userId === userId);
const dup = mine.some(
(i) =>
i.fileName === file.name &&
i.fileSize === file.size &&
(i.lastModified ?? 0) === file.lastModified &&
(i.status === 'pending' || i.status === 'uploading')
);
if (dup) return;
if (dup) return 'duplicate';
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
// refuse silently rather than pile on.
// refuse and tell the caller so it can surface a "queue full" message (silent loss of a
// photo the user believed was queued is the worst outcome here).
if (mine.length >= MAX_QUEUE_ITEMS) {
const evictable = get(queueItems).find(
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
@@ -204,7 +277,7 @@ export async function addToQueue(
await database.delete(STORE_NAME, evictable.id);
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
} else {
return;
return 'full';
}
}
@@ -214,6 +287,7 @@ export async function addToQueue(
userId,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
mimeType: file.type,
caption,
hashtags,
@@ -229,6 +303,7 @@ export async function addToQueue(
userId,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
mimeType: file.type,
caption,
hashtags,
@@ -238,6 +313,7 @@ export async function addToQueue(
]);
processQueue();
return 'queued';
}
export async function retryItem(id: string): Promise<void> {
@@ -305,9 +381,20 @@ async function processQueue(): Promise<void> {
}, e.retryAfterSecs * 1000);
break;
}
if (e instanceof AuthError) {
// Dead session — stop the batch (every further item would 401 too). Items
// stay retryable with blobs intact; the re-auth flow (clearAuth) takes over.
break;
}
if (e instanceof LockedError) {
// Event locked — stop the batch (every item would hit the same lock). Items
// stay retryable with blobs intact; the `event-opened` SSE resumes them.
break;
}
if (e instanceof NetworkError) {
// Connectivity dropped mid-flight the item is back to 'pending'. Stop the
// loop; the `online` listener resumes it. No error state, no manual tap.
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
// and the `online` listener resumes it; if the failure hit while nominally
// online it's now a retryable 'error'. Either way, stop hammering here.
break;
}
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
@@ -364,37 +451,50 @@ async function uploadItem(id: string): Promise<void> {
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else if (xhr.status === 429) {
// Rate limit only (quota-full is now a distinct 413, handled below as
// terminal) — back off and auto-resume when the window lifts.
const body = (() => {
try {
const body = JSON.parse(xhr.responseText);
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
return JSON.parse(xhr.responseText);
} catch {
return null;
}
})();
switch (classifyUploadStatus(xhr.status)) {
case 'success':
resolve();
break;
case 'rate_limit': {
// Back off and auto-resume when the window lifts (quota-full is a distinct
// 413, classified 'terminal' below).
const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60;
reject(new RateLimitError(secs));
} catch {
reject(new RateLimitError(60));
break;
}
} else if (xhr.status >= 400 && xhr.status < 500) {
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
// Retrying just re-hits the same rejection forever, so mark it terminal.
let msg = 'Upload nicht möglich.';
try {
const body = JSON.parse(xhr.responseText);
if (body.message) msg = body.message;
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
} catch {
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
}
reject(new TerminalError(msg));
} else {
// 5xx / unexpected — transient, keep it retryable.
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.message || `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
case 'auth':
// Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping
// this into the terminal bucket (which purges the blob) irrecoverably
// destroyed queued photos whenever a sliding session lapsed or a host reset
// the PIN — precisely when the `online` auto-resume kicks in.
reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.'));
break;
case 'transient':
// 408 (request timeout) behaves like a network blip so it stays retryable;
// 5xx is a generic retryable error. Neither purges the blob.
if (xhr.status === 408) reject(new NetworkError('Zeitüberschreitung'));
else reject(new Error(body?.message || `HTTP ${xhr.status}`));
break;
case 'terminal': {
// A REVERSIBLE lock (event closed / gallery released) is tagged
// `uploads_locked` by the backend — keep the blob and park it retryable so
// a host reopen resumes it, instead of purging it like a permanent 4xx.
if (body?.error === 'uploads_locked') {
reject(new LockedError(body?.message || 'Event ist geschlossen.'));
break;
}
// Any other 4xx the server will keep rejecting (banned / quota).
let msg = body?.message || 'Upload nicht möglich.';
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
reject(new TerminalError(msg));
break;
}
}
});
@@ -420,12 +520,48 @@ async function uploadItem(id: string): Promise<void> {
updateItemStatus(id, 'pending');
throw e; // Propagate to processQueue for scheduling
}
if (e instanceof NetworkError) {
// No connectivity — keep the item pending and propagate so processQueue stops
// the loop (no point hammering while offline). The `online` listener resumes it.
entry.status = 'pending';
if (e instanceof LockedError) {
// Event closed / gallery released, but a host can reopen — KEEP the blob and park
// the item as retryable so it survives until reopen. The `event-opened` SSE
// (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here.
entry.status = 'error';
entry.error = e.message;
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
updateItemStatus(id, 'error', e.message);
throw e;
}
if (e instanceof AuthError) {
// Dead session — KEEP the blob (never purge on auth failure) and mark retryable so
// the file survives re-auth. Route through the app's single de-auth path (matches
// api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the
// user signs back in with the SAME identity (via /recover), the next time the upload
// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
entry.status = 'error';
entry.error = e.message;
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'error', e.message);
clearAuth();
throw e;
}
if (e instanceof NetworkError) {
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
if (offline) {
// Genuinely offline — keep the item pending; the `online` listener resumes it
// automatically with no user action.
entry.status = 'pending';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
} else {
// Network-level failure while the OS still reports online (server down,
// connection refused, TLS error, captive portal). The `online` event will
// NEVER fire in this case, so leaving it 'pending' would strand the item with
// no retry path and no spinner. Mark it retryable 'error' so the user gets a
// working "Erneut" button and a later real reconnect requeues it.
entry.status = 'error';
entry.error = 'Netzwerkfehler. Erneut versuchen.';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'error', 'Netzwerkfehler. Erneut versuchen.');
}
throw e;
}
if (e instanceof TerminalError) {