/** * API client — talks to the backend (Section 8 endpoints). * - Injects `Authorization: Bearer ` on every call. * - On 401, transparently calls /auth/refresh (using the httpOnly cookie) and * retries the original request once (Section 6.4 session expiry UX). * - Refresh tokens never touch JS (httpOnly cookie set by the backend). */ const BASE = '/api'; export class ApiError extends Error { code: string; status: number; constructor(status: number, code: string, message: string) { super(message); this.status = status; this.code = code; } } let accessToken: string | null = null; let onAuthFailure: (() => void) | null = null; export function setAccessToken(t: string | null) { accessToken = t; } export function getAccessToken() { return accessToken; } export function setOnAuthFailure(cb: () => void) { onAuthFailure = cb; } let refreshing: Promise | null = null; async function tryRefresh(): Promise { if (!refreshing) { refreshing = (async () => { try { const res = await fetch(`${BASE}/auth/refresh`, { method: 'POST', credentials: 'include' }); if (!res.ok) return false; const data = await res.json(); accessToken = data.access_token; return true; } catch { return false; } finally { refreshing = null; } })(); } return refreshing; } async function request( method: string, path: string, body?: unknown, attempt = 0, ): Promise { const headers: Record = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: 'include', }); if (res.status === 401 && attempt === 0) { // Transparent refresh + retry once (Section 6.4). const ok = await tryRefresh(); if (ok) return request(method, path, body, attempt + 1); onAuthFailure?.(); throw new ApiError(401, 'SESSION_EXPIRED', 'Session expired, please log in again'); } const text = await res.text(); let data: any = null; if (text) { try { data = JSON.parse(text); } catch { data = text; } } if (!res.ok) { const code = data?.error?.code || 'ERROR'; const message = data?.error?.message || (typeof data === 'string' ? data : 'Request failed'); throw new ApiError(res.status, code, message); } // 204 No Content if (res.status === 204) return undefined as T; return data as T; } export const api = { // Auth & Licensing (8.1) register: (email: string, password: string) => request<{ user_id: string; access_token: string }>('POST', '/auth/register', { email, password }), login: (email: string, password: string) => request<{ user_id: string; access_token: string; license_status: string }>('POST', '/auth/login', { email, password }), refresh: () => request<{ access_token: string }>('POST', '/auth/refresh'), logout: () => request('POST', '/auth/logout'), activateLicense: (license_key: string) => request<{ status: string; activated_at: string }>('POST', '/auth/activate-license', { license_key }), // Campaigns (8.2 / 8.3) createCampaign: (payload: any) => request<{ campaign_id: string; status: string }>('POST', '/campaigns', payload), listCampaigns: (query: Record) => request<{ campaigns: any[]; total: number; page: number }>('GET', `/campaigns?${new URLSearchParams(query)}`), getCampaign: (id: string) => request<{ campaign: any }>('GET', `/campaigns/${id}`), getAssets: (id: string) => request<{ assets: Record }>('GET', `/campaigns/${id}/assets`), duplicateCampaign: (id: string) => request<{ new_campaign_id: string }>('POST', `/campaigns/${id}/duplicate`), deleteCampaign: (id: string) => request('DELETE', `/campaigns/${id}`), updateAsset: (id: string, assetType: string, content: string) => request<{ asset: any; version: number; is_manual_edit: boolean }>('PATCH', `/campaigns/${id}/assets/${assetType}`, { content }), regenerateAsset: (id: string, assetType: string, custom_instruction?: string) => request<{ asset: any; version: number }>('POST', `/campaigns/${id}/assets/${assetType}/regenerate`, { custom_instruction }), // Export (8.4) createExport: (id: string, formats: string[], bundle_as_zip: boolean) => request<{ export_id: string; status: string }>('POST', `/campaigns/${id}/export`, { formats, bundle_as_zip }), listExports: (id: string) => request<{ exports: any[] }>('GET', `/campaigns/${id}/exports`), downloadUrl: (id: string) => `${BASE}/exports/${id}/download`, }; export function downloadFile(url: string) { // Opens with the Authorization header via a fetch blob (cookie + token). const a = document.createElement('a'); a.href = url + (url.includes('?') ? '&' : '?') + '_t=' + Date.now(); a.download = ''; // Note: for token-protected downloads we rely on the same-origin cookie path // in dev proxy; production would use a signed URL from storage. a.click(); }