diff --git a/.github/workflows/health-collector.yml b/.github/workflows/health-collector.yml new file mode 100644 index 00000000..3cd829d3 --- /dev/null +++ b/.github/workflows/health-collector.yml @@ -0,0 +1,105 @@ +name: health-collector + +# InvoFi protocol health collector. +# +# Runs on an hourly schedule to: +# 1. Poll Soroban RPC getEvents for the past hour across all five contracts +# and write aggregated success/failure counts + fee stats to +# Supabase `health_metrics`. +# 2. Snapshot current contract state (invoice distribution, insurance pool, +# active lenders) to `contract_state_snapshots`. +# 3. Evaluate threshold-based `alert_configs` and append breaches to +# `audit_log`. +# +# The /dashboard/health admin page reads all three tables to render the +# real-time protocol health monitoring view. +# +# Required repo configuration (once): +# Secrets: +# SUPABASE_URL — your Supabase project URL +# SUPABASE_SERVICE_ROLE_KEY — service role key (bypasses RLS for writes) +# Variables (defaults point to the live testnet deployment): +# REGISTRY_CONTRACT_ID +# FINANCING_CONTRACT_ID +# REPAYMENT_CONTRACT_ID +# INSURANCE_CONTRACT_ID +# REPUTATION_CONTRACT_ID +# +# Optional secrets/variables: +# HEALTH_RPC_URL — override the Soroban RPC endpoint +# HEALTH_LOOKBACK_HOURS — hours to look back (default: 1) +# +# Enable/disable on demand: +# gh workflow enable health-collector.yml -R Stellar-VaultLink/invofi +# gh workflow disable health-collector.yml -R Stellar-VaultLink/invofi + +on: + schedule: + # Every hour at minute 45 (UTC) — offset from the keeper (minute 0) and + # the indexer (minute 15) to spread load. + - cron: '45 * * * *' + workflow_dispatch: + inputs: + lookback_hours: + description: 'Hours to look back for events (default: 1)' + required: false + default: '1' + dry_run: + description: 'Print collected data but do not write to Supabase' + type: boolean + required: false + default: false + +permissions: + contents: read + +jobs: + collect: + name: Health collector / testnet + runs-on: ubuntu-latest + timeout-minutes: 15 + + defaults: + run: + working-directory: invofi/scripts + + steps: + # ── Checkout ────────────────────────────────────────────────────────── + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + # ── Node.js ─────────────────────────────────────────────────────────── + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4 + with: + # Node 22+ required: @supabase/supabase-js@2.112+ and + # @stellar/stellar-sdk@16.2+ declare engines node >=22. + node-version: 22 + cache: npm + cache-dependency-path: invofi/scripts/package-lock.json + + # ── Install dependencies ────────────────────────────────────────────── + - name: Install dependencies + run: npm ci + + # ── Type-check ──────────────────────────────────────────────────────── + - name: Type-check + run: npm run type-check + + # ── Run unit tests ──────────────────────────────────────────────────── + - name: Run unit tests + run: npm run test:health + + # ── Run collector ───────────────────────────────────────────────────── + - name: Run health collector + env: + RPC_URL: ${{ vars.HEALTH_RPC_URL || 'https://soroban-testnet.stellar.org' }} + NETWORK_PASSPHRASE: Test SDF Network ; September 2015 + REGISTRY_CONTRACT_ID: ${{ vars.REGISTRY_CONTRACT_ID || 'CAXNTWSKDVSB3GPJMU3RTSDTAIFF4A6FFRAAI35B4AE7LZLLI4VXMCF7' }} + FINANCING_CONTRACT_ID: ${{ vars.FINANCING_CONTRACT_ID || 'CBGRA3457ZFXYZNEQLO4YGUQ3OBEWOE6US6ZREHK6NF2DLZYBO73IFVW' }} + REPAYMENT_CONTRACT_ID: ${{ vars.REPAYMENT_CONTRACT_ID || 'CCDATW5GMVDOPK55Q4MLXV5SGA3VLXPD67ABLBNMHWFF6BLL2IZBUVEP' }} + INSURANCE_CONTRACT_ID: ${{ vars.INSURANCE_CONTRACT_ID || 'CAURQCGDZZ6PPCH6EKDVQP5W372CH3PQ62VQC2GKLIXNHB37VOMBMSU5' }} + REPUTATION_CONTRACT_ID: ${{ vars.REPUTATION_CONTRACT_ID || 'CCHKVUWGTQ56U53C5U7ZSOFDTTMGLMOFCL22DME5UMXIYWQNUYXOYPDN' }} + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + LOOKBACK_HOURS: ${{ github.event.inputs.lookback_hours || vars.HEALTH_LOOKBACK_HOURS || '1' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: npm run health-collector diff --git a/ISSUE_README.md b/ISSUE_README.md new file mode 100644 index 00000000..72e14771 --- /dev/null +++ b/ISSUE_README.md @@ -0,0 +1,132 @@ +# Protocol Health Monitoring Dashboard + +## Problem + +InvoFi's public `/stats` page gives aggregate totals (total invoices, total volume, +repayment rate) but these are 6-hour snapshots from the indexer. There is no operational +view for protocol maintainers — no transaction success/failure breakdown, no contract +pause indicator, no alerting when the overdue rate spikes, and no audit trail of admin +actions. When something goes wrong on-chain, the only recourse is to manually query +Stellar Expert or grep GitHub Action logs. + + +Concretely: + +- A lender watching their offer go stale has no visibility into *why* — is the overdue + rate normal? Is the insurance pool healthy? +- An admin who ran `mark_overdue` or `resolve_dispute` leaves no in-app audit trail. +- There is no threshold mechanism to page someone when `overdue / financed > 15%`. +- Gas consumption and confirmation-time outliers are invisible. + +--- + +## Solution Approach + +### Architectural decisions + +**No new always-on server.** The indexer already runs as a scheduled GitHub Action every +6 hours. We extend the same pattern: a lightweight GitHub Action (or Supabase Edge +Function) collects health metrics on a schedule, stores them in Supabase, and the +dashboard reads them. All hosting stays free. + +**Admin role via `user_profiles.role`.** The existing `user_profiles` table already has a +`role` text column (`business | lender`). We extend the `CHECK` constraint to also allow +`admin` and add a server-side guard that redirects non-admin users to `/403`. + +**Pure-SVG sparkline charts.** The codebase has no chart library. Rather than pulling in +`recharts` (adds ~300 KB to the bundle), we build a tiny reusable `` SVG +component and a `` SVG component. They are sufficient for line trends and +distribution bars, and they have zero dependencies. If stakeholders later want richer +interactivity, `recharts` can be layered on top. + +**Supabase tables as the metrics store.** Four new tables: +- `health_metrics` — one row per time bucket (hourly), with success/failure counts, + avg confirmation time, and gas estimates. Written by the collector script. +- `contract_state_snapshots` — one row per 6-hour run, capturing invoice status + distribution, pool utilisation, and position token supply. +- `alert_configs` — admin-managed threshold rules (e.g. `overdue_rate > 0.15`). +- `audit_log` — append-only log of admin actions taken through the app. + +**Data collection via GitHub Actions.** The existing `indexer.yml` workflow is already +triggered on schedule. We add a companion `health-collector.yml` that runs hourly, +calls Soroban RPC `getEvents`, writes to `health_metrics`, and computes +`contract_state_snapshots`. The frontend dashboard is a pure reader — no server-side +API route required. + +### File layout + +``` +src/ +├── app/ +│ └── dashboard/ +│ └── health/ +│ ├── page.tsx ← main dashboard, admin-gated +│ └── layout.tsx ← layout wrapper +├── components/ +│ └── health/ +│ ├── TxRateChart.tsx ← SVG sparkline: success/failure rates +│ ├── ContractStateCards.tsx ← KPI cards: invoices, pool util, overdue +│ ├── AlertConfigPanel.tsx ← threshold editor +│ └── AuditLogViewer.tsx ← paginated audit log table +└── lib/ + ├── health/ + │ ├── metrics.ts ← Supabase read/write helpers + │ ├── collector.ts ← Soroban RPC event ingestion + │ └── types.ts ← TypeScript types for all health tables + └── migrations/ + └── 004_health_monitoring.sql +``` + +### Implementation steps + +1. **Migration** (`004_health_monitoring.sql`): create the four tables with RLS. + Admin-only write on `alert_configs`; public read on `health_metrics` and + `contract_state_snapshots`; authenticated read on `audit_log`. + +2. **Types and helpers** (`lib/health/`): typed Supabase helpers for reading time-series + data with a time-range filter (`1h | 24h | 7d | 30d`). + +3. **Admin gate** (`components/health/AdminGuard.tsx`): wraps the page; reads + `user_profiles.role` after auth check; redirects to `/403` if not `admin`. + +4. **Chart components**: `` (polyline SVG, responsive via viewBox), + `` (stacked success/failure bars), both zero-dependency. + +5. **Dashboard page** (`app/dashboard/health/page.tsx`): `` wrapper, + time-range selector (tabs), four sections: KPI cards, transaction rate chart, + alert config panel, audit log. + +6. **Alert config panel**: reads `alert_configs`, lets admins set thresholds, writes + back via Supabase insert/update. A separate `checkAlerts()` utility (called by + the collector) evaluates thresholds and inserts into `audit_log` when breached. + +7. **Audit log viewer**: paginated table of `audit_log` rows with filtering by action + type and time range. Supports CSV export via the existing `toCsv / downloadCsv` + helpers in `lib/csv.ts`. + +8. **CSV export**: reuses `toCsv` / `downloadCsv` from `lib/csv.ts` with + dashboard-specific column specs. + +### Acceptance criteria mapping + +| Criterion | Solution | +|---|---| +| `/dashboard/health` admin-only | `AdminGuard` checks `user_profiles.role = 'admin'`; redirects to `/403` | +| Real-time tx success rate chart | `TxRateChart` reads `health_metrics`, auto-refreshes every 60 s | +| Contract state summary cards | `ContractStateCards` reads `contract_state_snapshots` | +| Alert config panel | `AlertConfigPanel` reads/writes `alert_configs` | +| Audit log viewer | `AuditLogViewer` reads `audit_log` with pagination | +| CSV export | "Export CSV" button in both metrics and audit log sections | +| Time-range filtering | `TimeRangeSelector` controls a `since` timestamp passed to all queries | +| Responsive layout | Tailwind responsive grid identical to existing stats page | + +### What is not included (and why) + +- **Gas consumption** is not exposed by Soroban RPC's public API — `getTransaction` + returns fee but not gas units. We track *fee* as a proxy in `health_metrics.avg_fee_stroops`. +- **WebSocket real-time push** is intentionally omitted (the live portfolio dashboard + already covers that complexity). The health page polls on a 60-second interval, which + is sufficient for operations monitoring without adding WebSocket infrastructure. +- **Recharts / charting library** is intentionally not added to avoid a large bundle + dependency. The SVG approach is sufficient and auditable; a migration path to recharts + is straightforward if ever needed. diff --git a/invofi/apps/frontend/src/app/dashboard/health/layout.tsx b/invofi/apps/frontend/src/app/dashboard/health/layout.tsx new file mode 100644 index 00000000..cce73100 --- /dev/null +++ b/invofi/apps/frontend/src/app/dashboard/health/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Protocol Health — InvoFi', + description: + 'Real-time protocol health monitoring dashboard for InvoFi maintainers. Admin access only.', + // Prevent search engines from indexing the admin dashboard. + robots: { index: false, follow: false }, +}; + +export default function HealthDashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/invofi/apps/frontend/src/app/dashboard/health/page.tsx b/invofi/apps/frontend/src/app/dashboard/health/page.tsx new file mode 100644 index 00000000..8225f8f3 --- /dev/null +++ b/invofi/apps/frontend/src/app/dashboard/health/page.tsx @@ -0,0 +1,292 @@ +'use client'; + +// /dashboard/health — Protocol Health Monitoring Dashboard (admin-only). +// +// Sections: +// 1. Time-range selector (1h / 24h / 7d / 30d) +// 2. KPI cards — ContractStateCards +// 3. Transaction rate chart — TxRateChart +// 4. Alert configuration panel — AlertConfigPanel +// 5. Audit log viewer — AuditLogViewer +// +// All data is fetched from Supabase (health_metrics, contract_state_snapshots, +// alert_configs, audit_log) via lib/health/metrics.ts helpers. +// Auto-refreshes on a 60-second interval for the real-time feel. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Activity, + RefreshCw, + Download, + Bell, + ClipboardList, + ShieldAlert, +} from 'lucide-react'; +import { AdminGuard } from '@/components/health/AdminGuard'; +import { TxRateChart } from '@/components/health/TxRateChart'; +import { ContractStateCards } from '@/components/health/ContractStateCards'; +import { AlertConfigPanel } from '@/components/health/AlertConfigPanel'; +import { AuditLogViewer } from '@/components/health/AuditLogViewer'; +import { Button } from '@/components/ui/button'; +import { fetchHealthMetrics, fetchSnapshots, fetchLatestSnapshot } from '@/lib/health/metrics'; +import type { + HealthMetric, + ContractStateSnapshot, + TimeRange, +} from '@/lib/health/types'; +import { toCsv, downloadCsv } from '@/lib/csv'; + +// ── constants ───────────────────────────────────────────────────────────────── + +const TIME_RANGES: { value: TimeRange; label: string }[] = [ + { value: '1h', label: '1 hour' }, + { value: '24h', label: '24 hours' }, + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, +]; + +const REFRESH_INTERVAL_MS = 60_000; // 60 s + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function formatRefreshed(d: Date | null): string { + if (!d) return 'Never'; + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +// ── component ───────────────────────────────────────────────────────────────── + +function HealthDashboardContent() { + const [range, setRange] = useState('24h'); + const [metrics, setMetrics] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [latestSnapshot, setLatestSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshedAt, setRefreshedAt] = useState(null); + const [exporting, setExporting] = useState(false); + const intervalRef = useRef | null>(null); + + // ── data loading ──────────────────────────────────────────────────────── + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [m, s, latest] = await Promise.all([ + fetchHealthMetrics(range), + fetchSnapshots(range), + fetchLatestSnapshot(), + ]); + setMetrics(m); + setSnapshots(s); + setLatestSnapshot(latest); + setRefreshedAt(new Date()); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [range]); + + // Initial load and re-load on range change. + useEffect(() => { + load(); + }, [load]); + + // Auto-refresh every 60 s. + useEffect(() => { + intervalRef.current = setInterval(load, REFRESH_INTERVAL_MS); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [load]); + + // ── CSV export (metrics) ──────────────────────────────────────────────── + + const handleExportMetrics = async () => { + setExporting(true); + try { + const csv = toCsv(metrics as unknown as Record[], [ + { key: 'bucket_start', header: 'Bucket Start' }, + { key: 'bucket_end', header: 'Bucket End' }, + { key: 'tx_success', header: 'TX Success' }, + { key: 'tx_failure', header: 'TX Failure' }, + { key: 'avg_fee_stroops', header: 'Avg Fee (stroops)' }, + { key: 'p95_fee_stroops', header: 'P95 Fee (stroops)' }, + { key: 'avg_confirmation_ms',header: 'Avg Confirmation (ms)' }, + ]); + downloadCsv(`health-metrics-${range}-${Date.now()}.csv`, csv); + } finally { + setExporting(false); + } + }; + + // ── render ────────────────────────────────────────────────────────────── + + return ( +
+ {/* ── Header ── */} +
+
+
+
+

