/** Тонкий клиент к 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() {
dropSession();
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");
}
// --- Личный кабинет (app/api/me_routes.py): любой аутентифицированный. ---
/** Профиль: без ролевого гейта, role в ответе (system_role) — с этого гейт роутера. */
export function me() {
return apiFetch("/api/v1/me");
}
// --- Сессия для роут-гейта: один общий промис, кешированный по access-токену.
// Тихое обновление токена меняет ключ кеша -> рефетч; параллельные вызовы
// сходятся в один полёт. Ошибка не кешируется навечно — гейт уведёт на /login. ---
let meCache = null;
let meCacheToken = "";
export function getSession() {
const token = getToken();
if (!token) return Promise.resolve(null);
if (meCache && meCacheToken === token) return meCache;
meCacheToken = token;
meCache = me().catch((err) => {
meCache = null;
throw err;
});
return meCache;
}
function dropSession() {
meCache = null;
meCacheToken = "";
}
// --- Admin API (app/api/admin_routes.py). Все эндпоинты под require_admin. ---
export const api = {
// Источники и ключи
listSources: () => apiFetch("/api/v1/admin/sources"),
createSource: (data) => apiFetch("/api/v1/admin/sources", { method: "POST", body: JSON.stringify(data) }),
deleteSource: (id) => apiFetch(`/api/v1/admin/sources/${id}`, { method: "DELETE" }),
listKeys: (sourceId) => apiFetch(`/api/v1/admin/sources/${sourceId}/keys`),
createKey: (sourceId, data) =>
apiFetch(`/api/v1/admin/sources/${sourceId}/keys`, { method: "POST", body: JSON.stringify(data) }),
revokeKey: (sourceId, keyId) =>
apiFetch(`/api/v1/admin/sources/${sourceId}/keys/${keyId}/revoke`, { method: "POST" }),
// Типы
listTypes: () => apiFetch("/api/v1/admin/types"),
createType: (data) => apiFetch("/api/v1/admin/types", { method: "POST", body: JSON.stringify(data) }),
deleteType: (id) => apiFetch(`/api/v1/admin/types/${id}`, { method: "DELETE" }),
// Цели каналов
listTargets: () => apiFetch("/api/v1/admin/targets"),
createTarget: (data) => apiFetch("/api/v1/admin/targets", { method: "POST", body: JSON.stringify(data) }),
patchTarget: (id, data) => apiFetch(`/api/v1/admin/targets/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteTarget: (id) => apiFetch(`/api/v1/admin/targets/${id}`, { method: "DELETE" }),
// Правила
listRules: () => apiFetch("/api/v1/admin/rules"),
createRule: (data) => apiFetch("/api/v1/admin/rules", { method: "POST", body: JSON.stringify(data) }),
patchRule: (id, data) => apiFetch(`/api/v1/admin/rules/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteRule: (id) => apiFetch(`/api/v1/admin/rules/${id}`, { method: "DELETE" }),
// Поток (только чтение)
listEvents: (query = {}) => apiFetch(`/api/v1/admin/events?${new URLSearchParams(query)}`),
listDeliveries: (query = {}) => apiFetch(`/api/v1/admin/deliveries?${new URLSearchParams(query)}`),
// Личный кабинет
myEvents: (query = {}) => apiFetch(`/api/v1/me/events?${new URLSearchParams(query)}`),
};
/** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */
export async function logout() {
try {
await apiFetch("/auth/logout", { method: "POST" });
} finally {
clearToken();
}
}