/** * Fetch wrapper for the FinData OS API (same origin, prefix /api/v1). * - cookie session (`credentials: same-origin`) * - X-CSRF-Token on every non-GET request (kept in memory + sessionStorage) * - error normalization: throws ApiError {code, message, request_id, status, extra} */ export const API_PREFIX = "/api/v1"; const CSRF_KEY = "findata.csrf"; let csrfToken: string | null = null; export function getCsrf(): string | null { if (csrfToken) return csrfToken; if (typeof window !== "undefined") { try { csrfToken = window.sessionStorage.getItem(CSRF_KEY); } catch { csrfToken = null; } } return csrfToken; } export function setCsrf(token: string | null | undefined) { csrfToken = token ?? null; if (typeof window === "undefined") return; try { if (token) window.sessionStorage.setItem(CSRF_KEY, token); else window.sessionStorage.removeItem(CSRF_KEY); } catch { /* storage unavailable */ } } export class ApiError extends Error { code: string; status: number; request_id: string | null; extra: Record; constructor(status: number, code: string, message: string, request_id: string | null, extra: Record = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.request_id = request_id; this.extra = extra; } } export function isApiError(e: unknown): e is ApiError { return e instanceof ApiError; } export type Query = Record; export function qs(params?: Query): string { if (!params) return ""; const u = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; u.set(k, String(v)); } const s = u.toString(); return s ? `?${s}` : ""; } interface RequestOptions { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; body?: unknown; params?: Query; signal?: AbortSignal; } async function parseError(res: Response): Promise { let payload: unknown = null; try { payload = await res.json(); } catch { /* not JSON */ } const err = (payload as { error?: Record } | null)?.error; if (err && typeof err === "object") { const { code, message, request_id, ...extra } = err as Record; return new ApiError(res.status, String(code ?? "http_error"), String(message ?? res.statusText), (request_id as string) ?? null, extra); } return new ApiError(res.status, "http_error", res.statusText || `HTTP ${res.status}`, res.headers.get("x-request-id")); } export async function request(path: string, opts: RequestOptions = {}): Promise { const method = opts.method ?? "GET"; const headers: Record = { Accept: "application/json" }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; if (method !== "GET") { const t = getCsrf(); if (t) headers["X-CSRF-Token"] = t; } const url = (path.startsWith("/api/") ? path : API_PREFIX + path) + qs(opts.params); const res = await fetch(url, { method, headers, credentials: "same-origin", body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, signal: opts.signal, }); if (!res.ok) throw await parseError(res); if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; return JSON.parse(text) as T; } export const api = { get: (path: string, params?: Query, signal?: AbortSignal) => request(path, { params, signal }), post: (path: string, body?: unknown, params?: Query) => request(path, { method: "POST", body, params }), put: (path: string, body?: unknown) => request(path, { method: "PUT", body }), del: (path: string) => request(path, { method: "DELETE" }), }; // ---------------------------------------------------------------- types (mirroring the FastAPI routers) export interface Me { kind: "user" | "api_key" | "anonymous"; email: string | null; role: string | null; scopes: string[]; mfa_passed: boolean; totp_enabled: boolean | null; csrf_token: string | null; } export interface LoginOut { ok: boolean; mfa_required: boolean; csrf_token: string; email: string; role: string; } export interface HealthAlert { severity: "info" | "warning" | "error"; code: string; message: string; } export interface HealthReport { status: "ok" | "warning" | "degraded"; checked_at?: string; alerts: HealthAlert[]; components: { postgres?: string; lake?: { root: string; free_gb: number; curated_gb: number; raw_gb: number; soft_limit_gb: number; hard_min_free_gb: number }; ingest_worker?: string; backup?: { last: string | null; age_hours?: number; verified?: boolean | null }; ssd_smart?: string; }; datasets?: { dataset_id: string; version: number | null; last_session: string | null; days_since_last_session: number | null; vendor_last_update: string | null; vendor_lag_days: number | null; }[]; } export interface HealthSummary { datasets: { active: number; planned: number; unsupported: number; pending: number }; instruments: { n: number; active: number }; bars_1m: { symbols: number; sessions: number; bars: number | null; last: string | null }; jobs: Record; recent_jobs: Job[]; fundamentals: { entities: number; facts: number }; anomalies_7d: Record; health: HealthReport; } export type DatasetStatus = "active" | "planned" | "pending_configuration" | "unsupported" | "paused"; export interface Dataset { id: string; name: string; description: string | null; kind: string; asset_class: string | null; interval: string | null; adjustment: string | null; license_class: string; license_text: string; status: DatasetStatus; timestamp_convention: string | null; timezone: string | null; current_version: number | null; snapshot_id?: string | null; file_count?: number | null; row_count?: number | null; bytes?: number | null; version_created_at?: string | null; first_session?: string | null; last_session?: string | null; symbols?: number | null; source_name?: string; source_id?: string; vendor?: string; product?: string; notes?: string | null; [k: string]: unknown; } export interface DatasetVersion { version: number; snapshot_id: string | null; file_count: number | null; row_count: number | null; bytes: number | null; created_at: string; note: string | null; } export interface DatasetDetail { data: Dataset; versions: DatasetVersion[]; files: { layout: string; n: number; rows: number | null; bytes: number | null; d0: string | null; d1: string | null }[]; checkpoints: { key: string; value: unknown; updated_at: string }[]; } export interface Instrument { instrument_id: string; symbol: string; name: string | null; asset_class: string; status: string; first_date: string | null; last_date: string | null; currency: string | null; [k: string]: unknown; } export interface InstrumentDetail { instrument: Instrument | null; aliases: Record[]; corporate_actions: { action_type: string; ex_date: string; value: unknown }[]; coverage: { dataset_id: string; sessions: number; d0: string; d1: string; bars: number }[]; fundamentals: { facts: number; last_period: string | null; last_filing: string | null } | null; } export interface Bar { timestamp_utc: string; session_date: string; open: number; high: number; low: number; close: number; volume: number; vwap?: number | null; trade_count?: number | null; quality_flags: string[]; adjustment_type?: string; revision_id?: number; [k: string]: unknown; } export interface BarsOut { data: Bar[]; meta: { symbol: string; interval: string; adjustment: string; start: string; end: string; rows: number; rows_matching: number; adjustment_notes: string[]; timestamp_convention: string; license: string; version?: number; files_scanned?: number; [k: string]: unknown; }; next_cursor: string | null; } export interface SessionRow { session_date: string; bar_count: number; expected_bars: number | null; first_ts: string | null; last_ts: string | null; flags: string[] | null; symbol?: string; } export interface Heatmap { sessions: { session_date: string; symbols: number; bars: number; completeness: number | null; non_trading_rows: number }[]; top_symbols: { symbol: string; sessions: number; bars: number; completeness: number | null }[]; meta: { dataset_id: string; start: string; end: string }; } export interface QualityOut { latest: Record | null; anomalies_by_code_30d: { code: string; severity: string; n: number; rows: number | null }[]; reports: { id: string; created_at: string; rows_checked: number; anomalies: number; summary: unknown }[]; codes: Record; } export interface Anomaly { id?: string; code: string; severity: string; symbol: string | null; session_date?: string | null; detail: Record | null; created_at: string; [k: string]: unknown; } export interface QueryOut { columns: { name: string; type: string }[]; rows: Record[]; meta: { truncated?: boolean; rows: number; elapsed_ms: number; views: string[]; engine: string; [k: string]: unknown }; } export interface ExportRow { id: string; status: "queued" | "running" | "ready" | "failed" | "expired"; request: { dataset_id: string; symbols: string[]; start: string; end: string; adjustment: string; format: string; version?: number }; format: string; bytes: number | null; row_count: number | null; created_at: string; finished_at: string | null; expires_at: string | null; error: string | null; snapshot: unknown; download?: string; } export interface Fact { entity_id: string; metric: string; value: number | null; unit: string; currency: string | null; fiscal_period_start: string | null; fiscal_period_end: string; fiscal_period_type: string; fiscal_year: number | null; fiscal_period: string | null; filing_date: string | null; published_at: string | null; available_at: string; observed_at: string; source_document: string | null; source_id: string; revision_id: number; restatement_flag: boolean; superseded_at: string | null; } export interface FundamentalsOut { data: Fact[]; entity: Record | null; meta: { as_of: string; rows: number; facts_hidden_by_as_of: number; pit_policy: string; license: string }; } export interface PeriodsOut { periods: { fiscal_period_end: string; fiscal_period_type: string; fiscal_year: number | null; fiscal_period: string | null; filing_date: string | null; available_at: string; metrics: number; max_revision: number; restated: boolean; }[]; earnings: { event_date: string; eps_estimate: number | null; eps_reported: number | null; release_time: string | null }[]; } export interface Job { id: string; kind: string; dataset_id: string | null; params?: Record; priority?: number; status: string; attempts?: number; max_attempts?: number; next_run_at?: string | null; created_at?: string; started_at: string | null; finished_at: string | null; last_error: string | null; progress?: Record | null; result?: Record | null; created_by?: string | null; } export interface ApiKey { id: string; name: string; prefix: string; scopes: string[]; rate_limit_per_minute: number; expires_at: string | null; last_used_at: string | null; revoked_at: string | null; created_at: string; email: string; } export interface UsageRow { principal: string; day: string; requests: number; rows_returned: number; bytes_exported: number; } export interface AuditRow { id?: string; at: string; action: string; actor?: string | null; principal?: string | null; target?: string | null; outcome?: string | null; ip?: string | null; request_id?: string | null; detail?: Record | null; [k: string]: unknown; } export interface StorageOut { lake_root: string; volume: { total_gb: number; free_gb: number }; budget: { soft_limit_gb: number; hard_min_free_gb: number }; curated_bytes: number; raw_bytes: number; retired_bytes: number; exports_bytes: number; per_dataset: { dataset_id: string; layout: string; files: number; bytes: number | null; rows: number | null }[]; raw_archives: { status: string; n: number; bytes: number | null }[]; exports: { status: string; n: number; bytes: number }[]; retired_files: { n: number; bytes: number }; postgres_bytes: number; last_backup: { value: Record; updated_at: string } | null; capacity_model: { bytes_per_1m_bar_curated: number; note: string }; } export interface SettingsOut { settings: { key: string; value: unknown; updated_at: string }[]; env: { env: string; lake: string; public_url: string; backup_target: string; vendor_configured: boolean; sec_edgar_configured: boolean; ai_providers: { anthropic: boolean; openai: boolean }; }; }