/** Тонкий клиент к API Synapse. Токены — opaque Bearer от gnexus-auth, приходят
* с /auth/callback (сервер сам ведёт OAuth, UI только принимает результат). */
const TOKEN_KEY = "synapse.access_token";
const REFRESH_KEY = "synapse.refresh_token";
function getItem(key) {
try {
return localStorage.getItem(key) || "";
} catch {
return "";
}
}
export function getToken() {
return getItem(TOKEN_KEY);
}
export function setToken(token) {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* приватный режим — жить без сохранения сессии */
}
}
export function getRefreshToken() {
return getItem(REFRESH_KEY);
}
export function setRefreshToken(token) {
try {
localStorage.setItem(REFRESH_KEY, token);
} catch {
/* см. setToken */
}
}
export function clearToken() {
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
} catch {
/* игнорируем */
}
}
let refreshing = null;
/** Тихое обновление access-токена по refresh-токену (одним полётом на все 401). */
function refreshAccessToken() {
refreshing ??= fetch("/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: getRefreshToken() }),
})
.then(async (response) => {
if (!response.ok) throw new Error("refresh_failed");
const payload = await response.json();
setToken(payload.access_token);
if (payload.refresh_token) setRefreshToken(payload.refresh_token);
return payload.access_token;
})
.finally(() => {
refreshing = null;
});
return refreshing;
}
async function apiFetch(path, options = {}, retried = false) {
const headers = { ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
if (options.body) headers["Content-Type"] = "application/json";
let response = await fetch(path, { ...options, headers });
// Токен истёк — пробуем обновить и повторить запрос один раз.
if (response.status === 401 && retried === false && getRefreshToken()) {
try {
await refreshAccessToken();
return apiFetch(path, options, true);
} catch {
clearToken();
}
}
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
if (payload.detail) detail = payload.detail;
} catch {
/* не JSON — берём statusText */
}
const error = new Error(detail);
error.status = response.status;
throw error;
}
return response.json();
}
export function healthz() {
return apiFetch("/api/healthz");
}
/** Профиль текущего пользователя. 401 — токен невалиден, 403 — роль ниже admin. */
export function adminMe() {
return apiFetch("/api/v1/admin/me");
}
/** Логаут: revoke access-токена на gnexus-auth. */
export async function logout() {
try {
await apiFetch("/auth/revoke", { method: "POST" });
} finally {
clearToken();
}
}