const jsonHeaders = { "Content-Type": "application/json", Accept: "application/json" };
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestWithRetry(path, options = {}, retries = 3) {
let lastError;
for (let attempt = 0; attempt < retries; attempt++) {
try {
const response = await fetch(path, {
credentials: "include",
...options,
headers: {
...jsonHeaders,
...(options.headers || {})
}
});
if (response.status === 204) {
return null;
}
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload?.error?.message || "Request failed");
error.status = response.status;
// A session can die mid-flight (global logout webhook, TTL) — any
// 401 tells the SPA to drop onto the sign-in screen without a reload.
if (response.status === 401) {
window.dispatchEvent(new Event("gncreds:unauthenticated"));
}
throw error;
}
return payload;
} catch (err) {
lastError = err;
// Retry only what a repeat could fix: network failures (no status)
// and 5xx. A 4xx answer is final — notably a 401 must not be re-sent.
const isRetryable = err.status ? err.status >= 500 : true;
if (!isRetryable || attempt >= retries - 1) {
throw err;
}
await sleep(300 * Math.pow(2, attempt));
}
}
throw lastError;
}
function request(path, options = {}) {
return requestWithRetry(path, options, 3);
}
export const api = {
health: () => request("/health"),
ready: () => request("/ready"),
me: () => request("/api/v1/me"),
updateMe: (payload) =>
request("/api/v1/me", { method: "PATCH", body: JSON.stringify(payload) }),
logout: () => request("/auth/logout", { method: "POST" }),
listSecrets: (params = {}) => {
const query = new URLSearchParams(params);
return request(`/api/v1/secrets?${query}`);
},
createSecret: (payload) =>
request("/api/v1/secrets", { method: "POST", body: JSON.stringify(payload) }),
getSecret: (id) => request(`/api/v1/secrets/${id}`),
updateSecret: (id, payload) =>
request(`/api/v1/secrets/${id}`, { method: "PATCH", body: JSON.stringify(payload) }),
deleteSecret: (id) => request(`/api/v1/secrets/${id}`, { method: "DELETE" }),
revealSecret: (id) => request(`/api/v1/secrets/${id}/reveal`, { method: "POST" }),
versions: (id) => request(`/api/v1/secrets/${id}/versions`),
revealVersion: (id, versionId) =>
request(`/api/v1/secrets/${id}/versions/${versionId}/reveal`, { method: "POST" }),
audit: (params = {}) => {
const query = new URLSearchParams(params);
return request(`/api/v1/audit-events?${query}`);
},
secretAudit: (id, params = {}) => {
const query = new URLSearchParams(params);
return request(`/api/v1/secrets/${id}/audit-events?${query}`);
},
tokens: () => request("/api/v1/api-tokens"),
createToken: (payload) =>
request("/api/v1/api-tokens", { method: "POST", body: JSON.stringify(payload) }),
revokeToken: (id) =>
request(`/api/v1/api-tokens/${id}/revoke`, { method: "PATCH" }),
exportData: () => request("/api/v1/export", { method: "POST" }),
importData: (payload) =>
request("/api/v1/import", { method: "POST", body: JSON.stringify(payload) }),
deleteAccountData: () => request("/api/v1/account-data", { method: "DELETE" }),
adminUsers: (params = {}) => {
const query = new URLSearchParams(params);
return request(`/api/v1/admin/users?${query}`);
},
stats: () => request("/api/v1/stats"),
extension: () => request("/api/v1/extension"),
downloadExtension: (filename) => `/api/v1/extension/download/${encodeURIComponent(filename)}`,
createBackup: () => request("/api/v1/admin/backup", { method: "POST" }),
listBackups: () => request("/api/v1/admin/backups"),
downloadBackup: (filename) => `/api/v1/admin/backups/${encodeURIComponent(filename)}`,
restoreBackup: (filename) =>
request("/api/v1/admin/restore", { method: "POST", body: JSON.stringify({ filename }) })
};