From 80d6f376b07c8abd894fc8740bbcb422adb1395b Mon Sep 17 00:00:00 2001 From: curo1305 Date: Wed, 10 Jun 2026 18:39:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(08-07):=20create=20api/utils.js=20with=20r?= =?UTF-8?q?equest()=20and=20fetchWithRetry()=20=E2=80=94=20CODE-08?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move request() verbatim from client.js to utils.js (breaks circular dep) - Add fetchWithRetry() consolidating 3 blob-download 401-retry patterns - Lazy import of authStore preserved for Pinia bootstrap cycle safety - Bearer token from authStore memory only per CLAUDE.md security rule --- frontend/src/api/utils.js | 119 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 frontend/src/api/utils.js diff --git a/frontend/src/api/utils.js b/frontend/src/api/utils.js new file mode 100644 index 0000000..fe564b5 --- /dev/null +++ b/frontend/src/api/utils.js @@ -0,0 +1,119 @@ +/** + * HTTP transport + 401-retry consolidator. + * + * `request()` moved from client.js to break the circular dependency: + * domain modules import request from here; client.js re-exports from those + * same domain modules, so request cannot also live in client.js. + * + * `fetchWithRetry()` consolidates 3 blob-download patterns that previously + * duplicated identical auth-injection + 401-retry boilerplate: + * - adminExportAuditLogCsv (admin.js) + * - adminDownloadDailyExport (admin.js) + * - fetchDocumentContent (documents.js) + * + * Security: Bearer token injected from authStore (Pinia memory only — CLAUDE.md). + * Token is NEVER read from localStorage or sessionStorage. + */ + +/** + * Core HTTP transport. All JSON-returning endpoints go through this function. + * + * On 401: attempts one token refresh via authStore.refresh() then retries. + * Skip-refresh guard: login/register return 401 for bad credentials (not expired + * tokens), and refresh itself must not retry (would cause infinite loop). + * + * @param {string} path — API path (e.g. '/api/documents') + * @param {RequestInit & {_retry?: boolean}} [options] — fetch options + * @returns {Promise} — parsed JSON response, or null for 204/empty body + */ +export async function request(path, options = {}) { + // Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js) + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = { ...(options.headers || {}) } + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(path, { ...options, headers, credentials: 'include' }) + + // 401 → attempt refresh → retry once + // Skip refresh for auth endpoints: login/register return 401 for bad credentials (not expired tokens), + // and refresh itself must not retry to avoid an infinite loop. + const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh'] + if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) { + try { + await authStore.refresh() + return request(path, { ...options, _retry: true }) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + + if (!res.ok) { + let msg = `HTTP ${res.status}` + let payload = null + try { + const body = await res.json() + if (typeof body.detail === 'object' && body.detail !== null) { + payload = body.detail + msg = body.detail.message || `HTTP ${res.status}` + } else { + msg = body.detail || msg + } + } catch {} + const err = new Error(msg) + err.status = res.status + if (payload) err.payload = payload + throw err + } + if (res.status === 204 || res.headers.get('content-length') === '0') return null + return res.json() +} + +/** + * Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response). + * + * Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent + * which share identical auth-injection + 401-retry boilerplate (CODE-08). + * + * Unlike request(), this does NOT parse the response — the raw Response is returned + * so callers can call .text(), .blob(), etc. as appropriate. + * + * Security: Bearer token from authStore.accessToken (memory only — CLAUDE.md). + * On 401-and-not-already-retried: calls authStore.refresh() and recurses with + * _retry=true. On refresh failure: clears in-memory token state and throws + * 'Session expired'. + * + * @param {string} url — full URL to fetch + * @param {RequestInit} [options] — fetch options (method, headers, etc.) + * @param {boolean} [_retry] — internal retry guard; callers must NOT pass this + * @returns {Promise} — raw Response; caller decides how to consume it + */ +export async function fetchWithRetry(url, options = {}, _retry = false) { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = { ...(options.headers || {}) } + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(url, { ...options, headers, credentials: 'include' }) + + if (res.status === 401 && !_retry) { + try { + await authStore.refresh() + return fetchWithRetry(url, options, true) + } catch { + authStore.accessToken = null + authStore.user = null + throw new Error('Session expired') + } + } + + return res +}