// Request wrapper — all calls go through the Express proxy at /syncro-api/ // The Authorization header is injected server-side; never sent from the browser. // Session cookie is attached automatically by the browser (same-origin, httpOnly). export async function apiRequest(method, path, body = null, params = {}, { base = '/syncro-api' } = {}) { const url = new URL(base + path, window.location.origin); for (const [k, v] of Object.entries(params)) { if (v !== null && v !== undefined && v !== '') { url.searchParams.set(k, v); } } const options = { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }; if (body && (method === 'PUT' || method === 'POST')) { options.body = JSON.stringify(body); } let response; try { response = await fetch(url.toString(), options); } catch (err) { throw new Error('Network error — check connection. ' + err.message); } // Session expired — redirect to login only if the 401 came from our own auth // wall (code: SESSION_EXPIRED), not from an upstream Syncro API auth error. if (response.status === 401) { let body = {}; try { body = await response.clone().json(); } catch { /* ignore */ } if (body.code === 'SESSION_EXPIRED') { window.location.href = '/login.html?expired=1'; throw new Error('Session expired.'); } throw new Error('Upstream authorization error (HTTP 401).'); } // Syncro returns 204 No Content on successful PUT if (response.status === 204) return {}; let data; try { data = await response.json(); } catch { throw new Error(`Non-JSON response (HTTP ${response.status})`); } if (!response.ok) { const msg = data?.error || data?.message || data?.errors?.join?.(', ') || `HTTP ${response.status}`; throw new Error(msg); } return data; }