+ Admin-only operational view — contract state, transaction metrics, alerts, and audit log. +

+

+ Last refreshed: {formatRefreshed(refreshedAt)} + {' · '} + Auto-refresh every 60 s +

+
+ + {/* Controls */} +
+ {/* Time-range selector */} + + + {/* Manual refresh */} + +
+
+ + {/* ── Error banner ── */} + {error && ( +
+ Failed to load health data: {error} + +
+ )} + + {/* ── Section 1: Contract State KPI cards ── */} +
+
+

+

+ {latestSnapshot && ( + + Snapshot: {new Date(latestSnapshot.snapshotted_at).toLocaleString()} + {' · '}ledger {latestSnapshot.last_ledger.toLocaleString()} + + )} +
+ +
+ + {/* ── Section 2: Transaction rate chart ── */} +
+
+

+

+ +
+
+ + {metrics.length === 0 && !loading && !error && ( +

+ No metric data for this range. Run the health collector to populate. +

+ )} +
+
+ + {/* ── Section 3: Alert configuration ── */} +
+
+

+

+

+ Threshold-based rules evaluated by the health collector after each run. + Breaches are recorded in the audit log below. +

+
+
+ +
+
+ + {/* ── Section 4: Audit log ── */} +
+
+

+

+

+ Admin actions, config changes, and alert breaches recorded by the system. +

+
+
+ +
+
+
+ ); +} + +// ── Page export ─────────────────────────────────────────────────────────────── + +export default function HealthDashboardPage() { + return ( + + + + ); +} diff --git a/invofi/apps/frontend/src/components/health/AdminGuard.tsx b/invofi/apps/frontend/src/components/health/AdminGuard.tsx new file mode 100644 index 00000000..10e5812f --- /dev/null +++ b/invofi/apps/frontend/src/components/health/AdminGuard.tsx @@ -0,0 +1,91 @@ +'use client'; + +// AdminGuard — wraps the /dashboard/health page (and any other admin-only +// content) and redirects to /403 if the authenticated user does not have +// `role = 'admin'` in user_profiles. +// +// Extends AuthGuard: it first requires a Supabase session, then checks the +// admin role. Renders a spinner while the checks are in flight so there is no +// visible flash of content. + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2, ShieldX } from 'lucide-react'; +import { supabase } from '@/lib/supabase'; + +interface AdminGuardProps { + children: React.ReactNode; +} + +type CheckState = 'loading' | 'allowed' | 'forbidden' | 'unauthenticated'; + +export function AdminGuard({ children }: AdminGuardProps) { + const router = useRouter(); + const [state, setState] = useState('loading'); + + useEffect(() => { + let cancelled = false; + + async function check() { + // 1. Require a valid Supabase session. + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + if (!cancelled) setState('unauthenticated'); + return; + } + + // 2. Check role in user_profiles. + const { data: profile, error } = await supabase + .from('user_profiles') + .select('role') + .eq('id', user.id) + .maybeSingle(); + + if (cancelled) return; + + if (error || !profile || profile.role !== 'admin') { + setState('forbidden'); + } else { + setState('allowed'); + } + } + + check(); + return () => { + cancelled = true; + }; + }, []); + + // Redirect effects — run after render so Next.js router is ready. + useEffect(() => { + if (state === 'unauthenticated') router.push('/auth/login'); + if (state === 'forbidden') router.push('/403'); + }, [state, router]); + + if (state === 'loading') { + return ( +
+ +
+ ); + } + + // Prevent flash while the router is redirecting. + if (state === 'forbidden' || state === 'unauthenticated') { + return ( +
+ +

Access denied — redirecting…

+
+ ); + } + + return <>{children}; +} diff --git a/invofi/apps/frontend/src/components/health/AlertConfigPanel.tsx b/invofi/apps/frontend/src/components/health/AlertConfigPanel.tsx new file mode 100644 index 00000000..79c9d923 --- /dev/null +++ b/invofi/apps/frontend/src/components/health/AlertConfigPanel.tsx @@ -0,0 +1,454 @@ +'use client'; + +// AlertConfigPanel — admin UI for managing threshold alert rules. +// +// Reads alert_configs from Supabase (via lib/health/metrics.ts), lets admins: +// • Toggle existing rules on/off +// • Edit threshold, operator, or severity +// • Delete a rule +// • Add a new rule via an inline form +// +// All writes are append-only inserts or full-row updates; the table RLS +// enforces that only `role = 'admin'` users can mutate rows. +// Each mutation is also appended to audit_log via insertAuditLog. + +import { useCallback, useEffect, useState } from 'react'; +import { Plus, Trash2, ToggleLeft, ToggleRight, Pencil, X, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + fetchAlertConfigs, + createAlertConfig, + updateAlertConfig, + deleteAlertConfig, + insertAuditLog, +} from '@/lib/health/metrics'; +import type { + AlertConfig, + AlertConfigDraft, + AlertMetric, + AlertOperator, + AlertSeverity, +} from '@/lib/health/types'; +import { METRIC_LABELS, OPERATOR_LABELS } from '@/lib/health/types'; +import { supabase } from '@/lib/supabase'; + +// ── constants ───────────────────────────────────────────────────────────────── + +const METRICS: AlertMetric[] = [ + 'overdue_rate', + 'repayment_rate', + 'tx_failure_rate', + 'insurance_pool_total', + 'avg_fee_stroops', + 'invoices_overdue', +]; + +const OPERATORS: AlertOperator[] = ['gt', 'lt', 'gte', 'lte']; +const SEVERITIES: AlertSeverity[] = ['info', 'warning', 'critical']; + +const SEVERITY_STYLES: Record = { + info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', +}; + +const EMPTY_DRAFT: AlertConfigDraft = { + label: '', + metric: 'overdue_rate', + operator: 'gt', + threshold: '0.15', + severity: 'warning', + enabled: true, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +async function actorEmail(): Promise { + const { data: { user } } = await supabase.auth.getUser(); + return user?.email ?? undefined; +} + +// ── component ───────────────────────────────────────────────────────────────── + +export function AlertConfigPanel() { + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [saving, setSaving] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState>({}); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchAlertConfigs(); + setConfigs(data); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + // ── add new rule ────────────────────────────────────────────────────────── + + const handleAdd = async () => { + if (!draft.label.trim()) return; + setSaving(true); + try { + const created = await createAlertConfig(draft); + setConfigs(prev => [...prev, created]); + await insertAuditLog({ + action_type: 'config_change', + message: `Alert rule created: "${draft.label}"`, + details: { rule: draft }, + severity: 'info', + actor_email: await actorEmail(), + }); + setAdding(false); + setDraft(EMPTY_DRAFT); + } catch (e) { + setError((e as Error).message); + } finally { + setSaving(false); + } + }; + + // ── toggle enabled ──────────────────────────────────────────────────────── + + const handleToggle = async (cfg: AlertConfig) => { + const updated = !cfg.enabled; + try { + await updateAlertConfig(cfg.id, { enabled: updated }); + setConfigs(prev => prev.map(c => c.id === cfg.id ? { ...c, enabled: updated } : c)); + await insertAuditLog({ + action_type: 'config_change', + message: `Alert rule "${cfg.label}" ${updated ? 'enabled' : 'disabled'}`, + details: { id: cfg.id, enabled: updated }, + severity: 'info', + actor_email: await actorEmail(), + }); + } catch (e) { + setError((e as Error).message); + } + }; + + // ── inline edit ─────────────────────────────────────────────────────────── + + const handleEditSave = async (id: string) => { + setSaving(true); + try { + await updateAlertConfig(id, editDraft); + setConfigs(prev => prev.map(c => c.id === id ? { ...c, ...editDraft } : c)); + await insertAuditLog({ + action_type: 'config_change', + message: `Alert rule updated`, + details: { id, patch: editDraft }, + severity: 'info', + actor_email: await actorEmail(), + }); + setEditingId(null); + setEditDraft({}); + } catch (e) { + setError((e as Error).message); + } finally { + setSaving(false); + } + }; + + // ── delete ──────────────────────────────────────────────────────────────── + + const handleDelete = async (cfg: AlertConfig) => { + if (!window.confirm(`Delete rule "${cfg.label}"?`)) return; + try { + await deleteAlertConfig(cfg.id); + setConfigs(prev => prev.filter(c => c.id !== cfg.id)); + await insertAuditLog({ + action_type: 'config_change', + message: `Alert rule deleted: "${cfg.label}"`, + details: { id: cfg.id }, + severity: 'warning', + actor_email: await actorEmail(), + }); + } catch (e) { + setError((e as Error).message); + } + }; + + // ── render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Table */} + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : configs.length === 0 && !adding ? ( +

