const API_BASE = ""; const TOKEN_KEY = "dtp_auth_token"; const USER_KEY = "dtp_user"; let isRefreshing = false; let refreshPromise: Promise | null = null; /** * Callback invoked after a successful token refresh so that other * modules (e.g., the socket client) can update their stored JWT. * Set via `setOnTokenRefreshed()`. */ let onTokenRefreshedCallback: ((newToken: string) => void) | null = null; /** * Register a callback to be invoked whenever the JWT is refreshed. * Used by the socket client to update its auth token for reconnections. */ export function setOnTokenRefreshed(cb: (newToken: string) => void): void { onTokenRefreshedCallback = cb; } export interface ApiResponse { success: boolean; message?: string; user?: T; token?: string; data?: T; } function getToken(): string | null { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } } function setToken(token: string): void { localStorage.setItem(TOKEN_KEY, token); } function signOut(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); window.location.href = "/"; } /** * Check if the current JWT's `exp` claim is within the refresh threshold. * Decodes the payload (base64) without cryptographic verification. * Returns true if the token will expire within `marginMs` milliseconds. */ function isTokenExpiringSoon(token: string, marginMs = 5 * 60 * 1000): boolean { try { const parts = token.split("."); if (parts.length < 2) return true; const payload = JSON.parse(atob(parts[1]!)); if (!payload.exp) return true; const expiresAt = payload.exp * 1000; // seconds → ms return Date.now() > (expiresAt - marginMs); } catch { return true; // if we can't decode it, treat it as expiring } } async function refreshAuthToken(): Promise { const response = await fetch(`${API_BASE}/api/v1/auth/renew-token`, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`); } const text = await response.text(); try { const json = JSON.parse(text) as ApiResponse & { token?: string; data?: { token?: string } }; if (!json.success) { throw new Error(json.message || "Token refresh failed"); } /* * The API v1 renew-token endpoint returns { success: true, token: "..." } * at the top level (not nested under `data`). Handle both formats for * robustness: check json.token first, then fall back to json.data?.token. */ const newToken = json.token ?? json.data?.token; if (!newToken) { throw new Error("Token refresh response missing token"); } return newToken; } catch (err) { if (err instanceof Error && err.message.includes("Token refresh")) throw err; throw new Error(`Invalid refresh response: ${text.slice(0, 200)}`); } } async function request( method: string, path: string, body?: Record, retryCount = 0, ): Promise { let token = getToken(); /* * Proactive token refresh: if the JWT's `exp` claim shows it will expire * within 5 minutes, refresh it before making the request. This avoids * unnecessary 401 errors and the resulting socket disconnections. */ if (token && isTokenExpiringSoon(token)) { try { if (!isRefreshing) { isRefreshing = true; refreshPromise = refreshAuthToken(); } token = (await refreshPromise)!; setToken(token); onTokenRefreshedCallback?.(token); isRefreshing = false; refreshPromise = null; } catch { isRefreshing = false; refreshPromise = null; // Don't sign out on proactive refresh failure — the token may still // be valid at the DB level even if the JWT exp is close. Let the // reactive 401 handler below deal with it if needed. } } const headers: Record = { "Content-Type": "application/json", }; if (token) { headers["Authorization"] = `Bearer ${token}`; } const options: RequestInit = { method, headers, credentials: "include", }; if (body) { options.body = JSON.stringify(body); } const response = await fetch(`${API_BASE}${path}`, options); if (response.status === 401 && retryCount === 0) { try { if (!isRefreshing) { isRefreshing = true; refreshPromise = refreshAuthToken(); } const newToken = (await refreshPromise)!; setToken(newToken); onTokenRefreshedCallback?.(newToken); isRefreshing = false; refreshPromise = null; return request(method, path, body, retryCount + 1); } catch { isRefreshing = false; refreshPromise = null; signOut(); throw new Error("Session expired. Please sign in again."); } } const text = await response.text(); if (!text) { throw new Error("Empty response"); } try { var json = JSON.parse(text) as ApiResponse; } catch { throw new Error(`Invalid JSON: ${text.slice(0, 200)}`); } if (!json.success) { throw new Error(json.message || "Request failed"); } if (json.token !== undefined && json.user !== undefined) { return json as T; } if (json.data !== undefined) { return json.data as T; } return json as T; } export const api = { get: (path: string) => request("GET", path), post: (path: string, body?: Record) => request("POST", path, body), put: (path: string, body: Record) => request("PUT", path, body), delete: (path: string) => request("DELETE", path), }; export interface User { _id: string; email: string; displayName: string; flags: string[]; persona?: string; } export interface AuthResponse { user: User; token: string; } export interface ProjectSkill { _id: string; name: string; content: string; modes: string[]; } export interface ProjectTask { _id: string; name: string; provider: string; selectedModel: string; mode: string; crontab: string; content: string; enabled: boolean; lastRun?: string; } export interface Project { _id: string; createdAt: string; user: string; status: "active" | "inactive" | "archived"; name: string; slug: string; gitUrl?: string; description?: string; system?: string; skills: ProjectSkill[]; tasks: ProjectTask[]; } export const userApi = { updateSettings: (data: { displayName?: string; currentPassword?: string; password?: string; persona?: string; }) => api.put("/api/v1/user/settings", data), }; export const projectApi = { getAll: () => api.get("/api/v1/projects"), get: (id: string) => api.get(`/api/v1/projects/${id}`), create: (data: { name: string; slug: string; gitUrl?: string }) => api.post("/api/v1/projects", data), pull: (gitUrl: string) => api.post("/api/v1/projects/pull", { gitUrl }), update: ( id: string, data: Partial<{ name: string; slug: string; gitUrl: string; status: string; description: string; system: string; skills: ProjectSkill[]; tasks: ProjectTask[]; }>, ) => api.put(`/api/v1/projects/${id}`, data), delete: (id: string) => api.delete(`/api/v1/projects/${id}`), }; export interface SubProcessStat { pid: number; command: string; args: string[]; projectId: string; projectSlug: string; projectName: string; status: "running" | "stopped" | "error"; createdAt: string; updatedAt: string; stdoutFileName: string; stderrFileName: string; } export interface DroneRegistration { _id: string; hostname: string; workspaceDir: string; status: "starting" | "available" | "busy" | "offline"; user: string; createdAt: string; updatedAt: string; } export const droneApi = { getAll: () => api.get("/api/v1/drone/registration"), getForManager: () => api.get("/api/v1/drone/registration/manager"), terminate: (registrationId: string) => api.post( `/api/v1/drone/registration/${registrationId}/terminate`, ), }; export interface AiModelCapabilities { canCallTools: boolean; hasVision: boolean; hasEmbedding: boolean; hasThinking: boolean; isInstructTuned: boolean; } export interface AiModelSettings { temperature?: number; topP?: number; topK?: number; numCtx?: number; } export interface AiModel { id: string; name: string; parameterCount?: number; parameterLabel?: string; contextWindow?: number; capabilities: AiModelCapabilities; settings?: AiModelSettings; } export interface AiProvider { _id: string; name: string; apiType: "ollama" | "openai"; baseUrl: string; enabled: boolean; models: AiModel[]; lastModelRefresh: string; } export const providerApi = { getAll: () => api.get("/api/v1/providers"), get: (id: string) => api.get(`/api/v1/providers/${id}`), }; export enum ChatSessionMode { Plan = "plan", Build = "build", Test = "test", Ship = "ship", Develop = "dev", } export interface ChatSessionStats { turnCount: number; toolCallCount: number; inputTokens: number; outputTokens: number; } export interface ChatSession { _id: string; createdAt: string; lastMessageAt?: string; user: string | any; project: string | any; name: string; mode: ChatSessionMode; provider: string | AiProvider; selectedModel: string; reasoningEffort?: string; numCtx?: number; contextWindowUsage: number; stats: ChatSessionStats; pins: Array<{ _id?: string; content: string }>; } export interface ChatTurnStats { toolCallCount: number; inputTokens: number; thinkingTokenCount: number; responseTokens: number; durationMs: number; durationLabel: string; } export interface IWorkOrderCompleteStats { masterInputTokens: number; masterOutputTokens: number; masterThinkingTokens: number; toolCallCount: number; durationMs: number; } export interface ChatTurnPrompts { user: string; system?: string; } export interface ChatTurnBlockThinking { mode: 'thinking'; createdAt: string; content: string; } export interface ChatTurnBlockResponding { mode: 'responding'; createdAt: string; content: string; } export interface ChatTurnBlockTool { mode: 'tool'; createdAt: string; content: { callId: string; name: string; parameters?: string; response?: string; subagent?: { agentId: string; thinking: string; response: string; toolCalls: Array<{ callId: string; name: string; parameters?: string; response?: string; }>; stats: ChatTurnStats; }; }; } export type ChatTurnBlock = ChatTurnBlockThinking | ChatTurnBlockResponding | ChatTurnBlockTool; export interface ChatTurn { _id: string; createdAt: string; user: string | any; project: string | any; session: string | ChatSession; provider: string | AiProvider; llm: string; numCtx?: number; mode: ChatSessionMode; status: "processing" | "finished" | "aborted" | "error"; prompts: ChatTurnPrompts; blocks: ChatTurnBlock[]; errorMessage?: string; toolCalls: Array<{ callId: string; name: string; parameters?: string; response?: string; subagent?: ChatTurnBlockTool['content']['subagent']; }>; subagents: any[]; stats: ChatTurnStats; } export const chatSessionApi = { getAll: (projectId?: string, limit?: number) => api.get( `/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}${limit ? `${projectId ? "&" : "?"}limit=${limit}` : ""}`, ), get: (id: string) => api.get(`/api/v1/chat-sessions/${id}`), create: (data: { projectId: string; providerId: string; selectedModel: string; mode?: ChatSessionMode; name?: string; }) => api.post("/api/v1/chat-sessions", data), update: (id: string, data: Partial) => api.put(`/api/v1/chat-sessions/${id}`, data), delete: (id: string) => api.delete(`/api/v1/chat-sessions/${id}`), getTurns: (id: string) => api.get(`/api/v1/chat-sessions/${id}/turns`), };