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).
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
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()),
|
||||
};
|
||||
Reference in New Issue
Block a user