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 00000000..0018a244 Binary files /dev/null and b/diagnostic/build-eae330fb.logd differ 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'], + }, +});