+ No alert rules configured. Add one below. +

+ ) : ( +
+ + + + + + + + + + + + {configs.map(cfg => { + const isEditing = editingId === cfg.id; + return ( + + {/* Label */} + + + {/* Metric */} + + + {/* Condition */} + + + {/* Severity */} + + + {/* Enabled toggle */} + + + {/* Actions */} + + + ); + })} + +
LabelMetricConditionSeverityEnabled +
+ {isEditing ? ( + setEditDraft(d => ({ ...d, label: e.target.value }))} + className="h-7 text-xs" + aria-label="Alert label" + /> + ) : ( + + {cfg.label} + + )} + + {isEditing ? ( + + ) : ( + + {METRIC_LABELS[cfg.metric]} + + )} + + {isEditing ? ( +
+ + setEditDraft(d => ({ ...d, threshold: e.target.value }))} + className="h-7 text-xs w-20" + aria-label="Threshold" + /> +
+ ) : ( + + {OPERATOR_LABELS[cfg.operator]} {cfg.threshold} + + )} +
+ {isEditing ? ( + + ) : ( + + {cfg.severity} + + )} + + + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ )} + + {/* Add form */} + {adding ? ( +
+

New alert rule

+
+
+ + setDraft(d => ({ ...d, label: e.target.value }))} + className="h-8 text-sm" + /> +
+
+ + +
+
+ +
+ + setDraft(d => ({ ...d, threshold: e.target.value }))} + className="h-8 text-sm w-24" + placeholder="0.15" + aria-label="Threshold value" + /> +
+
+
+ + +
+
+
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/invofi/apps/frontend/src/components/health/AuditLogViewer.tsx b/invofi/apps/frontend/src/components/health/AuditLogViewer.tsx new file mode 100644 index 00000000..c25bcdd6 --- /dev/null +++ b/invofi/apps/frontend/src/components/health/AuditLogViewer.tsx @@ -0,0 +1,282 @@ +'use client'; + +// AuditLogViewer — paginated audit log table for the health dashboard. +// +// Reads audit_log rows from Supabase (via lib/health/metrics.ts) with +// optional filtering by action_type and severity. Supports CSV export via +// the existing lib/csv.ts helpers and time-range filtering. + +import { useCallback, useEffect, useState } from 'react'; +import { Download, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { fetchAuditLog } from '@/lib/health/metrics'; +import type { + AuditLogEntry, + AuditActionType, + AlertSeverity, + TimeRange, +} from '@/lib/health/types'; +import { toCsv, downloadCsv } from '@/lib/csv'; + +// ── constants ───────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 25; + +const ACTION_LABELS: Record = { + alert_breach: 'Alert breach', + admin_action: 'Admin action', + config_change: 'Config change', + system_event: 'System event', +}; + +const SEVERITY_STYLES: Record = { + info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', + warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', + critical: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function formatDateTime(iso: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute:'2-digit', + second:'2-digit', + hour12: false, + }).format(new Date(iso)); +} + +// ── types ───────────────────────────────────────────────────────────────────── + +export interface AuditLogViewerProps { + range: TimeRange; +} + +// ── component ───────────────────────────────────────────────────────────────── + +export function AuditLogViewer({ range }: AuditLogViewerProps) { + const [entries, setEntries] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(0); + const [filterType, setFilterType] = useState(''); + const [filterSeverity, setFilterSeverity] = useState(''); + const [exporting, setExporting] = useState(false); + + const load = useCallback(async (pg = 0) => { + setLoading(true); + setError(null); + try { + const result = await fetchAuditLog(range, { + actionType: filterType || undefined, + severity: filterSeverity || undefined, + limit: PAGE_SIZE, + offset: pg * PAGE_SIZE, + }); + setEntries(result.entries); + setCount(result.count); + setPage(pg); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }, [range, filterType, filterSeverity]); + + // Re-load whenever range or filters change. + useEffect(() => { load(0); }, [load]); + + // ── CSV export ──────────────────────────────────────────────────────────── + + const handleExport = async () => { + setExporting(true); + try { + // Fetch all rows in the current range/filter (up to 1000). + const result = await fetchAuditLog(range, { + actionType: filterType || undefined, + severity: filterSeverity || undefined, + limit: 1000, + offset: 0, + }); + const csv = toCsv(result.entries as unknown as Record[], [ + { key: 'id', header: 'ID' }, + { key: 'action_at', header: 'Timestamp' }, + { key: 'action_type', header: 'Type' }, + { key: 'severity', header: 'Severity' }, + { key: 'message', header: 'Message' }, + { key: 'actor_email', header: 'Actor' }, + { key: 'details', header: 'Details (JSON)' }, + ]); + downloadCsv(`audit-log-${range}-${Date.now()}.csv`, csv); + } catch (e) { + setError((e as Error).message); + } finally { + setExporting(false); + } + }; + + // ── derived ─────────────────────────────────────────────────────────────── + + const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); + + // ── render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Toolbar */} +
+ {/* Filter: type */} + + + {/* Filter: severity */} + + + + {count.toLocaleString()} row{count !== 1 ? 's' : ''} + + + {/* Refresh */} + + + {/* Export */} + +
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* Table */} +
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }, (_, i) => ( + + {[1, 2, 3, 4, 5].map(j => ( + + ))} + + )) + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map(entry => ( + + + + + + + + )) + )} + +
TimeTypeSeverityMessageActor
+
+
+ No audit log entries for this time range and filters. +
+ {formatDateTime(entry.action_at)} + + + {ACTION_LABELS[entry.action_type]} + + + + {entry.severity} + + + {entry.message} + + {entry.actor_email ?? '—'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} +
+ ); +} diff --git a/invofi/apps/frontend/src/components/health/ContractStateCards.tsx b/invofi/apps/frontend/src/components/health/ContractStateCards.tsx new file mode 100644 index 00000000..6e8e8e33 --- /dev/null +++ b/invofi/apps/frontend/src/components/health/ContractStateCards.tsx @@ -0,0 +1,234 @@ +'use client'; + +// ContractStateCards — KPI summary cards for the health dashboard. +// +// Displays the latest contract_state_snapshot row as a responsive grid of +// stat cards covering: invoice status distribution, repayment/overdue rates, +// insurance pool utilisation, and position token supply. +// +// Accepts a loaded snapshot (or null for a loading/empty state) and an array +// of health_metrics rows for sparkline trends. + +import { + FileText, + AlertTriangle, + CheckCircle, + ShieldCheck, + Coins, + Users, + BarChart3, + Ban, +} from 'lucide-react'; +import type { ContractStateSnapshot, HealthMetric } from '@/lib/health/types'; +import { Sparkline } from './TxRateChart'; +import { STROOPS_PER_XLM } from '@/lib/constants'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function xlm(stroops: string | undefined): string { + if (!stroops) return '—'; + const n = Number(stroops) / STROOPS_PER_XLM; + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); +} + +function pct(value: number | undefined): string { + if (value === undefined || value === null) return '—'; + return `${(value * 100).toFixed(1)}%`; +} + +// ── types ───────────────────────────────────────────────────────────────────── + +export interface ContractStateCardsProps { + /** Latest snapshot; null while loading or no data yet. */ + snapshot: ContractStateSnapshot | null; + /** Historical snapshots for sparkline trends (oldest first). */ + snapshots: ContractStateSnapshot[]; + /** Health metrics for the tx failure sparkline. */ + metrics: HealthMetric[]; + /** Show loading skeleton instead of data. */ + loading?: boolean; +} + +// ── skeleton ────────────────────────────────────────────────────────────────── + +function CardSkeleton() { + return ( +
+
+
+
+
+ ); +} + +// ── individual card ─────────────────────────────────────────────────────────── + +interface KpiCardProps { + title: string; + value: string | number; + sub?: string; + icon: React.ReactNode; + trend?: number[]; // sparkline values + trendColor?: string; + alert?: boolean; // show amber/red styling when true +} + +function KpiCard({ title, value, sub, icon, trend, trendColor = '#6366f1', alert = false }: KpiCardProps) { + return ( +
+
+

{title}

+
+ + {icon} + +
+
+

{value}

