// Real in-browser API client for demo.flow-master.ai. // - dev-login → bearer (cached in sessionStorage, refresh on 401) // - typed wrappers for the endpoints Mission Control needs // - all fetches honor an AbortSignal so unmounts cancel cleanly // - never throws into render: returns { ok, error } shapes export interface ApiConfig { baseUrl: string; email: string; } // In dev, route via vite proxy (same-origin) to dodge CORS; in production // the build is deployed at the same origin as the backend, so an empty // baseUrl is correct. const DEFAULT_CONFIG: ApiConfig = { baseUrl: import.meta.env.VITE_FM_BASE ?? "", email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai", }; const TOKEN_KEY = "fm.mc.token.v1"; let inflightLogin: Promise | null = null; async function login(cfg: ApiConfig, signal?: AbortSignal): Promise { if (inflightLogin) return inflightLogin; inflightLogin = (async () => { const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: cfg.email }), signal, }); if (!r.ok) throw new Error(`dev-login ${r.status}`); const body = (await r.json()) as { access_token: string }; sessionStorage.setItem(TOKEN_KEY, body.access_token); return body.access_token; })(); try { return await inflightLogin; } finally { inflightLogin = null; } } export interface WorkItem { transaction_id: string; short_id?: string; status: string; hub?: string; age_days?: number; active_step_display_name?: string; current_node?: string; next_action?: string; definition_key: string; business_subject?: string; created_at?: string; } export interface ProcessGraph { process_definition: { _key: string; name: string; display_name?: string; hub?: string; config: { nodes: unknown[]; edges: unknown[]; org_id?: string }; }; version_definitions?: Array<{ version: number; approved_at?: string }>; } export interface RuntimeTransaction { transaction_id: string; status: string; active_step?: { step_definition_id?: string; display_name?: string }; available_actions?: Array<{ display_label: string }>; created_at?: string; business_subject?: string; } export interface AuthMe { user_id: string; tenant_id: string; email: string; display_name?: string; } export interface Actor { mode: "direct_user" | "agent" | "system"; user_id?: string; } export interface ActionResult { transaction_id: string; action_id: string; status: string; previous_step?: { display_name?: string; step_run_id?: string }; next_step?: { display_name?: string; step_definition_id?: string }; active_step?: { display_name?: string; step_definition_id?: string }; } export type ApiCallObserver = (call: ApiCall) => void; export interface ApiCall { id: number; method: string; path: string; status: number; durationMs: number; reqBody?: unknown; resBody?: unknown; error?: string; at: number; } let callSeq = 0; const observers = new Set(); const emit = (c: ApiCall) => { for (const o of observers) try { o(c); } catch { /* swallow */ } }; async function instrumentedFetch( method: string, url: string, init: RequestInit, reqBody?: unknown, ): Promise { const id = ++callSeq; const at = Date.now(); try { const r = await fetch(url, init); const cloned = r.clone(); let resBody: unknown; try { const text = await cloned.text(); try { resBody = JSON.parse(text); } catch { resBody = text.slice(0, 500); } } catch { /* swallow */ } emit({ id, method, path: url, status: r.status, durationMs: Date.now() - at, reqBody, resBody, at }); return r; } catch (e) { emit({ id, method, path: url, status: 0, durationMs: Date.now() - at, reqBody, error: (e as Error).message, at }); throw e; } } async function authedRequest( cfg: ApiConfig, method: string, path: string, body?: unknown, signal?: AbortSignal, ): Promise { let token = sessionStorage.getItem(TOKEN_KEY); if (!token) token = await login(cfg, signal); const init: RequestInit = { method, headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, signal, body: body !== undefined ? JSON.stringify(body) : undefined, }; let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); if (r.status === 401) { sessionStorage.removeItem(TOKEN_KEY); token = await login(cfg, signal); init.headers = { ...init.headers, Authorization: `Bearer ${token}` }; r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); } if (!r.ok) { const text = await r.text().catch(() => ""); throw new Error(`${path} → ${r.status} ${text.slice(0, 200)}`); } return (await r.json()) as T; } export const api = { config: DEFAULT_CONFIG, /** Set the bearer token explicitly (e.g. from SSO or login form) */ setBearer(token: string) { sessionStorage.setItem(TOKEN_KEY, token); // Also set a non-httpOnly cookie for server-side middleware document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax`; }, /** Subscribe to all API calls. Returns unsubscribe. */ onCall(fn: ApiCallObserver): () => void { observers.add(fn); return () => observers.delete(fn); }, async me(signal?: AbortSignal): Promise { return authedRequest(this.config, "GET", "/api/v1/auth/me", undefined, signal); }, async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> { const payload = password ? { email, password } : { email }; const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login"; const init: RequestInit = { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(payload), signal, }; const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload); if (!r.ok) { const text = await r.text().catch(() => ""); throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`); } const data = await r.json() as { access_token: string; refresh_token?: string }; this.setBearer(data.access_token); return data; }, async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> { try { const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal }; const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init); if (r.ok) { return await r.json() as { enabled: boolean }; } } catch { // swallow } return { enabled: false }; }, async workItems(signal?: AbortSignal): Promise { const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal); return body.items ?? []; }, async graph(defKey: string, signal?: AbortSignal): Promise { try { return await authedRequest(this.config, "GET", `/api/ea2/process-definitions/${defKey}/graph`, undefined, signal); } catch { return null; } }, async transaction(txId: string, signal?: AbortSignal): Promise { try { return await authedRequest(this.config, "GET", `/api/runtime/transactions/${txId}`, undefined, signal); } catch { return null; } }, /** Start a fresh runtime instance of a process definition. */ async startTransaction( process_definition_id: string, business_subject?: string, signal?: AbortSignal, ): Promise { return authedRequest( this.config, "POST", "/api/runtime/transactions", { process_definition_id, business_subject: business_subject ?? null }, signal, ); }, /** Execute an action against the active step of a transaction. */ async executeAction( txId: string, actionId: string, actor: Actor, values: Record = {}, signal?: AbortSignal, ): Promise { return authedRequest( this.config, "POST", `/api/runtime/transactions/${txId}/actions/${actionId}`, { actor, values }, signal, ); }, /** Create a new published process definition (writes to /api/ea2/flow). */ async createProcess( payload: { name: string; display_name: string; label?: string; description?: string; hub?: string; config: { nodes: unknown[]; edges: unknown[]; org_id: string; executable?: boolean }; }, signal?: AbortSignal, ): Promise<{ _key: string; name: string }> { return authedRequest<{ _key: string; name: string }>( this.config, "POST", "/api/ea2/flow", { kind: "definition", status: "published", source_context: "mission-control-studio", version: 1, ...payload, }, signal, ); }, /** Probe whether the backend is reachable. Never throws. */ async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> { try { const me = await this.me(signal); return { ok: true, user: me.email, user_id: me.user_id }; } catch (e) { return { ok: false, reason: (e as Error).message }; } }, clearToken() { sessionStorage.removeItem(TOKEN_KEY); }, };