diff --git a/diagnostic/build-2b54872c.json b/diagnostic/build-2b54872c.json new file mode 100644 index 00000000..725e227d --- /dev/null +++ b/diagnostic/build-2b54872c.json @@ -0,0 +1,86 @@ +{ + "generated_at": "2026-06-22T08:32:50.396559+00:00", + "commit": "2b54872c", + "diagnostic_logd": null, + "diagnostic_logd_error": "d94bed58579b72cb97f5", + "chunked": false, + "chunk_size_bytes": null, + "password": null, + "decrypt_command": null, + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 1.804, + "artifact": "/Users/qingfeng/Desktop/\u81ea\u52a8\u642c\u7816/zeroeye-9904099-parse-errors/frontend/dist", + "output": "> 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.62 kB \u2502 gzip: 0.34 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.55 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.22 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.02 kB \u2502 map: 1,044.42 kB\n\u2713 built in 536ms" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "FAIL", + "elapsed_seconds": 0.196, + "artifact": null, + "output": "gcc -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\nsrc/arena.c:17:23: error: use of undeclared identifier 'MAP_HUGETLB'\n 17 | mmap_flags |= MAP_HUGETLB;\n | ^\nsrc/arena.c:179:17: warning: comparison of distinct pointer types ('const void *' and 'char *') [-Wcompare-distinct-pointer-types]\n 179 | ptr < (char *)region->start + region->size) {\n | ~~~ ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n1 warning and 1 error generated.\nmake: *** [build/src/arena.o] Error 1" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cmake'" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.009, + "artifact": null, + "output": "The operation couldn\u2019t be completed. Unable to locate a Java Runtime.\nPlease visit http://www.java.com for information on installing Java." + }, + { + "name": "v2-market-stream", + "status": "PASS", + "elapsed_seconds": 0.061, + "artifact": null, + "output": "Syntax OK" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. 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." +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..d12fb78e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -59,6 +59,11 @@ const API_VERSION_HEADER = 'X-API-Version'; // been updated. We send both the legacy and new auth headers. const LEGACY_API_KEY_HEADER = 'X-API-Key'; +const ACCESS_TOKEN_KEY = 'auth_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; +const TOKEN_BUNDLE_KEY = 'tot_auth_tokens'; +const USER_KEY = 'tot_user_data'; + // --------------------------------------------------------------------------- // TYPES // --------------------------------------------------------------------------- @@ -92,6 +97,27 @@ export interface ApiError { suggestion?: string; } +interface AuthTokens { + accessToken: string; + refreshToken: string; + expiresIn?: number; + tokenType?: string; + scope?: string; +} + +export class AuthenticationError extends Error { + code = 401; + details?: Record; + path?: string; + + constructor(message: string, details?: Record, path?: string) { + super(message); + this.name = 'AuthenticationError'; + this.details = details; + this.path = path; + } +} + export interface RequestConfig { timeout?: number; retries?: number; @@ -121,6 +147,7 @@ type ErrorInterceptor = (error: ApiError) => ApiError; const requestInterceptors: RequestInterceptor[] = []; const responseInterceptors: ResponseInterceptor[] = []; const errorInterceptors: ErrorInterceptor[] = []; +let tokenRefreshPromise: Promise | null = null; export function addRequestInterceptor(interceptor: RequestInterceptor): () => void { requestInterceptors.push(interceptor); @@ -149,7 +176,7 @@ export function addErrorInterceptor(interceptor: ErrorInterceptor): () => void { // Default request interceptor: adds auth headers addRequestInterceptor((config) => { const headers = config.headers as Record || {}; - const token = localStorage.getItem('auth_token'); + const token = getStoredAccessToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; // Legacy auth header for internal services @@ -182,9 +209,7 @@ 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...'); + console.warn('[API] Authentication failed after token refresh handling.'); } if (error.code === 429) { console.warn('[API] Rate limit exceeded, retrying with backoff...'); @@ -211,30 +236,24 @@ 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, - }; - - // Apply request interceptors - for (const interceptor of requestInterceptors) { - requestConfig = interceptor(requestConfig); - } - let lastError: Error | null = null; + let didRetryAfterRefresh = 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); + let responseData = await sendRequest(url, method, data, config, timeout); + + if (responseData.status === 401) { + if (!didRetryAfterRefresh && !isAuthRefreshPath(path)) { + await refreshAuthTokens(path); + didRetryAfterRefresh = true; + responseData = await sendRequest(url, method, data, config, timeout); + } + } - const responseData = await parseResponse(response); + if (responseData.status === 401) { + throw new AuthenticationError('Authentication failed', { status: responseData.status }, path); + } // Apply response interceptors let apiResponse: ApiResponse = responseData; @@ -246,6 +265,10 @@ async function request( } catch (error) { lastError = error as Error; + if (error instanceof AuthenticationError) { + break; + } + if (attempt < maxRetries && method === 'GET') { const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000; await new Promise(resolve => setTimeout(resolve, delay)); @@ -265,6 +288,160 @@ async function request( throw processedError; } +async function sendRequest( + url: string, + method: string, + data: unknown, + config: RequestConfig | undefined, + timeout: number +): Promise> { + let requestConfig: RequestInit & { url: string } = { + url, + method, + headers: { ...(config?.headers || {}) }, + body: data ? JSON.stringify(data) : undefined, + cache: config?.cache === false ? 'no-store' : undefined, + credentials: config?.withCredentials ? 'include' : undefined, + }; + + for (const interceptor of requestInterceptors) { + requestConfig = interceptor(requestConfig); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + requestConfig.signal = config?.signal || controller.signal; + + try { + const response = await fetch(requestConfig.url, requestConfig); + return await parseResponse(response); + } finally { + clearTimeout(timeoutId); + } +} + +function isAuthRefreshPath(path: string): boolean { + return path === Endpoints.auth.refresh || path.endsWith('/auth/refresh'); +} + +function getStoredAccessToken(): string | null { + const legacyToken = safeLocalStorageGet(ACCESS_TOKEN_KEY); + if (legacyToken) return legacyToken; + + const tokens = getStoredTokenBundle(); + return tokens?.accessToken || null; +} + +function getStoredTokenBundle(): AuthTokens | null { + const stored = safeLocalStorageGet(TOKEN_BUNDLE_KEY); + if (!stored) return null; + + try { + const tokens = JSON.parse(stored) as AuthTokens; + if (tokens?.accessToken || tokens?.refreshToken) { + return tokens; + } + } catch { + // Ignore malformed persisted auth state. + } + + return null; +} + +function getStoredRefreshToken(): string | null { + const tokens = getStoredTokenBundle(); + return tokens?.refreshToken || safeLocalStorageGet(REFRESH_TOKEN_KEY); +} + +function storeAuthTokens(tokens: AuthTokens): void { + try { + localStorage.setItem(TOKEN_BUNDLE_KEY, JSON.stringify(tokens)); + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken); + } catch { + // localStorage may be unavailable in test, private, or embedded contexts. + } +} + +function clearAuthState(): void { + try { + localStorage.removeItem(TOKEN_BUNDLE_KEY); + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } catch { + // ignore + } +} + +function safeLocalStorageGet(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +async function refreshAuthTokens(path: string): Promise { + if (!tokenRefreshPromise) { + tokenRefreshPromise = performTokenRefresh(path).finally(() => { + tokenRefreshPromise = null; + }); + } + + return tokenRefreshPromise; +} + +async function performTokenRefresh(path: string): Promise { + const refreshToken = getStoredRefreshToken(); + if (!refreshToken) { + clearAuthState(); + throw new AuthenticationError('No refresh token available', undefined, path); + } + + const response = await fetch(buildUrl(Endpoints.auth.refresh), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + [API_VERSION_HEADER]: '2024-01', + 'X-Trace-ID': generateTraceId(), + 'X-Client-ID': 'tent-of-trials-web', + 'X-Client-Version': '3.2.0', + }, + body: JSON.stringify({ refreshToken }), + }); + + if (response.status === 401 || response.status === 403) { + clearAuthState(); + throw new AuthenticationError('Token refresh was rejected', { status: response.status }, path); + } + + const refreshResponse = await parseResponse<{ tokens?: AuthTokens } & Partial>(response); + if (refreshResponse.status < 200 || refreshResponse.status >= 300) { + throw new Error(`Token refresh failed with status ${refreshResponse.status}`); + } + + const tokens = extractTokens(refreshResponse.data); + if (!tokens) { + throw new Error('Token refresh response did not include tokens'); + } + + storeAuthTokens(tokens); + return tokens; +} + +function extractTokens(data: ({ tokens?: AuthTokens } & Partial) | undefined): AuthTokens | null { + if (!data) return null; + if (data.tokens?.accessToken && data.tokens.refreshToken) { + return data.tokens; + } + if (data.accessToken && data.refreshToken) { + return data as AuthTokens; + } + return null; +} + function buildUrl(path: string, params?: QueryParams): string { const baseUrl = `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; if (!params) return baseUrl; @@ -344,6 +521,17 @@ function normalizeError(error: Error | null): ApiError { }; } + if (error instanceof AuthenticationError) { + return { + code: error.code, + message: error.message, + details: error.details, + path: error.path, + timestamp: new Date().toISOString(), + suggestion: 'Please sign in again.', + }; + } + if (error instanceof TypeError && error.message.includes('fetch')) { return { code: 0, diff --git a/frontend/src/services/api_refresh.validation.mjs b/frontend/src/services/api_refresh.validation.mjs new file mode 100755 index 00000000..5ff6c2f2 --- /dev/null +++ b/frontend/src/services/api_refresh.validation.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +class LocalStorageMock { + constructor() { + this.store = new Map(); + } + + getItem(key) { + return this.store.has(key) ? this.store.get(key) : null; + } + + setItem(key, value) { + this.store.set(key, String(value)); + } + + removeItem(key) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} + +globalThis.localStorage = new LocalStorageMock(); + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + statusText: status === 200 ? 'OK' : 'Unauthorized', + headers: { 'content-type': 'application/json' }, + }); +} + +async function loadApiModule() { + const apiPath = new URL('./api.ts', import.meta.url); + let source = await readFile(apiPath, 'utf8'); + source = source.replace( + "import { $httpLegacy, legacyToJson } from '../utils/legacyCompat';", + 'const $httpLegacy = async () => ({ data: undefined });\nconst legacyToJson = (value) => value;' + ); + + const transpiled = ts.transpileModule(source, { + fileName: fileURLToPath(apiPath), + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + isolatedModules: true, + }, + }).outputText; + + const encoded = Buffer.from(transpiled).toString('base64'); + return import(`data:text/javascript;base64,${encoded}#${Date.now()}-${Math.random()}`); +} + +function seedTokens(accessToken = 'expired-token', refreshToken = 'refresh-token') { + localStorage.clear(); + localStorage.setItem('tot_auth_tokens', JSON.stringify({ + accessToken, + refreshToken, + expiresIn: 60, + tokenType: 'Bearer', + })); +} + +async function testConcurrent401sShareOneRefresh(api) { + seedTokens(); + let refreshCalls = 0; + const protectedCalls = []; + + globalThis.fetch = async (url, init = {}) => { + if (String(url).endsWith('/protected')) { + protectedCalls.push({ + auth: init.headers.Authorization, + method: init.method, + }); + if (init.headers.Authorization === 'Bearer fresh-token') { + return jsonResponse(200, { ok: true }); + } + return jsonResponse(401, { error: 'expired' }); + } + + if (String(url).endsWith('/auth/refresh')) { + refreshCalls += 1; + await new Promise(resolve => setTimeout(resolve, 10)); + return jsonResponse(200, { + tokens: { + accessToken: 'fresh-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + tokenType: 'Bearer', + }, + }); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const [first, second] = await Promise.all([ + api.get('/protected'), + api.get('/protected'), + ]); + + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal(refreshCalls, 1); + assert.deepEqual(protectedCalls.map(call => call.auth), [ + 'Bearer expired-token', + 'Bearer expired-token', + 'Bearer fresh-token', + 'Bearer fresh-token', + ]); + assert.equal(localStorage.getItem('auth_token'), 'fresh-token'); +} + +async function testPostRetryPreservesMethodHeadersAndBody(api) { + seedTokens('stale-token', 'refresh-token'); + const orderCalls = []; + + globalThis.fetch = async (url, init = {}) => { + if (String(url).endsWith('/orders')) { + orderCalls.push({ + method: init.method, + customHeader: init.headers['X-Custom'], + auth: init.headers.Authorization, + body: init.body, + }); + if (init.headers.Authorization === 'Bearer fresh-post-token') { + return jsonResponse(200, { id: 42 }); + } + return jsonResponse(401, { error: 'expired' }); + } + + if (String(url).endsWith('/auth/refresh')) { + return jsonResponse(200, { + tokens: { + accessToken: 'fresh-post-token', + refreshToken: 'refresh-token-2', + expiresIn: 3600, + tokenType: 'Bearer', + }, + }); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + const response = await api.post('/orders', { id: 42 }, undefined, { + headers: { 'X-Custom': 'keep-me' }, + }); + + assert.equal(response.status, 200); + assert.equal(orderCalls.length, 2); + for (const call of orderCalls) { + assert.equal(call.method, 'POST'); + assert.equal(call.customHeader, 'keep-me'); + assert.equal(call.body, JSON.stringify({ id: 42 })); + } + assert.equal(orderCalls[1].auth, 'Bearer fresh-post-token'); +} + +async function testRefreshFailureClearsAuthAndThrows(api) { + seedTokens('expired-token', 'bad-refresh-token'); + let refreshCalls = 0; + + globalThis.fetch = async (url, init = {}) => { + if (String(url).endsWith('/needs-auth')) { + return jsonResponse(401, { error: 'expired' }); + } + + if (String(url).endsWith('/auth/refresh')) { + refreshCalls += 1; + assert.equal(JSON.parse(init.body).refreshToken, 'bad-refresh-token'); + return jsonResponse(401, { error: 'invalid refresh' }); + } + + throw new Error(`Unexpected URL ${url}`); + }; + + await assert.rejects( + () => api.get('/needs-auth'), + error => error.code === 401 && /refresh/i.test(error.message) + ); + assert.equal(refreshCalls, 1); + assert.equal(localStorage.getItem('tot_auth_tokens'), null); + assert.equal(localStorage.getItem('auth_token'), null); +} + +async function testRefreshEndpointDoesNotLoop(api) { + seedTokens('expired-token', 'refresh-token'); + let calls = 0; + + globalThis.fetch = async (url) => { + assert.ok(String(url).endsWith('/auth/refresh')); + calls += 1; + return jsonResponse(401, { error: 'refresh rejected' }); + }; + + await assert.rejects( + () => api.post('/auth/refresh', { refreshToken: 'refresh-token' }), + error => error.code === 401 + ); + assert.equal(calls, 1); +} + +const api = await loadApiModule(); + +for (const [name, test] of [ + ['concurrent 401s share one refresh', testConcurrent401sShareOneRefresh], + ['POST retry preserves method headers and body', testPostRetryPreservesMethodHeadersAndBody], + ['refresh failure clears auth and throws', testRefreshFailureClearsAuthAndThrows], + ['refresh endpoint does not loop', testRefreshEndpointDoesNotLoop], +]) { + try { + await test(api); + } catch (error) { + console.error(`api refresh validation failed: ${name}`); + throw error; + } +} + +console.log('api refresh validation passed');