Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions diagnostic/build-2b54872c.json
Original file line number Diff line number Diff line change
@@ -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."
}
234 changes: 211 additions & 23 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, unknown>;
path?: string;

constructor(message: string, details?: Record<string, unknown>, path?: string) {
super(message);
this.name = 'AuthenticationError';
this.details = details;
this.path = path;
}
}

export interface RequestConfig {
timeout?: number;
retries?: number;
Expand Down Expand Up @@ -121,6 +147,7 @@ type ErrorInterceptor = (error: ApiError) => ApiError;
const requestInterceptors: RequestInterceptor[] = [];
const responseInterceptors: ResponseInterceptor[] = [];
const errorInterceptors: ErrorInterceptor[] = [];
let tokenRefreshPromise: Promise<AuthTokens> | null = null;

export function addRequestInterceptor(interceptor: RequestInterceptor): () => void {
requestInterceptors.push(interceptor);
Expand Down Expand Up @@ -149,7 +176,7 @@ export function addErrorInterceptor(interceptor: ErrorInterceptor): () => void {
// Default request interceptor: adds auth headers
addRequestInterceptor((config) => {
const headers = config.headers as Record<string, string> || {};
const token = localStorage.getItem('auth_token');
const token = getStoredAccessToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
// Legacy auth header for internal services
Expand Down Expand Up @@ -182,9 +209,7 @@ addResponseInterceptor(<T>(response: ApiResponse<T>): ApiResponse<T> => {
// 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...');
Expand All @@ -211,30 +236,24 @@ async function request<T>(
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<string, string>,
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<T>(url, method, data, config, timeout);

if (responseData.status === 401) {
if (!didRetryAfterRefresh && !isAuthRefreshPath(path)) {
await refreshAuthTokens(path);
didRetryAfterRefresh = true;
responseData = await sendRequest<T>(url, method, data, config, timeout);
}
}

const responseData = await parseResponse<T>(response);
if (responseData.status === 401) {
throw new AuthenticationError('Authentication failed', { status: responseData.status }, path);
}

// Apply response interceptors
let apiResponse: ApiResponse<T> = responseData;
Expand All @@ -246,6 +265,10 @@ async function request<T>(
} 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));
Expand All @@ -265,6 +288,160 @@ async function request<T>(
throw processedError;
}

async function sendRequest<T>(
url: string,
method: string,
data: unknown,
config: RequestConfig | undefined,
timeout: number
): Promise<ApiResponse<T>> {
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<T>(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<AuthTokens> {
if (!tokenRefreshPromise) {
tokenRefreshPromise = performTokenRefresh(path).finally(() => {
tokenRefreshPromise = null;
});
}

return tokenRefreshPromise;
}

async function performTokenRefresh(path: string): Promise<AuthTokens> {
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<AuthTokens>>(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<AuthTokens>) | 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;
Expand Down Expand Up @@ -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,
Expand Down
Loading