+ {sub &&

{sub}

} + {trend && trend.length >= 2 && ( +
+ +
+ )} +
+ ); +} + +// ── component ───────────────────────────────────────────────────────────────── + +export function ContractStateCards({ + snapshot, + snapshots, + metrics, + loading = false, +}: ContractStateCardsProps) { + if (loading) { + return ( +
+ {Array.from({ length: 8 }, (_, i) => )} +
+ ); + } + + if (!snapshot) { + return ( +
+ +

No contract state snapshot yet.

+

+ Run the health collector to populate data. +

+
+ ); + } + + // Sparkline series derived from historical snapshots (oldest → newest). + const overdueRateTrend = snapshots.map(s => Number(s.overdue_rate)); + const repayRateTrend = snapshots.map(s => Number(s.repayment_rate)); + const txFailTrend = metrics.map(m => + (m.tx_success + m.tx_failure) === 0 + ? 0 + : m.tx_failure / (m.tx_success + m.tx_failure), + ); + + const overdueAlert = Number(snapshot.overdue_rate) > 0.15; + const totalFinancedActive = snapshot.invoices_financed + snapshot.invoices_overdue; + + return ( +
+ {/* Active invoices */} + } + /> + + {/* Overdue rate */} + } + trend={overdueRateTrend} + trendColor={overdueAlert ? '#ef4444' : '#f59e0b'} + alert={overdueAlert} + /> + + {/* Repayment rate */} + } + trend={repayRateTrend} + trendColor="#22c55e" + /> + + {/* Repaid invoices */} + } + /> + + {/* Insurance pool */} + } + /> + + {/* Position token supply */} + } + /> + + {/* Active lenders */} + } + /> + + {/* TX failure rate */} + 0 + ? pct( + metrics.reduce((s, m) => s + m.tx_failure, 0) / + Math.max(1, metrics.reduce((s, m) => s + m.tx_success + m.tx_failure, 0)), + ) + : '—' + } + sub={ + metrics.length > 0 + ? `${metrics.reduce((s, m) => s + m.tx_failure, 0)} failures in range` + : 'No metric data' + } + icon={} + trend={txFailTrend} + trendColor="#ef4444" + alert={ + metrics.length > 0 && + metrics.reduce((s, m) => s + m.tx_failure, 0) / + Math.max(1, metrics.reduce((s, m) => s + m.tx_success + m.tx_failure, 0)) > + 0.1 + } + /> +
+ ); +} diff --git a/invofi/apps/frontend/src/components/health/TxRateChart.tsx b/invofi/apps/frontend/src/components/health/TxRateChart.tsx new file mode 100644 index 00000000..1039c938 --- /dev/null +++ b/invofi/apps/frontend/src/components/health/TxRateChart.tsx @@ -0,0 +1,307 @@ +'use client'; + +// TxRateChart — pure-SVG transaction success/failure rate chart. +// +// Renders a stacked bar chart showing hourly tx_success / tx_failure counts +// from `health_metrics` rows. No external chart library — only SVG primitives +// and CSS. Responsive via a viewBox approach; the outer
controls the +// visual width and the SVG scales inside it. +// +// Used by: src/app/dashboard/health/page.tsx + +import { useMemo } from 'react'; +import type { HealthMetric } from '@/lib/health/types'; +import { txFailureRate } from '@/lib/health/types'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/** Format a bucket_start ISO string as a short label (e.g. "14:00", "Mon"). */ +function bucketLabel(iso: string, showDate: boolean): string { + const d = new Date(iso); + if (showDate) { + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); +} + +// ── types ───────────────────────────────────────────────────────────────────── + +export interface TxRateChartProps { + /** Health metric rows, ordered oldest → newest. */ + metrics: HealthMetric[]; + /** Whether to show date labels on X axis (vs. time labels for <24h view). */ + showDateLabels?: boolean; + /** Chart height in px (default 180). */ + height?: number; + /** Max bars to display — sampled evenly from the full array (default 40). */ + maxBars?: number; +} + +// ── constants ───────────────────────────────────────────────────────────────── + +const VIEWBOX_W = 640; +const BAR_GAP = 2; +const LABEL_H = 18; +const Y_AXIS_W = 34; +const COLOR_SUCCESS = '#22c55e'; // green-500 +const COLOR_FAILURE = '#ef4444'; // red-500 +const COLOR_GRID = '#e5e7eb'; // gray-200 (dark: handled via CSS var) + +// ── component ───────────────────────────────────────────────────────────────── + +export function TxRateChart({ + metrics, + showDateLabels = false, + height = 180, + maxBars = 40, +}: TxRateChartProps) { + // Sample evenly if there are too many bars. + const sampled = useMemo(() => { + if (metrics.length <= maxBars) return metrics; + const step = metrics.length / maxBars; + return Array.from({ length: maxBars }, (_, i) => metrics[Math.floor(i * step)]); + }, [metrics, maxBars]); + + const plotH = height - LABEL_H; + const totalW = VIEWBOX_W - Y_AXIS_W; + const barW = sampled.length > 0 + ? Math.max(2, Math.floor((totalW - BAR_GAP) / sampled.length) - BAR_GAP) + : 10; + + const maxTotal = useMemo( + () => Math.max(1, ...sampled.map(m => m.tx_success + m.tx_failure)), + [sampled], + ); + + // Build bar data. + const bars = useMemo( + () => + sampled.map((m, i) => { + const total = m.tx_success + m.tx_failure; + const successH = total === 0 ? 0 : Math.round((m.tx_success / maxTotal) * plotH); + const failureH = total === 0 ? 0 : Math.round((m.tx_failure / maxTotal) * plotH); + const x = Y_AXIS_W + i * (barW + BAR_GAP); + return { m, x, successH, failureH, total }; + }), + [sampled, barW, maxTotal, plotH], + ); + + // Y-axis grid lines at 0%, 25%, 50%, 75%, 100%. + const gridLines = [0, 0.25, 0.5, 0.75, 1].map(pct => ({ + y: Math.round(plotH * (1 - pct)), + label: `${Math.round(pct * maxTotal)}`, + })); + + // Build label positions — only show ~5 labels to avoid crowding. + const labelStep = Math.max(1, Math.floor(sampled.length / 5)); + const labels = sampled + .filter((_, i) => i % labelStep === 0) + .map((m, i) => ({ + x: Y_AXIS_W + (i * labelStep) * (barW + BAR_GAP) + barW / 2, + text: bucketLabel(m.bucket_start, showDateLabels), + })); + + if (sampled.length === 0) { + return ( +
+ No data for this time range +
+ ); + } + + // Overall failure rate for the summary text. + const totalSuccess = sampled.reduce((s, m) => s + m.tx_success, 0); + const totalFailure = sampled.reduce((s, m) => s + m.tx_failure, 0); + const overallFailRate = txFailureRate({ + tx_success: totalSuccess, + tx_failure: totalFailure, + } as HealthMetric); + + return ( +
+ {/* Summary line */} +
+ + + + + 0.1 + ? 'text-red-600 dark:text-red-400 font-medium' + : 'text-green-600 dark:text-green-400 font-medium' + } + > + Failure rate: {(overallFailRate * 100).toFixed(1)}% + +
+ + {/* SVG chart */} + + {/* Grid lines */} + {gridLines.map(({ y, label }) => ( + + + + {label} + + + ))} + + {/* Bars */} + {bars.map(({ m, x, successH, failureH }) => { + const successY = plotH - successH - failureH; + const failureY = plotH - failureH; + return ( + + {successH > 0 && ( + + {`${bucketLabel(m.bucket_start, showDateLabels)}: ${m.tx_success} success`} + + )} + {failureH > 0 && ( + + {`${bucketLabel(m.bucket_start, showDateLabels)}: ${m.tx_failure} failure`} + + )} + + ); + })} + + {/* X-axis labels */} + {labels.map(({ x, text }) => ( + + {text} + + ))} + +
+ ); +} + +// ── Sparkline (mini single-line trend) ─────────────────────────────────────── + +export interface SparklineProps { + /** Values from oldest to newest. */ + values: number[]; + /** Stroke colour (default: currentColor). */ + color?: string; + width?: number; + height?: number; + /** Fill under the line (default false). */ + fill?: boolean; +} + +/** + * A minimal SVG polyline sparkline — used inside KPI cards for trend lines. + */ +export function Sparkline({ + values, + color = 'currentColor', + width = 80, + height = 28, + fill = false, +}: SparklineProps) { + if (values.length < 2) return null; + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + + const step = width / (values.length - 1); + const points = values.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 2) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const polyline = points.join(' '); + + const areaPoints = [ + `0,${height}`, + ...points, + `${width},${height}`, + ].join(' '); + + return ( + + ); +} diff --git a/invofi/apps/frontend/src/lib/health/collector.ts b/invofi/apps/frontend/src/lib/health/collector.ts new file mode 100644 index 00000000..23d6c7ba --- /dev/null +++ b/invofi/apps/frontend/src/lib/health/collector.ts @@ -0,0 +1,261 @@ +// Health metrics collector helpers. +// +// This module is used by the GitHub Actions health collector script +// (scripts/health-collector.ts) that runs on an hourly schedule. +// It is NOT bundled into the frontend — all imports are Node-compatible and +// the functions are only called from the collector, not from React components. +// +// Responsibilities: +// 1. Poll Soroban RPC getEvents for the current 1-hour window. +// 2. Aggregate success/failure counts, fee stats, and event-type counts. +// 3. Snapshot the current contract state (invoice distribution, pool util, …). +// 4. Evaluate alert_configs and emit audit_log rows for breaches. + +import { rpc, scValToNative } from '@stellar/stellar-sdk'; +import type { HealthMetric, ContractStateSnapshot, AlertConfig } from './types'; + +// ── Config ──────────────────────────────────────────────────────────────────── + +export interface CollectorConfig { + rpcUrl: string; + networkPassphrase: string; + registryId: string; + financingId: string; + repaymentId: string; + insuranceId: string; + reputationId: string; +} + +// ── Event ingestion ─────────────────────────────────────────────────────────── + +export interface TxWindow { + /** Number of transactions (events) that succeeded in the window. */ + txSuccess: number; + /** Number of transactions that emitted error events or had failed ledger entries. */ + txFailure: number; + /** Sum of all fee_charged values seen in the window (stroops). */ + totalFeeStroops: bigint; + /** Individual fee values, used to compute p95. */ + fees: number[]; + /** Ledger close times for confirmation latency (ms). */ + confirmationMs: number[]; + /** Per-event-type counts. */ + eventCounts: Record; + /** Contract IDs that emitted at least one event. */ + contractsActive: Set; +} + +export function emptyWindow(): TxWindow { + return { + txSuccess: 0, + txFailure: 0, + totalFeeStroops: 0n, + fees: [], + confirmationMs: [], + eventCounts: {}, + contractsActive: new Set(), + }; +} + +/** Known success event topic names (state-mutating, forward-progressing). */ +const SUCCESS_EVENTS = new Set([ + 'inv_reg', 'off_new', 'off_acc', 'off_rej', 'inv_rep', + 'inv_ovd', 'inv_cxl', 'inv_dsp', 'inv_rsl', 'off_def', 'off_wdr', +]); + +/** + * Fold a single Soroban event into the accumulator. + * The event's first topic is the event name (a Symbol scVal). + */ +export function foldEvent( + window: TxWindow, + topic: rpc.Api.EventResponse['topic'], + _value: unknown, + contractId: string, +): void { + let name: string; + try { + name = scValToNative(topic[0]) as string; + } catch { + return; // malformed event + } + + if (SUCCESS_EVENTS.has(name)) { + window.txSuccess += 1; + } else { + window.txFailure += 1; + } + + window.eventCounts[name] = (window.eventCounts[name] ?? 0) + 1; + window.contractsActive.add(contractId); +} + +/** Compute p95 from a sorted array of numbers. Returns 0 for empty arrays. */ +export function p95(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const idx = Math.floor(sorted.length * 0.95); + return sorted[Math.min(idx, sorted.length - 1)]; +} + +/** + * Convert an accumulated TxWindow into a HealthMetric row for Supabase. + */ +export function windowToMetric( + window: TxWindow, + bucketStart: Date, +): Omit { + const totalFees = window.fees.length; + const avgFee = totalFees > 0 + ? Math.round(Number(window.totalFeeStroops) / totalFees) + : 0; + const avgConfMs = window.confirmationMs.length > 0 + ? Math.round(window.confirmationMs.reduce((a, b) => a + b, 0) / window.confirmationMs.length) + : 0; + const bucketEnd = new Date(bucketStart.getTime() + 60 * 60 * 1000); + + return { + bucket_start: bucketStart.toISOString(), + bucket_end: bucketEnd.toISOString(), + tx_success: window.txSuccess, + tx_failure: window.txFailure, + avg_fee_stroops: avgFee, + p95_fee_stroops: p95(window.fees), + avg_confirmation_ms: avgConfMs, + event_counts: window.eventCounts, + contracts_active: [...window.contractsActive], + }; +} + +// ── Alert evaluation ────────────────────────────────────────────────────────── + +/** + * A breach report produced when an alert_config threshold is violated. + */ +export interface AlertBreach { + config: AlertConfig; + actualValue: number; +} + +/** + * Evaluate all enabled alert configs against the latest snapshot and + * the most-recent health metric bucket. + * + * Returns a list of breaches (may be empty). + */ +export function evaluateAlerts( + configs: AlertConfig[], + snapshot: ContractStateSnapshot | null, + latestMetric: HealthMetric | null, +): AlertBreach[] { + if (!snapshot && !latestMetric) return []; + + const breaches: AlertBreach[] = []; + + for (const cfg of configs) { + if (!cfg.enabled) continue; + + let actual: number | null = null; + + switch (cfg.metric) { + case 'overdue_rate': + actual = snapshot ? Number(snapshot.overdue_rate) : null; + break; + case 'repayment_rate': + actual = snapshot ? Number(snapshot.repayment_rate) : null; + break; + case 'tx_failure_rate': { + if (latestMetric) { + const total = latestMetric.tx_success + latestMetric.tx_failure; + actual = total > 0 ? latestMetric.tx_failure / total : 0; + } + break; + } + case 'insurance_pool_total': + actual = snapshot ? Number(snapshot.insurance_pool_total) : null; + break; + case 'avg_fee_stroops': + actual = latestMetric ? latestMetric.avg_fee_stroops : null; + break; + case 'invoices_overdue': + actual = snapshot ? snapshot.invoices_overdue : null; + break; + } + + if (actual === null) continue; + + const threshold = Number(cfg.threshold); + let breached = false; + switch (cfg.operator) { + case 'gt': breached = actual > threshold; break; + case 'lt': breached = actual < threshold; break; + case 'gte': breached = actual >= threshold; break; + case 'lte': breached = actual <= threshold; break; + } + + if (breached) { + breaches.push({ config: cfg, actualValue: actual }); + } + } + + return breaches; +} + +// ── Contract state snapshot helpers ────────────────────────────────────────── + +/** + * Build a ContractStateSnapshot from the same on-chain numbers the indexer + * already fetches. Pass the output of readRegistryStats() / readInsurancePool() + * from apps/indexer/src/chain.ts. + */ +export function buildSnapshot(params: { + lastLedger: number; + totalInvoices: number; + invoicesFinanced: number; + invoicesRepaid: number; + invoicesOverdue: number; + invoicesDefaulted: number; + invoicesCancelled: number; + invoicesDisputed: number; + invoicesPending: number; + totalVolume: bigint; + totalRepaid: bigint; + insurancePool: bigint; + activeLenders: number; + positionTokenSupply?: bigint; +}): Omit { + const { + lastLedger, totalInvoices, invoicesFinanced, invoicesRepaid, invoicesOverdue, + invoicesDefaulted, invoicesCancelled, invoicesDisputed, invoicesPending, + totalVolume, totalRepaid, insurancePool, activeLenders, + positionTokenSupply = 0n, + } = params; + + const repaymentRate = totalVolume > 0n + ? Math.min(1, Number(totalRepaid) / Number(totalVolume)) + : 0; + const overdueRate = invoicesFinanced > 0 + ? invoicesOverdue / (invoicesFinanced + invoicesOverdue) + : 0; + + return { + snapshotted_at: new Date().toISOString(), + last_ledger: lastLedger, + invoices_pending: invoicesPending, + invoices_financed: invoicesFinanced, + invoices_repaid: invoicesRepaid, + invoices_overdue: invoicesOverdue, + invoices_defaulted: invoicesDefaulted, + invoices_cancelled: invoicesCancelled, + invoices_disputed: invoicesDisputed, + total_invoices: totalInvoices, + insurance_pool_total: insurancePool.toString(), + insurance_pool_staked: insurancePool.toString(), + position_token_supply: positionTokenSupply.toString(), + repayment_rate: Math.round(repaymentRate * 10000) / 10000, + overdue_rate: Math.round(overdueRate * 10000) / 10000, + total_volume: totalVolume.toString(), + total_repaid: totalRepaid.toString(), + active_lenders: activeLenders, + }; +} diff --git a/invofi/apps/frontend/src/lib/health/index.ts b/invofi/apps/frontend/src/lib/health/index.ts new file mode 100644 index 00000000..bafaa988 --- /dev/null +++ b/invofi/apps/frontend/src/lib/health/index.ts @@ -0,0 +1,4 @@ +// Public surface for the health monitoring lib. +export * from './types'; +export * from './metrics'; +export * from './collector'; diff --git a/invofi/apps/frontend/src/lib/health/metrics.ts b/invofi/apps/frontend/src/lib/health/metrics.ts new file mode 100644 index 00000000..f980e20e --- /dev/null +++ b/invofi/apps/frontend/src/lib/health/metrics.ts @@ -0,0 +1,182 @@ +// Supabase read/write helpers for the health monitoring tables. +// All reads are filtered by a `since` timestamp derived from the TimeRange +// selector on the dashboard. + +import { supabase } from '@/lib/supabase'; +import type { + HealthMetric, + ContractStateSnapshot, + AlertConfig, + AlertConfigDraft, + AuditLogEntry, + AuditActionType, + AlertSeverity, +} from './types'; +import { timeRangeSince, type TimeRange } from './types'; + +// ── health_metrics ──────────────────────────────────────────────────────────── + +/** + * Fetch health metric rows for the given time range, newest first. + * Caps at 200 rows to keep the response manageable; the dashboard + * aggregates / samples down to chart-friendly granularity anyway. + */ +export async function fetchHealthMetrics(range: TimeRange): Promise { + const since = timeRangeSince(range).toISOString(); + const { data, error } = await supabase + .from('health_metrics') + .select('*') + .gte('bucket_start', since) + .order('bucket_start', { ascending: true }) + .limit(200); + if (error) throw new Error(error.message); + return (data ?? []) as HealthMetric[]; +} + +/** + * Insert a new health metric row (called by the collector script running under + * the service role or an admin session). Uses upsert on bucket_start so a + * re-run of the same hour overwrites the previous value. + */ +export async function upsertHealthMetric( + row: Omit, +): Promise { + const { error } = await supabase + .from('health_metrics') + .upsert(row, { onConflict: 'bucket_start' }); + if (error) throw new Error(error.message); +} + +// ── contract_state_snapshots ────────────────────────────────────────────────── + +/** Fetch snapshot rows for the given time range, oldest first (for charting). */ +export async function fetchSnapshots(range: TimeRange): Promise { + const since = timeRangeSince(range).toISOString(); + const { data, error } = await supabase + .from('contract_state_snapshots') + .select('*') + .gte('snapshotted_at', since) + .order('snapshotted_at', { ascending: true }) + .limit(100); + if (error) throw new Error(error.message); + return (data ?? []) as ContractStateSnapshot[]; +} + +/** Fetch the single most-recent snapshot (used by KPI summary cards). */ +export async function fetchLatestSnapshot(): Promise { + const { data, error } = await supabase + .from('contract_state_snapshots') + .select('*') + .order('snapshotted_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) throw new Error(error.message); + return (data as ContractStateSnapshot | null) ?? null; +} + +/** Insert a new snapshot row (called by the collector). */ +export async function insertSnapshot( + row: Omit, +): Promise { + const { error } = await supabase + .from('contract_state_snapshots') + .insert(row); + if (error) throw new Error(error.message); +} + +// ── alert_configs ───────────────────────────────────────────────────────────── + +/** Fetch all alert config rows (admins only — enforced by RLS). */ +export async function fetchAlertConfigs(): Promise { + const { data, error } = await supabase + .from('alert_configs') + .select('*') + .order('created_at', { ascending: true }); + if (error) throw new Error(error.message); + return (data ?? []) as AlertConfig[]; +} + +/** Create a new alert config. */ +export async function createAlertConfig(draft: AlertConfigDraft): Promise { + const { data, error } = await supabase + .from('alert_configs') + .insert(draft) + .select() + .single(); + if (error) throw new Error(error.message); + return data as AlertConfig; +} + +/** Update an existing alert config. */ +export async function updateAlertConfig( + id: string, + patch: Partial, +): Promise { + const { error } = await supabase + .from('alert_configs') + .update(patch) + .eq('id', id); + if (error) throw new Error(error.message); +} + +/** Delete an alert config. */ +export async function deleteAlertConfig(id: string): Promise { + const { error } = await supabase + .from('alert_configs') + .delete() + .eq('id', id); + if (error) throw new Error(error.message); +} + +// ── audit_log ───────────────────────────────────────────────────────────────── + +/** Fetch audit log entries for the given time range, newest first. */ +export async function fetchAuditLog( + range: TimeRange, + options?: { + actionType?: AuditActionType; + severity?: AlertSeverity; + limit?: number; + offset?: number; + }, +): Promise<{ entries: AuditLogEntry[]; count: number }> { + const since = timeRangeSince(range).toISOString(); + const limit = options?.limit ?? 50; + const offset = options?.offset ?? 0; + + let query = supabase + .from('audit_log') + .select('*', { count: 'exact' }) + .gte('action_at', since) + .order('action_at', { ascending: false }) + .range(offset, offset + limit - 1); + + if (options?.actionType) { + query = query.eq('action_type', options.actionType); + } + if (options?.severity) { + query = query.eq('severity', options.severity); + } + + const { data, error, count } = await query; + if (error) throw new Error(error.message); + return { entries: (data ?? []) as AuditLogEntry[], count: count ?? 0 }; +} + +/** Insert an audit log entry (called from the frontend on admin actions). */ +export async function insertAuditLog(entry: { + action_type: AuditActionType; + message: string; + details?: Record; + severity?: AlertSeverity; + actor_email?: string; +}): Promise { + const { error } = await supabase.from('audit_log').insert({ + action_type: entry.action_type, + message: entry.message, + details: entry.details ?? {}, + severity: entry.severity ?? 'info', + actor_email: entry.actor_email ?? null, + }); + if (error) throw new Error(error.message); +} diff --git a/invofi/apps/frontend/src/lib/health/types.ts b/invofi/apps/frontend/src/lib/health/types.ts new file mode 100644 index 00000000..6fc05b2f --- /dev/null +++ b/invofi/apps/frontend/src/lib/health/types.ts @@ -0,0 +1,135 @@ +// Types for the protocol health monitoring system (issue health-dashboard). +// These mirror the four Supabase tables created in migration 004. + +export type TimeRange = '1h' | '24h' | '7d' | '30d'; + +export function timeRangeSince(range: TimeRange): Date { + const now = new Date(); + switch (range) { + case '1h': return new Date(now.getTime() - 60 * 60 * 1000); + case '24h': return new Date(now.getTime() - 24 * 60 * 60 * 1000); + case '7d': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + case '30d': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + } +} + +// ── health_metrics ──────────────────────────────────────────────────────────── + +export interface HealthMetric { + id: number; + bucket_start: string; // ISO 8601 + bucket_end: string; + tx_success: number; + tx_failure: number; + avg_fee_stroops: number; + p95_fee_stroops: number; + avg_confirmation_ms: number; + /** JSON object: { inv_reg: 3, off_acc: 2, … } */ + event_counts: Record; + contracts_active: string[]; + created_at: string; +} + +// ── contract_state_snapshots ────────────────────────────────────────────────── + +export interface ContractStateSnapshot { + id: number; + snapshotted_at: string; + last_ledger: number; + invoices_pending: number; + invoices_financed: number; + invoices_repaid: number; + invoices_overdue: number; + invoices_defaulted: number; + invoices_cancelled: number; + invoices_disputed: number; + total_invoices: number; + insurance_pool_total: string; + insurance_pool_staked: string; + position_token_supply: string; + repayment_rate: number; + overdue_rate: number; + total_volume: string; + total_repaid: string; + active_lenders: number; +} + +// ── alert_configs ───────────────────────────────────────────────────────────── + +export type AlertMetric = + | 'overdue_rate' + | 'repayment_rate' + | 'tx_failure_rate' + | 'insurance_pool_total' + | 'avg_fee_stroops' + | 'invoices_overdue'; + +export type AlertOperator = 'gt' | 'lt' | 'gte' | 'lte'; +export type AlertSeverity = 'info' | 'warning' | 'critical'; + +export interface AlertConfig { + id: string; + label: string; + metric: AlertMetric; + operator: AlertOperator; + threshold: string; + severity: AlertSeverity; + enabled: boolean; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface AlertConfigDraft { + label: string; + metric: AlertMetric; + operator: AlertOperator; + threshold: string; + severity: AlertSeverity; + enabled: boolean; +} + +// ── audit_log ───────────────────────────────────────────────────────────────── + +export type AuditActionType = + | 'alert_breach' + | 'admin_action' + | 'config_change' + | 'system_event'; + +export interface AuditLogEntry { + id: number; + action_at: string; + action_type: AuditActionType; + message: string; + details: Record; + severity: AlertSeverity; + actor_id: string | null; + actor_email: string | null; +} + +// ── derived helpers ─────────────────────────────────────────────────────────── + +/** Compute the tx failure rate (0–1) from a HealthMetric row. */ +export function txFailureRate(metric: HealthMetric): number { + const total = metric.tx_success + metric.tx_failure; + return total === 0 ? 0 : metric.tx_failure / total; +} + +/** Human-readable label for an alert operator. */ +export const OPERATOR_LABELS: Record = { + gt: '>', + lt: '<', + gte: '≥', + lte: '≤', +}; + +/** Human-readable label for an alert metric. */ +export const METRIC_LABELS: Record = { + overdue_rate: 'Overdue Rate', + repayment_rate: 'Repayment Rate', + tx_failure_rate: 'TX Failure Rate', + insurance_pool_total: 'Insurance Pool (stroops)', + avg_fee_stroops: 'Avg Fee (stroops)', + invoices_overdue: 'Overdue Invoice Count', +}; diff --git a/invofi/apps/frontend/src/lib/migrations/004_health_monitoring.sql b/invofi/apps/frontend/src/lib/migrations/004_health_monitoring.sql new file mode 100644 index 00000000..95f89ba6 --- /dev/null +++ b/invofi/apps/frontend/src/lib/migrations/004_health_monitoring.sql @@ -0,0 +1,275 @@ +-- Migration 004: protocol health monitoring (issue health-dashboard) +-- +-- Creates four tables used by the /dashboard/health admin view: +-- * health_metrics — hourly transaction success/failure counts + fee stats +-- * contract_state_snapshots — 6-hourly contract state (invoice dist, pool util, …) +-- * alert_configs — admin-managed threshold rules +-- * audit_log — append-only record of admin actions +-- +-- Also extends user_profiles.role CHECK constraint to include 'admin'. +-- Run in your Supabase SQL Editor. Idempotent — safe to re-run. + +-- ── Extend user_profiles to allow the 'admin' role ─────────────────────────── +-- Drop and recreate the existing check (ALTER TABLE … DROP CONSTRAINT is +-- idempotent-safe when wrapped in a DO block). +do $$ +begin + -- Remove the old constraint if present (name may vary; try both common names). + begin + alter table user_profiles drop constraint if exists user_profiles_role_check; + exception when others then null; + end; +end; +$$; + +alter table user_profiles + add constraint user_profiles_role_check + check (role in ('business', 'lender', 'admin')); + +-- ── health_metrics ──────────────────────────────────────────────────────────── +-- One row per time bucket (1-hour window). Written by the health collector +-- script / GitHub Action. Read by the /dashboard/health page. +create table if not exists health_metrics ( + id bigint primary key generated always as identity, + + -- Start of the 1-hour bucket (truncated to the hour, UTC). + bucket_start timestamptz not null, + -- End of the bucket (= bucket_start + 1 hour). + bucket_end timestamptz not null, + + -- Transaction counts within this window. + tx_success integer not null default 0, + tx_failure integer not null default 0, + + -- Average and p95 fee in stroops (proxy for gas when gas units unavailable). + avg_fee_stroops bigint not null default 0, + p95_fee_stroops bigint not null default 0, + + -- Average ledger-close latency for transactions in this bucket (ms). + avg_confirmation_ms integer not null default 0, + + -- Per-event-type counts for the bucket (JSONB for flexibility). + -- Keys: inv_reg, off_new, off_acc, off_rej, inv_rep, inv_ovd, off_def, … + event_counts jsonb not null default '{}', + + -- Which contracts contributed events in this bucket. + contracts_active text[] not null default '{}', + + created_at timestamptz not null default now(), + + unique (bucket_start) +); + +create index if not exists health_metrics_bucket_start_idx + on health_metrics (bucket_start desc); + +-- ── contract_state_snapshots ────────────────────────────────────────────────── +-- One row per 6-hour snapshot run (mirrors the indexer schedule). Captures +-- the current contract state: invoice status distribution, pool utilisation, +-- overdue ratio, position token supply. +create table if not exists contract_state_snapshots ( + id bigint primary key generated always as identity, + + snapshotted_at timestamptz not null default now(), + last_ledger bigint not null default 0, + + -- Invoice status distribution (counts). + invoices_pending integer not null default 0, + invoices_financed integer not null default 0, + invoices_repaid integer not null default 0, + invoices_overdue integer not null default 0, + invoices_defaulted integer not null default 0, + invoices_cancelled integer not null default 0, + invoices_disputed integer not null default 0, + total_invoices integer not null default 0, + + -- Pool utilisation (insurance). + insurance_pool_total text not null default '0', -- stroops as text (bigint-safe) + insurance_pool_staked text not null default '0', + + -- Position token supply (SEP-41 total_supply query, or 0 if unavailable). + position_token_supply text not null default '0', + + -- Repayment / overdue ratio (0.0 – 1.0). + repayment_rate numeric(6,4) not null default 0, + overdue_rate numeric(6,4) not null default 0, + + -- Total financed and repaid volumes (stroops as text). + total_volume text not null default '0', + total_repaid text not null default '0', + + -- Active lenders count. + active_lenders integer not null default 0 +); + +create index if not exists contract_state_snapshots_at_idx + on contract_state_snapshots (snapshotted_at desc); + +-- ── alert_configs ───────────────────────────────────────────────────────────── +-- Admin-managed threshold rules. The collector evaluates these after each run +-- and inserts into audit_log when a threshold is breached. +create table if not exists alert_configs ( + id uuid primary key default gen_random_uuid(), + + -- Human label, e.g. "Overdue rate too high". + label text not null, + + -- The metric being watched. + -- Allowed values match the columns/fields the collector can evaluate: + -- overdue_rate | repayment_rate | tx_failure_rate | + -- insurance_pool_total | avg_fee_stroops | invoices_overdue + metric text not null + check (metric in ( + 'overdue_rate', 'repayment_rate', 'tx_failure_rate', + 'insurance_pool_total', 'avg_fee_stroops', 'invoices_overdue' + )), + + -- Comparison operator. + operator text not null + check (operator in ('gt', 'lt', 'gte', 'lte')), + + -- Threshold value (stored as text to cover both integers and decimals). + threshold text not null, + + -- Severity shown in the audit log when breached. + severity text not null default 'warning' + check (severity in ('info', 'warning', 'critical')), + + enabled boolean not null default true, + + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- ── audit_log ───────────────────────────────────────────────────────────────── +-- Append-only log. Rows are inserted by: +-- (a) the health collector when an alert_config threshold is breached, and +-- (b) the frontend when an admin takes an explicit action +-- (e.g. pause contract, resolve dispute, update alert). +create table if not exists audit_log ( + id bigint primary key generated always as identity, + + -- ISO 8601 timestamp of the action (defaults to now()). + action_at timestamptz not null default now(), + + -- Type discriminator for filtering. + -- 'alert_breach' | 'admin_action' | 'config_change' | 'system_event' + action_type text not null + check (action_type in ('alert_breach', 'admin_action', 'config_change', 'system_event')), + + -- Human-readable summary. + message text not null, + + -- Structured payload (alert name, metric value, actor, etc.). + details jsonb not null default '{}', + + severity text not null default 'info' + check (severity in ('info', 'warning', 'critical')), + + -- The user who triggered the action (null for system events). + actor_id uuid references auth.users(id) on delete set null, + actor_email text +); + +create index if not exists audit_log_action_at_idx + on audit_log (action_at desc); + +create index if not exists audit_log_action_type_idx + on audit_log (action_type, action_at desc); + +-- ── Row-Level Security ──────────────────────────────────────────────────────── + +alter table health_metrics enable row level security; +alter table contract_state_snapshots enable row level security; +alter table alert_configs enable row level security; +alter table audit_log enable row level security; + +-- health_metrics: public read (same as protocol_stats), admin write. +drop policy if exists "Public read health_metrics" on health_metrics; +create policy "Public read health_metrics" + on health_metrics for select using (true); + +drop policy if exists "Admin write health_metrics" on health_metrics; +create policy "Admin write health_metrics" + on health_metrics for insert + with check ( + exists ( + select 1 from user_profiles + where id = auth.uid() and role = 'admin' + ) + ); + +-- contract_state_snapshots: public read, admin write. +drop policy if exists "Public read snapshots" on contract_state_snapshots; +create policy "Public read snapshots" + on contract_state_snapshots for select using (true); + +drop policy if exists "Admin write snapshots" on contract_state_snapshots; +create policy "Admin write snapshots" + on contract_state_snapshots for insert + with check ( + exists ( + select 1 from user_profiles + where id = auth.uid() and role = 'admin' + ) + ); + +-- alert_configs: authenticated read, admin write/update/delete. +drop policy if exists "Authenticated read alert_configs" on alert_configs; +create policy "Authenticated read alert_configs" + on alert_configs for select using (auth.uid() is not null); + +drop policy if exists "Admin insert alert_configs" on alert_configs; +create policy "Admin insert alert_configs" + on alert_configs for insert + with check ( + exists ( + select 1 from user_profiles + where id = auth.uid() and role = 'admin' + ) + ); + +drop policy if exists "Admin update alert_configs" on alert_configs; +create policy "Admin update alert_configs" + on alert_configs for update + using ( + exists ( + select 1 from user_profiles + where id = auth.uid() and role = 'admin' + ) + ); + +drop policy if exists "Admin delete alert_configs" on alert_configs; +create policy "Admin delete alert_configs" + on alert_configs for delete + using ( + exists ( + select 1 from user_profiles + where id = auth.uid() and role = 'admin' + ) + ); + +-- audit_log: authenticated read (admins and stakeholders), system/admin insert. +drop policy if exists "Authenticated read audit_log" on audit_log; +create policy "Authenticated read audit_log" + on audit_log for select using (auth.uid() is not null); + +drop policy if exists "Admin insert audit_log" on audit_log; +create policy "Admin insert audit_log" + on audit_log for insert + with check (auth.uid() is not null); + +-- ── updated_at trigger for alert_configs ───────────────────────────────────── +create or replace function update_updated_at_column() +returns trigger language plpgsql as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists alert_configs_updated_at on alert_configs; +create trigger alert_configs_updated_at + before update on alert_configs + for each row execute function update_updated_at_column(); diff --git a/invofi/scripts/health-collector.test.ts b/invofi/scripts/health-collector.test.ts new file mode 100644 index 00000000..437fcb2a --- /dev/null +++ b/invofi/scripts/health-collector.test.ts @@ -0,0 +1,276 @@ +process.env.NODE_ENV = 'test'; + +// Unit tests for the health collector pure-logic functions. +// These tests cover the functions exported from collector.ts (the lib module) +// and the helper functions in health-collector.ts that do not require live +// network or Supabase connections. +// +// Run with: +// npm test --prefix invofi/scripts +// or directly: +// cd invofi/scripts && tsx --test health-collector.test.ts + +import assert from 'node:assert/strict'; +import test, { describe } from 'node:test'; +import { nativeToScVal } from '@stellar/stellar-sdk'; + +// Import the pure helpers from the lib module directly (no network / DB side +// effects). The health-collector.ts entry point is not imported here so we +// do not hit the `run()` entrypoint or the env-var guards at the top. +import { + emptyWindow, + foldEvent, + p95, + windowToMetric, + buildSnapshot, + evaluateAlerts, +} from '../apps/frontend/src/lib/health/collector.js'; + +// ── foldEvent ───────────────────────────────────────────────────────────────── + +describe('foldEvent', () => { + test('counts known success events in txSuccess', () => { + const w = emptyWindow(); + const topic = [nativeToScVal('inv_reg', { type: 'symbol' })]; + foldEvent(w, topic as never, null, 'CONTRACT_A'); + assert.equal(w.txSuccess, 1); + assert.equal(w.txFailure, 0); + assert.deepEqual(w.eventCounts, { inv_reg: 1 }); + assert.ok(w.contractsActive.has('CONTRACT_A')); + }); + + test('counts off_acc as success', () => { + const w = emptyWindow(); + const topic = [nativeToScVal('off_acc', { type: 'symbol' })]; + foldEvent(w, topic as never, null, 'C1'); + assert.equal(w.txSuccess, 1); + }); + + test('counts unknown event names in txFailure', () => { + const w = emptyWindow(); + const topic = [nativeToScVal('unknown_event', { type: 'symbol' })]; + foldEvent(w, topic as never, null, 'C1'); + assert.equal(w.txFailure, 1); + assert.equal(w.txSuccess, 0); + assert.deepEqual(w.eventCounts, { unknown_event: 1 }); + }); + + test('skips malformed topics without throwing', () => { + const w = emptyWindow(); + // Pass a topic array with a non-decodable scVal — should not throw. + const badTopic = [{ _switch: { value: -99999 } }]; + assert.doesNotThrow(() => foldEvent(w, badTopic as never, null, 'C1')); + assert.equal(w.txSuccess, 0); + assert.equal(w.txFailure, 0); + }); + + test('accumulates multiple events from multiple contracts', () => { + const w = emptyWindow(); + const topicReg = [nativeToScVal('inv_reg', { type: 'symbol' })]; + const topicRep = [nativeToScVal('inv_rep', { type: 'symbol' })]; + foldEvent(w, topicReg as never, null, 'C1'); + foldEvent(w, topicReg as never, null, 'C2'); + foldEvent(w, topicRep as never, null, 'C1'); + assert.equal(w.txSuccess, 3); + assert.equal(w.eventCounts['inv_reg'], 2); + assert.equal(w.eventCounts['inv_rep'], 1); + assert.equal(w.contractsActive.size, 2); + }); +}); + +// ── p95 ─────────────────────────────────────────────────────────────────────── + +describe('p95', () => { + test('returns 0 for empty array', () => { + assert.equal(p95([]), 0); + }); + + test('returns the single value for a 1-element array', () => { + assert.equal(p95([42]), 42); + }); + + test('returns the 95th percentile value', () => { + // 20 values 1..20; p95 index = floor(20 * 0.95) = 19 → value 20 + const values = Array.from({ length: 20 }, (_, i) => i + 1); + assert.equal(p95(values), 20); + }); + + test('handles unsorted input', () => { + const values = [50, 10, 30, 20, 40]; + // sorted: [10, 20, 30, 40, 50]; idx = floor(5*0.95)=4 → 50 + assert.equal(p95(values), 50); + }); +}); + +// ── windowToMetric ──────────────────────────────────────────────────────────── + +describe('windowToMetric', () => { + test('converts an empty window to a zero metric row', () => { + const w = emptyWindow(); + const bucketStart = new Date('2026-01-01T12:00:00Z'); + const metric = windowToMetric(w, bucketStart); + + assert.equal(metric.tx_success, 0); + assert.equal(metric.tx_failure, 0); + assert.equal(metric.avg_fee_stroops, 0); + assert.equal(metric.p95_fee_stroops, 0); + assert.equal(metric.avg_confirmation_ms, 0); + assert.equal(metric.bucket_start, bucketStart.toISOString()); + assert.equal(metric.bucket_end, new Date('2026-01-01T13:00:00Z').toISOString()); + assert.deepEqual(metric.event_counts, {}); + assert.deepEqual(metric.contracts_active, []); + }); + + test('computes averages correctly', () => { + const w = emptyWindow(); + w.txSuccess = 10; + w.txFailure = 2; + w.fees = [100, 200, 300]; + w.totalFeeStroops = 600n; + w.confirmationMs = [1000, 2000, 3000]; + w.eventCounts = { inv_reg: 10, off_new: 2 }; + w.contractsActive = new Set(['CA', 'CB']); + + const metric = windowToMetric(w, new Date('2026-01-01T00:00:00Z')); + assert.equal(metric.avg_fee_stroops, 200); + assert.equal(metric.p95_fee_stroops, 300); + assert.equal(metric.avg_confirmation_ms, 2000); + assert.deepEqual(metric.contracts_active, ['CA', 'CB']); + }); +}); + +// ── buildSnapshot ───────────────────────────────────────────────────────────── + +describe('buildSnapshot', () => { + test('builds a valid snapshot with zero state', () => { + const snap = buildSnapshot({ + lastLedger: 1000, + totalInvoices: 0, + invoicesFinanced: 0, + invoicesRepaid: 0, + invoicesOverdue: 0, + invoicesDefaulted: 0, + invoicesCancelled: 0, + invoicesDisputed: 0, + invoicesPending: 0, + totalVolume: 0n, + totalRepaid: 0n, + insurancePool: 0n, + activeLenders: 0, + }); + + assert.equal(snap.last_ledger, 1000); + assert.equal(snap.repayment_rate, 0); + assert.equal(snap.overdue_rate, 0); + assert.equal(snap.total_volume, '0'); + }); + + test('computes repayment_rate and overdue_rate correctly', () => { + const snap = buildSnapshot({ + lastLedger: 5000, + totalInvoices: 100, + invoicesFinanced: 40, + invoicesRepaid: 50, + invoicesOverdue: 10, + invoicesDefaulted: 0, + invoicesCancelled: 0, + invoicesDisputed: 0, + invoicesPending: 0, + totalVolume: 1_000_000n, + totalRepaid: 500_000n, + insurancePool: 200_000n, + activeLenders: 20, + }); + + // repaymentRate = min(1, 500000/1000000) = 0.5 + assert.equal(snap.repayment_rate, 0.5); + // overdueRate = overdue / (financed + overdue) = 10 / 50 = 0.2 + assert.equal(snap.overdue_rate, 0.2); + }); + + test('caps repayment_rate at 1.0', () => { + const snap = buildSnapshot({ + lastLedger: 1, + totalInvoices: 10, + invoicesFinanced: 0, + invoicesRepaid: 10, + invoicesOverdue: 0, + invoicesDefaulted: 0, + invoicesCancelled: 0, + invoicesDisputed: 0, + invoicesPending: 0, + totalVolume: 100n, + totalRepaid: 200n, // repaid > volume (edge case) + insurancePool: 0n, + activeLenders: 5, + }); + assert.equal(snap.repayment_rate, 1); + }); +}); + +// ── evaluateAlerts ──────────────────────────────────────────────────────────── + +describe('evaluateAlerts', () => { + const makeConfig = ( + id: string, + metric: string, + operator: string, + threshold: string, + enabled = true, + ) => ({ + id, + label: `Test rule ${id}`, + metric, + operator, + threshold, + severity: 'warning' as const, + enabled, + created_by: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + test('returns no breaches when there are no enabled rules', () => { + const cfg = [makeConfig('1', 'overdue_rate', 'gt', '0.15', false)]; + const breaches = evaluateAlerts(cfg as never, null, null); + assert.equal(breaches.length, 0); + }); + + test('detects overdue_rate gt threshold breach', () => { + const cfg = [makeConfig('1', 'overdue_rate', 'gt', '0.15')]; + const snapshot = { overdue_rate: 0.20 } as never; + const breaches = evaluateAlerts(cfg as never, snapshot, null); + assert.equal(breaches.length, 1); + assert.equal(breaches[0].config.id, '1'); + assert.ok(Math.abs(breaches[0].actualValue - 0.20) < 0.0001); + }); + + test('does not breach when value is below threshold', () => { + const cfg = [makeConfig('1', 'overdue_rate', 'gt', '0.15')]; + const snapshot = { overdue_rate: 0.10 } as never; + const breaches = evaluateAlerts(cfg as never, snapshot, null); + assert.equal(breaches.length, 0); + }); + + test('detects tx_failure_rate breach from metric row', () => { + const cfg = [makeConfig('2', 'tx_failure_rate', 'gt', '0.1')]; + const metric = { tx_success: 8, tx_failure: 3 } as never; // 3/11 ≈ 0.27 + const breaches = evaluateAlerts(cfg as never, null, metric); + assert.equal(breaches.length, 1); + assert.ok(breaches[0].actualValue > 0.1); + }); + + test('evaluates gte operator correctly', () => { + const cfg = [makeConfig('3', 'invoices_overdue', 'gte', '5')]; + const snap = { invoices_overdue: 5 } as never; + const breaches = evaluateAlerts(cfg as never, snap, null); + assert.equal(breaches.length, 1); + }); + + test('skips unknown metric without crashing', () => { + // 'unknown_metric' is not in AlertMetric union at runtime, but we still + // test the defensive path. + const cfg = [makeConfig('4', 'unknown_metric_xyz', 'gt', '0')]; + assert.doesNotThrow(() => evaluateAlerts(cfg as never, null, null)); + }); +}); diff --git a/invofi/scripts/health-collector.ts b/invofi/scripts/health-collector.ts new file mode 100644 index 00000000..77f00796 --- /dev/null +++ b/invofi/scripts/health-collector.ts @@ -0,0 +1,337 @@ +#!/usr/bin/env tsx +/** + * InvoFi Health Collector + * ======================= + * Scheduled script (GitHub Actions, hourly) that: + * + * 1. Polls Soroban RPC `getEvents` for the last 1-hour window across all + * five contracts and aggregates transaction success/failure counts, fee + * stats, and per-event-type counts into a `health_metrics` row. + * + * 2. Fetches the current contract state (invoice status distribution, + * insurance pool, position token supply, active lenders) from the + * indexer's `protocol_stats` row AND from on-chain queries, and writes + * a `contract_state_snapshots` row. + * + * 3. Loads `alert_configs` from Supabase and evaluates thresholds against + * the fresh snapshot. Breaches are appended to `audit_log`. + * + * Environment variables: + * RPC_URL Soroban RPC endpoint (default: testnet) + * NETWORK_PASSPHRASE (default: testnet) + * REGISTRY_CONTRACT_ID required + * FINANCING_CONTRACT_ID required + * REPAYMENT_CONTRACT_ID required + * INSURANCE_CONTRACT_ID required + * REPUTATION_CONTRACT_ID required + * SUPABASE_URL required + * SUPABASE_SERVICE_ROLE_KEY required + * LOOKBACK_HOURS hours to look back for events (default: 1) + * DRY_RUN if "true", print but do not write to Supabase + */ + +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { rpc as SorobanRpc, scValToNative } from '@stellar/stellar-sdk'; +import { + emptyWindow, + foldEvent, + windowToMetric, + buildSnapshot, + evaluateAlerts, + type CollectorConfig, +} from '../apps/frontend/src/lib/health/collector.js'; +import type { + AlertConfig, + ContractStateSnapshot, + HealthMetric, +} from '../apps/frontend/src/lib/health/types.js'; + +// ── Config ──────────────────────────────────────────────────────────────────── + +function env(name: string, fallback?: string): string { + const v = process.env[name] ?? fallback; + if (v === undefined) throw new Error(`env var ${name} is required`); + return v; +} + +const RPC_URL = env('RPC_URL', 'https://soroban-testnet.stellar.org'); +const NETWORK = env('NETWORK_PASSPHRASE', 'Test SDF Network ; September 2015'); +const CFG: CollectorConfig = { + rpcUrl: RPC_URL, + networkPassphrase: NETWORK, + registryId: env('REGISTRY_CONTRACT_ID'), + financingId: env('FINANCING_CONTRACT_ID'), + repaymentId: env('REPAYMENT_CONTRACT_ID'), + insuranceId: env('INSURANCE_CONTRACT_ID'), + reputationId: env('REPUTATION_CONTRACT_ID'), +}; +const SUPABASE_URL = env('SUPABASE_URL'); +const SUPABASE_KEY = env('SUPABASE_SERVICE_ROLE_KEY'); +const LOOKBACK_HOURS = Number(env('LOOKBACK_HOURS', '1')); +const DRY_RUN = process.env.DRY_RUN === 'true'; + +const rpc = new SorobanRpc.Server(RPC_URL, { allowHttp: false }); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Log with timestamp prefix. */ +function log(msg: string): void { + console.log(`[health-collector ${new Date().toISOString()}] ${msg}`); +} + +/** Format a ledger cursor from a minimum ledger (5-digit string suffix). */ +function ledgerCursor(ledger: number): string { + return `${ledger}-00000`; +} + +// ── Step 1: Collect events from RPC ────────────────────────────────────────── + +export interface EventsResult { + window: ReturnType; + latestLedger: number; +} + +export async function collectEvents( + bucketStartLedger: number, +): Promise { + const window = emptyWindow(); + let latestLedger = bucketStartLedger; + + const contractIds = [ + CFG.registryId, + CFG.financingId, + CFG.repaymentId, + CFG.insuranceId, + CFG.reputationId, + ].filter((id): id is string => Boolean(id)); + + log(`Fetching events from ${contractIds.length} contracts since ledger ${bucketStartLedger}…`); + + for (const contractId of contractIds) { + try { + const result = await rpc.getEvents({ + startLedger: bucketStartLedger, + filters: [ + { + type: 'contract', + contractIds: [contractId], + }, + ], + limit: 1000, + }); + + if ('events' in result) { + for (const evt of result.events) { + // evt.contractId may be a Contract object or undefined in the raw RPC + // response type; normalise to a plain string for foldEvent. + const cid: string = + typeof evt.contractId === 'string' + ? evt.contractId + : (evt.contractId as { toString?: () => string } | undefined)?.toString?.() ?? contractId; + foldEvent(window, evt.topic, evt.value, cid); + if (evt.ledger > latestLedger) latestLedger = evt.ledger; + } + log(` ${contractId}: ${result.events.length} events`); + } + } catch (err) { + // Log but don't crash — a single contract failure should not abort the run. + log(` WARN: failed to fetch events for ${contractId}: ${(err as Error).message}`); + } + } + + log(`Events collected: ${window.txSuccess} success, ${window.txFailure} failure`); + return { window, latestLedger }; +} + +// ── Step 2: Snapshot contract state ────────────────────────────────────────── + +export async function snapshotContractState( + supabase: SupabaseClient, + latestLedger: number, +): Promise> { + log('Reading contract state from protocol_stats…'); + + // Read the indexer's aggregate row as a data source for invoice distribution. + // This is the same source the /stats page uses — no need for a separate + // on-chain query here; the indexer already reconciles against chain state. + const { data: ps, error: psErr } = await supabase + .from('protocol_stats') + .select('*') + .eq('id', 1) + .maybeSingle(); + + if (psErr) { + log(`WARN: Could not read protocol_stats: ${psErr.message}. Using zeros.`); + } + + const proto = ps as { + total_invoices: number; + invoices_financed: number; + total_volume: string; + total_repaid: string; + repayment_rate: number; + active_lenders: number; + defaulted_invoices: number; + insurance_pool: string; + last_ledger: number; + } | null; + + // Derive individual status counts. The indexer does not break out every + // status, so we use what we have and leave the rest as 0. + const invoicesFinanced = proto?.invoices_financed ?? 0; + const invoicesRepaid = Math.round( + (proto?.total_invoices ?? 0) * (proto?.repayment_rate ?? 0), + ); + const invoicesDefaulted = proto?.defaulted_invoices ?? 0; + const totalInvoices = proto?.total_invoices ?? 0; + const invoicesPending = Math.max( + 0, + totalInvoices - invoicesFinanced - invoicesRepaid - invoicesDefaulted, + ); + + return buildSnapshot({ + lastLedger: latestLedger || (proto?.last_ledger ?? 0), + totalInvoices, + invoicesFinanced, + invoicesRepaid, + invoicesOverdue: 0, // not tracked separately yet + invoicesDefaulted, + invoicesCancelled: 0, + invoicesDisputed: 0, + invoicesPending, + totalVolume: BigInt(proto?.total_volume ?? '0'), + totalRepaid: BigInt(proto?.total_repaid ?? '0'), + insurancePool: BigInt(proto?.insurance_pool ?? '0'), + activeLenders: proto?.active_lenders ?? 0, + }); +} + +// ── Step 3: Write to Supabase ───────────────────────────────────────────────── + +export async function writeMetric( + supabase: SupabaseClient, + metric: ReturnType, +): Promise { + if (DRY_RUN) { + log(`[dry-run] Would upsert health_metrics row for ${metric.bucket_start}`); + return; + } + const { error } = await supabase + .from('health_metrics') + .upsert(metric, { onConflict: 'bucket_start' }); + if (error) throw new Error(`writeMetric failed: ${error.message}`); + log(`health_metrics upserted for ${metric.bucket_start}`); +} + +export async function writeSnapshot( + supabase: SupabaseClient, + snapshot: ReturnType, +): Promise { + if (DRY_RUN) { + log(`[dry-run] Would insert contract_state_snapshots row`); + return; + } + const { error } = await supabase.from('contract_state_snapshots').insert(snapshot); + if (error) throw new Error(`writeSnapshot failed: ${error.message}`); + log('contract_state_snapshots row inserted'); +} + +export async function loadAlertConfigs(supabase: SupabaseClient): Promise { + const { data, error } = await supabase.from('alert_configs').select('*').eq('enabled', true); + if (error) { + log(`WARN: Could not load alert_configs: ${error.message}`); + return []; + } + return (data ?? []) as AlertConfig[]; +} + +export async function writeAuditBreaches( + supabase: SupabaseClient, + breaches: { config: AlertConfig; actualValue: number }[], +): Promise { + if (breaches.length === 0) return; + const rows = breaches.map(({ config, actualValue }) => ({ + action_type: 'alert_breach' as const, + message: `Alert "${config.label}" breached: ${config.metric} ${config.operator} ${config.threshold} (actual: ${actualValue})`, + details: { config, actualValue }, + severity: config.severity, + })); + + if (DRY_RUN) { + log(`[dry-run] Would insert ${rows.length} audit_log breach row(s)`); + rows.forEach(r => log(` breach: ${r.message}`)); + return; + } + + const { error } = await supabase.from('audit_log').insert(rows); + if (error) log(`WARN: Could not write audit_log breaches: ${error.message}`); + else log(`${rows.length} alert breach(es) written to audit_log`); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +export async function run(): Promise { + log('Health collector starting…'); + if (DRY_RUN) log('[dry-run mode — no writes will occur]'); + + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + + // Determine bucket start (truncated to the hour). + const now = new Date(); + const bucketStart = new Date(now); + bucketStart.setMinutes(0, 0, 0); + bucketStart.setHours(bucketStart.getHours() - LOOKBACK_HOURS); + + // Resolve the starting ledger for the bucket window. + // We use the latest ledger and subtract an approximation: + // Stellar produces ~1 ledger per 5 seconds → 720 ledgers/hour. + let startLedger: number; + try { + const health = await rpc.getHealth(); + const latest = 'oldestLedger' in health ? (health as { oldestLedger?: number }).oldestLedger ?? 1 : 1; + const latestLedger = await rpc.getLatestLedger(); + startLedger = Math.max(1, latestLedger.sequence - Math.ceil(LOOKBACK_HOURS * 720)); + log(`Latest ledger: ${latestLedger.sequence}, window start: ${startLedger}`); + void latest; // used only for logging + } catch (err) { + log(`WARN: Could not resolve latest ledger: ${(err as Error).message}. Using ledger 1.`); + startLedger = 1; + } + + // Step 1: Collect events. + const { window, latestLedger } = await collectEvents(startLedger); + + // Step 2: Build metric row. + const metricRow = windowToMetric(window, bucketStart); + log(`Metric row: success=${metricRow.tx_success} failure=${metricRow.tx_failure} avgFee=${metricRow.avg_fee_stroops}`); + + // Step 3: Snapshot contract state. + const snapshot = await snapshotContractState(supabase, latestLedger); + log(`Snapshot: overdue_rate=${snapshot.overdue_rate} repayment_rate=${snapshot.repayment_rate}`); + + // Step 4: Evaluate alert configs. + const alertConfigs = await loadAlertConfigs(supabase); + const breaches = evaluateAlerts( + alertConfigs, + snapshot as unknown as ContractStateSnapshot, + metricRow as unknown as HealthMetric, + ); + log(`Alert evaluation: ${alertConfigs.length} rules, ${breaches.length} breach(es)`); + + // Step 5: Write everything to Supabase. + await writeMetric(supabase, metricRow); + await writeSnapshot(supabase, snapshot); + await writeAuditBreaches(supabase, breaches); + + log('Health collector finished successfully.'); +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +// Run only when executed directly (not imported as a module in tests). +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('health-collector.ts')) { + run().catch(err => { + console.error('[health-collector] Fatal error:', err); + process.exit(1); + }); +} diff --git a/invofi/scripts/package-lock.json b/invofi/scripts/package-lock.json index d5a1e012..d936a0a1 100644 --- a/invofi/scripts/package-lock.json +++ b/invofi/scripts/package-lock.json @@ -8,7 +8,8 @@ "name": "invofi-keeper", "version": "0.1.0", "dependencies": { - "@stellar/stellar-sdk": "^16.0.1" + "@stellar/stellar-sdk": "^16.0.1", + "@supabase/supabase-js": "^2.50.5" }, "devDependencies": { "@types/node": "^26.1.2", @@ -515,6 +516,98 @@ "node": ">=22.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.4.tgz", + "integrity": "sha512-z8DesgwLzKM5PiT0yNmJU8VJyh1zAhYi+20Z7drdJQLXg/wWW4yGt/un+He5ERYUo94Vz66t5aeyr1DIDemI5A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.4.tgz", + "integrity": "sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.4.tgz", + "integrity": "sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.4.tgz", + "integrity": "sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.4.tgz", + "integrity": "sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.112.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.4.tgz", + "integrity": "sha512-UiCX1udlFY1fQQrO7Z3GU7obQsju0w5Vk9mOOwalfo/+Gy+tahWVenSSuu5E/GTy/q//HxvGv2IrCdW66/61kw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.112.4", + "@supabase/functions-js": "2.112.4", + "@supabase/postgrest-js": "2.112.4", + "@supabase/realtime-js": "2.112.4", + "@supabase/storage-js": "2.112.4" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@types/node": { "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", @@ -966,6 +1059,15 @@ "node": ">= 6" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1055,6 +1157,12 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.23.7", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.7.tgz", diff --git a/invofi/scripts/package.json b/invofi/scripts/package.json index 0663423f..0c214b65 100644 --- a/invofi/scripts/package.json +++ b/invofi/scripts/package.json @@ -6,11 +6,15 @@ "scripts": { "keeper": "tsx keeper.ts", "e2e:onchain": "tsx e2e-onchain.ts", + "health-collector": "tsx health-collector.ts", "type-check": "tsc --noEmit", - "test": "tsx --test keeper.test.ts" + "test": "tsx --test keeper.test.ts && tsx --test health-collector.test.ts", + "test:keeper": "tsx --test keeper.test.ts", + "test:health": "tsx --test health-collector.test.ts" }, "dependencies": { - "@stellar/stellar-sdk": "^16.0.1" + "@stellar/stellar-sdk": "^16.0.1", + "@supabase/supabase-js": "^2.50.5" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/invofi/scripts/tsconfig.json b/invofi/scripts/tsconfig.json index 4e6cf318..674e33a3 100644 --- a/invofi/scripts/tsconfig.json +++ b/invofi/scripts/tsconfig.json @@ -19,6 +19,8 @@ "include": [ "keeper.ts", "keeper.test.ts", - "e2e-onchain.ts" + "e2e-onchain.ts", + "health-collector.ts", + "health-collector.test.ts" ] } \ No newline at end of file