diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c091570..1209b66 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.34.0" +version = "0.35.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/package.json b/frontend/package.json index 159dad9..6a36df7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.34.0", + "version": "0.35.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/client.test.ts b/frontend/src/lib/api/client.test.ts index 49e292c..417de75 100644 --- a/frontend/src/lib/api/client.test.ts +++ b/frontend/src/lib/api/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; -import { ApiError, request } from './client'; +import { ApiError, request, setOn401Hook } from './client'; import { getManga } from './mangas'; describe('request error envelope parsing', () => { @@ -73,3 +73,88 @@ describe('request error envelope parsing', () => { expect(err.code).toBe('http_error'); }); }); + +describe('on401 hook', () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + afterEach(() => { + vi.restoreAllMocks(); + // Critical: reset the module-level hook between tests so a + // hook installed by one test doesn't leak into the next. + setOn401Hook(null); + }); + + it('invokes the hook exactly once on a 401 response and re-throws', async () => { + const hook = vi.fn(); + setOn401Hook(hook); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'unauthenticated', message: 'no auth' } }), + { status: 401, headers: { 'content-type': 'application/json' } } + ) + ); + await expect(getManga('x')).rejects.toMatchObject({ + status: 401, + code: 'unauthenticated' + }); + expect(hook).toHaveBeenCalledTimes(1); + }); + + it('does not invoke the hook on non-401 errors', async () => { + const hook = vi.fn(); + setOn401Hook(hook); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'not_found', message: 'no' } }), + { status: 404, headers: { 'content-type': 'application/json' } } + ) + ); + await expect(getManga('x')).rejects.toMatchObject({ status: 404 }); + expect(hook).not.toHaveBeenCalled(); + }); + + it('does not invoke the hook on successful responses', async () => { + const hook = vi.fn(); + setOn401Hook(hook); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'm1', + title: 't', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [], + genres: [], + tags: [] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + await getManga('m1'); + expect(hook).not.toHaveBeenCalled(); + }); + + it('swallows hook exceptions so the original ApiError still propagates', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + setOn401Hook(() => { + throw new Error('hook boom'); + }); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: 'unauthenticated', message: 'x' } }), + { status: 401, headers: { 'content-type': 'application/json' } } + ) + ); + await expect(getManga('x')).rejects.toMatchObject({ status: 401 }); + // The original ApiError won — the hook's panic was logged but + // didn't replace the API error. + expect(consoleSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index a272be7..6b690be 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -25,6 +25,21 @@ export class ApiError extends Error { type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } }; +/** + * Optional hook fired the first moment `request()` observes a 401 on + * any endpoint. Used by the session store to clear the cached user + * when the server reports the session is no longer valid (expired + * cookie, rotated server-side, password changed on another device). + * + * Set to `null` (or `undefined`) to disable. Tests that don't want + * the side effect should leave it unset. + */ +let on401Hook: (() => void) | null = null; + +export function setOn401Hook(handler: (() => void) | null): void { + on401Hook = handler; +} + export async function request(path: string, init?: RequestInit): Promise { // Forward credentials (session cookie) explicitly so cross-origin // deployments — those configured via CORS_ALLOWED_ORIGINS — keep @@ -54,6 +69,16 @@ export async function request(path: string, init?: RequestInit): Promise { } catch { // Body wasn't parseable; keep the http_error fallback. } + if (res.status === 401 && on401Hook) { + // Fire before throwing so the session store updates even + // if the caller swallows the ApiError (e.g. the *OrEmpty + // wrappers used by guest-rendering pages). + try { + on401Hook(); + } catch (e) { + console.error('on401 hook threw:', e); + } + } throw new ApiError(res.status, code, message); } // Any empty body (not just 204) returns undefined — the manga-add diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts index cd72d91..cdb13ff 100644 --- a/frontend/src/lib/session.svelte.ts +++ b/frontend/src/lib/session.svelte.ts @@ -3,7 +3,17 @@ // 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 { @@ -31,3 +41,16 @@ class SessionStore { } 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)); +}