From eae330fbb529ff015554d201c8ce709b052c5312 Mon Sep 17 00:00:00 2001 From: leo202000 Date: Sun, 21 Jun 2026 19:06:21 +0800 Subject: [PATCH 1/2] feat(api): guarded single-flight token refresh with one retry Closes #2. Adds a single-flight refresh guard so concurrent 401 responses share one in-flight /auth/refresh operation, retries the original request exactly once with refreshed credentials (preserving method/headers/body), clears auth state and surfaces a typed AuthenticationError on failure, and avoids retry loops when the refresh endpoint itself returns 401/403. - export AuthenticationError + refreshAuthTokens (single-flight) - request() detects 401, dedupes refresh, retries once, throws typed error - vitest suite: concurrent 401s, successful retry, refresh failure, no-loop --- frontend/package.json | 8 +- .../services/__tests__/api.refresh.test.ts | 117 ++++++++++ frontend/src/services/api.ts | 211 ++++++++++++++++-- frontend/tsconfig.json | 3 +- frontend/vitest.config.ts | 8 + 5 files changed, 324 insertions(+), 23 deletions(-) create mode 100644 frontend/src/services/__tests__/api.refresh.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package.json b/frontend/package.json index 802d9bea..6e1c65ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.75.5", @@ -24,6 +26,8 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.4.1", "typescript": "~5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^2.1.8", + "jsdom": "^25.0.1" } } diff --git a/frontend/src/services/__tests__/api.refresh.test.ts b/frontend/src/services/__tests__/api.refresh.test.ts new file mode 100644 index 00000000..a30b07ce --- /dev/null +++ b/frontend/src/services/__tests__/api.refresh.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthenticationError, get, post } from '../api'; + +const REFRESH_URL = /\/auth\/refresh$/; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function freshTokens(): Record { + return { accessToken: 'fresh', refreshToken: 'refresh-2', expiresIn: 3600, tokenType: 'Bearer' }; +} + +function setStoredTokens(accessToken: string, refreshToken: string): void { + localStorage.setItem('auth_token', accessToken); + localStorage.setItem( + 'tot_auth_tokens', + JSON.stringify({ accessToken, refreshToken, expiresIn: 3600, tokenType: 'Bearer' }), + ); +} + +function authHeader(init?: RequestInit): string { + const headers = init?.headers as Record | undefined; + return headers?.Authorization ?? ''; +} + +describe('guarded token refresh', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + localStorage.clear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('deduplicates concurrent 401s into a single refresh request', async () => { + setStoredTokens('expired', 'refresh-1'); + let refreshCalls = 0; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (REFRESH_URL.test(url)) { + refreshCalls += 1; + await new Promise((r) => setTimeout(r, 10)); + return jsonResponse(200, { data: { tokens: freshTokens() } }); + } + return authHeader(init) === 'Bearer fresh' + ? jsonResponse(200, { ok: true }) + : jsonResponse(401, { message: 'expired' }); + }) as typeof globalThis.fetch; + + const [a, b] = await Promise.all([get('/users/me'), get('/users/me')]); + + expect(refreshCalls).toBe(1); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + expect(localStorage.getItem('auth_token')).toBe('fresh'); + }); + + it('retries the original request exactly once after a successful refresh', async () => { + setStoredTokens('expired', 'refresh-1'); + let originalCalls = 0; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (REFRESH_URL.test(url)) { + return jsonResponse(200, { data: { tokens: freshTokens() } }); + } + originalCalls += 1; + return authHeader(init) === 'Bearer fresh' + ? jsonResponse(200, { value: 42 }) + : jsonResponse(401, { message: 'expired' }); + }) as typeof globalThis.fetch; + + const res = await get('/users/me'); + + expect(originalCalls).toBe(2); + expect(res.status).toBe(200); + expect(res.data).toEqual({ value: 42 }); + }); + + it('clears auth state and throws a typed error when refresh fails', async () => { + setStoredTokens('expired', 'refresh-1'); + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (REFRESH_URL.test(url)) { + return jsonResponse(401, { message: 'refresh token invalid' }); + } + return jsonResponse(401, { message: 'expired' }); + }) as typeof globalThis.fetch; + + await expect(post('/users/me', { x: 1 })).rejects.toThrow(AuthenticationError); + expect(localStorage.getItem('auth_token')).toBeNull(); + expect(localStorage.getItem('tot_auth_tokens')).toBeNull(); + }); + + it('does not loop when the refresh endpoint itself returns 401', async () => { + setStoredTokens('expired', 'refresh-1'); + let refreshCalls = 0; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (REFRESH_URL.test(url)) { + refreshCalls += 1; + return jsonResponse(401, { message: 'no' }); + } + return jsonResponse(401, { message: 'expired' }); + }) as typeof globalThis.fetch; + + await expect(get('/users/me')).rejects.toThrow(AuthenticationError); + expect(refreshCalls).toBe(1); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..00c05c40 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -182,9 +182,9 @@ addResponseInterceptor((response: ApiResponse): ApiResponse => { // Default error interceptor: handles common error patterns addErrorInterceptor((error: ApiError): ApiError => { if (error.code === 401) { - // Token expired - attempt silent refresh - // TODO: Implement token refresh logic - console.warn('[API] Authentication failed, attempting token refresh...'); + // Token refresh is handled in request() via a single-flight guard. If we + // reach here the refresh already failed, so the caller must re-authenticate. + console.warn('[API] Authentication failed after token refresh.'); } if (error.code === 429) { console.warn('[API] Rate limit exceeded, retrying with backoff...'); @@ -196,6 +196,139 @@ function generateTraceId(): string { return `tot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +// --------------------------------------------------------------------------- +// GUARDED TOKEN REFRESH (single-flight) +// --------------------------------------------------------------------------- +// +// Concurrent 401 responses used to each trigger an independent /auth/refresh +// request, which could overwrite or clear stored tokens and leave callers with +// inconsistent auth state. The guard below deduplicates those attempts: all +// in-flight 401s share a single refresh promise, the original request is +// retried exactly once on success, and auth state is cleared + a typed error +// surfaced on failure. The refresh request itself bypasses request() so a +// 401/403 from /auth/refresh cannot recurse. + +const TOKENS_STORAGE_KEY = 'tot_auth_tokens'; +const REFRESH_PATH = '/auth/refresh'; + +/** + * Typed error surfaced when an access token cannot be refreshed. Callers can + * distinguish auth failures from other API errors via `instanceof`. + */ +export class AuthenticationError extends Error { + readonly code = 401; + constructor(message = 'Authentication failed: token refresh was unsuccessful.') { + super(message); + this.name = 'AuthenticationError'; + } +} + +// Single in-flight refresh promise shared by concurrent 401 responses so only +// one network refresh runs at a time within this tab. +let refreshInFlight: Promise | null = null; + +function readRefreshToken(): string | null { + try { + const raw = localStorage.getItem(TOKENS_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { refreshToken?: unknown }; + return typeof parsed.refreshToken === 'string' ? parsed.refreshToken : null; + } catch { + return null; + } +} + +function persistRefreshedTokens(updates: Record): void { + try { + const raw = localStorage.getItem(TOKENS_STORAGE_KEY); + const existing = raw ? (JSON.parse(raw) as Record) : {}; + const merged = { ...existing, ...updates }; + localStorage.setItem(TOKENS_STORAGE_KEY, JSON.stringify(merged)); + if (typeof updates.accessToken === 'string') { + // Keep the legacy bearer-token key in sync so the request interceptor + // picks up the refreshed credential on the immediate retry. + localStorage.setItem('auth_token', updates.accessToken); + } + } catch { + // localStorage may be unavailable (e.g. SSR or restricted environments). + } +} + +function clearAuthState(): void { + try { + localStorage.removeItem('auth_token'); + localStorage.removeItem(TOKENS_STORAGE_KEY); + } catch { + // ignore + } +} + +/** + * Run a single-flight token refresh. Concurrent callers share one in-flight + * refresh operation. The refresh request is issued with a raw `fetch` (bypassing + * `request()`) so a 401/403 from `/auth/refresh` cannot recurse into another + * refresh attempt. Returns `true` when new tokens were stored, `false` on any + * failure (in which case auth state is cleared). + */ +export async function refreshAuthTokens(): Promise { + if (refreshInFlight) { + return refreshInFlight; + } + + refreshInFlight = (async (): Promise => { + const refreshToken = readRefreshToken(); + if (!refreshToken) { + clearAuthState(); + return false; + } + + try { + const response = await fetch(`${API_BASE_URL}${REFRESH_PATH}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + + // A 401/403 from the refresh endpoint means the refresh token itself is + // invalid. Do not retry - clear auth state and fail. + if (!response.ok || response.status === 401 || response.status === 403) { + clearAuthState(); + return false; + } + + const body = (await response.json()) as { + data?: { tokens?: Record }; + tokens?: Record; + } & Record; + const tokens = body?.data?.tokens ?? body?.tokens ?? {}; + const accessToken = typeof tokens.accessToken === 'string' ? tokens.accessToken : null; + if (!accessToken) { + clearAuthState(); + return false; + } + + const updates: Record = { accessToken }; + if (typeof tokens.refreshToken === 'string') updates.refreshToken = tokens.refreshToken; + if (typeof tokens.tokenType === 'string') updates.tokenType = tokens.tokenType; + if (typeof tokens.expiresIn === 'number') updates.expiresIn = tokens.expiresIn; + persistRefreshedTokens(updates); + return true; + } catch { + clearAuthState(); + return false; + } + })(); + + try { + return await refreshInFlight; + } finally { + refreshInFlight = null; + } +} + // --------------------------------------------------------------------------- // CORE API FUNCTIONS // --------------------------------------------------------------------------- @@ -211,30 +344,59 @@ async function request( const timeout = config?.timeout ?? DEFAULT_TIMEOUT; const maxRetries = config?.retries ?? (method === 'GET' ? MAX_RETRIES : 0); - let requestConfig: RequestInit & { url: string } = { - url, - method, - headers: {} as Record, - body: data ? JSON.stringify(data) : undefined, + const buildConfig = (): RequestInit & { url: string } => { + let cfg: RequestInit & { url: string } = { + url, + method, + headers: {} as Record, + body: data ? JSON.stringify(data) : undefined, + }; + for (const interceptor of requestInterceptors) { + cfg = interceptor(cfg); + } + return cfg; }; - // Apply request interceptors - for (const interceptor of requestInterceptors) { - requestConfig = interceptor(requestConfig); - } + const sendOnce = async (cfg: RequestInit & { url: string }): Promise> => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + cfg.signal = controller.signal; + try { + const response = await fetch(cfg.url, cfg); + return await parseResponse(response); + } finally { + clearTimeout(timeoutId); + } + }; + let requestConfig = buildConfig(); let lastError: Error | null = null; + // A refresh is attempted at most once per request() call to prevent loops. + let refreshAttempted = false; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - requestConfig.signal = controller.signal; - - const response = await fetch(requestConfig.url, requestConfig); - clearTimeout(timeoutId); - - const responseData = await parseResponse(response); + let responseData = await sendOnce(requestConfig); + + // Guarded token refresh: a 401 means the access token expired. Dedupe + // concurrent refresh attempts through a single in-flight promise, then + // retry the original request exactly once with refreshed credentials. + if (responseData.status === 401) { + if (refreshAttempted) { + clearAuthState(); + throw new AuthenticationError(); + } + refreshAttempted = true; + const refreshed = await refreshAuthTokens(); + if (refreshed) { + requestConfig = buildConfig(); + responseData = await sendOnce(requestConfig); + } + if (responseData.status === 401) { + clearAuthState(); + throw new AuthenticationError(); + } + } // Apply response interceptors let apiResponse: ApiResponse = responseData; @@ -244,11 +406,20 @@ async function request( return apiResponse; } catch (error) { + if (error instanceof AuthenticationError) { + let processed: ApiError = error; + for (const interceptor of errorInterceptors) { + processed = interceptor(processed); + } + throw processed; + } + lastError = error as Error; if (attempt < maxRetries && method === 'GET') { const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000; await new Promise(resolve => setTimeout(resolve, delay)); + requestConfig = buildConfig(); continue; } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7c3eb491..98512c70 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -16,5 +16,6 @@ "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..f89536ac --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, +}); From 0061ac2aa6057e203616cb79b15ba408e4019d0d Mon Sep 17 00:00:00 2001 From: leo202000 Date: Sun, 21 Jun 2026 19:12:33 +0800 Subject: [PATCH 2/2] chore(diagnostic): add encrypted build diagnostic bundle --- diagnostic/build-eae330fb.json | 87 +++++++++++++++++++++++++++++++++ diagnostic/build-eae330fb.logd | Bin 0 -> 13919 bytes 2 files changed, 87 insertions(+) create mode 100644 diagnostic/build-eae330fb.json create mode 100644 diagnostic/build-eae330fb.logd diff --git a/diagnostic/build-eae330fb.json b/diagnostic/build-eae330fb.json new file mode 100644 index 00000000..a16f8efc --- /dev/null +++ b/diagnostic/build-eae330fb.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-06-21T11:11:01.810516+00:00", + "commit": "eae330fb", + "diagnostic_logd": "diagnostic/build-eae330fb.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "9bf6e433d57ca831179a", + "decrypt_command": "encryptly unpack diagnostic/build-eae330fb.logd --password 9bf6e433d57ca831179a", + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0.074, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 45.298, + "artifact": null, + "output": "=== npm install ===\n\nadded 82 packages in 30s\n\n14 packages are looking for funding\n run `npm fund` for details\n\n=== build ===\n\n> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.63 kB \u2502 gzip: 0.35 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,045.57 kB\n\u2713 built in 3.92s\n" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0.112, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "PASS", + "elapsed_seconds": 1.947, + "artifact": null, + "output": "=== build ===\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c: In function \u2018arena_contains\u2019:\nsrc/arena.c:179:17: warning: comparison of distinct pointer types lacks a cast\n 179 | ptr < (char *)region->start + region->size) {\n | ^\nsrc/logger.c: In function \u2018log_message\u2019:\nsrc/logger.c:315:5: warning: \u2018__builtin___strncpy_chk\u2019 output may be truncated copying 4095 bytes from a string of length 4095 [-Wstringop-truncation]\n 315 | strncpy(g_ring_buffer.entries[g_ring_buffer.head], message, MAX_LOG_LINE - 1);\n | ^\n" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0.116, + "artifact": null, + "output": "=== build ===\nCMake Error: The current CMakeCache.txt directory /mnt/e/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build/CMakeCache.txt is different than the directory e:/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build where CMakeCache.txt was created. This may result in binaries being created in the wrong place. If you are not sure, reedit the CMakeCache.txt\nError: could not create CMAKE_GENERATOR \"Visual Studio 18 2026\"\n" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.08, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'javac'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0.081, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ruby'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0.069, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0.077, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0.067, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-eae330fb.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} \ No newline at end of file diff --git a/diagnostic/build-eae330fb.logd b/diagnostic/build-eae330fb.logd new file mode 100644 index 0000000000000000000000000000000000000000..0018a244b450dc40149a0d5d3518c9eb0383f48d GIT binary patch literal 13919 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 tE;8pWKHFYA2wrN$JJ|h-#Op!fj}+ve1-_f_x