// ========================================== // Switchboard Backend API Client // ========================================== // Handles auth + chat/message CRUD against the // Switchboard backend. Token refresh is automatic. // ========================================== const Backend = { baseUrl: '', accessToken: null, refreshToken: null, user: null, _refreshPromise: null, // ── Connection ────────────────────────── get isConnected() { return !!(this.baseUrl && this.accessToken); }, get isManaged() { return this.isConnected; }, // ── Init ──────────────────────────────── init() { // Auto-detect: if we're served from a domain with /api/v1, // the backend is co-located. Otherwise check saved URL. const saved = Storage.get('switchboard_backend'); if (saved) { this.baseUrl = saved.baseUrl || ''; this.accessToken = saved.accessToken || null; this.refreshToken = saved.refreshToken || null; this.user = saved.user || null; } }, save() { Storage.set('switchboard_backend', { baseUrl: this.baseUrl, accessToken: this.accessToken, refreshToken: this.refreshToken, user: this.user }); }, clear() { this.accessToken = null; this.refreshToken = null; this.user = null; this.save(); }, // ── Auth ──────────────────────────────── async register(username, email, password) { const data = await this._fetch('/api/v1/auth/register', { method: 'POST', body: JSON.stringify({ username, email, password }) }, true); this._setAuth(data); return data; }, async login(login, password) { const data = await this._fetch('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ login, password }) }, true); this._setAuth(data); return data; }, async logout() { try { await this._fetch('/api/v1/auth/logout', { method: 'POST', body: JSON.stringify({ refresh_token: this.refreshToken }) }, true); } catch (e) { // Best-effort } this.clear(); }, async refresh() { // Deduplicate concurrent refresh calls if (this._refreshPromise) return this._refreshPromise; this._refreshPromise = (async () => { try { const data = await this._fetch('/api/v1/auth/refresh', { method: 'POST', body: JSON.stringify({ refresh_token: this.refreshToken }) }, true); this._setAuth(data); return true; } catch (e) { this.clear(); return false; } finally { this._refreshPromise = null; } })(); return this._refreshPromise; }, _setAuth(data) { this.accessToken = data.access_token; this.refreshToken = data.refresh_token; this.user = data.user; this.save(); }, // ── Health ─────────────────────────────── async checkHealth(url) { try { const resp = await fetch(url + '/api/v1/health', { signal: AbortSignal.timeout(5000) }); if (!resp.ok) return null; return await resp.json(); } catch (e) { return null; } }, // ── Chats ─────────────────────────────── async listChats(page = 1, perPage = 50) { return this._authedFetch(`/api/v1/chats?page=${page}&per_page=${perPage}`); }, async createChat(title, model, systemPrompt) { const body = { title }; if (model) body.model = model; if (systemPrompt) body.system_prompt = systemPrompt; return this._authedFetch('/api/v1/chats', { method: 'POST', body: JSON.stringify(body) }); }, async getChat(chatId) { return this._authedFetch(`/api/v1/chats/${chatId}`); }, async updateChat(chatId, updates) { return this._authedFetch(`/api/v1/chats/${chatId}`, { method: 'PUT', body: JSON.stringify(updates) }); }, async deleteChat(chatId) { return this._authedFetch(`/api/v1/chats/${chatId}`, { method: 'DELETE' }); }, // ── Messages ──────────────────────────── async listMessages(chatId, page = 1, perPage = 100) { return this._authedFetch( `/api/v1/chats/${chatId}/messages?page=${page}&per_page=${perPage}` ); }, async createMessage(chatId, role, content, model) { const body = { role, content }; if (model) body.model = model; return this._authedFetch(`/api/v1/chats/${chatId}/messages`, { method: 'POST', body: JSON.stringify(body) }); }, // ── HTTP Layer ────────────────────────── async _authedFetch(path, opts = {}) { try { return await this._fetch(path, { ...opts, headers: { ...opts.headers, 'Authorization': `Bearer ${this.accessToken}` } }); } catch (e) { // If 401, try refresh once if (e.status === 401 && this.refreshToken) { const refreshed = await this.refresh(); if (refreshed) { return this._fetch(path, { ...opts, headers: { ...opts.headers, 'Authorization': `Bearer ${this.accessToken}` } }); } } throw e; } }, async _fetch(path, opts = {}, skipAuth = false) { const url = this.baseUrl + path; const resp = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', ...opts.headers } }); if (!resp.ok) { const body = await resp.json().catch(() => ({})); const err = new Error(body.error || `HTTP ${resp.status}`); err.status = resp.status; throw err; } return resp.json(); } };