Three features bundled into one release: - rate-limit /auth/login, /register, /me/password (token bucket, 5 req/sec sustained with 10-request burst by default; 429 + Retry-After header on hit; tracing::warn! per hit so operators see attack patterns; AUTH_RATE_PER_SEC / AUTH_RATE_BURST env knobs) - handle SIGTERM for graceful container stops (replaces bare ctrl_c() with a select over ctrl_c + SignalKind::terminate() so docker compose stop runs the daemon shutdown path instead of letting Chromium leak past SIGKILL) - clear session.user on 401 from any API call (setOn401Hook in api/client.ts, registered from session.svelte.ts gated on $app/environment::browser so the SSR bundle never installs it; fixes "logged in but no bookmarks/collections" mid-session expiry state) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
57 lines
2.1 KiB
TypeScript
57 lines
2.1 KiB
TypeScript
// Per-tab session state for the currently logged-in user.
|
|
//
|
|
// Only mutated client-side (onMount / form submits) so the module-level
|
|
// instance can't leak across SSR requests — SSR always renders the
|
|
// `loaded === false` state, and the client refreshes after hydration.
|
|
//
|
|
// IMPORTANT: do not call any `api/*` helper from `+page.server.ts` /
|
|
// `+layout.server.ts`. The `setOn401Hook` below is registered at
|
|
// module load (gated on `browser`, so it only fires in the client
|
|
// bundle), so a 401 from a server-side fetch would mutate this
|
|
// module-level `session.user` across SvelteKit requests — a real
|
|
// cross-request state leak. The `if (browser)` guard makes that
|
|
// failure mode mechanical rather than convention-based.
|
|
|
|
import { browser } from '$app/environment';
|
|
import { setOn401Hook } from './api/client';
|
|
import { me, type User } from './api/auth';
|
|
|
|
class SessionStore {
|
|
user = $state<User | null>(null);
|
|
loaded = $state(false);
|
|
// Bumped on every explicit setUser so an in-flight refresh started before
|
|
// a login/logout can't clobber the fresh state when it resolves.
|
|
private seq = 0;
|
|
|
|
async refresh(): Promise<void> {
|
|
const seq = this.seq;
|
|
try {
|
|
const u = await me();
|
|
if (seq === this.seq) this.user = u;
|
|
} finally {
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
setUser(user: User | null): void {
|
|
this.seq++;
|
|
this.user = user;
|
|
this.loaded = true;
|
|
}
|
|
}
|
|
|
|
export const session = new SessionStore();
|
|
|
|
// When any backend call returns 401, drop the cached user. Before this
|
|
// hook, the `*OrEmpty` wrappers silently returned empty pages on 401
|
|
// — so a mid-session expiry left the UI rendering as "logged in but
|
|
// no bookmarks/collections/etc." until the user manually reloaded.
|
|
// With the hook the session.user reactive store flips to null on the
|
|
// first 401, so the layout re-renders the login affordance.
|
|
//
|
|
// Gated on `browser` so it's only installed in the client bundle.
|
|
// See the module-level comment above for the SSR rationale.
|
|
if (browser) {
|
|
setOn401Hook(() => session.setUser(null));
|
|
}
|