Files
CIAgent/apps/web/lib/api-client.ts
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

416 lines
14 KiB
TypeScript

import { API_BASE_URL } from "./config";
import type {
AlertDetailResponse,
AlertListFilters,
AlertResponse,
AlertUpdatePayload,
ApiErrorBody,
BanIpPayload,
CompanyCreatePayload,
CompanyResponse,
CompanyUpdatePayload,
ConfirmPasswordResetPayload,
DashboardAnalytics,
DiscoverCompanyRequest,
DiscoveredCompanyProfile,
IpBan,
LogEntry,
LoginPayload,
MeResponse,
MonitorConfigurationResponse,
MonitorConfigurationUpdatePayload,
MonitoringRunResponse,
NotificationDestinationCreatePayload,
NotificationDestinationResponse,
NotificationDestinationUpdatePayload,
NotificationTestResult,
RegisterPayload,
ReportListItem,
ReportResponse,
RequestPasswordResetPayload,
ResendVerificationPayload,
SecurityEvent,
SetSystemSecretPayload,
SetUserApiKeyPayload,
SnapshotResponse,
SourceCreatePayload,
SourceResponse,
SourceTestResult,
SourceUpdatePayload,
SystemSecretStatus,
SystemStatus,
TokenResponse,
UnbanRequestPayload,
UnbanRequestRecord,
UserApiKeyStatus,
UserResponse,
VerifyEmailPayload,
} from "./types";
const ACCESS_TOKEN_KEY = "ciagent_access_token";
const REFRESH_TOKEN_KEY = "ciagent_refresh_token";
// Local-storage token persistence keeps this MVP's client simple to run
// entirely outside Docker/without a backend session store. Tokens are
// short-lived (see JWT_ACCESS_TOKEN_MINUTES) and refresh tokens rotate on
// use; see KNOWN_LIMITATIONS.md for the httpOnly-cookie hardening this
// would need before a real multi-user production deployment.
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(accessToken: string, refreshToken: string): void {
window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export class ApiError extends Error {
constructor(
public status: number,
public body: ApiErrorBody | undefined,
) {
super(body?.detail ?? `Request failed with status ${status}`);
this.name = "ApiError";
}
}
interface RequestOptions extends RequestInit {
auth?: boolean;
}
// Access tokens are short-lived (JWT_ACCESS_TOKEN_MINUTES, 15min) while
// refresh tokens last days (JWT_REFRESH_TOKEN_DAYS, 7) - without this, any
// idle period past 15 minutes turned every authenticated request into a
// hard 401, which bounced the dashboard layout to /login while /login's own
// stale-cache "am I logged in?" check bounced right back, flickering
// between the two. A single in-flight refresh is shared across concurrent
// 401s so simultaneous requests don't each rotate (and invalidate) the
// refresh token out from under one another.
let refreshPromise: Promise<void> | null = null;
async function performRefresh(): Promise<void> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new ApiError(401, { detail: "No refresh token available" });
}
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
clearTokens();
throw new ApiError(response.status, undefined);
}
const tokens = (await response.json()) as TokenResponse;
setTokens(tokens.access_token, tokens.refresh_token);
}
async function request<T>(path: string, options: RequestOptions = {}, isRetry = false): Promise<T> {
const { auth = true, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
finalHeaders.set("Content-Type", "application/json");
if (auth) {
const token = getAccessToken();
if (token) finalHeaders.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...rest, headers: finalHeaders });
if (response.status === 401 && auth && !isRetry && getRefreshToken()) {
try {
refreshPromise ??= performRefresh().finally(() => {
refreshPromise = null;
});
await refreshPromise;
return request<T>(path, options, true);
} catch {
// Refresh itself failed (refresh token also expired/revoked) - fall
// through and report the original 401 below, which is now a real,
// final "you're actually logged out" rather than a transient one.
}
}
if (!response.ok) {
let body: ApiErrorBody | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
throw new ApiError(response.status, body);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
systemStatus: () => request<SystemStatus>("/api/v1/system/status", { auth: false }),
systemLogs: () => request<LogEntry[]>("/api/v1/system/logs"),
listSystemSecrets: () => request<SystemSecretStatus[]>("/api/v1/system/secrets"),
setSystemSecret: (key: string, payload: SetSystemSecretPayload) =>
request<SystemSecretStatus>(`/api/v1/system/secrets/${key}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
register: (payload: RegisterPayload) =>
request<UserResponse>("/api/v1/auth/register", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
login: (payload: LoginPayload) =>
request<TokenResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
refresh: (refreshToken: string) =>
request<TokenResponse>("/api/v1/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
auth: false,
}),
logout: (refreshToken: string) =>
request<void>("/api/v1/auth/logout", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
}),
me: () => request<MeResponse>("/api/v1/auth/me"),
verifyEmail: (payload: VerifyEmailPayload) =>
request<void>("/api/v1/auth/verify-email", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
resendVerification: (payload: ResendVerificationPayload) =>
request<void>("/api/v1/auth/resend-verification", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
requestPasswordReset: (payload: RequestPasswordResetPayload) =>
request<void>("/api/v1/auth/request-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
confirmPasswordReset: (payload: ConfirmPasswordResetPayload) =>
request<void>("/api/v1/auth/confirm-password-reset", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
securityEvents: () => request<SecurityEvent[]>("/api/v1/auth/security-events"),
submitUnbanRequest: (payload: UnbanRequestPayload) =>
request<void>("/api/v1/unban-requests", {
method: "POST",
body: JSON.stringify(payload),
auth: false,
}),
listIpBans: () => request<IpBan[]>("/api/v1/admin/ip-bans"),
createIpBan: (payload: BanIpPayload) =>
request<IpBan>("/api/v1/admin/ip-bans", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteIpBan: (ipAddress: string) =>
request<void>(`/api/v1/admin/ip-bans/${encodeURIComponent(ipAddress)}`, {
method: "DELETE",
}),
listUnbanRequests: () => request<UnbanRequestRecord[]>("/api/v1/admin/unban-requests"),
acceptUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}/accept`, { method: "POST" }),
rejectUnbanRequest: (requestId: string) =>
request<void>(`/api/v1/admin/unban-requests/${requestId}`, { method: "DELETE" }),
listUserApiKeys: () => request<UserApiKeyStatus[]>("/api/v1/user-api-keys"),
setUserApiKey: (provider: string, payload: SetUserApiKeyPayload) =>
request<UserApiKeyStatus>(`/api/v1/user-api-keys/${provider}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
listCompanies: () => request<CompanyResponse[]>("/api/v1/companies"),
discoverCompany: (payload: DiscoverCompanyRequest) =>
request<DiscoveredCompanyProfile>("/api/v1/companies/discover", {
method: "POST",
body: JSON.stringify(payload),
}),
getCompany: (companyId: string) => request<CompanyResponse>(`/api/v1/companies/${companyId}`),
createCompany: (payload: CompanyCreatePayload) =>
request<CompanyResponse>("/api/v1/companies", {
method: "POST",
body: JSON.stringify(payload),
}),
updateCompany: (companyId: string, payload: CompanyUpdatePayload) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteCompany: (companyId: string) =>
request<void>(`/api/v1/companies/${companyId}`, { method: "DELETE" }),
pauseCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/pause`, { method: "POST" }),
resumeCompany: (companyId: string) =>
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
listNotificationDestinations: () =>
request<NotificationDestinationResponse[]>("/api/v1/notification-destinations"),
createNotificationDestination: (payload: NotificationDestinationCreatePayload) =>
request<NotificationDestinationResponse>("/api/v1/notification-destinations", {
method: "POST",
body: JSON.stringify(payload),
}),
updateNotificationDestination: (
destinationId: string,
payload: NotificationDestinationUpdatePayload,
) =>
request<NotificationDestinationResponse>(`/api/v1/notification-destinations/${destinationId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteNotificationDestination: (destinationId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}`, { method: "DELETE" }),
unlinkNotificationDestinationCompany: (destinationId: string, companyId: string) =>
request<void>(`/api/v1/notification-destinations/${destinationId}/companies/${companyId}`, {
method: "DELETE",
}),
testNotificationDestination: (destinationId: string) =>
request<NotificationTestResult>(`/api/v1/notification-destinations/${destinationId}/test`, {
method: "POST",
}),
listAlerts: (filters: AlertListFilters = {}) => {
const params = new URLSearchParams();
if (filters.company_id) params.set("company_id", filters.company_id);
if (filters.severity) params.set("severity", filters.severity);
if (filters.read !== undefined) params.set("read", String(filters.read));
if (filters.resolved !== undefined) params.set("resolved", String(filters.resolved));
const query = params.toString();
return request<AlertResponse[]>(`/api/v1/alerts${query ? `?${query}` : ""}`);
},
getAlert: (alertId: string) => request<AlertDetailResponse>(`/api/v1/alerts/${alertId}`),
updateAlert: (alertId: string, payload: AlertUpdatePayload) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
markAlertRead: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/read`, { method: "POST" }),
resolveAlert: (alertId: string) =>
request<AlertResponse>(`/api/v1/alerts/${alertId}/resolve`, { method: "POST" }),
getDashboardAnalytics: () => request<DashboardAnalytics>("/api/v1/dashboard/analytics"),
listSources: (companyId: string) =>
request<SourceResponse[]>(`/api/v1/companies/${companyId}/sources`),
listSnapshots: (companyId: string) =>
request<SnapshotResponse[]>(`/api/v1/companies/${companyId}/snapshots`),
createSource: (companyId: string, payload: SourceCreatePayload) =>
request<SourceResponse>(`/api/v1/companies/${companyId}/sources`, {
method: "POST",
body: JSON.stringify(payload),
}),
updateSource: (sourceId: string, payload: SourceUpdatePayload) =>
request<SourceResponse>(`/api/v1/sources/${sourceId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteSource: (sourceId: string) =>
request<void>(`/api/v1/sources/${sourceId}`, { method: "DELETE" }),
testSource: (sourceId: string) =>
request<SourceTestResult>(`/api/v1/sources/${sourceId}/test`, { method: "POST" }),
runCompanyNow: (companyId: string) =>
request<MonitoringRunResponse>(`/api/v1/companies/${companyId}/run`, { method: "POST" }),
listCompanyRuns: (companyId: string) =>
request<MonitoringRunResponse[]>(`/api/v1/companies/${companyId}/runs`),
getRun: (runId: string) => request<MonitoringRunResponse>(`/api/v1/runs/${runId}`),
listReports: (companyId: string) =>
request<ReportListItem[]>(`/api/v1/companies/${companyId}/reports`),
generateReport: (companyId: string) =>
request<ReportResponse>(`/api/v1/companies/${companyId}/reports/generate`, {
method: "POST",
}),
getReport: (reportId: string) => request<ReportResponse>(`/api/v1/reports/${reportId}`),
getReportMarkdown: (reportId: string) =>
fetch(`${API_BASE_URL}/api/v1/reports/${reportId}/markdown`, {
headers: (() => {
const h = new Headers();
const token = getAccessToken();
if (token) h.set("Authorization", `Bearer ${token}`);
return h;
})(),
}).then((r) => r.text()),
};