import { CONFIG } from './config.js'; export class ApiError extends Error { constructor(message, status = null) { super(message); this.name = 'ApiError'; this.status = status; } } // The API's explanation from an error response: FastAPI's {"detail": "..."}, or for validation // errors {"detail": [{"msg": "..."}, ...]} async function errorDetail(res) { try { const { detail } = await res.json(); if (typeof detail === 'string') return detail; if (Array.isArray(detail)) return detail.map((d) => d.msg).filter(Boolean).join('; '); } catch { // not JSON: fall back to the status code } return null; } async function request(path, { method = 'GET', body } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs); const headers = { Accept: 'application/json' }; if (method !== 'GET') { // Required by the API for changes: another site's page can't add it to a request here headers['X-Requested-By'] = 'backup-dashboard'; } if (body !== undefined) headers['Content-Type'] = 'application/json'; try { const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin', // the sign-in session cookie signal: controller.signal, }); // 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401); if (!res.ok) { throw new ApiError((await errorDetail(res)) ?? `Server returned HTTP ${res.status}`, res.status); } return await res.json(); } catch (err) { if (err.name === 'AbortError') throw new ApiError('Request timed out'); if (err instanceof ApiError) throw err; throw new ApiError(`Network error: ${err.message}`); } finally { clearTimeout(timer); } } const httpApi = { /** GET /jobs → { generated_at, jobs: Job[] } */ async listJobs() { const data = await request('/jobs'); if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs'); return data; }, /** GET /jobs/:id → JobDetail */ getJob(id) { return request(`/jobs/${encodeURIComponent(id)}`); }, /** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */ listRuns(id, days) { return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`); }, /** GET /runs/:id → Run (with message and details) */ getRun(id) { return request(`/runs/${encodeURIComponent(id)}`); }, /** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */ getMe() { return request('/me'); }, // ----- Admin (403 for non-admins) ----- /** GET /admin/keys → { keys: ApiKey[] } (active first) */ listKeys() { return request('/admin/keys'); }, /** POST /admin/keys → { key: ApiKey, secret } — the secret is only ever returned here */ createKey(name) { return request('/admin/keys', { method: 'POST', body: { name } }); }, /** POST /admin/keys/:id/revoke → { key: ApiKey } */ revokeKey(id) { return request(`/admin/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST' }); }, }; // Mock is loaded lazily so it never ships in the production request path. export async function getApi() { if (CONFIG.useMock) { const { mockApi } = await import('./mock.js'); return mockApi; } return httpApi; }