From 7a6aa50693e78df55bb9ab9d7694e9a26007d565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:29:36 -0400 Subject: [PATCH] feat(web): auto-logout on 401 inside apiFetch Dynamic import breaks the apiFetch <-> auth/store cycle. silent:true prevents re-POSTing /logout against an already-dead session. --- web/src/lib/api/client.test.ts | 21 +++++++++++++++++++++ web/src/lib/api/client.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts index 9491f024..b15e90e4 100644 --- a/web/src/lib/api/client.test.ts +++ b/web/src/lib/api/client.test.ts @@ -79,3 +79,24 @@ describe('api.get/post/del', () => { expect(result).toBeNull(); }); }); + +describe('apiFetch 401 interceptor', () => { + test('401 response triggers auth.logout({silent:true}) and still throws', async () => { + const logoutSpy = vi.fn(); + vi.doMock('$lib/auth/store.svelte', () => ({ + logout: logoutSpy, + login: vi.fn(), + bootstrap: vi.fn(), + user: { value: null } + })); + // Re-import to pick up the mock. + const { apiFetch: apiFetchFresh } = await import('./client'); + stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } }); + await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({ + code: 'unauthorized', + status: 401 + }); + expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); + vi.doUnmock('$lib/auth/store.svelte'); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 87c0fbf7..9c4f9244 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -23,6 +23,13 @@ export async function apiFetch(path: string, init?: RequestInit): Promise null); if (!res.ok) { + if (res.status === 401) { + // Lazy import: auth/store imports from this file, so a top-level + // import would be circular. By the time any 401 actually happens + // at runtime, both modules have finished loading. + const { logout } = await import('$lib/auth/store.svelte'); + await logout({ silent: true }); + } const envelope = body && (body as { error?: { code?: string; message?: string } }).error; const err: ApiError = { code: envelope?.code ?? 'unknown',