diff --git a/.env.example b/.env.example index 4a4f92e..f04a65a 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,19 @@ RESEND_FROM_EMAIL=onboarding@resend.dev # EMAIL_APP_NAME=CapScreen # EMAIL_LOGO_URL=https://your-domain.example/logo.png -# Required — get yours at console.anthropic.com +# ── Model provider (backend/src/lib/llm/providers/) ────────────────────── +# Default: Anthropic + claude-haiku-4-5 via ANTHROPIC_API_KEY (console.anthropic.com). ANTHROPIC_API_KEY= -# Model is locked to Claude Haiku in code (claude-haiku-4-5). ANTHROPIC_MODEL is ignored. +# Any other provider — presets: anthropic | openai | ollama | lmstudio | llamacpp | +# openrouter | groq | gemini. Anything else is treated as a custom OpenAI-compatible +# server and needs LLM_BASE_URL. Loopback base URLs count as local (no data egress). +# LLM_PROVIDER=ollama +# LLM_MODEL=qwen3:8b +# LLM_UTILITY_MODEL= # narratives / CSV inference / extraction (defaults to LLM_MODEL) +# LLM_BASE_URL= +# LLM_API_KEY= # overrides the preset's key env var (OPENAI_API_KEY, GROQ_API_KEY, …) +# LLM_SUPPORTS_TOOLS=false # for local models without function calling +# LLM_CONFIG_FILE= # JSON { providers, roles } — takes precedence over the vars above # Watchman sanctions screening API # Local: http://localhost:8084 (docker-compose.dev.yml) @@ -71,7 +81,7 @@ TAVILY_API_KEY= # SCREENING_LOG=1 # AUDIT_LOG_RETENTION_DAYS=1825 -# DATA_RESIDENCY= +# DATA_RESIDENCY= # restricted|eu|in — blocks PII to remote LLMs; local models still allowed # Rate limits (optional) # RATE_LIMIT_SCREENER_CHAT_MAX=60 @@ -94,3 +104,13 @@ UPSTASH_REDIS_REST_TOKEN= # with a vector index on the embeddings collection. OPENAI_API_KEY= MONGO_VECTOR_SEARCH_ENABLED=true + +# ── Cloud auth service (backend/src/authService, deployed on Vercel) ─────── +# Accounts only — no screening data. Set these in the Vercel project, not here. +# CAPSCREEN_DB=mongodb +# MONGODB_URI= +# JWT_ACCESS_TOKEN_SECRET_PRIVATE= # pnpm --filter vc-screener-backend run generate:jwt-keys +# JWT_ACCESS_TOKEN_SECRET_PUBLIC= +# FRONTEND_URL=https://.vercel.app +# RESEND_API_KEY= # email verification + password reset +# DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS=30 diff --git a/.gitignore b/.gitignore index ce25006..c559b46 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ next-env.d.ts # Test coverage coverage + +# Desktop build artifacts +desktop/resources/bin/ +desktop/resources/backend/ +desktop/resources/frontend/ +desktop/release/ diff --git a/CLAUDE.md b/CLAUDE.md index 10295f5..7c8b714 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,119 +4,109 @@ ## Project Overview -A VC compliance tool that screens startup cap tables for sanctions risk. Users create "startups," upload cap-table CSVs, and get an AI-powered risk report with an ownership graph, entity risk table, and a Claude-generated plain-English narrative. The screener chat carries full context (CSV content + screening results) across all turns in a session. +A **desktop app** (Electron) that screens startup cap tables for sanctions risk. Users create "startups," upload cap-table CSVs, and get a risk report with an ownership graph, entity risk table, and an AI-written plain-English narrative. **All data stays on the user's computer**: SQLite database, local files, bundled sanctions engine. AI runs on-device (Ollama/LM Studio) or with the user's own cloud API keys, chosen in the in-app **AI Control Center**. **It never decides guilt.** It narrows the review set for a human compliance officer. +The only cloud piece is a small **auth service** (accounts + sign-in, on Vercel + Atlas); no screening data goes there. No EC2, R2 or Upstash. + --- ## Architecture -**Canonical doc:** `docs/ARCHITECTURE.md` - -### Deployment topology +**Canonical doc:** `docs/DESKTOP_PLAN.md` -| Layer | Where | How | -|---|---|---| -| Frontend | **Vercel** | `frontend/` Next.js app; `NEXT_PUBLIC_API_BASE_URL` → EC2 API | -| Backend | **AWS EC2** | `docker-compose.yml` → `backend/Dockerfile`, port 3001 | -| Watchman | **AWS EC2** (same compose stack) | `moov/watchman:static`, internal Docker network only | -| MongoDB | **Atlas** | `MONGODB_URI` in `.env.production` | -| Cache | **Upstash Redis** | Watchman result cache (optional) | -| Storage | **Cloudflare R2** | Document uploads | -| LLM | **Anthropic** (+ OpenAI for RAG) | API keys on EC2 only | +### Runtime topology ``` -Vercel (Next.js) ──HTTPS/CORS──► EC2: backend + watchman (Docker) - ├── Atlas · Upstash · R2 · Resend +Electron main (desktop/src/main.ts) + ├─ Watchman sidecar 127.0.0.1: self-built Go binary (no libpostal) — OFAC/EU lists + ├─ Backend 127.0.0.1: Express, utilityProcess; per-launch token required + │ ├─ SQLite /capscreen.db mongoose models via lib/infra/sqliteStore + │ └─ Files /files/ + ├─ UI (Next.js) 127.0.0.1: standalone server, utilityProcess + └─ Window loads capscreen://app → protocol.ts proxies UI + /__api/* (adds token) ``` -Local dev: Watchman via `docker compose -f docker-compose.dev.yml up`; backend + frontend on host via `pnpm dev` and root `.env`. +`` = `~/Library/Application Support/CapScreen` (macOS), `%APPDATA%/CapScreen` (Windows). -Production EC2: `docker compose --env-file .env.production up -d --build`. Watchman URL is overridden to `http://watchman:8084` inside compose. +Network egress: public sanctions-list downloads (Watchman, every 12 h) and cloud LLM calls **only** when the user assigns a cloud model. Local-only mode blocks the latter in `getLlm()`. ### Backend stack -- `express` with `helmet`, `cors`, `express-rate-limit`, JWT auth (RS256 cookies) -- `@anthropic-ai/sdk` — Claude Haiku (`claude-haiku-4-5`) for analyst notes + agentic chat -- `mongoose` — MongoDB Atlas (startups, CSVs, chats, RAG vectors) +- `express` + `helmet`, `cors`, `express-rate-limit`; loopback-only; `middleware/desktopToken.ts` gate; single local user (`middleware/requireAuth.ts`) +- **SQLite via mongoose**: `lib/infra/sqliteStore/` implements the MongoDB driver surface mongoose calls (`node:sqlite`, no native deps). All mongoose models/queries run unchanged. `$vectorSearch` is in-process cosine. +- **Pluggable LLMs** (`lib/llm/providers/`): Anthropic, native Ollama, and OpenAI-compatible (OpenAI, Gemini, OpenRouter, Groq, LM Studio, llama.cpp, any custom endpoint). Roles: `agent`, `utility`, `embedding`. +- **AI Control Center** (`lib/llm/controlCenter/`, `routes/aiControlCenter.ts`): providers, model assignments, local-only policy; API keys AES-256-GCM encrypted with a master key sealed by the OS keychain (Electron `safeStorage`). - `graphology` — directed ownership graph (BFS traversal, circular ownership detection) -- Watchman — OFAC SDN + EU Consolidated via `GET /v2/search`; co-located in Docker on EC2 +- Watchman — `GET /v2/search`; every screening result stores `watchmanListInfo` (list counts + SHA-256 per list) ### Frontend stack -- Next.js 15 App Router, React 19, TypeScript — **deployed on Vercel** -- Tailwind CSS + Shadcn/ui components -- `@xyflow/react` (React Flow) — interactive ownership graph -- `recharts` — data visualisation -- JWT auth via httpOnly cookies to EC2 backend (`FRONTEND_URL` must match Vercel origin) +- Next.js App Router, React 19, TypeScript — built `output: "standalone"` for the desktop bundle +- Tailwind CSS + Shadcn/ui; `@xyflow/react` ownership graph; `recharts` +- `lib/apiBase.ts` — backend origin comes from the preload bridge (`window.capscreenDesktop`); never read `NEXT_PUBLIC_API_BASE_URL` directly +- `lib/desktop.ts` — typed bridge (`useDesktop()` for render-time use; hydration-safe) + +--- + +## Accounts & sign-in (cloud auth service) + +Accounts are the **only** cloud component. Sign-in works like Claude Code: the app opens the browser, the user signs in on the hosted portal, the browser hands a one-time code back to a loopback listener, and the app exchanges it (PKCE) for a session sealed in the OS keychain. Cap tables, documents and screenings never leave the machine. + +| Piece | Where | +|---|---| +| Auth service (Express) | `backend/src/authService/app.ts` — mounts `routes/auth.ts` (signup/signin/verify/reset) + `routes/desktopOAuth.ts` (RFC 8252 + PKCE) + portal | +| Hosted portal (sign-in UI) | `backend/src/authService/portal.ts` — one self-contained page; serves `/desktop/authorize`, `/verify-email`, `/reset-password` | +| Vercel entry | `backend/api/index.ts`, `backend/vercel.json` (project root: `backend/`) | +| Accounts DB | MongoDB Atlas — `CAPSCREEN_DB=mongodb` switches `lib/infra/db.ts` to Atlas (auth service only) | +| Desktop client | `desktop/src/authFlow.ts` (protocol, Electron-free) + `desktop/src/session.ts` (keychain, restore, 14-day offline grace, refresh every 12 h) | +| Gate | `desktop/src/main.ts` + `splash.ts` (`signInHtml`) — shown until signed in; sidecars boot in parallel | +| Attribution | Shell writes `/identity.json`; `middleware/requireAuth.ts` uses it for `userEmail` (audit, reviews). Data owner stays `local-user`. | + +Auth service env (Vercel): `CAPSCREEN_DB=mongodb`, `MONGODB_URI`, `JWT_ACCESS_TOKEN_SECRET_PRIVATE`/`_PUBLIC` (`pnpm --filter vc-screener-backend run generate:jwt-keys`), `FRONTEND_URL` = the service's own URL (email links), `RESEND_API_KEY` + `RESEND_FROM_EMAIL` (email verification; without it signups are auto-confirmed), optional `DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS` (default 30). + +Desktop: production auth URL is `capscreenAuthUrl` in `desktop/package.json` (override with `CAPSCREEN_AUTH_URL`). Dev defaults to `http://127.0.0.1:4100` — run `pnpm --filter vc-screener-backend auth:dev`, or skip sign-in with `CAPSCREEN_SKIP_AUTH=1` (dev only). --- ## Key Data Flow ``` -User uploads CSV - → POST /api/startups/:id/csvs (store raw CSV in MongoDB) +User uploads CSV (or drops it in a watched folder) + → POST /api/startups/:id/csvs (stored in SQLite) → POST /api/chat (send with csvContent) → screen_cap_table tool: parseCapTable → buildOwnershipGraph → searchWatchman → classifyScore → explainMatch - → returns ScreeningResult + Claude narrative - → screeningResult saved back to startup in MongoDB (lastScreeningResult) + → returns ScreeningResult (+ watchmanListInfo) + LLM narrative + → screeningResult saved to the startup (lastScreeningResult) → ScreeningResultsPanel opens: OwnershipGraph + RiskTable + RiskCards ``` +Screening fails **closed**: if Watchman is unavailable, `runScreening` throws instead of returning "clear". + --- ## Dev Setup -### 1. Watchman (required — sanctions data) - ```bash -docker compose -f docker-compose.dev.yml up -# Takes ~30–60 s to load SDN/EU lists. Must be running for any screening to work. -``` - -### 2. MongoDB - -Use local `mongod` or point `MONGODB_URI` at Atlas in root `.env` (same cluster as production is fine for dev). - -### 3. Environment & dev server - -```bash -cp .env.example .env # monorepo root — backend + frontend on host pnpm install -pnpm dev # backend :3001 + frontend :3000 +pnpm --filter capscreen-desktop run build:watchman # once; needs Go 1.24+ (brew install go) +pnpm desktop # Electron + backend (tsx) + Next dev, all sidecars ``` -Env vars (root `.env` — see `.env.example`): +`pnpm desktop` uses `~/Library/Application Support/CapScreen` like the packaged app. Configure models in the app's **AI Control Center** (or install Ollama and click "Use on-device AI"). Dev-only: `CAPSCREEN_REMOTE_DEBUG=9333` exposes CDP on loopback for automation. -| Variable | Local | Production (EC2 / Vercel) | -|---|---|---| -| `NEXT_PUBLIC_API_BASE_URL` | `http://localhost:3001` | EC2 public URL (Vercel env) | -| `FRONTEND_URL` | `http://localhost:3000` | Vercel deployment URL (backend CORS) | -| `PORT` | `3001` | `3001` (compose maps host port) | -| `WATCHMAN_URL` | `http://localhost:8084` | `http://watchman:8084` (set by compose) | -| `MONGODB_URI` | local or Atlas | Atlas | -| `ANTHROPIC_API_KEY` | — | Required on EC2 | -| *(model)* | `claude-haiku-4-5` | Locked in `backend/src/lib/llm/models.ts` | +Browser-only dev without Electron (`pnpm dev`) still works: the backend falls back to root `.env` (`LLM_PROVIDER`/`LLM_MODEL`/keys, see `.env.example`), data in `~/.capscreen`, and Watchman via `docker compose -f docker-compose.dev.yml up`. When launched by Electron (`CAPSCREEN_DESKTOP_TOKEN` set) the backend never reads `.env`. -Production secrets live in `.env.production` on EC2 (from `.env.example`); never commit. Vercel only needs `NEXT_PUBLIC_*` vars. - -> `pnpm dev` at the root starts both services on the host. Individual: `pnpm --filter vc-screener-backend dev` or `pnpm --filter capscreen dev`. - -**Preview mode:** Set `ALLOW_PREVIEW_MODE=true` and `NEXT_PUBLIC_ALLOW_PREVIEW_MODE=true` in root `.env` for local dev without auth — never in production. - -### 4. Production deploy (reference) +### Packaging ```bash -# EC2 -docker compose --env-file .env.production up -d --build - -# Vercel — deploy frontend/, set NEXT_PUBLIC_API_BASE_URL to EC2 API URL +pnpm --filter capscreen-desktop run dist:mac # → desktop/release/CapScreen--arm64.dmg (+ x64) +pnpm --filter capscreen-desktop run dist # all targets in electron-builder.yml ``` -See `docs/ARCHITECTURE.md` for full topology and checklist. +Build each OS on that OS (Next's traced `sharp` binary is platform-specific). Unsigned unless `CSC_*` signing env is set — sign + notarize before distributing. --- @@ -138,10 +128,17 @@ backend/src/ runScreening.ts — orchestrates parse→graph→watchman→classify→explain → ScreeningResult chatTools.ts — SCREENER_TOOLS (Anthropic Tool[]) + createToolExecutor llm/ - claude.ts — Claude API wrapper - agent.ts — runAgentWithTools (agentic loop with tool-use) + providers/ — neutral types, Anthropic + OpenAI-compatible adapters, registry (roles: agent/utility/embedding) + complete.ts — completeText / chatText (single-shot + multi-turn, provider-neutral) + streamAgent.ts — runAgentStream (SSE agentic loop with tool-use; tool-less fallback) + validateLlm.ts — boot-time ping of the configured agent model index.ts — re-exports - db.ts — mongoose connect (singleton, lazy) + infra/db.ts — opens SQLite store and attaches it to mongoose (applies pending restore) + infra/sqliteStore/ — MongoDB-driver-compatible store on node:sqlite (query, update, store) + infra/backup.ts — encrypted .capscreen-backup create/restore + infra/folderWatch.ts — watched data-room folders → auto-ingest + infra/fileStorage.ts — local file storage + signed download URLs + infra/settings.ts — settings.json (background screening, sanctions lists) models/ startup.ts — Startup mongoose schema (name, createdAt, lastScreeningResult) capTableCsv.ts — CapTableCsv schema (startupId, filename, content, uploadedAt) @@ -222,6 +219,8 @@ frontend/src/ | `/assistant` | CapScreen compliance assistant | Primary chat shell | | `/assistant/chat/:id` | Persisted chat view | | | `/screen` | Redirects → `/assistant` | Unused | +| `/ai` | AI Control Center | Providers, BYO keys, local models, model assignments, local-only mode | +| `/settings/data` | Data & Backup | Sanctions lists/versions, background re-screening, encrypted backup/restore, audit export | The sidebar shows "Startups" → `/startups` and "Recent Projects" (up to 5). @@ -241,9 +240,12 @@ The sidebar shows "Startups" → `/startups` and "Recent Projects" (up to 5). | Capability | Status | Command / doc | |---|---|---| -| Agent tool loop | ✅ | `backend/src/lib/llm/agent.ts` | +| Agent tool loop | ✅ | `backend/src/lib/llm/streamAgent.ts` | +| Pluggable / local models | ✅ | `backend/src/lib/llm/providers/` | +| Desktop app (Electron) | ✅ | `desktop/`, `docs/DESKTOP_PLAN.md` | +| AI Control Center (BYO keys, local models) | ✅ | `/ai`, `backend/src/routes/aiControlCenter.ts` | | Persistent agent memory | ✅ seeded | `.claude/agent-memory/project/` | -| Document RAG | 🔲 planned | `/compliance-rag-roadmap` | +| Document RAG | ✅ local embeddings | `backend/src/lib/rag/` | | Chat-first UI shell | ✅ restored | `frontend/.../assistant/` | | Gap audit | — | `/compliance-parity-check` | @@ -265,10 +267,17 @@ See the plan in conversation context for full details. Short version: ```bash # Root (pnpm workspace) pnpm install # install all workspaces -pnpm dev # start backend + frontend in parallel +pnpm desktop # run the desktop app (dev) +pnpm desktop:dist # build installers +pnpm dev # browser-only dev: backend + frontend on host pnpm -w run typecheck # backend tsc --noEmit pnpm -w run lint # frontend eslint +# Desktop only +pnpm --filter capscreen-desktop run build:watchman # build Watchman binaries (Go) +pnpm --filter capscreen-desktop run typecheck +pnpm --filter capscreen-desktop run dist:mac + # Backend only pnpm --filter vc-screener-backend run dev pnpm --filter vc-screener-backend run build @@ -279,11 +288,8 @@ pnpm --filter capscreen run dev pnpm --filter capscreen run build pnpm --filter capscreen run lint -# Watchman (local dev) +# Watchman for browser-only dev (the desktop app bundles its own) docker compose -f docker-compose.dev.yml up - -# Production stack (EC2) -docker compose --env-file .env.production up -d --build ``` ## TypeScript / Lint Notes diff --git a/backend/.gitignore b/backend/.gitignore index 949b23d..3ac1e62 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -13,3 +13,4 @@ logs/ .DS_Store storage/jwtRS256.key storage/jwtRS256.key.pub +.vercel diff --git a/backend/.vercelignore b/backend/.vercelignore new file mode 100644 index 0000000..094dfbc --- /dev/null +++ b/backend/.vercelignore @@ -0,0 +1,16 @@ +# Never upload secrets or local data to Vercel (the CLI ignores .gitignore). +storage/ +.env +.env.* +*.key +*.pem +*.db +*.db-* +node_modules/ +dist/ +sample-data/ +scripts/ +atlas-indexes/ +docs/ +Dockerfile +nixpacks.toml diff --git a/backend/api/index.ts b/backend/api/index.ts new file mode 100644 index 0000000..a70cfdc --- /dev/null +++ b/backend/api/index.ts @@ -0,0 +1,4 @@ +// Vercel entry for the CapScreen auth service (project root: backend/). +import { createAuthApp } from "../src/authService/app"; + +export default createAuthApp(); diff --git a/backend/package.json b/backend/package.json index 8b7e052..ee1c0d4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,12 +9,14 @@ "start": "node dist/index.js", "generate:jwt-keys": "bash scripts/generate-jwt-keys.sh", "seed:demo": "tsx scripts/seed-demo-startup.ts", - "db:reset": "tsx scripts/reset-database.ts" + "db:reset": "tsx scripts/reset-database.ts", + "auth:dev": "tsx watch src/authServer.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.1062.0", "@aws-sdk/s3-request-presigner": "^3.1062.0", + "@thednp/dommatrix": "3.1.2", "@upstash/redis": "^1.38.0", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -45,5 +47,8 @@ "tsx": "^4.19.3", "typescript": "^5.8.3" }, - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "engines": { + "node": ">=22.12" + } } diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/backend/src/authServer.ts b/backend/src/authServer.ts new file mode 100644 index 0000000..1a3fe78 --- /dev/null +++ b/backend/src/authServer.ts @@ -0,0 +1,6 @@ +import "./loadEnv"; +import { createAuthApp } from "./authService/app"; + +// Local run of the cloud auth service: pnpm --filter vc-screener-backend auth:dev +const port = Number(process.env.AUTH_PORT ?? 4100); +createAuthApp().listen(port, "127.0.0.1", () => console.log(`Auth service on http://127.0.0.1:${port}`)); diff --git a/backend/src/authService/app.ts b/backend/src/authService/app.ts new file mode 100644 index 0000000..8a9d65d --- /dev/null +++ b/backend/src/authService/app.ts @@ -0,0 +1,44 @@ +import cookieParser from "cookie-parser"; +import express from "express"; +import helmet from "helmet"; +import { authRouter } from "../routes/auth"; +import { desktopOAuthRouter } from "../routes/desktopOAuth"; +import { portalHtml } from "./portal"; + +/** + * Cloud auth service (Vercel): accounts + desktop sign-in only. No screening + * data ever reaches it. Requires CAPSCREEN_DB=mongodb, MONGODB_URI, JWT keys; + * RESEND_API_KEY enables email verification; FRONTEND_URL = this service's URL. + */ +export function createAuthApp(): express.Express { + const app = express(); + app.disable("x-powered-by"); + app.set("trust proxy", 1); // Vercel edge + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], + // The portal hands the browser to the app's loopback callback. + formAction: ["'self'"], + frameAncestors: ["'none'"], + }, + }, + }), + ); + app.use(cookieParser()); + app.use(express.json({ limit: "100kb" })); + + app.use("/api/auth/desktop", desktopOAuthRouter); + app.use("/api/auth", authRouter); + app.get("/health", (_req, res) => res.json({ ok: true })); + + const page = (_req: express.Request, res: express.Response) => res.type("html").send(portalHtml()); + for (const p of ["/", "/signin", "/desktop/authorize", "/verify-email", "/reset-password"]) app.get(p, page); + + app.use((_req, res) => res.status(404).json({ detail: "Not found" })); + return app; +} diff --git a/backend/src/authService/logo.ts b/backend/src/authService/logo.ts new file mode 100644 index 0000000..2ce985a --- /dev/null +++ b/backend/src/authService/logo.ts @@ -0,0 +1,2 @@ +/** Generated from frontend/public/capscreen-logo.svg — keep in sync with the brand assets. */ +export const LOGO_SVG = "\n \n \n \n"; diff --git a/backend/src/authService/portal.ts b/backend/src/authService/portal.ts new file mode 100644 index 0000000..b7482dc --- /dev/null +++ b/backend/src/authService/portal.ts @@ -0,0 +1,206 @@ +import { LOGO_SVG } from "./logo"; + +/** + * The hosted sign-in portal: one self-contained page (no build step, no + * external scripts) that drives the existing /api/auth endpoints. The desktop + * app opens it in the user's browser at /desktop/authorize. + */ +export function portalHtml(): string { + return ` + + + + + +Sign in · CapScreen + + + +
+ +
+ + +
+

Sign in

+

Use your CapScreen account.

+
+ + + +
+ +
+ + + + + + + + + +
+
+
CapScreen · screening decision support, not legal advice
+
+ + +`; +} + +/** Page the desktop app's loopback server shows after the browser hands back. */ +export function desktopDonePage(ok: boolean, message: string): string { + return `CapScreen + +

${ok ? "You're signed in" : "Sign-in didn't complete"}

${message}

`; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index bf6ba6c..d92b0d5 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,10 +4,6 @@ import cors from "cors"; import helmet from "helmet"; import cookieParser from "cookie-parser"; import rateLimit from "express-rate-limit"; -import { logAuthStartupWarnings } from "./lib/auth/config"; -import { logEncryptionSecretWarnings } from "./lib/auth/encryptionSecret"; -import { logEmailStartupWarnings } from "./lib/auth/emailConfig"; -import { logR2StartupWarnings } from "./lib/infra/r2Config"; import { logRagStartupWarnings } from "./lib/rag/embed"; import { logWebSearchStartupWarnings } from "./lib/infra/webSearchConfig"; import { @@ -25,10 +21,9 @@ import { chatsRouter } from "./routes/chats"; import { configRouter } from "./routes/config"; import { tabularReviewRouter } from "./routes/tabularReview"; import { portfolioRouter } from "./routes/portfolioMonitoring"; -import { authRouter } from "./routes/auth"; import { userRouter } from "./routes/user"; -import { getAnthropicModel } from "./lib/llm/models"; -import { validateAnthropicKeyLive } from "./lib/llm/validateKey"; +import { describeLlmConfig } from "./lib/llm/providers"; +import { validateLlmLive } from "./lib/llm/validateLlm"; import { checkWatchmanHealth } from "./lib/screening/watchman"; import { startRescreenScheduler } from "./lib/portfolio/rescreenScheduler"; import { disconnectDb } from "./lib/infra/db"; @@ -38,7 +33,12 @@ import { assistantChatLimiter, screenerChatLimiter, } from "./lib/infra/chatRateLimits"; -import { isCacheRedis } from "./lib/infra/cache"; +import { desktopToken } from "./middleware/desktopToken"; +import { aiControlCenterRouter } from "./routes/aiControlCenter"; +import { desktopDataRouter } from "./routes/desktopData"; +import { startFolderWatcher } from "./lib/infra/folderWatch"; +import { fileDownloadRouter } from "./lib/infra/fileStorage"; +import { connectDb } from "./lib/infra/db"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -71,7 +71,7 @@ const screenLimiter = rateLimit({ }); app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); +app.set("trust proxy", false); // loopback desktop API — no reverse proxy app.use( helmet({ @@ -84,13 +84,18 @@ app.use( }), ); +// The desktop token (below) is the real gate; CORS just admits the app's own +// origin and loopback dev servers, whose ports change every launch. +const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/; app.use( cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + origin: (origin, cb) => + cb(null, !origin || origin === process.env.FRONTEND_URL || origin === "capscreen://app" || LOOPBACK_ORIGIN.test(origin)), credentials: true, }), ); +app.use(desktopToken); app.use(generalLimiter); app.use(cookieParser()); app.use(express.json({ limit: "10mb" })); @@ -114,7 +119,9 @@ app.use("/api/audit-logs", auditLogsRouter); app.use("/api/config", configRouter); app.use("/api/portfolio", portfolioRouter); app.use("/tabular-review", tabularReviewRouter); -app.use("/auth", authRouter); +app.use("/api/ai", requireAuth, aiControlCenterRouter); +app.use("/api/desktop", requireAuth, desktopDataRouter); +app.use("/api/files", fileDownloadRouter); app.use("/user", userRouter); app.get("/health", (_req, res) => res.json({ ok: true })); @@ -152,20 +159,22 @@ app.use((err: unknown, _req: express.Request, res: express.Response, _next: expr res.status(status).json({ detail: message }); }); -const server = app.listen(PORT, () => { - console.log(`Backend running on port ${PORT}`); - logAuthStartupWarnings(); - logEncryptionSecretWarnings(); - logEmailStartupWarnings(); - logR2StartupWarnings(); +// Loopback only: the API is for this machine's CapScreen window, never the network. +const HOST = process.env.HOST ?? "127.0.0.1"; + +const server = app.listen(Number(PORT), HOST, () => { + console.log(`Backend running on http://${HOST}:${PORT}`); + // Open the local database before the first request and so the TTL sweeper runs. + void connectDb(); logRagStartupWarnings(); logWebSearchStartupWarnings(); - console.log(`Anthropic model: ${getAnthropicModel()}`); - console.log(`Cache backend: ${isCacheRedis() ? "Upstash Redis" : "in-memory (no UPSTASH_REDIS_REST_URL set)"}`); + const agentLlm = describeLlmConfig().roles.agent; + console.log(`LLM: ${agentLlm ? `${agentLlm.provider}/${agentLlm.model}${agentLlm.local ? " (local)" : ""}` : "not configured"}`); console.log("IC memo: POST /api/startups/:id/ic-memo"); - void validateAnthropicKeyLive(); + void validateLlmLive(); void checkWatchmanHealth(); startRescreenScheduler(); + startFolderWatcher(); }); function shutdown(signal: string): void { @@ -186,7 +195,7 @@ function shutdown(signal: string): void { process.exit(0); }) .catch((err) => { - console.error("Error closing MongoDB connection:", err); + console.error("Error closing local database:", err); process.exit(1); }); }); diff --git a/backend/src/lib/auth/cookies.ts b/backend/src/lib/auth/cookies.ts index 432f287..67094d8 100644 --- a/backend/src/lib/auth/cookies.ts +++ b/backend/src/lib/auth/cookies.ts @@ -13,7 +13,7 @@ export function setRefreshTokenCookie( httpOnly: true, secure: isProduction, sameSite: "strict", - path: "/auth", + path: "/api/auth", // auth service mounts its routes here (authService/app.ts) expires: expiresAt, }); } @@ -23,6 +23,6 @@ export function clearRefreshTokenCookie(res: Response): void { httpOnly: true, secure: isProduction, sameSite: "strict", - path: "/auth", + path: "/api/auth", // auth service mounts its routes here (authService/app.ts) }); } diff --git a/backend/src/lib/auth/tokens.ts b/backend/src/lib/auth/tokens.ts index f2bbaa5..182c54a 100644 --- a/backend/src/lib/auth/tokens.ts +++ b/backend/src/lib/auth/tokens.ts @@ -16,12 +16,14 @@ export interface AuthTokens { export async function generateAuthTokens( user: UserDocument, + /** Desktop sessions last longer (like a native app) than browser cookies. */ + refreshDays: number = REFRESH_TOKEN_EXPIRATION_DAYS, ): Promise { const accessExpires = new Date( Date.now() + JWT_ACCESS_TOKEN_EXPIRATION_MINUTES * 60 * 1000, ); const refreshExpires = new Date( - Date.now() + REFRESH_TOKEN_EXPIRATION_DAYS * 24 * 60 * 60 * 1000, + Date.now() + refreshDays * 24 * 60 * 60 * 1000, ); const accessToken = signAccessToken( diff --git a/backend/src/lib/documents/generateIcMemo.ts b/backend/src/lib/documents/generateIcMemo.ts index a036ffe..05d707c 100644 --- a/backend/src/lib/documents/generateIcMemo.ts +++ b/backend/src/lib/documents/generateIcMemo.ts @@ -1,5 +1,4 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ScreeningResult } from "../../types/screening"; import { wrapEntityName } from "../shared/promptDelimiters"; @@ -114,8 +113,7 @@ Structure: Use professional compliance tone. Be concise.`; try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You draft IC compliance memos for VC associates. Use only provided screening facts. Never confirm guilt or sanctions violations.", user: prompt, diff --git a/backend/src/lib/documents/generateScreeningAnalysis.ts b/backend/src/lib/documents/generateScreeningAnalysis.ts index 5a6cb03..97d5d17 100644 --- a/backend/src/lib/documents/generateScreeningAnalysis.ts +++ b/backend/src/lib/documents/generateScreeningAnalysis.ts @@ -1,5 +1,4 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ScreeningResult } from "../../types/screening"; import { wrapEntityName } from "../shared/promptDelimiters"; import { buildFactsBlock } from "./generateIcMemo"; @@ -44,8 +43,7 @@ Structure: Use clear analyst tone. Do not use "Investment Committee" headings. Be concise.`; try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You write sanctions screening analysis documents for VC compliance analysts. Use only provided facts. Never confirm guilt or sanctions violations.", user: prompt, diff --git a/backend/src/lib/infra/backup.ts b/backend/src/lib/infra/backup.ts new file mode 100644 index 0000000..ed8a629 --- /dev/null +++ b/backend/src/lib/infra/backup.ts @@ -0,0 +1,136 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import zlib from "zlib"; +import { dataDir, dataFile } from "./dataDir"; +import { getSqliteDb } from "./db"; + +/** + * Encrypted single-file backups. + * + * "CAPSCREENBAK1\n" | salt(16) | iv(12) | tag(16) | AES-256-GCM( gzip( manifest JSON "\n" + file bytes… ) ) + * + * Key = scrypt(password, salt). Includes the database, stored documents, app + * settings and AI model assignments. API keys are excluded on purpose: they are + * sealed to this computer's keychain and must be re-entered after a restore. + */ + +const MAGIC = Buffer.from("CAPSCREENBAK1\n"); +const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }; + +type Entry = { path: string; size: number }; +type Manifest = { version: 1; createdAt: string; app: "capscreen"; entries: Entry[] }; + +function deriveKey(password: string, salt: Buffer): Buffer { + return crypto.scryptSync(password, salt, 32, SCRYPT); +} + +function walk(dir: string, base = dir): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) out.push(...walk(full, base)); + else out.push(path.relative(base, full)); + } + return out; +} + +export function createBackup(password: string): { file: Buffer; manifest: Manifest } { + if (password.length < 8) throw new Error("Use a backup password of at least 8 characters"); + + const snapshot = path.join(dataDir("tmp"), `backup-${Date.now()}.db`); + getSqliteDb().backupTo(snapshot); + try { + const sources: Array<{ rel: string; abs: string }> = [ + { rel: "capscreen.db", abs: snapshot }, + ...walk(dataDir("files")).map((rel) => ({ rel: `files/${rel}`, abs: path.join(dataDir("files"), rel) })), + ]; + for (const rel of ["settings.json", "ai/config.json"]) { + const abs = dataFile(...rel.split("/")); + if (fs.existsSync(abs)) sources.push({ rel, abs }); + } + + const blobs = sources.map((s) => fs.readFileSync(s.abs)); + const manifest: Manifest = { + version: 1, + createdAt: new Date().toISOString(), + app: "capscreen", + entries: sources.map((s, i) => ({ path: s.rel, size: blobs[i].length })), + }; + const plain = zlib.gzipSync(Buffer.concat([Buffer.from(JSON.stringify(manifest) + "\n"), ...blobs])); + + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", deriveKey(password, salt), iv); + cipher.setAAD(MAGIC); + const body = Buffer.concat([cipher.update(plain), cipher.final()]); + return { file: Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), body]), manifest }; + } finally { + fs.rmSync(snapshot, { force: true }); + } +} + +const stagingDir = () => dataDir("restore-staging"); +const markerFile = () => dataFile("restore-pending.json"); + +/** Decrypt + validate, then stage for swap-in on next backend start. */ +export function stageRestore(file: Buffer, password: string): Manifest { + if (file.length < MAGIC.length + 44 || !file.subarray(0, MAGIC.length).equals(MAGIC)) { + throw new Error("Not a CapScreen backup file"); + } + let o = MAGIC.length; + const salt = file.subarray(o, (o += 16)); + const iv = file.subarray(o, (o += 12)); + const tag = file.subarray(o, (o += 16)); + const decipher = crypto.createDecipheriv("aes-256-gcm", deriveKey(password, salt), iv); + decipher.setAAD(MAGIC); + decipher.setAuthTag(tag); + let plain: Buffer; + try { + plain = zlib.gunzipSync(Buffer.concat([decipher.update(file.subarray(o)), decipher.final()])); + } catch { + throw new Error("Wrong password, or the backup file is damaged"); + } + + const nl = plain.indexOf(0x0a); + const manifest = JSON.parse(plain.subarray(0, nl).toString("utf8")) as Manifest; + if (manifest.app !== "capscreen" || manifest.version !== 1) throw new Error("Unsupported backup version"); + + fs.rmSync(stagingDir(), { recursive: true, force: true }); + let offset = nl + 1; + for (const e of manifest.entries) { + const target = path.resolve(stagingDir(), e.path); + if (!target.startsWith(stagingDir() + path.sep)) throw new Error("Backup contains an invalid path"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, plain.subarray(offset, offset + e.size), { mode: 0o600 }); + offset += e.size; + } + fs.writeFileSync(markerFile(), JSON.stringify({ stagedAt: new Date().toISOString(), createdAt: manifest.createdAt })); + return manifest; +} + +/** Called before the database opens: swap a staged restore into place. */ +export function applyPendingRestore(dbFile: string): boolean { + if (!fs.existsSync(markerFile())) return false; + const staged = stagingDir(); + const stagedDb = path.join(staged, "capscreen.db"); + if (fs.existsSync(stagedDb)) { + for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(dbFile + suffix, { force: true }); + fs.renameSync(stagedDb, dbFile); + } + if (fs.existsSync(path.join(staged, "files"))) { + // Plain path join: dataDir() would recreate the folder we are replacing. + const filesDir = path.join(dataDir(), "files"); + fs.rmSync(filesDir, { recursive: true, force: true }); + fs.renameSync(path.join(staged, "files"), filesDir); + } + for (const rel of ["settings.json", "ai/config.json"]) { + const from = path.join(staged, rel); + if (fs.existsSync(from)) fs.renameSync(from, dataFile(...rel.split("/"))); + } + fs.rmSync(staged, { recursive: true, force: true }); + fs.rmSync(markerFile(), { force: true }); + console.log("[backup] restored data from backup"); + return true; +} diff --git a/backend/src/lib/infra/cache.ts b/backend/src/lib/infra/cache.ts index 47582a1..210232b 100644 --- a/backend/src/lib/infra/cache.ts +++ b/backend/src/lib/infra/cache.ts @@ -1,8 +1,7 @@ -import { Redis } from "@upstash/redis"; import type { WatchmanMatch } from "../../types/screening"; // --------------------------------------------------------------------------- -// Shared async interface — implemented by both Redis and in-memory backends. +// Shared async interface. // --------------------------------------------------------------------------- export interface AsyncCache { @@ -14,7 +13,7 @@ export interface AsyncCache { } // --------------------------------------------------------------------------- -// In-memory fallback — used when UPSTASH_REDIS_REST_URL is not set. +// In-memory cache. // --------------------------------------------------------------------------- interface MemEntry { @@ -65,64 +64,13 @@ class MemoryCache implements AsyncCache { } // --------------------------------------------------------------------------- -// Upstash Redis backend. +// Factory — single desktop process, so an in-memory cache is always correct. // --------------------------------------------------------------------------- -class RedisCache implements AsyncCache { - constructor(private client: Redis) {} - - async get(key: string): Promise { - return this.client.get(key); - } - - async set(key: string, value: T, ttlMs: number): Promise { - const ex = Math.max(1, Math.ceil(ttlMs / 1000)); - await this.client.set(key, value, { ex }); - } - - async delete(key: string): Promise { - await this.client.del(key); - } - - async invalidatePrefix(prefix: string): Promise { - let cursor = 0; - do { - const [next, keys] = await this.client.scan(cursor, { - match: `${prefix}*`, - count: 100, - }); - cursor = Number(next); - if (keys.length > 0) { - await this.client.del(...keys); - } - } while (cursor !== 0); - } -} - -// --------------------------------------------------------------------------- -// Factory — picks Redis when Upstash env vars are present, else memory. -// --------------------------------------------------------------------------- - -let sharedRedis: Redis | null = null; - -function getRedis(): Redis | null { - const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); - const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); - if (!url || !token) return null; - if (!sharedRedis) sharedRedis = new Redis({ url, token }); - return sharedRedis; -} - function createCache(maxMemorySize = 1_000): AsyncCache { - const redis = getRedis(); - if (redis) return new RedisCache(redis); return new MemoryCache(maxMemorySize); } -export function isCacheRedis(): boolean { - return getRedis() !== null; -} - // --------------------------------------------------------------------------- // TTL constants (ms). // --------------------------------------------------------------------------- diff --git a/backend/src/lib/infra/dataDir.ts b/backend/src/lib/infra/dataDir.ts new file mode 100644 index 0000000..db0ca72 --- /dev/null +++ b/backend/src/lib/infra/dataDir.ts @@ -0,0 +1,20 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +/** + * Root for all on-device data (database, documents, model config). + * The Electron shell sets CAPSCREEN_DATA_DIR to app.getPath("userData"); + * `pnpm dev` falls back to ~/.capscreen. + */ +export function dataDir(...sub: string[]): string { + const root = process.env.CAPSCREEN_DATA_DIR?.trim() || path.join(os.homedir(), ".capscreen"); + const dir = path.join(root, ...sub); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; +} + +export function dataFile(...parts: string[]): string { + const file = parts.pop()!; + return path.join(dataDir(...parts), file); +} diff --git a/backend/src/lib/infra/db.ts b/backend/src/lib/infra/db.ts index 422bc8c..dea2214 100644 --- a/backend/src/lib/infra/db.ts +++ b/backend/src/lib/infra/db.ts @@ -1,28 +1,83 @@ import mongoose from "mongoose"; +import { dataFile } from "./dataDir"; +import type { SqliteDb } from "./sqliteStore/store"; +import { applyPendingRestore } from "./backup"; -let pending: Promise | null = null; +/** + * On-device database. Mongoose models run unchanged on top of a SQLite file + * (see sqliteStore/) — nothing is sent to a database server. + * + * Exception: the cloud auth service (src/authService, deployed to Vercel) sets + * CAPSCREEN_DB=mongodb and stores accounts — only accounts — in MongoDB Atlas. + * The desktop app never sets it. + */ + +let sqlite: SqliteDb | null = null; +let mongoPending: Promise | null = null; + +function usesMongo(): boolean { + return process.env.CAPSCREEN_DB === "mongodb"; +} + +export function databaseFile(): string { + return process.env.CAPSCREEN_DB_FILE?.trim() || dataFile("capscreen.db"); +} export async function connectDb(): Promise { - if (mongoose.connection.readyState === 1) return; - if (pending) return pending; - - const uri = - process.env.MONGODB_URI ?? "mongodb://localhost:27017/vc-screener"; - - pending = mongoose - .connect(uri) - .then(() => { - console.log("MongoDB connected"); - pending = null; - }) - .catch((err) => { - pending = null; + if (usesMongo()) { + if (mongoose.connection.readyState === 1) return; + const uri = process.env.MONGODB_URI?.trim(); + if (!uri) throw new Error("MONGODB_URI is required when CAPSCREEN_DB=mongodb"); + // Serverless: reuse one connection across warm invocations. + mongoPending ??= mongoose.connect(uri, { serverSelectionTimeoutMS: 8000 }).catch((err) => { + mongoPending = null; throw err; }); + await mongoPending; + return; + } + if (sqlite) return; + + // Loaded lazily so the cloud auth service never touches node:sqlite. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { SqliteClient, SqliteDb: SqliteDbImpl } = require("./sqliteStore/store") as typeof import("./sqliteStore/store"); + applyPendingRestore(databaseFile()); + sqlite = new SqliteDbImpl(databaseFile()); + const client = new SqliteClient(sqlite); + + // Mirror what mongoose's native driver does in _setClient(), then open. + const conn = mongoose.connection as unknown as { + db: unknown; + client: unknown; + name: string; + host: string; + port: number; + onOpen(): void; + }; + conn.db = sqlite; + conn.client = client; + conn.name = sqlite.databaseName; + conn.host = "localhost"; + conn.port = 0; + conn.onOpen(); + + sqlite.startTtlSweeper(); + console.log(`Local database: ${sqlite.file}`); +} - return pending; +export function getSqliteDb(): SqliteDb { + if (!sqlite) throw new Error("Database not connected"); + return sqlite; } export async function disconnectDb(): Promise { - await mongoose.disconnect(); + if (usesMongo()) { + await mongoose.disconnect(); + mongoPending = null; + return; + } + if (!sqlite) return; + sqlite.close(); + sqlite = null; + (mongoose.connection as unknown as { readyState: number }).readyState = 0; } diff --git a/backend/src/lib/infra/fileStorage.ts b/backend/src/lib/infra/fileStorage.ts new file mode 100644 index 0000000..a18b22d --- /dev/null +++ b/backend/src/lib/infra/fileStorage.ts @@ -0,0 +1,141 @@ +import crypto from "crypto"; +import fs from "fs/promises"; +import path from "path"; +import express from "express"; +import { dataDir } from "./dataDir"; + +/** + * On-device object storage. Keys are relative paths under /files; + * downloads are served by the backend through short-lived signed URLs. + */ + +const SIGNING_SECRET = crypto.randomBytes(32); + +function root(): string { + return dataDir("files"); +} + +function resolveKey(key: string): string { + const full = path.resolve(root(), key); + if (!full.startsWith(root() + path.sep)) throw new Error("Invalid storage key"); + return full; +} + +/** Always true on desktop — kept so callers read the same as before. */ +export function storageConfigured(): boolean { + return true; +} + +export function safeStorageFilename(filename: string): string { + const base = filename.split(/[/\\]/).pop()?.trim() || "file"; + const cleaned = base.replace(/[^\w.\-()+\s]/g, "_").replace(/\s+/g, "_"); + return cleaned.slice(0, 180) || "file"; +} + +export function buildUserDocumentKey(params: { + userId: string; + docId: string; + filename: string; + projectId?: string | null; +}): string { + const safe = safeStorageFilename(params.filename); + if (params.projectId) { + return `users/${params.userId}/projects/${params.projectId}/documents/${params.docId}/${safe}`; + } + return `users/${params.userId}/documents/${params.docId}/${safe}`; +} + +export function buildRagDocumentKey(params: { + userId: string; + startupId: string; + docId: string; + filename: string; +}): string { + const safe = safeStorageFilename(params.filename); + return `users/${params.userId}/startups/${params.startupId}/rag/${params.docId}/${safe}`; +} + +export async function uploadObject(params: { + key: string; + body: Buffer; + contentType: string; +}): Promise { + const file = resolveKey(params.key); + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + await fs.writeFile(file, params.body, { mode: 0o600 }); +} + +export async function downloadObject(key: string): Promise { + try { + return await fs.readFile(resolveKey(key)); + } catch { + return null; + } +} + +export async function deleteObject(key: string): Promise { + if (!key) return; + await fs.rm(resolveKey(key), { force: true }); +} + +function sign(payload: string): string { + return crypto.createHmac("sha256", SIGNING_SECRET).update(payload).digest("base64url"); +} + +function apiOrigin(): string { + return (process.env.CAPSCREEN_API_ORIGIN ?? `http://127.0.0.1:${process.env.PORT ?? 3001}`).replace(/\/$/, ""); +} + +export async function getObjectDownloadUrl( + key: string, + filename: string, + expiresInSeconds = 3600, +): Promise { + const payload = Buffer.from( + JSON.stringify({ k: key, f: safeStorageFilename(filename), e: Date.now() + expiresInSeconds * 1000 }), + ).toString("base64url"); + return `${apiOrigin()}/api/files/${payload}.${sign(payload)}`; +} + +/** GET /api/files/:token — serves a file for a signed, unexpired token. */ +export const fileDownloadRouter = express.Router(); + +fileDownloadRouter.get("/:token", async (req, res) => { + const [payload, sig] = req.params.token.split("."); + const expected = payload ? sign(payload) : ""; + if (!sig || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + res.status(403).json({ detail: "Invalid download link" }); + return; + } + const { k, f, e } = JSON.parse(Buffer.from(payload, "base64url").toString()) as { k: string; f: string; e: number }; + if (Date.now() > e) { + res.status(410).json({ detail: "Download link expired" }); + return; + } + const body = await downloadObject(k); + if (!body) { + res.status(404).json({ detail: "File not found" }); + return; + } + res.setHeader("Content-Disposition", `attachment; filename="${f.replace(/"/g, "")}"`); + res.type(path.extname(f) || "application/octet-stream").send(body); +}); + +export function detectFileType( + filename: string, + mimeType: string, +): string | null { + const ext = filename.split(".").pop()?.toLowerCase(); + if (ext === "pdf" || mimeType === "application/pdf") return "pdf"; + if ( + ext === "docx" || + mimeType === + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) { + return "docx"; + } + if (ext === "doc" || mimeType === "application/msword") return "doc"; + if (ext === "csv" || mimeType === "text/csv") return "csv"; + if (ext === "txt" || mimeType === "text/plain") return "txt"; + return ext ?? null; +} diff --git a/backend/src/lib/infra/folderWatch.ts b/backend/src/lib/infra/folderWatch.ts new file mode 100644 index 0000000..e57abc2 --- /dev/null +++ b/backend/src/lib/infra/folderWatch.ts @@ -0,0 +1,143 @@ +import fs from "fs"; +import path from "path"; +import { dataFile } from "./dataDir"; + +/** + * Watched data-room folders: drop a file into a startup's folder and CapScreen + * ingests it — CSVs as cap tables / entity lists, PDF/DOCX/TXT into document + * search. Polling (not fs.watch) so it behaves the same on macOS, Windows and + * Linux, with network drives, and with half-copied files. + * + * Ingestion goes through the app's own HTTP endpoints so uploads get exactly + * the same validation, parsing and audit logging as manual ones. + */ + +type WatchEntry = { path: string; processed: Record; lastScanAt?: string; lastError?: string | null }; +type WatchFile = Record; + +const SCAN_EVERY_MS = 20_000; +const STABLE_MS = 3_000; +const DOC_EXT = new Set([".pdf", ".docx", ".txt", ".md"]); +const MAX_DEPTH = 2; + +const ledgerPath = () => dataFile("folder-watch.json"); + +function readLedger(): WatchFile { + try { + return JSON.parse(fs.readFileSync(ledgerPath(), "utf8")) as WatchFile; + } catch { + return {}; + } +} + +function writeLedger(l: WatchFile): void { + fs.writeFileSync(ledgerPath(), JSON.stringify(l, null, 2), { mode: 0o600 }); +} + +export function getWatchFolder(startupId: string): (Omit & { ingestedCount: number }) | null { + const e = readLedger()[startupId]; + return e ? { path: e.path, lastScanAt: e.lastScanAt, lastError: e.lastError ?? null, ingestedCount: Object.keys(e.processed).length } : null; +} + +export function setWatchFolder(startupId: string, folder: string | null): void { + const l = readLedger(); + if (!folder) { + delete l[startupId]; + } else { + if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) throw new Error("Folder does not exist"); + l[startupId] = { path: path.resolve(folder), processed: l[startupId]?.path === path.resolve(folder) ? l[startupId].processed : {} }; + } + writeLedger(l); +} + +function listFiles(dir: string, depth = 0): string[] { + const out: string[] = []; + for (const name of fs.readdirSync(dir)) { + if (name.startsWith(".") || name.startsWith("~$")) continue; + const full = path.join(dir, name); + const st = fs.statSync(full); + if (st.isDirectory() && depth < MAX_DEPTH) out.push(...listFiles(full, depth + 1)); + else if (st.isFile()) out.push(full); + } + return out; +} + +function api(): { base: string; headers: Record } { + const token = process.env.CAPSCREEN_DESKTOP_TOKEN; + return { + base: `http://127.0.0.1:${process.env.PORT ?? 3001}`, + headers: token ? { "x-capscreen-token": token } : {}, + }; +} + +async function ingest(startupId: string, file: string): Promise { + const ext = path.extname(file).toLowerCase(); + const { base, headers } = api(); + const filename = path.basename(file); + let resp: Response; + if (ext === ".csv") { + resp = await fetch(`${base}/api/startups/${startupId}/csvs`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ filename, content: fs.readFileSync(file, "utf8") }), + }); + } else { + const form = new FormData(); + form.append("file", new Blob([fs.readFileSync(file)]), filename); + resp = await fetch(`${base}/api/startups/${startupId}/rag-documents`, { method: "POST", headers, body: form }); + } + if (!resp.ok) { + const body = (await resp.json().catch(() => ({}))) as { detail?: string }; + throw new Error(`${filename}: ${body.detail ?? resp.status}`); + } +} + +let scanning = false; + +export async function scanWatchedFolders(): Promise { + if (scanning) return; + scanning = true; + try { + const ledger = readLedger(); + for (const [startupId, entry] of Object.entries(ledger)) { + entry.lastScanAt = new Date().toISOString(); + if (!fs.existsSync(entry.path)) { + entry.lastError = "Folder is missing or disconnected"; + continue; + } + entry.lastError = null; + for (const file of listFiles(entry.path)) { + const ext = path.extname(file).toLowerCase(); + if (ext !== ".csv" && !DOC_EXT.has(ext)) continue; + const st = fs.statSync(file); + if (Date.now() - st.mtimeMs < STABLE_MS) continue; // still being written + const rel = path.relative(entry.path, file); + const sig = `${st.size}:${Math.floor(st.mtimeMs)}`; + if (entry.processed[rel] === sig) continue; + try { + await ingest(startupId, file); + entry.processed[rel] = sig; + console.log(`[folder-watch] ingested ${rel} for startup ${startupId}`); + } catch (err) { + // Mark as seen so a bad file isn't retried every 20 s; editing it retries. + entry.processed[rel] = sig; + entry.lastError = err instanceof Error ? err.message : String(err); + console.warn(`[folder-watch] ${entry.lastError}`); + } + } + } + // Merge into a fresh read: the user may have changed folders mid-scan. + const latest = readLedger(); + for (const [id, scanned] of Object.entries(ledger)) { + if (latest[id]?.path === scanned.path) latest[id] = scanned; + } + writeLedger(latest); + } finally { + scanning = false; + } +} + +export function startFolderWatcher(): void { + setTimeout(() => void scanWatchedFolders(), 10_000).unref(); + setInterval(() => void scanWatchedFolders(), SCAN_EVERY_MS).unref(); +} diff --git a/backend/src/lib/infra/index.ts b/backend/src/lib/infra/index.ts index 3b26296..8e54c5e 100644 --- a/backend/src/lib/infra/index.ts +++ b/backend/src/lib/infra/index.ts @@ -11,5 +11,4 @@ export { chatListCache, workflowListCache, cacheKey, - isCacheRedis, } from "./cache"; diff --git a/backend/src/lib/infra/r2Config.ts b/backend/src/lib/infra/r2Config.ts deleted file mode 100644 index 4263969..0000000 --- a/backend/src/lib/infra/r2Config.ts +++ /dev/null @@ -1,41 +0,0 @@ -export function getR2AccountId(): string | null { - return process.env.R2_ACCOUNT_ID?.trim() || null; -} - -export function getR2BucketName(): string { - return process.env.R2_BUCKET_NAME?.trim() || "capscreen-screener"; -} - -export function getR2EndpointUrl(): string | null { - const explicit = process.env.R2_ENDPOINT_URL?.trim(); - if (explicit) return explicit.replace(/\/$/, ""); - const accountId = getR2AccountId(); - if (!accountId) return null; - return `https://${accountId}.r2.cloudflarestorage.com`; -} - -export function r2Configured(): boolean { - return Boolean( - getR2EndpointUrl() && - process.env.R2_ACCESS_KEY_ID?.trim() && - process.env.R2_SECRET_ACCESS_KEY?.trim() && - getR2BucketName(), - ); -} - -export function getR2PublicUrl(): string | null { - const raw = process.env.R2_PUBLIC_URL?.trim(); - return raw ? raw.replace(/\/$/, "") : null; -} - -export function logR2StartupWarnings(): void { - if (r2Configured()) { - console.log( - `[storage] Cloudflare R2 configured — bucket ${getR2BucketName()}`, - ); - return; - } - console.warn( - "[storage] R2 is not configured — file uploads will fail until R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET_NAME are set", - ); -} diff --git a/backend/src/lib/infra/r2Storage.ts b/backend/src/lib/infra/r2Storage.ts deleted file mode 100644 index 41d5044..0000000 --- a/backend/src/lib/infra/r2Storage.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - DeleteObjectCommand, - GetObjectCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { - getR2BucketName, - getR2EndpointUrl, - getR2PublicUrl, - r2Configured, -} from "./r2Config"; - -let client: S3Client | null = null; - -function getClient(): S3Client { - if (!r2Configured()) { - throw new Error("R2 storage is not configured"); - } - if (!client) { - client = new S3Client({ - region: "auto", - endpoint: getR2EndpointUrl()!, - credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!.trim(), - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!.trim(), - }, - }); - } - return client; -} - -export { r2Configured }; - -export function safeStorageFilename(filename: string): string { - const base = filename.split(/[/\\]/).pop()?.trim() || "file"; - const cleaned = base.replace(/[^\w.\-()+\s]/g, "_").replace(/\s+/g, "_"); - return cleaned.slice(0, 180) || "file"; -} - -export function buildUserDocumentKey(params: { - userId: string; - docId: string; - filename: string; - projectId?: string | null; -}): string { - const safe = safeStorageFilename(params.filename); - if (params.projectId) { - return `users/${params.userId}/projects/${params.projectId}/documents/${params.docId}/${safe}`; - } - return `users/${params.userId}/documents/${params.docId}/${safe}`; -} - -export function buildRagDocumentKey(params: { - userId: string; - startupId: string; - docId: string; - filename: string; -}): string { - const safe = safeStorageFilename(params.filename); - return `users/${params.userId}/startups/${params.startupId}/rag/${params.docId}/${safe}`; -} - -export async function uploadObject(params: { - key: string; - body: Buffer; - contentType: string; -}): Promise { - await getClient().send( - new PutObjectCommand({ - Bucket: getR2BucketName(), - Key: params.key, - Body: params.body, - ContentType: params.contentType, - }), - ); -} - -export async function downloadObject(key: string): Promise { - if (!r2Configured()) return null; - try { - const response = await getClient().send( - new GetObjectCommand({ Bucket: getR2BucketName(), Key: key }), - ); - if (!response.Body) return null; - const bytes = await response.Body.transformToByteArray(); - return Buffer.from(bytes); - } catch { - return null; - } -} - -export async function deleteObject(key: string): Promise { - if (!r2Configured() || !key) return; - await getClient().send( - new DeleteObjectCommand({ Bucket: getR2BucketName(), Key: key }), - ); -} - -export async function getObjectDownloadUrl( - key: string, - filename: string, - expiresInSeconds = 3600, -): Promise { - const publicBase = getR2PublicUrl(); - if (publicBase) { - return `${publicBase}/${key.split("/").map(encodeURIComponent).join("/")}`; - } - if (!r2Configured()) return null; - const command = new GetObjectCommand({ - Bucket: getR2BucketName(), - Key: key, - ResponseContentDisposition: `attachment; filename="${safeStorageFilename(filename).replace(/"/g, "")}"`, - }); - return getSignedUrl(getClient(), command, { expiresIn: expiresInSeconds }); -} - -export function detectFileType( - filename: string, - mimeType: string, -): string | null { - const ext = filename.split(".").pop()?.toLowerCase(); - if (ext === "pdf" || mimeType === "application/pdf") return "pdf"; - if ( - ext === "docx" || - mimeType === - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) { - return "docx"; - } - if (ext === "doc" || mimeType === "application/msword") return "doc"; - if (ext === "csv" || mimeType === "text/csv") return "csv"; - if (ext === "txt" || mimeType === "text/plain") return "txt"; - return ext ?? null; -} diff --git a/backend/src/lib/infra/settings.ts b/backend/src/lib/infra/settings.ts new file mode 100644 index 0000000..5eaaf02 --- /dev/null +++ b/backend/src/lib/infra/settings.ts @@ -0,0 +1,53 @@ +import fs from "fs"; +import { dataFile } from "./dataDir"; + +/** + * Desktop app settings, shared with the Electron shell (which reads + * sanctionsLists to configure Watchman). One JSON file in the data dir. + */ +export type AppSettings = { + backgroundScreening: { enabled: boolean; intervalHours: number }; + /** Watchman source lists, e.g. us_ofac, eu_csl, uk_csl, un_csl, us_non_sdn. */ + sanctionsLists: string[]; +}; + +export const DEFAULT_SETTINGS: AppSettings = { + backgroundScreening: { enabled: true, intervalHours: 24 }, + sanctionsLists: ["us_ofac", "eu_csl"], +}; + +export const AVAILABLE_SANCTIONS_LISTS: Array<{ id: string; label: string }> = [ + { id: "us_ofac", label: "US OFAC — SDN list" }, + { id: "us_non_sdn", label: "US OFAC — Non-SDN consolidated" }, + { id: "us_csl", label: "US Consolidated Screening List (Commerce/State/Treasury)" }, + { id: "eu_csl", label: "EU Consolidated Financial Sanctions" }, + { id: "uk_csl", label: "UK HM Treasury (OFSI) Consolidated List" }, + { id: "un_csl", label: "UN Security Council Consolidated List" }, + { id: "us_fincen_311", label: "FinCEN Section 311 special measures" }, +]; + +const file = () => dataFile("settings.json"); + +export function readSettings(): AppSettings { + try { + const raw = JSON.parse(fs.readFileSync(file(), "utf8")) as Partial; + return { + backgroundScreening: { ...DEFAULT_SETTINGS.backgroundScreening, ...(raw.backgroundScreening ?? {}) }, + sanctionsLists: Array.isArray(raw.sanctionsLists) && raw.sanctionsLists.length ? raw.sanctionsLists : DEFAULT_SETTINGS.sanctionsLists, + }; + } catch { + return structuredClone(DEFAULT_SETTINGS); + } +} + +export function writeSettings(patch: Partial): AppSettings { + const cur = readSettings(); + const next: AppSettings = { + backgroundScreening: { ...cur.backgroundScreening, ...(patch.backgroundScreening ?? {}) }, + sanctionsLists: patch.sanctionsLists ?? cur.sanctionsLists, + }; + const tmp = `${file()}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file()); + return next; +} diff --git a/backend/src/lib/infra/sqliteStore/query.ts b/backend/src/lib/infra/sqliteStore/query.ts new file mode 100644 index 0000000..0c01931 --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/query.ts @@ -0,0 +1,405 @@ +/** + * MongoDB query semantics over plain JS documents — the subset mongoose emits + * for this codebase: comparison, logical, element, array and regex operators, + * dotted paths with implicit array traversal, projection and sort. + */ + +export type Doc = Record; +export type Filter = Record; + +export class UnsupportedOperatorError extends Error { + constructor(op: string) { + super(`SQLite store does not support MongoDB operator ${op}`); + this.name = "UnsupportedOperatorError"; + } +} + +// ── Value helpers ──────────────────────────────────────────────────────────── + +export function isBsonValue(v: unknown): v is { _bsontype: string } { + return !!v && typeof v === "object" && typeof (v as { _bsontype?: unknown })._bsontype === "string"; +} + +export function isPlainObject(v: unknown): v is Doc { + if (!v || typeof v !== "object" || Array.isArray(v)) return false; + if (v instanceof Date || v instanceof RegExp || Buffer.isBuffer(v) || isBsonValue(v)) return false; + return true; +} + +function bsonString(v: { _bsontype: string }): string { + const s = v as unknown as { toHexString?: () => string; toString: (enc?: string) => string }; + return s.toHexString ? s.toHexString() : s.toString("hex"); +} + +/** MongoDB BSON type ordering for cross-type comparisons. */ +function typeRank(v: unknown): number { + if (v === undefined || v === null) return 1; + if (typeof v === "number" || typeof v === "bigint") return 2; + if (typeof v === "string") return 3; + if (Array.isArray(v)) return 5; + if (v instanceof Date) return 9; + if (v instanceof RegExp) return 11; + if (typeof v === "boolean") return 8; + if (isBsonValue(v)) { + if (v._bsontype === "ObjectId") return 7; + if (v._bsontype === "Binary") return 6; + if (["Decimal128", "Long", "Int32", "Double"].includes(v._bsontype)) return 2; + return 10; + } + if (Buffer.isBuffer(v)) return 6; + return 4; +} + +function numeric(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "bigint") return Number(v); + return Number(String(v)); +} + +/** Total order across values, matching MongoDB's sort order closely enough for app use. */ +export function compareValues(a: unknown, b: unknown): number { + const ra = typeRank(a); + const rb = typeRank(b); + if (ra !== rb) return ra - rb; + switch (ra) { + case 1: + return 0; + case 2: + return numeric(a) - numeric(b); + case 3: + return (a as string) < (b as string) ? -1 : (a as string) > (b as string) ? 1 : 0; + case 7: + case 6: { + const sa = isBsonValue(a) ? bsonString(a) : (a as Buffer).toString("hex"); + const sb = isBsonValue(b) ? bsonString(b) : (b as Buffer).toString("hex"); + return sa < sb ? -1 : sa > sb ? 1 : 0; + } + case 8: + return Number(a) - Number(b); + case 9: + return (a as Date).getTime() - (b as Date).getTime(); + case 5: { + const aa = a as unknown[]; + const bb = b as unknown[]; + for (let i = 0; i < Math.min(aa.length, bb.length); i++) { + const c = compareValues(aa[i], bb[i]); + if (c !== 0) return c; + } + return aa.length - bb.length; + } + case 4: { + const ka = Object.keys(a as Doc); + const kb = Object.keys(b as Doc); + for (let i = 0; i < Math.min(ka.length, kb.length); i++) { + if (ka[i] !== kb[i]) return ka[i] < kb[i] ? -1 : 1; + const c = compareValues((a as Doc)[ka[i]], (b as Doc)[kb[i]]); + if (c !== 0) return c; + } + return ka.length - kb.length; + } + default: + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; + } +} + +export function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if ((a === null || a === undefined) && (b === null || b === undefined)) return true; + if (typeRank(a) !== typeRank(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => valuesEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a).filter((k) => a[k] !== undefined); + const kb = Object.keys(b).filter((k) => b[k] !== undefined); + return ka.length === kb.length && ka.every((k) => valuesEqual(a[k], b[k])); + } + return compareValues(a, b) === 0; +} + +// ── Path resolution ────────────────────────────────────────────────────────── + +/** All values reachable at a dotted path, traversing arrays like MongoDB does. */ +export function resolvePath(value: unknown, parts: string[], i = 0): unknown[] { + if (i === parts.length) return [value]; + if (Array.isArray(value)) { + const out: unknown[] = []; + if (/^\d+$/.test(parts[i])) out.push(...resolvePath(value[Number(parts[i])], parts, i + 1)); + for (const el of value) { + if (isPlainObject(el)) out.push(...resolvePath(el, parts, i)); + } + return out.length ? out : [undefined]; + } + if (isPlainObject(value)) return resolvePath(value[parts[i]], parts, i + 1); + return [undefined]; +} + +export function getPath(doc: unknown, path: string): unknown { + let cur: unknown = doc; + for (const p of path.split(".")) { + if (cur === null || cur === undefined) return undefined; + if (Array.isArray(cur) && /^\d+$/.test(p)) cur = cur[Number(p)]; + else if (isPlainObject(cur)) cur = cur[p]; + else return undefined; + } + return cur; +} + +// ── Matching ───────────────────────────────────────────────────────────────── + +function isOperatorObject(v: unknown): v is Doc { + if (!isPlainObject(v)) return false; + const keys = Object.keys(v); + return keys.length > 0 && keys.every((k) => k.startsWith("$")); +} + +/** Equality with array-contains semantics and regex support. */ +function candidateEquals(candidate: unknown, expected: unknown): boolean { + if (expected instanceof RegExp) { + if (typeof candidate === "string") return expected.test(candidate); + if (Array.isArray(candidate)) return candidate.some((c) => typeof c === "string" && expected.test(c)); + return false; + } + if (valuesEqual(candidate, expected)) return true; + if (Array.isArray(candidate)) return candidate.some((c) => valuesEqual(c, expected)); + return false; +} + +function anyEquals(candidates: unknown[], expected: unknown): boolean { + return candidates.some((c) => candidateEquals(c, expected)); +} + +function flatten(candidates: unknown[]): unknown[] { + const out: unknown[] = []; + for (const c of candidates) { + if (Array.isArray(c)) out.push(...c); + else out.push(c); + } + return out; +} + +function compareOp(candidates: unknown[], expected: unknown, test: (c: number) => boolean): boolean { + return flatten(candidates).some( + (c) => c !== undefined && typeRank(c) === typeRank(expected) && test(compareValues(c, expected)), + ); +} + +function toRegExp(pattern: unknown, options: unknown): RegExp { + if (pattern instanceof RegExp) { + return options ? new RegExp(pattern.source, String(options)) : pattern; + } + return new RegExp(String(pattern), typeof options === "string" ? options : undefined); +} + +function matchOperators(candidates: unknown[], ops: Doc): boolean { + for (const [op, arg] of Object.entries(ops)) { + switch (op) { + case "$eq": + if (!anyEquals(candidates, arg)) return false; + break; + case "$ne": + if (anyEquals(candidates, arg)) return false; + break; + case "$in": + if (!(arg as unknown[]).some((v) => anyEquals(candidates, v))) return false; + break; + case "$nin": + if ((arg as unknown[]).some((v) => anyEquals(candidates, v))) return false; + break; + case "$gt": + if (!compareOp(candidates, arg, (c) => c > 0)) return false; + break; + case "$gte": + if (!compareOp(candidates, arg, (c) => c >= 0)) return false; + break; + case "$lt": + if (!compareOp(candidates, arg, (c) => c < 0)) return false; + break; + case "$lte": + if (!compareOp(candidates, arg, (c) => c <= 0)) return false; + break; + case "$exists": { + const exists = candidates.some((c) => c !== undefined); + if (exists !== Boolean(arg)) return false; + break; + } + case "$regex": { + const re = toRegExp(arg, ops.$options); + if (!flatten(candidates).some((c) => typeof c === "string" && re.test(c))) return false; + break; + } + case "$options": + break; + case "$size": + if (!candidates.some((c) => Array.isArray(c) && c.length === arg)) return false; + break; + case "$all": + if (!(arg as unknown[]).every((v) => anyEquals(candidates, v))) return false; + break; + case "$elemMatch": { + const sub = arg as Doc; + const hit = candidates.some( + (c) => + Array.isArray(c) && + c.some((el) => + isOperatorObject(sub) ? matchOperators([el], sub) : isPlainObject(el) && matchesFilter(el, sub), + ), + ); + if (!hit) return false; + break; + } + case "$not": { + const inner = arg instanceof RegExp ? { $regex: arg } : (arg as Doc); + if (matchOperators(candidates, inner)) return false; + break; + } + case "$type": { + const want = String(arg); + const names: Record = { 1: "null", 2: "number", 3: "string", 4: "object", 5: "array", 7: "objectId", 8: "bool", 9: "date" }; + if (!candidates.some((c) => names[typeRank(c)] === want || (want === "double" && typeof c === "number"))) return false; + break; + } + default: + throw new UnsupportedOperatorError(op); + } + } + return true; +} + +export function matchesFilter(doc: Doc, filter: Filter | null | undefined): boolean { + if (!filter) return true; + for (const [key, cond] of Object.entries(filter)) { + if (key === "$and") { + if (!(cond as Filter[]).every((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$or") { + if (!(cond as Filter[]).some((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$nor") { + if ((cond as Filter[]).some((f) => matchesFilter(doc, f))) return false; + continue; + } + if (key === "$comment") continue; + if (key.startsWith("$")) throw new UnsupportedOperatorError(key); + + const candidates = resolvePath(doc, key.split(".")); + if (isOperatorObject(cond)) { + if (!matchOperators(candidates, cond)) return false; + } else if (!anyEquals(candidates, cond)) { + return false; + } + } + return true; +} + +/** Equality fields from a filter — seeds the document created by an upsert. */ +export function equalityFields(filter: Filter | null | undefined): Array<[string, unknown]> { + const out: Array<[string, unknown]> = []; + if (!filter) return out; + for (const [key, cond] of Object.entries(filter)) { + if (key === "$and") { + for (const f of cond as Filter[]) out.push(...equalityFields(f)); + } else if (!key.startsWith("$")) { + if (isOperatorObject(cond)) { + if ("$eq" in cond) out.push([key, cond.$eq]); + } else if (!(cond instanceof RegExp)) { + out.push([key, cond]); + } + } + } + return out; +} + +// ── Sort & projection ──────────────────────────────────────────────────────── + +export type SortSpec = Array<[string, 1 | -1]>; + +export function normalizeSort(sort: unknown): SortSpec { + if (!sort) return []; + const entries: Array<[string, unknown]> = + sort instanceof Map + ? [...sort.entries()] + : Array.isArray(sort) + ? (sort as Array<[string, unknown]>) + : typeof sort === "string" + ? [[sort, 1]] + : Object.entries(sort as Doc); + return entries.map(([k, v]) => { + const dir = v === -1 || v === "desc" || v === "descending" ? -1 : 1; + return [k, dir as 1 | -1]; + }); +} + +function sortKey(doc: Doc, path: string, dir: 1 | -1): unknown { + const values = flatten(resolvePath(doc, path.split("."))); + if (values.length <= 1) return values[0]; + // Arrays sort by their min (asc) or max (desc) element. + return values.reduce((best, v) => (compareValues(v, best) * dir < 0 ? v : best)); +} + +export function sortDocs(docs: T[], sort: SortSpec): T[] { + if (!sort.length) return docs; + return [...docs].sort((a, b) => { + for (const [path, dir] of sort) { + const c = compareValues(sortKey(a, path, dir), sortKey(b, path, dir)); + if (c !== 0) return c * dir; + } + return 0; + }); +} + +function setPathRaw(target: Doc, path: string, value: unknown): void { + const parts = path.split("."); + let cur: Doc = target; + for (let i = 0; i < parts.length - 1; i++) { + if (!isPlainObject(cur[parts[i]])) cur[parts[i]] = {}; + cur = cur[parts[i]] as Doc; + } + cur[parts[parts.length - 1]] = value; +} + +function unsetPathRaw(target: Doc, path: string): void { + const parts = path.split("."); + let cur: unknown = target; + for (let i = 0; i < parts.length - 1; i++) { + if (!isPlainObject(cur)) return; + cur = cur[parts[i]]; + } + if (isPlainObject(cur)) delete cur[parts[parts.length - 1]]; +} + +/** Apply a find projection. `meta` supplies values for { $meta: … } fields. */ +export function projectDoc(doc: Doc, projection: Doc | null | undefined, meta?: Record): Doc { + if (!projection || Object.keys(projection).length === 0) return doc; + + const entries = Object.entries(projection); + const isMeta = (v: unknown) => isPlainObject(v) && "$meta" in v; + const inclusive = entries.some(([k, v]) => k !== "_id" && !isMeta(v) && v !== 0 && v !== false); + + if (inclusive) { + const out: Doc = {}; + if (projection._id !== 0 && projection._id !== false && doc._id !== undefined) out._id = doc._id; + for (const [k, v] of entries) { + if (k === "_id") continue; + if (isMeta(v)) { + out[k] = meta?.[String((v as Doc).$meta)]; + continue; + } + if (v === 0 || v === false) continue; + if (isPlainObject(v)) throw new UnsupportedOperatorError(`projection ${JSON.stringify(v)}`); + const value = getPath(doc, k); + if (value !== undefined) setPathRaw(out, k, value); + } + return out; + } + + const out: Doc = { ...doc }; + for (const [k, v] of entries) { + if (isMeta(v)) out[k] = meta?.[String((v as Doc).$meta)]; + else if (k.includes(".")) unsetPathRaw(out, k); + else delete out[k]; + } + return out; +} diff --git a/backend/src/lib/infra/sqliteStore/store.ts b/backend/src/lib/infra/sqliteStore/store.ts new file mode 100644 index 0000000..b5ddd46 --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/store.ts @@ -0,0 +1,705 @@ +/** + * SQLite-backed implementation of the MongoDB driver surface that mongoose + * calls (Db / Collection / Cursor / ClientSession). + * + * Every collection lives in one `documents` table as EJSON. The process owning + * the file keeps a parsed in-memory copy and writes through on every mutation, + * so reads never touch disk. The desktop app is the only writer. + */ +import { DatabaseSync, type StatementSync } from "node:sqlite"; +import mongoose from "mongoose"; +import { + equalityFields, + getPath, + isBsonValue, + isPlainObject, + matchesFilter, + normalizeSort, + projectDoc, + sortDocs, + UnsupportedOperatorError, + valuesEqual, + type Doc, + type Filter, +} from "./query"; +import { applyUpdate, isUpdateDocument } from "./update"; + +const { EJSON } = mongoose.mongo.BSON; +const ObjectId = mongoose.Types.ObjectId; + +// ── Cloning / keys ─────────────────────────────────────────────────────────── + +export function cloneValue(v: T): T { + if (v === null || typeof v !== "object") return v; + if (v instanceof Date) return new Date(v.getTime()) as T; + if (isBsonValue(v) || v instanceof RegExp) return v; + // Mongoose documents/arrays expose toBSON() — exactly what the real driver + // serializes. (Mongoose overrides Array#map, so never map its arrays directly.) + const toBSON = (v as { toBSON?: () => unknown }).toBSON; + if (typeof toBSON === "function") return cloneValue(toBSON.call(v)) as T; + if (Array.isArray(v)) return Array.from(v, (x) => cloneValue(x)) as T; + if (Buffer.isBuffer(v)) return Buffer.from(v) as T; + if (v instanceof Map) { + const out: Doc = {}; + for (const [k, val] of v) out[String(k)] = cloneValue(val); + return out as T; + } + const out: Doc = {}; + for (const k of Object.keys(v as Doc)) { + const val = (v as Doc)[k]; + if (val !== undefined) out[k] = cloneValue(val); + } + return out as T; +} + +function idKey(id: unknown): string { + if (isBsonValue(id) && id._bsontype === "ObjectId") return `o:${(id as unknown as { toHexString(): string }).toHexString()}`; + if (typeof id === "string") return `s:${id}`; + if (typeof id === "number") return `n:${id}`; + return `j:${EJSON.stringify(id as Doc, { relaxed: false })}`; +} + +function duplicateKeyError(collection: string, keys: Doc, doc: Doc): Error { + const keyValue: Doc = {}; + for (const k of Object.keys(keys)) keyValue[k] = getPath(doc, k); + const err = new Error( + `E11000 duplicate key error collection: ${collection} index: ${Object.keys(keys).join("_")} dup key: ${EJSON.stringify(keyValue)}`, + ); + return Object.assign(err, { name: "MongoServerError", code: 11000, keyPattern: keys, keyValue }); +} + +type IndexSpec = { + name: string; + key: Doc; + unique?: boolean; + sparse?: boolean; + expireAfterSeconds?: number; + partialFilterExpression?: Filter; +}; + +type FindOptions = { + sort?: unknown; + limit?: number; + skip?: number; + projection?: Doc; + [k: string]: unknown; +}; + +// ── Cursor ─────────────────────────────────────────────────────────────────── + +export class SqliteCursor { + private _sort: unknown; + private _limit = 0; + private _skip = 0; + private _projection: Doc | undefined; + private _maps: Array<(d: unknown) => unknown> = []; + private buffer: T[] | null = null; + + constructor(private readonly source: () => Doc[], opts: FindOptions = {}) { + this._sort = opts.sort; + this._limit = opts.limit ?? 0; + this._skip = opts.skip ?? 0; + this._projection = opts.projection; + } + + sort(s: unknown) { this._sort = s; return this; } + limit(n: number) { this._limit = n; return this; } + skip(n: number) { this._skip = n; return this; } + project(p: Doc) { this._projection = p; return this; } + map(fn: (d: T) => U): SqliteCursor { + this._maps.push(fn as (d: unknown) => unknown); + return this as unknown as SqliteCursor; + } + batchSize() { return this; } + maxTimeMS() { return this; } + allowDiskUse() { return this; } + addCursorFlag() { return this; } + hint() { return this; } + collation() { return this; } + comment() { return this; } + + private materialize(): T[] { + if (this.buffer) return this.buffer; + let docs = sortDocs(this.source(), normalizeSort(this._sort)); + if (this._skip) docs = docs.slice(this._skip); + if (this._limit) docs = docs.slice(0, Math.abs(this._limit)); + let out: unknown[] = docs.map((d) => projectDoc(cloneValue(d), this._projection, (d as { __meta?: Doc }).__meta)); + for (const fn of this._maps) out = out.map(fn); + this.buffer = out as T[]; + return this.buffer; + } + + async toArray(): Promise { return [...this.materialize()]; } + async next(): Promise { return this.materialize().shift() ?? null; } + async tryNext(): Promise { return this.next(); } + async hasNext(): Promise { return this.materialize().length > 0; } + async forEach(fn: (d: T) => unknown): Promise { for (const d of this.materialize()) await fn(d); } + async count(): Promise { return this.materialize().length; } + async close(): Promise { this.buffer = []; } + rewind(): void { this.buffer = null; } + get closed(): boolean { return this.buffer !== null && this.buffer.length === 0; } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for (const d of this.materialize()) yield d; + } +} + +// ── Collection ─────────────────────────────────────────────────────────────── + +export class SqliteCollection { + readonly docs = new Map(); + readonly indexSpecs = new Map([["_id_", { name: "_id_", key: { _id: 1 } }]]); + + constructor( + readonly collectionName: string, + private readonly db: SqliteDb, + ) {} + + get namespace(): string { return `${this.db.databaseName}.${this.collectionName}`; } + get dbName(): string { return this.db.databaseName; } + + // internal --------------------------------------------------------------- + + private matching(filter: Filter | undefined): Doc[] { + const f = filter ?? {}; + // Fast path for _id lookups, which is what save()/findById emit. + const keys = Object.keys(f); + if (keys.length === 1 && keys[0] === "_id" && !isPlainObject(f._id)) { + const d = this.docs.get(idKey(f._id)); + return d ? [d] : []; + } + const out: Doc[] = []; + for (const d of this.docs.values()) if (matchesFilter(d, f)) out.push(d); + return out; + } + + private first(filter: Filter | undefined, sort?: unknown): Doc | undefined { + const docs = this.matching(filter); + return sort ? sortDocs(docs, normalizeSort(sort))[0] : docs[0]; + } + + private checkUnique(doc: Doc, selfKey: string): void { + for (const idx of this.indexSpecs.values()) { + if (!idx.unique || idx.name === "_id_") continue; + const fields = Object.keys(idx.key); + if (idx.partialFilterExpression && !matchesFilter(doc, idx.partialFilterExpression)) continue; + const values = fields.map((f) => getPath(doc, f)); + if (idx.sparse && values.every((v) => v === undefined)) continue; + for (const [k, other] of this.docs) { + if (k === selfKey) continue; + if (idx.partialFilterExpression && !matchesFilter(other, idx.partialFilterExpression)) continue; + if (fields.every((f, i) => valuesEqual(getPath(other, f), values[i]))) { + throw duplicateKeyError(this.namespace, idx.key, doc); + } + } + } + } + + private put(doc: Doc, isNew: boolean): void { + const key = idKey(doc._id); + if (isNew && this.docs.has(key)) throw duplicateKeyError(this.namespace, { _id: 1 }, doc); + this.checkUnique(doc, key); + this.docs.set(key, doc); + this.db.persist(this.collectionName, key, doc); + } + + private remove(doc: Doc): void { + const key = idKey(doc._id); + this.docs.delete(key); + this.db.unpersist(this.collectionName, key); + } + + private prepareInsert(raw: Doc): Doc { + const doc = cloneValue(raw); + if (doc._id === undefined) doc._id = new ObjectId(); + raw._id ??= doc._id; // driver mutates the input like this too + return doc; + } + + /** Apply update to a copy; commit only if every constraint passes. */ + private updateDoc(existing: Doc, update: Doc | Doc[], isInsert: boolean): Doc { + if (Array.isArray(update)) throw new UnsupportedOperatorError("update pipeline"); + const next = cloneValue(existing); + if (isUpdateDocument(update)) { + applyUpdate(next, update, isInsert, cloneValue); + } else { + for (const k of Object.keys(next)) if (k !== "_id") delete next[k]; + Object.assign(next, cloneValue(update)); + } + if (!valuesEqual(next._id, existing._id) && !isInsert) { + throw new Error("Performing an update on the path '_id' would modify the immutable field '_id'"); + } + return next; + } + + private upsertSeed(filter: Filter | undefined): Doc { + const seed: Doc = {}; + for (const [path, value] of equalityFields(filter)) { + applyUpdate(seed, { $set: { [path]: value } }, true, cloneValue); + } + return seed; + } + + private doUpdate(filter: Filter, update: Doc | Doc[], opts: Doc, many: boolean) { + const targets = many ? this.matching(filter) : [this.first(filter, opts.sort)].filter(Boolean) as Doc[]; + if (targets.length === 0 && opts.upsert) { + const seed = this.upsertSeed(filter); + const doc = this.updateDoc(seed, update, true); + if (doc._id === undefined) doc._id = new ObjectId(); + this.db.transaction(() => this.put(doc, true)); + return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedCount: 1, upsertedId: doc._id, doc, before: null }; + } + let modified = 0; + let last: { doc: Doc; before: Doc } | null = null; + this.db.transaction(() => { + for (const t of targets) { + const next = this.updateDoc(t, update, false); + if (!valuesEqual(next, t)) { + this.put(next, false); + modified++; + } + last = { doc: next, before: t }; + } + }); + const l = last as { doc: Doc; before: Doc } | null; + return { + acknowledged: true, + matchedCount: targets.length, + modifiedCount: modified, + upsertedCount: 0, + upsertedId: null, + doc: l?.doc ?? null, + before: l?.before ?? null, + }; + } + + private modifyResult(value: Doc | null, opts: Doc, meta: Doc) { + const projected = value ? projectDoc(cloneValue(value), opts.projection as Doc | undefined) : null; + return opts.includeResultMetadata ? { value: projected, ok: 1, lastErrorObject: meta } : projected; + } + + // driver API ------------------------------------------------------------- + + find(filter?: Filter, opts: FindOptions = {}) { + return new SqliteCursor(() => this.matching(filter), opts); + } + + async findOne(filter?: Filter, opts: FindOptions = {}): Promise { + const docs = await this.find(filter, { ...opts, limit: 1 }).toArray(); + return docs[0] ?? null; + } + + async countDocuments(filter?: Filter, opts: FindOptions = {}): Promise { + let n = this.matching(filter).length - (opts.skip ?? 0); + if (opts.limit) n = Math.min(n, opts.limit); + return Math.max(0, n); + } + + async estimatedDocumentCount(): Promise { + return this.docs.size; + } + + async distinct(key: string, filter?: Filter): Promise { + const out: unknown[] = []; + for (const d of this.matching(filter)) { + const v = getPath(d, key); + for (const item of Array.isArray(v) ? v : [v]) { + if (item === undefined) continue; + if (!out.some((o) => valuesEqual(o, item))) out.push(cloneValue(item)); + } + } + return out; + } + + async insertOne(raw: Doc) { + const doc = this.prepareInsert(raw); + this.db.transaction(() => this.put(doc, true)); + return { acknowledged: true, insertedId: doc._id }; + } + + async insertMany(raws: Doc[], opts: Doc = {}) { + const insertedIds: Record = {}; + const ordered = opts.ordered !== false; + const errors: Error[] = []; + this.db.transaction(() => { + raws.forEach((raw, i) => { + try { + const doc = this.prepareInsert(raw); + this.put(doc, true); + insertedIds[i] = doc._id; + } catch (err) { + if (ordered) throw err; + errors.push(err as Error); + } + }); + }); + if (errors.length) throw errors[0]; + return { acknowledged: true, insertedCount: Object.keys(insertedIds).length, insertedIds }; + } + + async updateOne(filter: Filter, update: Doc, opts: Doc = {}) { + const { doc: _d, before: _b, ...res } = this.doUpdate(filter, update, opts, false); + return res; + } + + async updateMany(filter: Filter, update: Doc, opts: Doc = {}) { + const { doc: _d, before: _b, ...res } = this.doUpdate(filter, update, opts, true); + return res; + } + + async replaceOne(filter: Filter, replacement: Doc, opts: Doc = {}) { + if (isUpdateDocument(replacement)) throw new Error("Replacement document must not contain atomic operators"); + return this.updateOne(filter, replacement, opts); + } + + async findOneAndUpdate(filter: Filter, update: Doc, opts: Doc = {}) { + const res = this.doUpdate(filter, update, opts, false); + const after = opts.returnDocument === "after" || opts.returnOriginal === false; + const value = res.upsertedCount ? (after ? res.doc : null) : after ? res.doc : res.before; + return this.modifyResult(value, opts, { + n: res.matchedCount || res.upsertedCount, + updatedExisting: res.matchedCount > 0, + ...(res.upsertedCount ? { upserted: res.upsertedId } : {}), + }); + } + + async findOneAndReplace(filter: Filter, replacement: Doc, opts: Doc = {}) { + if (isUpdateDocument(replacement)) throw new Error("Replacement document must not contain atomic operators"); + return this.findOneAndUpdate(filter, replacement, opts); + } + + async findOneAndDelete(filter: Filter, opts: Doc = {}) { + const doc = this.first(filter, opts.sort) ?? null; + if (doc) this.db.transaction(() => this.remove(doc)); + return this.modifyResult(doc, opts, { n: doc ? 1 : 0 }); + } + + async deleteOne(filter?: Filter) { + const doc = this.first(filter); + if (doc) this.db.transaction(() => this.remove(doc)); + return { acknowledged: true, deletedCount: doc ? 1 : 0 }; + } + + async deleteMany(filter?: Filter) { + const docs = this.matching(filter); + this.db.transaction(() => docs.forEach((d) => this.remove(d))); + return { acknowledged: true, deletedCount: docs.length }; + } + + async bulkWrite(ops: Doc[]) { + const res = { insertedCount: 0, matchedCount: 0, modifiedCount: 0, deletedCount: 0, upsertedCount: 0, insertedIds: {} as Doc, upsertedIds: {} as Doc }; + for (const [i, op] of ops.entries()) { + const [kind, a] = Object.entries(op)[0] as [string, Doc]; + if (kind === "insertOne") { + const r = await this.insertOne(a.document as Doc); + res.insertedCount++; + res.insertedIds[i] = r.insertedId; + } else if (kind === "updateOne" || kind === "updateMany" || kind === "replaceOne") { + const update = (kind === "replaceOne" ? a.replacement : a.update) as Doc; + const r = this.doUpdate(a.filter as Filter, update, a, kind === "updateMany"); + res.matchedCount += r.matchedCount; + res.modifiedCount += r.modifiedCount; + if (r.upsertedCount) { + res.upsertedCount++; + res.upsertedIds[i] = r.upsertedId; + } + } else if (kind === "deleteOne") { + res.deletedCount += (await this.deleteOne(a.filter as Filter)).deletedCount; + } else if (kind === "deleteMany") { + res.deletedCount += (await this.deleteMany(a.filter as Filter)).deletedCount; + } else { + throw new UnsupportedOperatorError(`bulkWrite ${kind}`); + } + } + return { ok: 1, ...res, isOk: () => true, hasWriteErrors: () => false, getWriteErrors: () => [] }; + } + + aggregate(pipeline: Doc[]) { + return new SqliteCursor(() => this.runPipeline(pipeline)); + } + + private runPipeline(pipeline: Doc[]): Doc[] { + let docs: Doc[] = [...this.docs.values()]; + for (const stage of pipeline) { + const [op, arg] = Object.entries(stage)[0] as [string, Doc]; + switch (op) { + case "$match": + docs = docs.filter((d) => matchesFilter(d, arg)); + break; + case "$vectorSearch": + docs = vectorSearch(docs, arg); + break; + case "$sort": + docs = sortDocs(docs, normalizeSort(arg)); + break; + case "$skip": + docs = docs.slice(Number(arg)); + break; + case "$limit": + docs = docs.slice(0, Number(arg)); + break; + case "$project": + docs = docs.map((d) => projectDoc(cloneValue(d), arg, (d as { __meta?: Doc }).__meta)); + break; + case "$count": + docs = [{ [String(arg)]: docs.length }]; + break; + default: + throw new UnsupportedOperatorError(`aggregate stage ${op}`); + } + } + return docs; + } + + async createIndex(key: Doc | string, opts: Doc = {}): Promise { + const k: Doc = typeof key === "string" ? { [key]: 1 } : key; + const name = (opts.name as string) ?? Object.entries(k).map(([f, d]) => `${f}_${d}`).join("_"); + this.indexSpecs.set(name, { + name, + key: k, + unique: Boolean(opts.unique), + sparse: Boolean(opts.sparse), + ...(typeof opts.expireAfterSeconds === "number" ? { expireAfterSeconds: opts.expireAfterSeconds } : {}), + ...(opts.partialFilterExpression ? { partialFilterExpression: opts.partialFilterExpression as Filter } : {}), + }); + return name; + } + + async createIndexes(specs: Array): Promise { + const names: string[] = []; + for (const { key, ...opts } of specs) names.push(await this.createIndex(key, opts)); + return names; + } + + listIndexes() { + return new SqliteCursor(() => [...this.indexSpecs.values()].map((i) => ({ v: 2, ...i }) as Doc)); + } + + async indexes(): Promise { return this.listIndexes().toArray(); } + async indexExists(names: string | string[]): Promise { + return (Array.isArray(names) ? names : [names]).every((n) => this.indexSpecs.has(n)); + } + async indexInformation(): Promise { + return Object.fromEntries([...this.indexSpecs.values()].map((i) => [i.name, Object.entries(i.key)])); + } + async dropIndex(name: string): Promise { this.indexSpecs.delete(name); return { ok: 1 }; } + async dropIndexes(): Promise { + for (const n of [...this.indexSpecs.keys()]) if (n !== "_id_") this.indexSpecs.delete(n); + return true; + } + async drop(): Promise { await this.deleteMany({}); return true; } + + watch(): never { + throw new UnsupportedOperatorError("change streams"); + } + + /** Delete documents whose TTL index field has expired. */ + sweepExpired(now = Date.now()): number { + let removed = 0; + for (const idx of this.indexSpecs.values()) { + if (idx.expireAfterSeconds === undefined) continue; + const field = Object.keys(idx.key)[0]; + const expired = [...this.docs.values()].filter((d) => { + const v = getPath(d, field); + return v instanceof Date && v.getTime() + idx.expireAfterSeconds! * 1000 <= now; + }); + if (expired.length) { + this.db.transaction(() => expired.forEach((d) => this.remove(d))); + removed += expired.length; + } + } + return removed; + } +} + +/** In-process stand-in for Atlas $vectorSearch: exact cosine over the candidate set. */ +function vectorSearch(docs: Doc[], spec: Doc): Doc[] { + const path = String(spec.path); + const q = spec.queryVector as number[]; + const limit = Number(spec.limit ?? 10); + const qNorm = Math.hypot(...q) || 1; + const filtered = spec.filter ? docs.filter((d) => matchesFilter(d, spec.filter as Filter)) : docs; + + const scored: Array<{ d: Doc; score: number }> = []; + for (const d of filtered) { + const v = getPath(d, path); + if (!Array.isArray(v) || v.length !== q.length) continue; + let dot = 0; + let norm = 0; + for (let i = 0; i < q.length; i++) { + dot += q[i] * (v[i] as number); + norm += (v[i] as number) ** 2; + } + if (!norm) continue; + const cosine = dot / (qNorm * Math.sqrt(norm)); + // Atlas reports cosine similarity normalised to [0, 1]. + scored.push({ d, score: (1 + cosine) / 2 }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map(({ d, score }) => Object.assign(Object.create(null), d, { __meta: { vectorSearchScore: score } }) as Doc); +} + +// ── Session / client / db ──────────────────────────────────────────────────── + +class SqliteSession { + readonly id = { id: new ObjectId() }; + hasEnded = false; + transaction = { state: "NO_TRANSACTION", isActive: false, isCommitted: false }; + inTransaction(): boolean { return false; } + startTransaction(): void {} + async commitTransaction(): Promise {} + async abortTransaction(): Promise {} + async endSession(): Promise { this.hasEnded = true; } + /** + * Single-writer local store: run the callback directly. Each driver call is + * already atomic; cross-call rollback is not provided. + */ + async withTransaction(fn: (s: SqliteSession) => Promise): Promise { + return fn(this); + } + [Symbol.asyncDispose] = () => this.endSession(); +} + +export class SqliteDb { + readonly databaseName = "capscreen"; + private readonly sql: DatabaseSync; + private readonly collections = new Map(); + private readonly upsertStmt: StatementSync; + private readonly deleteStmt: StatementSync; + private txDepth = 0; + private ttlTimer: NodeJS.Timeout | null = null; + + constructor(readonly file: string) { + this.sql = new DatabaseSync(file); + this.sql.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS documents ( + collection TEXT NOT NULL, + id TEXT NOT NULL, + body TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (collection, id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '1'); + `); + this.upsertStmt = this.sql.prepare( + `INSERT INTO documents (collection, id, body, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (collection, id) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at`, + ); + this.deleteStmt = this.sql.prepare(`DELETE FROM documents WHERE collection = ? AND id = ?`); + this.load(); + } + + private load(): void { + const rows = this.sql.prepare(`SELECT collection, id, body FROM documents`).all() as Array<{ + collection: string; + id: string; + body: string; + }>; + for (const row of rows) { + this.collection(row.collection).docs.set(row.id, EJSON.parse(row.body, { relaxed: true }) as Doc); + } + } + + persist(collection: string, id: string, doc: Doc): void { + this.upsertStmt.run(collection, id, EJSON.stringify(doc, { relaxed: true }), Date.now()); + } + + unpersist(collection: string, id: string): void { + this.deleteStmt.run(collection, id); + } + + /** Synchronous SQLite transaction; nested calls join the outer one. */ + transaction(fn: () => void): void { + if (this.txDepth > 0) { + fn(); + return; + } + this.txDepth++; + this.sql.exec("BEGIN"); + try { + fn(); + this.sql.exec("COMMIT"); + } catch (err) { + this.sql.exec("ROLLBACK"); + // Memory may have partially applied; reload this process's view from disk. + for (const c of this.collections.values()) c.docs.clear(); + this.load(); + throw err; + } finally { + this.txDepth--; + } + } + + collection(name: string): SqliteCollection { + let c = this.collections.get(name); + if (!c) { + c = new SqliteCollection(name, this); + this.collections.set(name, c); + } + return c; + } + + async createCollection(name: string): Promise { + return this.collection(name); + } + + listCollections() { + return new SqliteCursor(() => [...this.collections.keys()].map((name) => ({ name, type: "collection" }))); + } + + async dropCollection(name: string): Promise { + await this.collection(name).drop(); + this.collections.delete(name); + return true; + } + + async dropDatabase(): Promise { + for (const name of [...this.collections.keys()]) await this.dropCollection(name); + return true; + } + + async command(cmd: Doc): Promise { + if ("ping" in cmd) return { ok: 1 }; + if ("buildInfo" in cmd || "buildinfo" in cmd) return { ok: 1, version: "7.0.0", versionArray: [7, 0, 0, 0] }; + if ("hello" in cmd || "isMaster" in cmd) return { ok: 1, isWritablePrimary: true, maxWireVersion: 21 }; + throw new UnsupportedOperatorError(`command ${Object.keys(cmd)[0]}`); + } + + admin() { + return { command: (cmd: Doc) => this.command(cmd), ping: () => this.command({ ping: 1 }) }; + } + + startTtlSweeper(intervalMs = 60_000): void { + const sweep = () => { + for (const c of this.collections.values()) c.sweepExpired(); + }; + sweep(); + this.ttlTimer = setInterval(sweep, intervalMs); + this.ttlTimer.unref(); + } + + /** Consistent single-file copy (safe while the app is running). */ + backupTo(target: string): void { + this.sql.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`); + } + + close(): void { + if (this.ttlTimer) clearInterval(this.ttlTimer); + this.sql.close(); + } +} + +/** Minimal MongoClient stand-in: mongoose only needs db(), startSession() and close(). */ +export class SqliteClient { + constructor(readonly sqliteDb: SqliteDb) {} + db(): SqliteDb { return this.sqliteDb; } + startSession(): SqliteSession { return new SqliteSession(); } + async close(): Promise { this.sqliteDb.close(); } + on(): this { return this; } + once(): this { return this; } + removeListener(): this { return this; } +} diff --git a/backend/src/lib/infra/sqliteStore/update.ts b/backend/src/lib/infra/sqliteStore/update.ts new file mode 100644 index 0000000..0abf7ec --- /dev/null +++ b/backend/src/lib/infra/sqliteStore/update.ts @@ -0,0 +1,201 @@ +/** + * MongoDB update operators applied in place to a plain JS document. + */ +import { + UnsupportedOperatorError, + compareValues, + isPlainObject, + matchesFilter, + valuesEqual, + type Doc, +} from "./query"; + +function parentOf(doc: Doc, path: string, create: boolean): { parent: Doc | unknown[]; key: string } | null { + const parts = path.split("."); + let cur: unknown = doc; + for (let i = 0; i < parts.length - 1; i++) { + const p = parts[i]; + if (p === "$" || p.startsWith("$[")) throw new UnsupportedOperatorError(`positional path ${path}`); + if (Array.isArray(cur)) { + const idx = Number(p); + if (!Number.isInteger(idx)) return null; + if (cur[idx] === undefined || cur[idx] === null) { + if (!create) return null; + cur[idx] = {}; + } + cur = cur[idx]; + } else if (isPlainObject(cur)) { + if (cur[p] === undefined || cur[p] === null) { + if (!create) return null; + cur[p] = {}; + } + cur = cur[p]; + } else { + return null; + } + } + if (!Array.isArray(cur) && !isPlainObject(cur)) return null; + return { parent: cur as Doc | unknown[], key: parts[parts.length - 1] }; +} + +function getAt(doc: Doc, path: string): unknown { + const loc = parentOf(doc, path, false); + if (!loc) return undefined; + return Array.isArray(loc.parent) ? loc.parent[Number(loc.key)] : loc.parent[loc.key]; +} + +function setAt(doc: Doc, path: string, value: unknown): void { + const loc = parentOf(doc, path, true); + if (!loc) throw new Error(`Cannot set path "${path}"`); + if (Array.isArray(loc.parent)) loc.parent[Number(loc.key)] = value; + else loc.parent[loc.key] = value; +} + +function unsetAt(doc: Doc, path: string): void { + const loc = parentOf(doc, path, false); + if (!loc) return; + if (Array.isArray(loc.parent)) loc.parent[Number(loc.key)] = null; + else delete loc.parent[loc.key]; +} + +function arrayAt(doc: Doc, path: string, op: string): unknown[] { + const cur = getAt(doc, path); + if (cur === undefined || cur === null) { + const arr: unknown[] = []; + setAt(doc, path, arr); + return arr; + } + if (!Array.isArray(cur)) throw new Error(`${op} on non-array field "${path}"`); + return cur; +} + +function matchesPullCondition(el: unknown, cond: unknown): boolean { + if (isPlainObject(cond)) { + const keys = Object.keys(cond); + if (keys.length && keys.every((k) => k.startsWith("$"))) { + return matchesFilter({ v: el }, { v: cond }); + } + return isPlainObject(el) ? matchesFilter(el, cond) : false; + } + return valuesEqual(el, cond); +} + +export function isUpdateDocument(update: Doc): boolean { + return Object.keys(update).some((k) => k.startsWith("$")); +} + +/** + * Apply `update` to `doc` in place. `isInsert` enables $setOnInsert. + * `clone` copies incoming values so stored documents never alias caller objects. + */ +export function applyUpdate(doc: Doc, update: Doc, isInsert: boolean, clone: (v: T) => T): void { + for (const [op, fields] of Object.entries(update)) { + const entries = Object.entries((fields ?? {}) as Doc); + switch (op) { + case "$set": + for (const [p, v] of entries) setAt(doc, p, clone(v)); + break; + case "$setOnInsert": + if (isInsert) for (const [p, v] of entries) setAt(doc, p, clone(v)); + break; + case "$unset": + for (const [p] of entries) unsetAt(doc, p); + break; + case "$inc": + for (const [p, v] of entries) setAt(doc, p, (Number(getAt(doc, p)) || 0) + Number(v)); + break; + case "$mul": + for (const [p, v] of entries) setAt(doc, p, (Number(getAt(doc, p)) || 0) * Number(v)); + break; + case "$min": + for (const [p, v] of entries) { + const cur = getAt(doc, p); + if (cur === undefined || compareValues(v, cur) < 0) setAt(doc, p, clone(v)); + } + break; + case "$max": + for (const [p, v] of entries) { + const cur = getAt(doc, p); + if (cur === undefined || compareValues(v, cur) > 0) setAt(doc, p, clone(v)); + } + break; + case "$currentDate": + for (const [p] of entries) setAt(doc, p, new Date()); + break; + case "$rename": + for (const [p, to] of entries) { + const v = getAt(doc, p); + if (v === undefined) continue; + unsetAt(doc, p); + setAt(doc, String(to), v); + } + break; + case "$push": + for (const [p, v] of entries) { + const arr = arrayAt(doc, p, op); + const spec = isPlainObject(v) && "$each" in v ? v : null; + const items = (spec ? (spec.$each as unknown[]) : [v]).map((x) => clone(x)); + if (spec && typeof spec.$position === "number") arr.splice(spec.$position, 0, ...items); + else arr.push(...items); + if (spec && spec.$sort !== undefined) { + const s = spec.$sort; + if (typeof s === "number") arr.sort((a, b) => compareValues(a, b) * s); + else { + const keys = Object.entries(s as Doc); + arr.sort((a, b) => { + for (const [k, d] of keys) { + const c = compareValues((a as Doc)?.[k], (b as Doc)?.[k]); + if (c) return c * Number(d); + } + return 0; + }); + } + } + if (spec && typeof spec.$slice === "number") { + const n = spec.$slice; + const kept = n >= 0 ? arr.slice(0, n) : arr.slice(n); + arr.length = 0; + arr.push(...kept); + } + } + break; + case "$addToSet": + for (const [p, v] of entries) { + const arr = arrayAt(doc, p, op); + const items = isPlainObject(v) && "$each" in v ? (v.$each as unknown[]) : [v]; + for (const item of items) { + if (!arr.some((x) => valuesEqual(x, item))) arr.push(clone(item)); + } + } + break; + case "$pull": + for (const [p, cond] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + const kept = cur.filter((el) => !matchesPullCondition(el, cond)); + cur.length = 0; + cur.push(...kept); + } + break; + case "$pullAll": + for (const [p, values] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + const kept = cur.filter((el) => !(values as unknown[]).some((v) => valuesEqual(el, v))); + cur.length = 0; + cur.push(...kept); + } + break; + case "$pop": + for (const [p, dir] of entries) { + const cur = getAt(doc, p); + if (!Array.isArray(cur)) continue; + if (Number(dir) < 0) cur.shift(); + else cur.pop(); + } + break; + default: + throw new UnsupportedOperatorError(op); + } + } +} diff --git a/backend/src/lib/llm/agent.ts b/backend/src/lib/llm/agent.ts deleted file mode 100644 index 8286916..0000000 --- a/backend/src/lib/llm/agent.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import type { ToolActivity } from "../chat/chatTools"; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} - -function truncate(s: string, max = 120): string { - const one = s.replace(/\s+/g, " ").trim(); - return one.length <= max ? one : `${one.slice(0, max)}…`; -} - -export async function runAgentWithTools(params: { - model: string; - systemPrompt: string; - messages: Anthropic.MessageParam[]; - tools: Anthropic.Tool[]; - executeTool: ( - name: string, - input: unknown, - ) => Promise<{ content: string }>; - maxTokens?: number; - maxRounds?: number; -}): Promise<{ text: string; toolActivity: ToolActivity[] }> { - const anthropic = client(); - const toolActivity: ToolActivity[] = []; - const messages = [...params.messages]; - const maxRounds = params.maxRounds ?? 8; - - for (let round = 0; round < maxRounds; round++) { - const resp = await anthropic.messages.create({ - model: params.model, - max_tokens: params.maxTokens ?? 2048, - system: params.systemPrompt, - tools: params.tools, - messages, - }); - - const textBlocks = resp.content.filter( - (b): b is Anthropic.TextBlock => b.type === "text", - ); - const text = textBlocks.map((b) => b.text).join(""); - - if (resp.stop_reason === "end_turn" || resp.stop_reason === "max_tokens") { - return { text, toolActivity }; - } - - if (resp.stop_reason !== "tool_use") { - return { text, toolActivity }; - } - - messages.push({ role: "assistant", content: resp.content }); - - const toolResults: Anthropic.ToolResultBlockParam[] = []; - for (const block of resp.content) { - if (block.type !== "tool_use") continue; - - toolActivity.push({ name: block.name, status: "running" }); - const { content } = await params.executeTool(block.name, block.input); - const idx = toolActivity.length - 1; - toolActivity[idx] = { - name: block.name, - status: "done", - summary: truncate(content), - }; - - toolResults.push({ - type: "tool_result", - tool_use_id: block.id, - content, - }); - } - - messages.push({ role: "user", content: toolResults }); - } - - return { - text: "I hit the tool-call limit for this turn. Please try a simpler question.", - toolActivity, - }; -} diff --git a/backend/src/lib/llm/allowedModels.ts b/backend/src/lib/llm/allowedModels.ts deleted file mode 100644 index 2966a36..0000000 --- a/backend/src/lib/llm/allowedModels.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ALLOWED_TABULAR_MODELS = new Set([ - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "gpt-5.5", - "gpt-5.4-mini", -]); - -export function isAllowedTabularModel(model: string): boolean { - return ALLOWED_TABULAR_MODELS.has(model.trim()); -} diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts deleted file mode 100644 index 2990003..0000000 --- a/backend/src/lib/llm/claude.ts +++ /dev/null @@ -1,44 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; - -export type ChatMessage = { role: "user" | "assistant"; content: string }; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} - -// Single-shot completion — used for the explanation narrator in explain.ts -export async function completeClaudeText(params: { - model: string; - systemPrompt?: string; - user: string; - maxTokens?: number; -}): Promise { - return chatWithClaude({ - model: params.model, - systemPrompt: params.systemPrompt, - messages: [{ role: "user", content: params.user }], - maxTokens: params.maxTokens, - }); -} - -// Multi-turn chat — caller owns the full message history and sends it each time -export async function chatWithClaude(params: { - model: string; - systemPrompt?: string; - messages: ChatMessage[]; - maxTokens?: number; -}): Promise { - const anthropic = client(); - const resp = await anthropic.messages.create({ - model: params.model, - max_tokens: params.maxTokens ?? 1024, - system: params.systemPrompt, - messages: params.messages as Anthropic.MessageParam[], - }); - return resp.content - .filter((b): b is Anthropic.TextBlock => b.type === "text") - .map((b) => b.text) - .join(""); -} diff --git a/backend/src/lib/llm/complete.ts b/backend/src/lib/llm/complete.ts new file mode 100644 index 0000000..56553df --- /dev/null +++ b/backend/src/lib/llm/complete.ts @@ -0,0 +1,35 @@ +import { getLlm, responseText, type LlmRole } from "./providers"; + +export type ChatMessage = { role: "user" | "assistant"; content: string }; + +// Single-shot completion — narratives, CSV schema inference, entity extraction +export async function completeText(params: { + systemPrompt?: string; + user: string; + maxTokens?: number; + role?: LlmRole; +}): Promise { + return chatText({ + systemPrompt: params.systemPrompt, + messages: [{ role: "user", content: params.user }], + maxTokens: params.maxTokens, + role: params.role, + }); +} + +// Multi-turn chat — caller owns the full message history and sends it each time +export async function chatText(params: { + systemPrompt?: string; + messages: ChatMessage[]; + maxTokens?: number; + role?: LlmRole; +}): Promise { + const llm = getLlm(params.role ?? "utility"); + const resp = await llm.provider.complete({ + model: llm.model, + systemPrompt: params.systemPrompt, + messages: params.messages, + maxTokens: params.maxTokens ?? 1024, + }); + return responseText(resp); +} diff --git a/backend/src/lib/llm/controlCenter/localRuntime.ts b/backend/src/lib/llm/controlCenter/localRuntime.ts new file mode 100644 index 0000000..2b98fb6 --- /dev/null +++ b/backend/src/lib/llm/controlCenter/localRuntime.ts @@ -0,0 +1,165 @@ +import os from "os"; + +/** + * On-device model runtimes (Ollama, LM Studio) — detection, hardware fit, and + * model management. Everything here talks to 127.0.0.1 only. + */ + +export const OLLAMA_URL = (process.env.OLLAMA_HOST?.trim() || "http://127.0.0.1:11434").replace(/\/$/, ""); +const LMSTUDIO_URL = "http://127.0.0.1:1234"; + +export type Hardware = { + platform: NodeJS.Platform; + arch: string; + cpu: string; + cores: number; + totalMemGB: number; + freeMemGB: number; + /** Apple Silicon shares RAM with the GPU, so most of it is usable for models. */ + unifiedMemory: boolean; +}; + +export function detectHardware(): Hardware { + const cpus = os.cpus(); + const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10; + return { + platform: process.platform, + arch: process.arch, + cpu: cpus[0]?.model ?? "unknown", + cores: cpus.length, + totalMemGB: gb(os.totalmem()), + freeMemGB: gb(os.freemem()), + unifiedMemory: process.platform === "darwin" && process.arch === "arm64", + }; +} + +export type CatalogModel = { + id: string; + label: string; + family: string; + sizeGB: number; + /** RAM needed to run alongside the app comfortably. */ + minRamGB: number; + tools: boolean; + kind: "chat" | "embedding"; + note: string; +}; + +/** Curated Ollama models known to work with CapScreen's tool-calling agent. */ +export const MODEL_CATALOG: CatalogModel[] = [ + { id: "qwen3:4b", label: "Qwen3 4B", family: "Qwen", sizeGB: 2.5, minRamGB: 8, tools: true, kind: "chat", note: "Best fit for 8 GB machines. Solid tool calling." }, + { id: "qwen3:8b", label: "Qwen3 8B", family: "Qwen", sizeGB: 5.2, minRamGB: 12, tools: true, kind: "chat", note: "Stronger reasoning; runs on 8 GB but slowly." }, + { id: "llama3.1:8b", label: "Llama 3.1 8B", family: "Llama", sizeGB: 4.9, minRamGB: 12, tools: true, kind: "chat", note: "Reliable general model with tool support." }, + { id: "qwen3:14b", label: "Qwen3 14B", family: "Qwen", sizeGB: 9.3, minRamGB: 20, tools: true, kind: "chat", note: "Noticeably better analysis; needs 24 GB+." }, + { id: "gpt-oss:20b", label: "gpt-oss 20B", family: "OpenAI", sizeGB: 14, minRamGB: 24, tools: true, kind: "chat", note: "Open-weight reasoning model; 24 GB+." }, + { id: "qwen3:30b", label: "Qwen3 30B (MoE)", family: "Qwen", sizeGB: 19, minRamGB: 32, tools: true, kind: "chat", note: "Fast mixture-of-experts; 32 GB+." }, + { id: "gemma3:4b", label: "Gemma 3 4B", family: "Gemma", sizeGB: 3.3, minRamGB: 8, tools: false, kind: "chat", note: "No tool calling — use only for narratives/utility." }, + { id: "nomic-embed-text", label: "Nomic Embed", family: "Nomic", sizeGB: 0.3, minRamGB: 4, tools: false, kind: "embedding", note: "Document search embeddings (768-dim)." }, + { id: "mxbai-embed-large", label: "mxbai Embed Large", family: "Mixedbread", sizeGB: 0.7, minRamGB: 6, tools: false, kind: "embedding", note: "Higher-quality embeddings (1024-dim)." }, +]; + +export type Fit = "great" | "ok" | "tight" | "too-large"; + +export function modelFit(model: CatalogModel, hw: Hardware): Fit { + // Discrete-GPU PCs keep weights in RAM too; be a little more conservative. + const usable = hw.unifiedMemory ? hw.totalMemGB : hw.totalMemGB * 0.9; + if (usable >= model.minRamGB * 1.5) return "great"; + if (usable >= model.minRamGB) return "ok"; + if (usable >= model.sizeGB + 2.5) return "tight"; + return "too-large"; +} + +export function recommendedChatModel(hw: Hardware): string { + const fits = MODEL_CATALOG.filter((m) => m.kind === "chat" && m.tools && ["great", "ok"].includes(modelFit(m, hw))); + return fits.sort((a, b) => b.sizeGB - a.sizeGB)[0]?.id ?? "qwen3:4b"; +} + +async function getJson(url: string, timeoutMs = 2500): Promise { + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!resp.ok) return null; + return (await resp.json()) as T; + } catch { + return null; + } +} + +export type InstalledModel = { name: string; sizeGB: number; family: string | null; parameterSize: string | null; modifiedAt: string }; + +export async function ollamaStatus(): Promise<{ running: boolean; version: string | null; models: InstalledModel[] }> { + const version = await getJson<{ version: string }>(`${OLLAMA_URL}/api/version`); + if (!version) return { running: false, version: null, models: [] }; + const tags = await getJson<{ + models: Array<{ name: string; size: number; modified_at: string; details?: { family?: string; parameter_size?: string } }>; + }>(`${OLLAMA_URL}/api/tags`); + return { + running: true, + version: version.version, + models: (tags?.models ?? []).map((m) => ({ + name: m.name, + sizeGB: Math.round((m.size / 1024 ** 3) * 10) / 10, + family: m.details?.family ?? null, + parameterSize: m.details?.parameter_size ?? null, + modifiedAt: m.modified_at, + })), + }; +} + +export async function lmStudioStatus(): Promise<{ running: boolean; models: string[] }> { + const models = await getJson<{ data: Array<{ id: string }> }>(`${LMSTUDIO_URL}/v1/models`, 1500); + return { running: !!models, models: models?.data.map((m) => m.id) ?? [] }; +} + +/** Ollama model capabilities ("tools", "embedding", "completion", …) where the server reports them. */ +export async function ollamaCapabilities(model: string): Promise { + try { + const resp = await fetch(`${OLLAMA_URL}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + signal: AbortSignal.timeout(5000), + }); + if (!resp.ok) return null; + const body = (await resp.json()) as { capabilities?: string[] }; + return body.capabilities ?? null; + } catch { + return null; + } +} + +export type PullProgress = { status: string; completed?: number; total?: number; error?: string }; + +/** Stream `ollama pull` progress. Resolves when the pull ends or fails. */ +export async function pullOllamaModel(model: string, onProgress: (p: PullProgress) => void, signal?: AbortSignal): Promise { + const resp = await fetch(`${OLLAMA_URL}/api/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, stream: true }), + signal, + }); + if (!resp.ok || !resp.body) throw new Error(`Ollama pull failed (${resp.status})`); + + const decoder = new TextDecoder(); + let buffered = ""; + for await (const chunk of resp.body as unknown as AsyncIterable) { + buffered += decoder.decode(chunk, { stream: true }); + let nl: number; + while ((nl = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + if (!line) continue; + const p = JSON.parse(line) as PullProgress; + if (p.error) throw new Error(p.error); + onProgress(p); + } + } +} + +export async function deleteOllamaModel(model: string): Promise { + const resp = await fetch(`${OLLAMA_URL}/api/delete`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + if (!resp.ok) throw new Error(`Ollama delete failed (${resp.status})`); +} diff --git a/backend/src/lib/llm/controlCenter/store.ts b/backend/src/lib/llm/controlCenter/store.ts new file mode 100644 index 0000000..79e7afc --- /dev/null +++ b/backend/src/lib/llm/controlCenter/store.ts @@ -0,0 +1,153 @@ +import crypto from "crypto"; +import fs from "fs"; +import { dataFile } from "../../infra/dataDir"; +import type { LlmRole, ProviderConfig, RoleBinding } from "../providers/registry"; + +/** + * AI Control Center persistence. + * + * /ai/config.json providers, role bindings, privacy policy (no secrets) + * /ai/secrets.json API keys, AES-256-GCM encrypted with the master key + * + * The master key comes from the Electron shell (CAPSCREEN_MASTER_KEY, itself + * sealed by the OS keychain via safeStorage). Under `pnpm dev` a key file is + * generated next to the data instead. + */ + +export type StoredProvider = Omit; + +export type AiConfig = { + version: 1; + providers: StoredProvider[]; + roles: Partial>; + /** When true, only on-device providers may be used. */ + localOnly: boolean; + updatedAt: string; +}; + +type SealedSecret = { iv: string; tag: string; data: string; hint: string; updatedAt: string }; +type SecretsFile = Record; + +const configPath = () => dataFile("ai", "config.json"); +const secretsPath = () => dataFile("ai", "secrets.json"); + +let masterKey: Buffer | null = null; + +function getMasterKey(): Buffer { + if (masterKey) return masterKey; + const fromEnv = process.env.CAPSCREEN_MASTER_KEY?.trim(); + if (fromEnv) { + const key = Buffer.from(fromEnv, "base64"); + if (key.length !== 32) throw new Error("CAPSCREEN_MASTER_KEY must be 32 bytes (base64)"); + masterKey = key; + return key; + } + const devKeyFile = dataFile("ai", "dev-master.key"); + if (!fs.existsSync(devKeyFile)) { + fs.writeFileSync(devKeyFile, crypto.randomBytes(32).toString("base64"), { mode: 0o600 }); + console.warn(`[ai] No CAPSCREEN_MASTER_KEY — generated dev key at ${devKeyFile} (the desktop app uses the OS keychain)`); + } + masterKey = Buffer.from(fs.readFileSync(devKeyFile, "utf8").trim(), "base64"); + return masterKey; +} + +function writeJsonAtomic(file: string, value: unknown): void { + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +function readJson(file: string): T | null { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; + } catch { + return null; + } +} + +// ── config ─────────────────────────────────────────────────────────────────── + +let configCache: { mtimeMs: number; value: AiConfig } | null = null; + +export function readAiConfig(): AiConfig | null { + const file = configPath(); + let mtimeMs: number; + try { + mtimeMs = fs.statSync(file).mtimeMs; + } catch { + return null; + } + if (configCache?.mtimeMs === mtimeMs) return configCache.value; + const value = readJson(file); + if (!value) return null; + configCache = { mtimeMs, value }; + return value; +} + +export function writeAiConfig(next: Omit): AiConfig { + const value: AiConfig = { version: 1, ...next, updatedAt: new Date().toISOString() }; + writeJsonAtomic(configPath(), value); + configCache = null; + return value; +} + +export function updateAiConfig(fn: (cfg: Omit) => void): AiConfig { + const cur = readAiConfig() ?? { version: 1 as const, providers: [], roles: {}, localOnly: false, updatedAt: "" }; + const draft = structuredClone({ providers: cur.providers, roles: cur.roles, localOnly: cur.localOnly }); + fn(draft); + return writeAiConfig(draft); +} + +// ── secrets ────────────────────────────────────────────────────────────────── + +let secretsCache: SecretsFile | null = null; + +function readSecrets(): SecretsFile { + secretsCache ??= readJson(secretsPath()) ?? {}; + return secretsCache; +} + +export function setProviderKey(providerId: string, apiKey: string): void { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", getMasterKey(), iv); + cipher.setAAD(Buffer.from(providerId)); + const data = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]); + const secrets = { ...readSecrets() }; + secrets[providerId] = { + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + data: data.toString("base64"), + hint: apiKey.length > 8 ? `…${apiKey.slice(-4)}` : "set", + updatedAt: new Date().toISOString(), + }; + writeJsonAtomic(secretsPath(), secrets); + secretsCache = secrets; + configCache = null; // force registry rebuild with the new key +} + +export function deleteProviderKey(providerId: string): void { + const secrets = { ...readSecrets() }; + delete secrets[providerId]; + writeJsonAtomic(secretsPath(), secrets); + secretsCache = secrets; + configCache = null; +} + +export function getProviderKey(providerId: string): string | null { + const sealed = readSecrets()[providerId]; + if (!sealed) return null; + try { + const decipher = crypto.createDecipheriv("aes-256-gcm", getMasterKey(), Buffer.from(sealed.iv, "base64")); + decipher.setAAD(Buffer.from(providerId)); + decipher.setAuthTag(Buffer.from(sealed.tag, "base64")); + return Buffer.concat([decipher.update(Buffer.from(sealed.data, "base64")), decipher.final()]).toString("utf8"); + } catch { + console.error(`[ai] Could not decrypt key for "${providerId}" — re-enter it in the AI Control Center`); + return null; + } +} + +export function providerKeyInfo(providerId: string): { configured: boolean; hint: string | null; updatedAt: string | null } { + const sealed = readSecrets()[providerId]; + return { configured: !!sealed, hint: sealed?.hint ?? null, updatedAt: sealed?.updatedAt ?? null }; +} diff --git a/backend/src/lib/llm/friendlyError.ts b/backend/src/lib/llm/friendlyError.ts index f9f47eb..5f31479 100644 --- a/backend/src/lib/llm/friendlyError.ts +++ b/backend/src/lib/llm/friendlyError.ts @@ -14,7 +14,7 @@ export function friendlyLlmError(err: unknown): string { msg.includes("invalid x-api-key") || msg.includes("401") ) { - return "Anthropic API key is invalid. Set ANTHROPIC_API_KEY in root `.env` (or `.env.production` on EC2) and restart the backend."; + return "The model provider rejected the API key. Update the key in model settings (or `.env`) and restart the backend."; } if (msg.includes("rate_limit") || msg.includes("429")) { @@ -25,13 +25,18 @@ export function friendlyLlmError(err: unknown): string { return "AI service is temporarily overloaded. Try again shortly."; } - if (msg.includes("ANTHROPIC_API_KEY is not set")) { - return "Anthropic API key is not configured on the server."; + if (msg.includes("API key for provider")) { + return "No API key is configured for the selected model provider."; } - if (msg.includes("ECONNREFUSED") && msg.includes("8084")) { - return "Watchman sanctions service is not running. Local dev: docker compose -f docker-compose.dev.yml up"; + if (msg.includes("does not support tools")) { + return "The selected local model does not support tool calling. Pick a tool-capable model or disable tools for it in model settings."; } + if (msg.includes("ECONNREFUSED") && /:(11434|1234|8080)\b/.test(msg)) { + return "The local model server is not running. Start Ollama / LM Studio and try again."; + } + + return msg; } diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 4cd6441..9e7ddc9 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,2 +1,2 @@ -export { completeClaudeText, chatWithClaude, type ChatMessage } from "./claude"; -export { getAnthropicModel } from "./models"; +export { completeText, chatText, type ChatMessage } from "./complete"; +export { getLlm, describeLlmConfig, type LlmRole } from "./providers"; diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts deleted file mode 100644 index 19402a0..0000000 --- a/backend/src/lib/llm/models.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Anthropic model — Haiku only (dev + production). - * Do not switch to Sonnet/Opus here; cost is prohibitive at screening volume. - * Model id is server-side only; never expose selection to the frontend. - */ - -export const ANTHROPIC_MODEL_ID = "claude-haiku-4-5"; - -export function getAnthropicModel(): string { - const override = process.env.ANTHROPIC_MODEL?.trim(); - if (override && override !== ANTHROPIC_MODEL_ID) { - console.warn( - `[llm] Ignoring ANTHROPIC_MODEL=${override} — this app uses Haiku only (${ANTHROPIC_MODEL_ID})`, - ); - } - return ANTHROPIC_MODEL_ID; -} diff --git a/backend/src/lib/llm/providers/anthropic.ts b/backend/src/lib/llm/providers/anthropic.ts new file mode 100644 index 0000000..045a442 --- /dev/null +++ b/backend/src/lib/llm/providers/anthropic.ts @@ -0,0 +1,92 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, +} from "./types"; + +function toStopReason(reason: string | null): LlmStopReason { + if (reason === "end_turn" || reason === "tool_use" || reason === "max_tokens") { + return reason; + } + return "other"; +} + +function toResponse(msg: Anthropic.Message): LlmResponse { + const content: LlmResponse["content"] = []; + for (const b of msg.content) { + if (b.type === "text") content.push({ type: "text", text: b.text }); + else if (b.type === "tool_use") { + content.push({ type: "tool_use", id: b.id, name: b.name, input: b.input }); + } + } + return { content, stopReason: toStopReason(msg.stop_reason) }; +} + +export class AnthropicProvider implements LlmProvider { + readonly kind = "anthropic" as const; + readonly local = false; + private client: Anthropic | null = null; + + constructor( + readonly id: string, + private readonly apiKey: string, + private readonly baseUrl?: string, + ) {} + + private sdk(): Anthropic { + if (!this.apiKey) throw new Error(`API key for provider "${this.id}" is not set`); + this.client ??= new Anthropic({ + apiKey: this.apiKey, + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + return this.client; + } + + private params(req: LlmRequest): Anthropic.MessageCreateParamsNonStreaming { + return { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + ...(req.systemPrompt ? { system: req.systemPrompt } : {}), + // Neutral blocks share Anthropic's field names, so this is structural. + messages: req.messages as Anthropic.MessageParam[], + ...(req.tools?.length ? { tools: req.tools as Anthropic.Tool[] } : {}), + ...(req.tools?.length && req.toolChoice + ? { tool_choice: req.toolChoice as Anthropic.ToolChoice } + : {}), + }; + } + + async complete(req: LlmRequest): Promise { + return toResponse(await this.sdk().messages.create(this.params(req))); + } + + async *stream(req: LlmRequest): AsyncIterable { + const stream = this.sdk().messages.stream(this.params(req)); + for await (const event of stream) { + if ( + event.type === "content_block_start" && + event.content_block.type === "tool_use" + ) { + yield { + type: "tool_use_start", + id: event.content_block.id, + name: event.content_block.name, + }; + } else if ( + event.type === "content_block_delta" && + event.delta.type === "text_delta" + ) { + yield { type: "text_delta", text: event.delta.text }; + } + } + yield { type: "done", response: toResponse(await stream.finalMessage()) }; + } + + async listModels(): Promise { + const page = await this.sdk().models.list({ limit: 100 }); + return page.data.map((m) => m.id); + } +} diff --git a/backend/src/lib/llm/providers/index.ts b/backend/src/lib/llm/providers/index.ts new file mode 100644 index 0000000..ba15f6f --- /dev/null +++ b/backend/src/lib/llm/providers/index.ts @@ -0,0 +1,18 @@ +export * from "./types"; +export { + getLlm, + getLlmConfig, + hasLlmRole, + describeLlmConfig, + isLoopbackUrl, + PROVIDER_PRESETS, + type LlmRole, + type LlmConfig, + type ProviderConfig, + type RoleBinding, + type ResolvedLlm, + type LlmRoleSummary, + isProviderLocal, + buildProvider, + mergeWithPreset, +} from "./registry"; diff --git a/backend/src/lib/llm/providers/ollama.ts b/backend/src/lib/llm/providers/ollama.ts new file mode 100644 index 0000000..6b0a2cb --- /dev/null +++ b/backend/src/lib/llm/providers/ollama.ts @@ -0,0 +1,266 @@ +import crypto from "crypto"; +import http from "http"; +import https from "https"; +import os from "os"; +import type { + LlmMessage, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, + LlmToolUseBlock, +} from "./types"; + +/** + * Native Ollama adapter (/api/chat). Preferred over Ollama's OpenAI-compatible + * endpoint because it lets us set the context window (Ollama defaults to 4K, + * too small for CapScreen's tool schemas + screening results) and keep a + * reasoning model's "thinking" out of the reply text. + */ + +type OllamaMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string; + tool_calls?: Array<{ function: { name: string; arguments: unknown } }>; + tool_name?: string; +}; + +type OllamaChunk = { + message?: { content?: string; thinking?: string; tool_calls?: Array<{ function: { name: string; arguments: unknown } }> }; + done?: boolean; + done_reason?: string; + error?: string; +}; + +/** Context window sized to the machine: KV cache grows with it. */ +function defaultContextWindow(): number { + const fromEnv = Number(process.env.OLLAMA_NUM_CTX); + if (Number.isFinite(fromEnv) && fromEnv >= 2048) return fromEnv; + const gb = os.totalmem() / 1024 ** 3; + if (gb <= 10) return 8192; + if (gb <= 20) return 16384; + return 32768; +} + +function toolId(): string { + return `call_${crypto.randomBytes(8).toString("hex")}`; +} + +function toOllamaMessages(systemPrompt: string | undefined, messages: LlmMessage[]): OllamaMessage[] { + const out: OllamaMessage[] = []; + if (systemPrompt) out.push({ role: "system", content: systemPrompt }); + // Ollama links tool results by name, not id. + const namesById = new Map(); + + for (const m of messages) { + if (typeof m.content === "string") { + out.push({ role: m.role, content: m.content }); + continue; + } + if (m.role === "assistant") { + const text = m.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join(""); + const calls = m.content.filter((b): b is LlmToolUseBlock => b.type === "tool_use"); + calls.forEach((c) => namesById.set(c.id, c.name)); + out.push({ + role: "assistant", + content: text, + ...(calls.length ? { tool_calls: calls.map((c) => ({ function: { name: c.name, arguments: c.input ?? {} } })) } : {}), + }); + continue; + } + const text: string[] = []; + for (const b of m.content) { + if (b.type === "tool_result") { + out.push({ role: "tool", content: b.content, tool_name: namesById.get(b.tool_use_id) ?? "" }); + } else if (b.type === "text") { + text.push(b.text); + } + } + if (text.length) out.push({ role: "user", content: text.join("\n") }); + } + return out; +} + +function stopReason(doneReason: string | undefined, hasTools: boolean): LlmStopReason { + if (hasTools) return "tool_use"; + if (doneReason === "length") return "max_tokens"; + return "end_turn"; +} + +export class OllamaProvider implements LlmProvider { + readonly kind = "ollama" as const; + readonly local: boolean; + private readonly base: string; + private readonly caps = new Map>(); + + constructor( + readonly id: string, + baseUrl: string | undefined, + local: boolean, + ) { + // Accept either the server root or an OpenAI-style /v1 URL. + this.base = (baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "").replace(/\/v1$/, ""); + this.local = local; + } + + private capabilities(model: string): Promise { + let p = this.caps.get(model); + if (!p) { + p = fetch(`${this.base}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }) + .then(async (r) => (r.ok ? (((await r.json()) as { capabilities?: string[] }).capabilities ?? []) : [])) + .catch(() => []); + this.caps.set(model, p); + } + return p; + } + + private async body(req: LlmRequest, stream: boolean) { + const caps = await this.capabilities(req.model); + const thinking = caps.includes("thinking"); + let systemPrompt = req.systemPrompt; + // Ollama has no tool_choice; ask for the forced tool explicitly instead. + if (req.tools?.length && req.toolChoice?.type === "tool") { + systemPrompt = `${systemPrompt ?? ""}\n\nIMPORTANT: Your first action must be to call the \`${req.toolChoice.name}\` tool.`; + } else if (req.tools?.length && req.toolChoice?.type === "any") { + systemPrompt = `${systemPrompt ?? ""}\n\nIMPORTANT: You must call one of the available tools before answering.`; + } + return { + model: req.model, + messages: toOllamaMessages(systemPrompt, req.messages), + ...(req.tools?.length + ? { + tools: req.tools.map((t) => ({ + type: "function", + function: { name: t.name, description: t.description ?? "", parameters: t.input_schema }, + })), + } + : {}), + stream, + // Reasoning models: some builds keep reasoning even with think:false and + // then write it into the answer. Asking for it explicitly makes Ollama + // return it in a separate field, which we drop — the reply stays clean. + ...(thinking ? { think: true } : {}), + // Release memory soon after use — matters on 8–16 GB machines. + keep_alive: "5m", + options: { + num_ctx: defaultContextWindow(), + // Reasoning tokens count against the budget; leave room for the answer. + num_predict: (req.maxTokens ?? 2048) + (thinking ? 4096 : 0), + temperature: 0.2, + }, + }; + } + + /** + * POST via node:http rather than fetch: on low-memory machines prompt + * processing can exceed fetch's fixed 300 s header timeout, and Ollama sends + * no headers until it starts generating. + */ + private post(path: string, body: unknown): Promise { + const url = new URL(`${this.base}${path}`); + const payload = JSON.stringify(body); + const lib = url.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + const req = lib.request( + url, + { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } }, + (res) => { + if ((res.statusCode ?? 500) < 400) { + resolve(res); + return; + } + let detail = ""; + res.setEncoding("utf8"); + res.on("data", (c: string) => (detail += c)); + res.on("end", () => { + let msg = detail; + try { + msg = (JSON.parse(detail) as { error?: string }).error ?? detail; + } catch { + /* plain text */ + } + reject(new Error(`Ollama ${res.statusCode}: ${msg}`)); + }); + }, + ); + req.on("error", reject); + req.end(payload); + }); + } + + private async readJson(res: http.IncomingMessage): Promise { + let text = ""; + for await (const chunk of res) text += chunk; + return JSON.parse(text) as T; + } + + async complete(req: LlmRequest): Promise { + const chunk = await this.readJson(await this.post("/api/chat", await this.body(req, false))); + return this.finalize(chunk.message?.content ?? "", chunk.message?.tool_calls ?? [], chunk.done_reason); + } + + async *stream(req: LlmRequest): AsyncIterable { + const resp = await this.post("/api/chat", await this.body(req, true)); + const decoder = new TextDecoder(); + let buffered = ""; + let text = ""; + let doneReason: string | undefined; + const calls: Array<{ function: { name: string; arguments: unknown } }> = []; + + for await (const part of resp as AsyncIterable) { + buffered += decoder.decode(part, { stream: true }); + let nl: number; + while ((nl = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, nl).trim(); + buffered = buffered.slice(nl + 1); + if (!line) continue; + const chunk = JSON.parse(line) as OllamaChunk; + if (chunk.error) throw new Error(`Ollama: ${chunk.error}`); + const content = chunk.message?.content; + if (content) { + text += content; + yield { type: "text_delta", text: content }; + } + for (const call of chunk.message?.tool_calls ?? []) { + calls.push(call); + yield { type: "tool_use_start", id: "", name: call.function.name }; + } + if (chunk.done) doneReason = chunk.done_reason; + } + } + yield { type: "done", response: this.finalize(text, calls, doneReason) }; + } + + private finalize(text: string, calls: Array<{ function: { name: string; arguments: unknown } }>, doneReason?: string): LlmResponse { + const content: LlmResponse["content"] = []; + if (text) content.push({ type: "text", text }); + for (const c of calls) { + let input = c.function.arguments; + if (typeof input === "string") { + try { + input = JSON.parse(input); + } catch { + input = {}; + } + } + content.push({ type: "tool_use", id: toolId(), name: c.function.name, input: input ?? {} }); + } + return { content, stopReason: stopReason(doneReason, calls.length > 0) }; + } + + async embed(model: string, texts: string[]): Promise { + const res = await this.post("/api/embed", { model, input: texts, keep_alive: "5m" }); + return (await this.readJson<{ embeddings: number[][] }>(res)).embeddings; + } + + async listModels(): Promise { + const resp = await fetch(`${this.base}/api/tags`); + if (!resp.ok) throw new Error(`Ollama ${resp.status}`); + return ((await resp.json()) as { models: Array<{ name: string }> }).models.map((m) => m.name); + } +} diff --git a/backend/src/lib/llm/providers/openaiCompatible.ts b/backend/src/lib/llm/providers/openaiCompatible.ts new file mode 100644 index 0000000..f1fbd36 --- /dev/null +++ b/backend/src/lib/llm/providers/openaiCompatible.ts @@ -0,0 +1,197 @@ +import crypto from "crypto"; +import OpenAI from "openai"; +import type { + LlmMessage, + LlmProvider, + LlmRequest, + LlmResponse, + LlmStopReason, + LlmStreamEvent, + LlmToolChoice, + LlmToolUseBlock, +} from "./types"; + +type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; +type ChatTool = OpenAI.Chat.Completions.ChatCompletionTool; + +function toolId(): string { + return `call_${crypto.randomBytes(8).toString("hex")}`; +} + +function parseArgs(raw: string | undefined): unknown { + if (!raw?.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +} + +function toChatMessages(systemPrompt: string | undefined, messages: LlmMessage[]): ChatMessage[] { + const out: ChatMessage[] = []; + if (systemPrompt) out.push({ role: "system", content: systemPrompt }); + + for (const m of messages) { + if (typeof m.content === "string") { + out.push({ role: m.role, content: m.content }); + continue; + } + + if (m.role === "assistant") { + const text = m.content + .filter((b) => b.type === "text") + .map((b) => (b as { text: string }).text) + .join(""); + const toolCalls = m.content + .filter((b): b is LlmToolUseBlock => b.type === "tool_use") + .map((b) => ({ + id: b.id, + type: "function" as const, + function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) }, + })); + out.push({ + role: "assistant", + content: text || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }); + continue; + } + + // User turn: tool results become `tool` messages, text stays a user message. + const text: string[] = []; + for (const b of m.content) { + if (b.type === "tool_result") { + out.push({ role: "tool", tool_call_id: b.tool_use_id, content: b.content }); + } else if (b.type === "text") { + text.push(b.text); + } + } + if (text.length) out.push({ role: "user", content: text.join("\n") }); + } + return out; +} + +function toToolChoice(choice: LlmToolChoice | undefined) { + if (!choice) return undefined; + if (choice.type === "auto") return "auto" as const; + if (choice.type === "any") return "required" as const; + return { type: "function" as const, function: { name: choice.name } }; +} + +function toStopReason(finish: string | null | undefined, hasToolCalls: boolean): LlmStopReason { + // Several local servers report "stop" even when they emitted tool calls. + if (hasToolCalls) return "tool_use"; + if (finish === "stop") return "end_turn"; + if (finish === "length") return "max_tokens"; + return "other"; +} + +export class OpenAICompatibleProvider implements LlmProvider { + readonly kind = "openai-compatible" as const; + private client: OpenAI | null = null; + + constructor( + readonly id: string, + private readonly baseUrl: string | undefined, + private readonly apiKey: string, + readonly local: boolean, + ) {} + + private sdk(): OpenAI { + // Local servers ignore the key, but the SDK requires a non-empty value. + this.client ??= new OpenAI({ + apiKey: this.apiKey || "not-needed", + ...(this.baseUrl ? { baseURL: this.baseUrl } : {}), + }); + return this.client; + } + + private params(req: LlmRequest) { + const tools: ChatTool[] | undefined = req.tools?.length + ? req.tools.map((t) => ({ + type: "function", + function: { + name: t.name, + description: t.description ?? "", + parameters: t.input_schema as Record, + }, + })) + : undefined; + return { + model: req.model, + max_tokens: req.maxTokens ?? 2048, + messages: toChatMessages(req.systemPrompt, req.messages), + ...(tools ? { tools } : {}), + ...(tools && req.toolChoice ? { tool_choice: toToolChoice(req.toolChoice) } : {}), + }; + } + + async complete(req: LlmRequest): Promise { + const resp = await this.sdk().chat.completions.create({ ...this.params(req), stream: false }); + const choice = resp.choices[0]; + const content: LlmResponse["content"] = []; + if (choice?.message.content) content.push({ type: "text", text: choice.message.content }); + for (const tc of choice?.message.tool_calls ?? []) { + if (tc.type !== "function") continue; + content.push({ + type: "tool_use", + id: tc.id || toolId(), + name: tc.function.name, + input: parseArgs(tc.function.arguments), + }); + } + const hasTools = content.some((b) => b.type === "tool_use"); + return { content, stopReason: toStopReason(choice?.finish_reason, hasTools) }; + } + + async *stream(req: LlmRequest): AsyncIterable { + const stream = await this.sdk().chat.completions.create({ ...this.params(req), stream: true }); + + let text = ""; + let finish: string | null = null; + const calls = new Map(); + + for await (const chunk of stream) { + const choice = chunk.choices[0]; + if (!choice) continue; + if (choice.finish_reason) finish = choice.finish_reason; + + const delta = choice.delta; + if (delta?.content) { + text += delta.content; + yield { type: "text_delta", text: delta.content }; + } + for (const tc of delta?.tool_calls ?? []) { + let call = calls.get(tc.index); + if (!call) { + call = { id: tc.id || toolId(), name: tc.function?.name ?? "", args: "" }; + calls.set(tc.index, call); + if (call.name) yield { type: "tool_use_start", id: call.id, name: call.name }; + } else if (!call.name && tc.function?.name) { + call.name = tc.function.name; + yield { type: "tool_use_start", id: call.id, name: call.name }; + } + if (tc.function?.arguments) call.args += tc.function.arguments; + } + } + + const content: LlmResponse["content"] = []; + if (text) content.push({ type: "text", text }); + for (const call of [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)) { + if (!call.name) continue; + content.push({ type: "tool_use", id: call.id, name: call.name, input: parseArgs(call.args) }); + } + const hasTools = content.some((b) => b.type === "tool_use"); + yield { type: "done", response: { content, stopReason: toStopReason(finish, hasTools) } }; + } + + async embed(model: string, texts: string[]): Promise { + const resp = await this.sdk().embeddings.create({ model, input: texts }); + return resp.data.map((d) => d.embedding); + } + + async listModels(): Promise { + const page = await this.sdk().models.list(); + return page.data.map((m) => m.id); + } +} diff --git a/backend/src/lib/llm/providers/registry.ts b/backend/src/lib/llm/providers/registry.ts new file mode 100644 index 0000000..a49bfa0 --- /dev/null +++ b/backend/src/lib/llm/providers/registry.ts @@ -0,0 +1,274 @@ +import { AnthropicProvider } from "./anthropic"; +import { getProviderKey, readAiConfig } from "../controlCenter/store"; +import { OpenAICompatibleProvider } from "./openaiCompatible"; +import { OllamaProvider } from "./ollama"; +import type { LlmProvider, ProviderKind } from "./types"; + +/** + * Model provider configuration. + * + * Two sources, in priority order: + * 1. AI Control Center — /ai/config.json + encrypted keys (set in the app) + * 2. Env vars — LLM_PROVIDER / LLM_MODEL / LLM_UTILITY_MODEL / LLM_BASE_URL / LLM_API_KEY + * (developer fallback for `pnpm dev`) + */ + +export type LlmRole = "agent" | "utility" | "embedding"; + +export type ProviderConfig = { + id: string; + kind: ProviderKind; + label?: string; + baseUrl?: string; + /** Literal key — desktop injects from the OS keychain; avoid in files. */ + apiKey?: string; + /** Name of an env var holding the key. */ + apiKeyEnv?: string; + /** Override locality detection. Defaults to true for loopback base URLs. */ + local?: boolean; +}; + +export type RoleBinding = { + provider: string; + model: string; + /** Set false for local models without function calling; the agent then runs tool-less. */ + supportsTools?: boolean; +}; + +export type LlmConfig = { + providers: ProviderConfig[]; + roles: Partial>; + /** Refuse any provider that is not on this machine. */ + localOnly?: boolean; + source: "control-center" | "env"; +}; + +export type ResolvedLlm = { + provider: LlmProvider; + providerId: string; + model: string; + supportsTools: boolean; + local: boolean; +}; + +const DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5"; + +/** Known endpoints. Any other OpenAI-compatible server works via `custom` + LLM_BASE_URL. */ +export const PROVIDER_PRESETS: Record> = { + anthropic: { kind: "anthropic", label: "Anthropic", apiKeyEnv: "ANTHROPIC_API_KEY" }, + openai: { kind: "openai-compatible", label: "OpenAI", apiKeyEnv: "OPENAI_API_KEY" }, + ollama: { kind: "ollama", label: "Ollama (local)", baseUrl: "http://127.0.0.1:11434" }, + lmstudio: { kind: "openai-compatible", label: "LM Studio (local)", baseUrl: "http://127.0.0.1:1234/v1" }, + llamacpp: { kind: "openai-compatible", label: "llama.cpp server (local)", baseUrl: "http://127.0.0.1:8080/v1" }, + openrouter: { + kind: "openai-compatible", + label: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + apiKeyEnv: "OPENROUTER_API_KEY", + }, + groq: { + kind: "openai-compatible", + label: "Groq", + baseUrl: "https://api.groq.com/openai/v1", + apiKeyEnv: "GROQ_API_KEY", + }, + gemini: { + kind: "openai-compatible", + label: "Google Gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", + apiKeyEnv: "GEMINI_API_KEY", + }, +}; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); + +export function isLoopbackUrl(url: string | undefined): boolean { + if (!url) return false; + try { + return LOOPBACK_HOSTS.has(new URL(url).hostname); + } catch { + return false; + } +} + +function env(name: string): string | undefined { + const v = process.env[name]?.trim(); + return v ? v : undefined; +} + +function configFromEnv(): LlmConfig { + // Desktop app: models are configured only in the AI Control Center. + if (process.env.CAPSCREEN_DESKTOP_TOKEN || (!env("LLM_PROVIDER") && !env("ANTHROPIC_API_KEY") && !env("LLM_API_KEY"))) { + return { providers: [], roles: {}, source: "env" }; + } + const presetId = env("LLM_PROVIDER") ?? "anthropic"; + const preset = PROVIDER_PRESETS[presetId] ?? { kind: "openai-compatible" as const }; + const provider: ProviderConfig = { + id: presetId, + ...preset, + ...(env("LLM_BASE_URL") ? { baseUrl: env("LLM_BASE_URL") } : {}), + ...(env("LLM_API_KEY") ? { apiKey: env("LLM_API_KEY") } : {}), + }; + + const model = + env("LLM_MODEL") ?? (provider.kind === "anthropic" ? DEFAULT_ANTHROPIC_MODEL : ""); + if (!model) { + throw new Error(`LLM_MODEL must be set when LLM_PROVIDER=${presetId}`); + } + if (env("ANTHROPIC_MODEL")) { + console.warn("[llm] ANTHROPIC_MODEL is ignored — use LLM_MODEL instead"); + } + const supportsTools = env("LLM_SUPPORTS_TOOLS")?.toLowerCase() !== "false"; + + const providers = [provider]; + const roles: LlmConfig["roles"] = { + agent: { provider: provider.id, model, supportsTools }, + utility: { provider: provider.id, model: env("LLM_UTILITY_MODEL") ?? model, supportsTools }, + }; + + // Embeddings keep their historical default (OpenAI) unless configured. + const embedProvider = env("EMBEDDING_PROVIDER"); + if (embedProvider) { + if (embedProvider !== provider.id) { + providers.push({ id: embedProvider, ...(PROVIDER_PRESETS[embedProvider] ?? { kind: "openai-compatible" }) }); + } + roles.embedding = { provider: embedProvider, model: env("EMBEDDING_MODEL") ?? "text-embedding-3-small" }; + } else if (env("OPENAI_API_KEY")) { + if (provider.id !== "openai") providers.push({ id: "openai", ...PROVIDER_PRESETS.openai }); + roles.embedding = { provider: "openai", model: env("EMBEDDING_MODEL") ?? "text-embedding-3-small" }; + } + + return { providers, roles, source: "env" }; +} + +/** Stored overrides on top of a preset. A preset's adapter kind always wins. */ +export function mergeWithPreset(p: ProviderConfig | (Omit & { kind?: ProviderKind })): ProviderConfig { + const preset = PROVIDER_PRESETS[p.id]; + return { ...(preset ?? {}), ...p, ...(preset ? { kind: preset.kind } : {}) } as ProviderConfig; +} + +function configFromControlCenter(): LlmConfig | null { + const cfg = readAiConfig(); + if (!cfg) return null; + return { + providers: cfg.providers.map((p) => { + const merged = mergeWithPreset(p); + // Keys entered in the app win; a preset's env var still works in dev. + const key = getProviderKey(p.id); + return key ? { ...merged, apiKey: key } : merged; + }), + roles: cfg.roles, + localOnly: cfg.localOnly, + source: "control-center", + }; +} + +export function getLlmConfig(): LlmConfig { + return configFromControlCenter() ?? configFromEnv(); +} + +const providerCache = new Map(); + +function providerKey(p: ProviderConfig): string { + return JSON.stringify([p.id, p.kind, p.baseUrl, p.apiKey, p.apiKeyEnv, p.local]); +} + +function isProviderLocal(p: ProviderConfig): boolean { + return p.local ?? isLoopbackUrl(p.baseUrl); +} + +export function buildProvider(p: ProviderConfig): LlmProvider { + const key = providerKey(p); + const cached = providerCache.get(key); + if (cached) return cached; + + const apiKey = p.apiKey ?? (p.apiKeyEnv ? env(p.apiKeyEnv) : undefined) ?? ""; + const provider = + p.kind === "anthropic" + ? new AnthropicProvider(p.id, apiKey, p.baseUrl) + : p.kind === "ollama" + ? new OllamaProvider(p.id, p.baseUrl, isProviderLocal(p)) + : new OpenAICompatibleProvider(p.id, p.baseUrl, apiKey, isProviderLocal(p)); + providerCache.set(key, provider); + return provider; +} + +export function getLlm(role: LlmRole): ResolvedLlm { + const config = getLlmConfig(); + // utility falls back to agent so single-model setups need one binding. + const binding = config.roles[role] ?? (role === "utility" ? config.roles.agent : undefined); + if (!binding) throw new Error(`No model configured for role "${role}"`); + + const pc = config.providers.find((p) => p.id === binding.provider); + if (!pc) throw new Error(`Role "${role}" references unknown provider "${binding.provider}"`); + + const provider = buildProvider(pc); + if (config.localOnly && !provider.local) { + throw new Error( + `Local-only mode is on, so "${pc.label ?? pc.id}" (a cloud provider) is blocked. ` + + "Pick an on-device model or turn off Local-only mode in the AI Control Center.", + ); + } + return { + provider, + providerId: pc.id, + model: binding.model, + supportsTools: binding.supportsTools ?? true, + local: provider.local, + }; +} + +export function hasLlmRole(role: LlmRole): boolean { + try { + getLlm(role); + return true; + } catch { + return false; + } +} + +/** Safe-to-expose summary (no keys) for /api/config and the settings UI. */ +export type LlmRoleSummary = { + provider: string; + label: string; + model: string; + local: boolean; + supportsTools: boolean; + error?: string; +}; + +export function describeLlmConfig(): { + roles: Partial>; + localOnly: boolean; + source: LlmConfig["source"]; + error?: string; +} { + let config: LlmConfig; + try { + config = getLlmConfig(); + } catch (err) { + return { roles: {}, localOnly: false, source: "env", error: err instanceof Error ? err.message : String(err) }; + } + const roles: Partial> = {}; + for (const role of ["agent", "utility", "embedding"] as const) { + const binding = config.roles[role] ?? (role === "utility" ? config.roles.agent : undefined); + if (!binding) continue; + const pc = config.providers.find((p) => p.id === binding.provider); + const base: LlmRoleSummary = { + provider: binding.provider, + label: pc?.label ?? binding.provider, + model: binding.model, + local: pc ? isProviderLocal(pc) : false, + supportsTools: binding.supportsTools ?? true, + }; + try { + getLlm(role); + roles[role] = base; + } catch (err) { + roles[role] = { ...base, error: err instanceof Error ? err.message : String(err) }; + } + } + return { roles, localOnly: !!config.localOnly, source: config.source }; +} + +export { isProviderLocal }; diff --git a/backend/src/lib/llm/providers/types.ts b/backend/src/lib/llm/providers/types.ts new file mode 100644 index 0000000..16fe093 --- /dev/null +++ b/backend/src/lib/llm/providers/types.ts @@ -0,0 +1,84 @@ +/** + * Provider-neutral LLM types. + * + * Field names deliberately mirror the Anthropic wire format (input_schema, + * tool_use_id, …) so existing tool definitions work unchanged; adapters + * translate to each provider's own format. + */ + +export type LlmTextBlock = { type: "text"; text: string }; +export type LlmToolUseBlock = { + type: "tool_use"; + id: string; + name: string; + input: unknown; +}; +export type LlmToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +export type LlmContentBlock = LlmTextBlock | LlmToolUseBlock | LlmToolResultBlock; + +export type LlmMessage = { + role: "user" | "assistant"; + content: string | LlmContentBlock[]; +}; + +export type LlmToolSchema = { + name: string; + description?: string; + input_schema: { + type: "object"; + properties?: Record | null; + required?: string[] | null; + [k: string]: unknown; + }; +}; + +export type LlmToolChoice = + | { type: "auto" } + | { type: "any" } + | { type: "tool"; name: string }; + +export type LlmStopReason = "end_turn" | "tool_use" | "max_tokens" | "other"; + +export type LlmRequest = { + model: string; + systemPrompt?: string; + messages: LlmMessage[]; + tools?: LlmToolSchema[]; + toolChoice?: LlmToolChoice; + maxTokens?: number; +}; + +export type LlmResponse = { + content: Array; + stopReason: LlmStopReason; +}; + +export type LlmStreamEvent = + | { type: "text_delta"; text: string } + | { type: "tool_use_start"; id: string; name: string } + | { type: "done"; response: LlmResponse }; + +export type ProviderKind = "anthropic" | "openai-compatible" | "ollama"; + +export interface LlmProvider { + readonly id: string; + readonly kind: ProviderKind; + /** True when requests never leave this machine (Ollama, LM Studio, …). */ + readonly local: boolean; + complete(req: LlmRequest): Promise; + stream(req: LlmRequest): AsyncIterable; + embed?(model: string, texts: string[]): Promise; + listModels?(): Promise; +} + +export function responseText(resp: LlmResponse): string { + return resp.content + .filter((b): b is LlmTextBlock => b.type === "text") + .map((b) => b.text) + .join(""); +} diff --git a/backend/src/lib/llm/streamAgent.ts b/backend/src/lib/llm/streamAgent.ts index 818b9c1..c07266a 100644 --- a/backend/src/lib/llm/streamAgent.ts +++ b/backend/src/lib/llm/streamAgent.ts @@ -1,11 +1,14 @@ -import Anthropic from "@anthropic-ai/sdk"; import type { ToolActivity, ToolDocumentResult } from "../chat/chatTools"; - -function client(): Anthropic { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (!key) throw new Error("ANTHROPIC_API_KEY is not set"); - return new Anthropic({ apiKey: key }); -} +import { + getLlm, + type LlmMessage, + type LlmResponse, + type LlmRole, + type LlmToolChoice, + type LlmToolResultBlock, + type LlmToolSchema, + type LlmToolUseBlock, +} from "./providers"; function truncate(s: string, max = 120): string { const one = s.replace(/\s+/g, " ").trim(); @@ -14,11 +17,14 @@ function truncate(s: string, max = 120): string { export type SseWrite = (line: string) => void; +const NO_TOOLS_NOTE = + "\n\n## Tool access\nThe configured model does not support tool calling. Answer only from the context in this prompt, " + + "say plainly when data you would normally look up is unavailable, and never invent entities, scores, or matches."; + export async function runAgentStream(params: { - model: string; systemPrompt: string; - messages: Anthropic.MessageParam[]; - tools: Anthropic.Tool[]; + messages: LlmMessage[]; + tools: LlmToolSchema[]; executeTool: ( name: string, input: unknown, @@ -27,75 +33,59 @@ export async function runAgentStream(params: { maxTokens?: number; maxRounds?: number; /** Force a specific tool on round 0 only. Subsequent rounds always use auto. */ - toolChoice?: Anthropic.ToolChoiceAuto | Anthropic.ToolChoiceAny | Anthropic.ToolChoiceTool; + toolChoice?: LlmToolChoice; + role?: LlmRole; }): Promise<{ fullText: string; toolActivity: ToolActivity[] }> { - const anthropic = client(); + const llm = getLlm(params.role ?? "agent"); + const tools = llm.supportsTools ? params.tools : []; + const systemPrompt = llm.supportsTools ? params.systemPrompt : params.systemPrompt + NO_TOOLS_NOTE; + const toolActivity: ToolActivity[] = []; const messages = [...params.messages]; const maxRounds = params.maxRounds ?? 8; let fullText = ""; for (let round = 0; round < maxRounds; round++) { - const stream = anthropic.messages.stream({ - model: params.model, - max_tokens: params.maxTokens ?? 2048, - system: params.systemPrompt, - tools: params.tools, - messages, - ...(round === 0 && params.toolChoice ? { tool_choice: params.toolChoice } : {}), - }); - - let roundText = ""; const announcedTools = new Set(); + let finalResponse: LlmResponse | null = null; - for await (const event of stream) { - if (event.type === "content_block_start") { - if (event.content_block.type === "tool_use") { - const name = event.content_block.name; - if (!announcedTools.has(name)) { - announcedTools.add(name); - params.write( - `data: ${JSON.stringify({ - type: "tool_call_start", - name, - })}\n\n`, - ); - } + for await (const event of llm.provider.stream({ + model: llm.model, + maxTokens: params.maxTokens ?? 2048, + systemPrompt, + tools, + messages, + ...(round === 0 && params.toolChoice ? { toolChoice: params.toolChoice } : {}), + })) { + if (event.type === "tool_use_start") { + if (!announcedTools.has(event.name)) { + announcedTools.add(event.name); + params.write( + `data: ${JSON.stringify({ type: "tool_call_start", name: event.name })}\n\n`, + ); } - } - - if ( - event.type === "content_block_delta" && - event.delta.type === "text_delta" - ) { - const text = event.delta.text; - roundText += text; - fullText += text; + } else if (event.type === "text_delta") { + fullText += event.text; params.write( - `data: ${JSON.stringify({ type: "content_delta", text })}\n\n`, + `data: ${JSON.stringify({ type: "content_delta", text: event.text })}\n\n`, ); + } else { + finalResponse = event.response; } } - const finalMessage = await stream.finalMessage(); - const stopReason = finalMessage.stop_reason; - const toolUses = finalMessage.content.filter( - (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", + const toolUses = (finalResponse?.content ?? []).filter( + (b): b is LlmToolUseBlock => b.type === "tool_use", ); - if (stopReason === "end_turn" || stopReason === "max_tokens") { - params.write(`data: ${JSON.stringify({ type: "content_done" })}\n\n`); - return { fullText, toolActivity }; - } - - if (stopReason !== "tool_use" || toolUses.length === 0) { + if (finalResponse?.stopReason !== "tool_use" || toolUses.length === 0) { params.write(`data: ${JSON.stringify({ type: "content_done" })}\n\n`); return { fullText, toolActivity }; } - messages.push({ role: "assistant", content: finalMessage.content }); + messages.push({ role: "assistant", content: finalResponse.content }); - const toolResults: Anthropic.ToolResultBlockParam[] = []; + const toolResults: LlmToolResultBlock[] = []; for (const block of toolUses) { toolActivity.push({ name: block.name, status: "running" }); const { content, document } = await params.executeTool( diff --git a/backend/src/lib/llm/validateKey.ts b/backend/src/lib/llm/validateKey.ts deleted file mode 100644 index 3b10a1e..0000000 --- a/backend/src/lib/llm/validateKey.ts +++ /dev/null @@ -1,56 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import { getAnthropicModel } from "./models"; - -const PLACEHOLDERS = new Set([ - "", - "your-anthropic-key", - "sk-ant-your-key-here", -]); - -export function anthropicKeyStatus(): { - ok: boolean; - message: string; -} { - const key = process.env.ANTHROPIC_API_KEY?.trim() ?? ""; - if (PLACEHOLDERS.has(key)) { - return { - ok: false, - message: - "ANTHROPIC_API_KEY is missing or still the .env.example placeholder", - }; - } - if (!key.startsWith("sk-ant-")) { - return { - ok: false, - message: "ANTHROPIC_API_KEY does not look like a valid Anthropic key", - }; - } - return { ok: true, message: "format OK" }; -} - -/** One-token ping — fails fast on boot if the key is rejected by Anthropic. */ -export async function validateAnthropicKeyLive(): Promise { - const status = anthropicKeyStatus(); - if (!status.ok) { - console.error(`[startup] ${status.message}`); - return; - } - - try { - const client = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY!.trim(), - }); - await client.messages.create({ - model: getAnthropicModel(), - max_tokens: 1, - messages: [{ role: "user", content: "ping" }], - }); - console.log("[startup] Anthropic API key verified"); - } catch (err) { - const msg = - err instanceof Error ? err.message : "Anthropic authentication failed"; - console.error( - `[startup] Anthropic API key rejected (401). Update backend/.env and restart the server — tsx watch does not reload .env changes.\n ${msg}`, - ); - } -} diff --git a/backend/src/lib/llm/validateLlm.ts b/backend/src/lib/llm/validateLlm.ts new file mode 100644 index 0000000..91c4188 --- /dev/null +++ b/backend/src/lib/llm/validateLlm.ts @@ -0,0 +1,56 @@ +import { getLlm } from "./providers"; + +const ANTHROPIC_PLACEHOLDERS = new Set([ + "", + "your-anthropic-key", + "sk-ant-your-key-here", +]); + +/** One-token ping of the agent model — fails fast on boot if the provider rejects us. */ +export async function validateLlmLive(): Promise { + let llm; + try { + llm = getLlm("agent"); + } catch (err) { + console.error(`[startup] LLM config: ${err instanceof Error ? err.message : String(err)}`); + return; + } + + const label = `${llm.providerId}/${llm.model}${llm.local ? " (local)" : ""}`; + if ( + llm.providerId === "anthropic" && + ANTHROPIC_PLACEHOLDERS.has(process.env.ANTHROPIC_API_KEY?.trim() ?? "") && + !process.env.LLM_API_KEY?.trim() + ) { + console.error("[startup] ANTHROPIC_API_KEY is missing or still the .env.example placeholder"); + return; + } + + // Local models: confirm it is installed, but don't load gigabytes of weights + // into memory at every launch just to say "ping". + if (llm.local && llm.provider.listModels) { + try { + const models = await llm.provider.listModels(); + const ok = models.some((m) => m === llm.model || m.replace(/:latest$/, "") === llm.model); + if (ok) console.log(`[startup] LLM installed: ${label}`); + else console.error(`[startup] ${label} is not installed — download it in the AI Control Center`); + } catch (err) { + console.error(`[startup] Local model server unreachable for ${label}: ${err instanceof Error ? err.message : String(err)}`); + } + return; + } + + try { + await llm.provider.complete({ + model: llm.model, + maxTokens: 1, + messages: [{ role: "user", content: "ping" }], + }); + console.log(`[startup] LLM reachable: ${label}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[startup] LLM check failed for ${label}. Fix the provider config and restart — tsx watch does not reload .env changes.\n ${msg}`, + ); + } +} diff --git a/backend/src/lib/portfolio/rescreenScheduler.ts b/backend/src/lib/portfolio/rescreenScheduler.ts index 8abf53d..c1a499f 100644 --- a/backend/src/lib/portfolio/rescreenScheduler.ts +++ b/backend/src/lib/portfolio/rescreenScheduler.ts @@ -6,6 +6,8 @@ import { saveScreeningSnapshot } from "../screening/screeningDelta"; import { syncPortfolioMonitoringReview } from "./portfolioGrid"; import type { RiskLevel, ScreeningResult } from "../../types/screening"; import { ownerFilter } from "../../routes/startups/middleware"; +import { LOCAL_USER_ID } from "../../middleware/requireAuth"; +import { readSettings } from "../infra/settings"; function entityRiskMap( result: ScreeningResult | null | undefined, @@ -185,7 +187,7 @@ export async function runPortfolioRescreen(options?: { } await syncPortfolioMonitoringReview({ - userId: options?.ownerId ?? "preview-user", + userId: options?.ownerId ?? LOCAL_USER_ID, userEmail: options?.userEmail ?? "admin@capscreen.local", }); @@ -227,22 +229,37 @@ export async function runPortfolioRescreen(options?: { }; } -export function startRescreenScheduler(): void { - const enabled = process.env.RESCREEN_SCHEDULER_ENABLED === "true"; - if (!enabled) return; +const CHECK_EVERY_MS = 15 * 60 * 1000; +let rescreenRunning = false; - const hours = Number.parseInt(process.env.RESCREEN_INTERVAL_HOURS ?? "168", 10); - const ms = Math.max(1, hours) * 60 * 60 * 1000; +/** + * Desktop background re-screening. Rather than a fixed timer from launch, this + * checks every 15 minutes whether a run is due (last digest older than the + * configured interval), so it catches up after sleep, restarts, or days off. + */ +export function startRescreenScheduler(): void { + const tick = async () => { + const { enabled, intervalHours } = readSettings().backgroundScreening; + if (!enabled || rescreenRunning) return; + const last = (await ScreeningDigest.findOne().sort({ generatedAt: -1 }).lean()) as { generatedAt?: Date } | null; + const dueAt = (last?.generatedAt ? new Date(last.generatedAt).getTime() : 0) + Math.max(1, intervalHours) * 3_600_000; + if (Date.now() < dueAt) return; + if ((await Startup.countDocuments({ deletedAt: null, latestCsvId: { $ne: null } })) === 0) return; - const tick = () => { - void runPortfolioRescreen().catch((err) => { + rescreenRunning = true; + try { + const r = await runPortfolioRescreen(); + console.log(`[rescreen-scheduler] re-screened ${r.rescreened}/${r.total} startups`); + } catch (err) { console.error("[rescreen-scheduler]", err); - }); + } finally { + rescreenRunning = false; + } }; - setTimeout(tick, 30_000); - setInterval(tick, ms); - console.log(`[rescreen-scheduler] enabled — every ${hours}h`); + // First check shortly after boot (Watchman lists need a minute to load). + setTimeout(() => void tick(), 2 * 60_000).unref(); + setInterval(() => void tick(), CHECK_EVERY_MS).unref(); } export async function getLatestDigest() { diff --git a/backend/src/lib/rag/embed.ts b/backend/src/lib/rag/embed.ts index aad3607..b4db3df 100644 --- a/backend/src/lib/rag/embed.ts +++ b/backend/src/lib/rag/embed.ts @@ -1,64 +1,40 @@ -import OpenAI from "openai"; +import { getLlm, hasLlmRole } from "../llm/providers"; -const OPENAI_EMBED_MODEL = "text-embedding-3-small"; const EMBED_BATCH_SIZE = parseInt(process.env.RAG_EMBED_BATCH_SIZE ?? "16", 10); -const EMBEDDING_DIM = 1536; -let client: OpenAI | null = null; - -function getClient(): OpenAI | null { - const key = process.env.OPENAI_API_KEY; - if (!key) return null; - if (!client) client = new OpenAI({ apiKey: key }); - return client; +/** + * Embeddings go through the "embedding" model role — e.g. nomic-embed-text in + * Ollama (on-device) or a cloud embedding model chosen in the AI Control Center. + * With no embedding model configured, chunks get empty vectors and retrieval + * uses keyword search. + */ +export function isEmbeddingEnabled(): boolean { + if (!hasLlmRole("embedding")) return false; + return !!getLlm("embedding").provider.embed; } export async function embedTexts(texts: string[]): Promise { - const openai = getClient(); - if (!openai) { - console.warn("[rag] OPENAI_API_KEY not set — returning zero vectors (RAG disabled)"); - return texts.map(() => new Array(EMBEDDING_DIM).fill(0) as number[]); - } + if (!isEmbeddingEnabled()) return texts.map(() => []); + const llm = getLlm("embedding"); const results: number[][] = []; for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) { const batch = texts.slice(i, i + EMBED_BATCH_SIZE); - const response = await openai.embeddings.create({ - model: OPENAI_EMBED_MODEL, - input: batch, - }); - for (const item of response.data) { - results.push(item.embedding); - } + results.push(...(await llm.provider.embed!(llm.model, batch))); } return results; } export async function embedQuery(text: string): Promise { - const openai = getClient(); - if (!openai) { - return new Array(EMBEDDING_DIM).fill(0) as number[]; - } - const response = await openai.embeddings.create({ - model: OPENAI_EMBED_MODEL, - input: text, - }); - return response.data[0]?.embedding ?? (new Array(EMBEDDING_DIM).fill(0) as number[]); -} - -export function isEmbeddingEnabled(): boolean { - return !!process.env.OPENAI_API_KEY; + const [vector] = await embedTexts([text]); + return vector ?? []; } export function logRagStartupWarnings(): void { if (isEmbeddingEnabled()) { - const vectorOn = process.env.MONGO_VECTOR_SEARCH_ENABLED === "true"; - console.log( - `[rag] OpenAI embeddings enabled${vectorOn ? " — Atlas vector search on" : " — keyword fallback (local MongoDB)"}`, - ); + const llm = getLlm("embedding"); + console.log(`[rag] embeddings: ${llm.providerId}/${llm.model}${llm.local ? " (local)" : ""}`); return; } - console.warn( - "[rag] OPENAI_API_KEY is not set — document embeddings disabled; search_startup_documents uses keyword fallback only", - ); + console.warn("[rag] no embedding model configured — document search uses keyword matching"); } diff --git a/backend/src/lib/rag/extractEntities.ts b/backend/src/lib/rag/extractEntities.ts index ef86684..46159b9 100644 --- a/backend/src/lib/rag/extractEntities.ts +++ b/backend/src/lib/rag/extractEntities.ts @@ -1,5 +1,4 @@ -import Anthropic from "@anthropic-ai/sdk"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; export type ExtractedEntity = { name: string; @@ -7,13 +6,12 @@ export type ExtractedEntity = { context: string; }; -const MAX_TEXT_CHARS = 18000; // ~4500 tokens — enough for Haiku +const MAX_TEXT_CHARS = 18000; // ~4500 tokens — fits small local models too export async function extractEntitiesFromText( text: string, filename: string, ): Promise { - const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); const truncated = text.length > MAX_TEXT_CHARS ? text.slice(0, MAX_TEXT_CHARS) + "\n…[truncated]" : text; const prompt = `Extract all named persons and legal entities (companies, funds, LLCs, trusts, partnerships) from the document below. @@ -34,16 +32,7 @@ ${truncated} Respond with JSON only, no prose.`; - const message = await client.messages.create({ - model: getAnthropicModel(), - max_tokens: 2048, - messages: [{ role: "user", content: prompt }], - }); - - const raw = message.content - .filter((b) => b.type === "text") - .map((b) => (b as { type: "text"; text: string }).text) - .join(""); + const raw = await completeText({ user: prompt, maxTokens: 2048 }); try { const jsonMatch = raw.match(/\[[\s\S]*\]/); diff --git a/backend/src/lib/rag/parseDocument.ts b/backend/src/lib/rag/parseDocument.ts index cca1e09..425e28e 100644 --- a/backend/src/lib/rag/parseDocument.ts +++ b/backend/src/lib/rag/parseDocument.ts @@ -1,5 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const pdfParse = require("pdf-parse") as (buffer: Buffer) => Promise<{ text: string; numpages: number }>; import mammoth from "mammoth"; export type ParsedPage = { pageNum: number | null; text: string }; @@ -32,15 +30,37 @@ export async function parseDocumentToText( throw new Error(`Unsupported file type: ${mimeType} (${filename})`); } -async function parsePdf(buffer: Buffer): Promise { - const data = await pdfParse(buffer); - const raw = data.text ?? ""; +type PdfParseModule = typeof import("pdf-parse"); +let pdfModule: Promise | null = null; - // Split by form-feed character for rough page boundaries - const pageSplits = raw.split(/\f/).filter((p: string) => p.trim().length > 0); - if (pageSplits.length === 0) return []; +/** + * Loaded on first use: pdf.js (inside pdf-parse v2) touches DOMMatrix at import + * time, which plain Node lacks. A pure-JS polyfill covers text extraction, and + * lazy loading keeps any PDF-stack problem from taking down the whole backend. + */ +function loadPdfParse(): Promise { + pdfModule ??= (async () => { + const g = globalThis as { DOMMatrix?: unknown }; + if (!g.DOMMatrix) { + const { default: CSSMatrix } = await import("@thednp/dommatrix"); + g.DOMMatrix = CSSMatrix; + } + return import("pdf-parse"); + })(); + return pdfModule; +} - return pageSplits.map((text: string, i: number) => ({ pageNum: i + 1, text: text.trim() })); +async function parsePdf(buffer: Buffer): Promise { + const { PDFParse } = await loadPdfParse(); + const parser = new PDFParse({ data: new Uint8Array(buffer) }); + try { + const result = await parser.getText(); + return result.pages + .map((p) => ({ pageNum: p.num, text: p.text.trim() })) + .filter((p) => p.text.length > 0); + } finally { + await parser.destroy(); + } } async function parseDocx(buffer: Buffer): Promise { diff --git a/backend/src/lib/rag/retrieveChunks.ts b/backend/src/lib/rag/retrieveChunks.ts index ecb16e1..aca813a 100644 --- a/backend/src/lib/rag/retrieveChunks.ts +++ b/backend/src/lib/rag/retrieveChunks.ts @@ -2,7 +2,6 @@ import mongoose from "mongoose"; import { DocChunk } from "../../models/rag/docChunk"; import { embedQuery, isEmbeddingEnabled } from "./embed"; -const VECTOR_SEARCH_ENABLED = process.env.MONGO_VECTOR_SEARCH_ENABLED === "true"; const TOP_K = parseInt(process.env.RAG_TOP_K ?? "6", 10); const MIN_SCORE = 0.60; @@ -21,8 +20,11 @@ export async function retrieveRelevantChunks(params: { }): Promise { const { startupId, query, topK = TOP_K, documentId } = params; - if (VECTOR_SEARCH_ENABLED && isEmbeddingEnabled()) { - return vectorSearch(startupId, query, topK, documentId); + if (isEmbeddingEnabled()) { + // Chunks embedded with a different model (dimension mismatch) are skipped + // by vector search, so fall back to keywords rather than return nothing. + const hits = await vectorSearch(startupId, query, topK, documentId); + if (hits.length) return hits; } return keywordFallback(startupId, query, topK, documentId); } diff --git a/backend/src/lib/screening/explain.ts b/backend/src/lib/screening/explain.ts index 21f3a8c..96613a5 100644 --- a/backend/src/lib/screening/explain.ts +++ b/backend/src/lib/screening/explain.ts @@ -1,5 +1,5 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; +import { describeLlmConfig } from "../llm/providers"; import type { WatchmanMatch } from "./watchman"; import type { GraphNode } from "./graph"; import type { RiskLevel } from "./classify"; @@ -83,7 +83,7 @@ Write exactly 3–4 plain-English sentences for a human compliance reviewer (pla Do NOT make a legal determination or confirm a sanctions violation. You are narrating a screening signal for human review, not deciding guilt.`; - screeningLog("Claude narrative (AI explains Watchman match — does not create the match)", { + screeningLog("LLM narrative (AI explains Watchman match — does not create the match)", { entity: entity.name, riskLevel, watchmanMatches: matches.slice(0, 3).map((m) => ({ @@ -91,12 +91,11 @@ Do NOT make a legal determination or confirm a sanctions violation. You are narr score: `${(m.match * 100).toFixed(0)}%`, program: m.programs.join(", ") || "n/a", })), - source: "anthropic-claude", + source: describeLlmConfig().roles.utility?.provider ?? "unconfigured", }); try { - return await completeClaudeText({ - model: getAnthropicModel(), + return await completeText({ systemPrompt: "You are a cautious compliance analyst. Always recommend human verification. Never conclude guilt.", user: prompt, diff --git a/backend/src/lib/screening/inferCsvSchema.ts b/backend/src/lib/screening/inferCsvSchema.ts index 0c5406c..58f9966 100644 --- a/backend/src/lib/screening/inferCsvSchema.ts +++ b/backend/src/lib/screening/inferCsvSchema.ts @@ -1,11 +1,11 @@ -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import type { ColumnMapping, CsvKind } from "../../types/screening"; import { UNTRUSTED_DATA_PROMPT_GUARD, wrapCsvData, } from "../shared/promptDelimiters"; import { isExternalLlmBlocked } from "../shared/dataResidency"; +import { hasLlmRole } from "../llm/providers"; export interface InferredCsvSchema { csvKind: CsvKind; @@ -96,10 +96,7 @@ export async function inferCsvSchema(params: { headers: string[]; sampleRows: string[][]; }): Promise { - if (isExternalLlmBlocked()) return null; - - const key = process.env.ANTHROPIC_API_KEY?.trim(); - if (!key) return null; + if (isExternalLlmBlocked() || !hasLlmRole("utility")) return null; const sample = params.sampleRows .slice(0, 8) @@ -117,8 +114,7 @@ export async function inferCsvSchema(params: { .join("\n"); try { - const text = await completeClaudeText({ - model: getAnthropicModel(), + const text = await completeText({ systemPrompt: SYSTEM, user, maxTokens: 1024, diff --git a/backend/src/lib/screening/watchman.ts b/backend/src/lib/screening/watchman.ts index feaef59..83e9386 100644 --- a/backend/src/lib/screening/watchman.ts +++ b/backend/src/lib/screening/watchman.ts @@ -22,7 +22,7 @@ let listInfoCache: { data: WatchmanListInfo; at: number } | null = null; export class WatchmanUnavailableError extends Error { constructor( - message = "Sanctions data source (Watchman) is unavailable. Start Watchman Docker and retry.", + message = "Sanctions screening engine (Watchman) is unavailable. Restart CapScreen; if it persists, check logs/watchman.log.", ) { super(message); this.name = "WatchmanUnavailableError"; @@ -45,7 +45,7 @@ export function getWatchmanBaseUrl(): string { return (process.env.WATCHMAN_URL ?? DEFAULT_BASE).replace(/\/$/, ""); } -/** Ping Watchman at startup — logs whether Docker sanctions data is reachable. */ +/** Ping Watchman at startup — logs whether the local sanctions engine is reachable. */ export async function checkWatchmanHealth(): Promise<{ ok: boolean; url: string; @@ -58,10 +58,10 @@ export async function checkWatchmanHealth(): Promise<{ }); if (resp.ok) { const body = (await resp.text()).trim(); - screeningLog("Watchman reachable — sanctions lists loaded from Docker", { + screeningLog("Watchman reachable — sanctions lists loaded", { url, ping: body || "ok", - source: "watchman-docker", + source: "watchman-local", }); return { ok: true, url, detail: body || "ok" }; } @@ -73,7 +73,7 @@ export async function checkWatchmanHealth(): Promise<{ } catch (err) { const detail = err instanceof Error ? err.message : String(err); screeningWarn( - "Watchman unreachable — screening will return all CLEAR (no sanctions data)", + "Watchman unreachable — screenings are refused until the sanctions engine is available", { url, error: detail }, ); return { ok: false, url, detail }; @@ -264,7 +264,7 @@ async function fetchFromWatchman( const data = (await resp.json()) as Record; const matches = normalizeResponse(data, minMatch); if (matches.length > 0) { - screeningLog("Watchman match (deterministic — from Docker sanctions lists)", { + screeningLog("Watchman match (deterministic — from local sanctions lists)", { entity: name, type, endpoint: label, @@ -273,7 +273,7 @@ async function fetchFromWatchman( topScore: `${(matches[0].match * 100).toFixed(0)}%`, program: matches[0].programs[0] ?? "n/a", sourceList: matches[0].sourceList ?? "n/a", - source: "watchman-docker", + source: "watchman-local", requestId: options.requestId, }); } else { @@ -281,7 +281,7 @@ async function fetchFromWatchman( entity: name, type, endpoint: label, - source: "watchman-docker", + source: "watchman-local", requestId: options.requestId, }); } diff --git a/backend/src/lib/shared/dataResidency.ts b/backend/src/lib/shared/dataResidency.ts index ecd3055..b596550 100644 --- a/backend/src/lib/shared/dataResidency.ts +++ b/backend/src/lib/shared/dataResidency.ts @@ -1,5 +1,20 @@ -/** When set, blocks sending PII to external LLM APIs (Anthropic). */ -export function isExternalLlmBlocked(): boolean { +import { getLlm, type LlmRole } from "../llm/providers"; + +/** When set, PII may not be sent to LLM APIs that run off this machine. */ +export function isDataResidencyRestricted(): boolean { const value = process.env.DATA_RESIDENCY?.trim().toLowerCase(); return value === "restricted" || value === "eu" || value === "in"; } + +/** + * True when the model bound to `role` may not receive PII: residency is + * restricted and the provider is remote. Local providers are always allowed. + */ +export function isExternalLlmBlocked(role: LlmRole = "utility"): boolean { + if (!isDataResidencyRestricted()) return false; + try { + return !getLlm(role).local; + } catch { + return true; + } +} diff --git a/backend/src/lib/tools/documentTypes.ts b/backend/src/lib/tools/documentTypes.ts index 1937422..c6a1669 100644 --- a/backend/src/lib/tools/documentTypes.ts +++ b/backend/src/lib/tools/documentTypes.ts @@ -4,8 +4,7 @@ import { createScreeningAnalysisDocument, } from "../documents/icMemoDocument"; import { buildFactsBlock } from "../documents/generateIcMemo"; -import { completeClaudeText } from "../llm/claude"; -import { getAnthropicModel } from "../llm/models"; +import { completeText } from "../llm/complete"; import { toToolDocument } from "./_shared"; import type { ToolContext, ToolDocumentResult, ToolExecutorResult } from "./types"; import type { ScreeningResult } from "../screening/runScreening"; @@ -66,8 +65,7 @@ async function genericClaudeDocument( const facts = buildFactsBlock(startupName, resolved.result); const intentLine = contentIntent ? `Intent: ${contentIntent}\n\n` : ""; - const customContent = await completeClaudeText({ - model: getAnthropicModel(), + const customContent = await completeText({ systemPrompt: "You are a VC compliance analyst. Write the requested document using only the provided screening facts. " + "Never confirm guilt or sanctions violations. Use Markdown formatting.", diff --git a/backend/src/lib/tools/registry.ts b/backend/src/lib/tools/registry.ts index 69933ed..cecc855 100644 --- a/backend/src/lib/tools/registry.ts +++ b/backend/src/lib/tools/registry.ts @@ -1,9 +1,9 @@ -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmToolSchema } from "../llm/providers"; import type { ToolContext, ToolExecutorResult } from "./types"; export interface ToolDefinition { name: string; - schema: Anthropic.Tool; + schema: LlmToolSchema; handler: (input: unknown, ctx: ToolContext) => Promise; /** Optional routing hint included in the system prompt's "Tool routing" section. */ routingHint?: string; @@ -20,7 +20,7 @@ export class ToolRegistry { return this; } - getTools(): Anthropic.Tool[] { + getTools(): LlmToolSchema[] { return [...this._tools.values()].map((d) => d.schema); } diff --git a/backend/src/lib/tools/searchTools.ts b/backend/src/lib/tools/searchTools.ts index 0a61e0b..56ae4bb 100644 --- a/backend/src/lib/tools/searchTools.ts +++ b/backend/src/lib/tools/searchTools.ts @@ -11,7 +11,8 @@ import { coalesceStartupId } from "../shared/startupId"; import { wrapEntityName } from "../shared/promptDelimiters"; import type { ToolDefinition } from "./registry"; -const CORPUS_DIR = join(__dirname, "../../compliance-corpus"); +// Packaged desktop app: the shell points this at the bundled copy. +const CORPUS_DIR = process.env.CAPSCREEN_CORPUS_DIR ?? join(__dirname, "../../compliance-corpus"); function searchCorpus(query: string): string { let files: string[]; diff --git a/backend/src/loadEnv.ts b/backend/src/loadEnv.ts index ff9d3ba..dffc42f 100644 --- a/backend/src/loadEnv.ts +++ b/backend/src/loadEnv.ts @@ -4,10 +4,14 @@ import path from "path"; const backendRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(backendRoot, ".."); -/** Monorepo root `.env` — single source of truth for local dev. */ -config({ path: path.join(repoRoot, ".env") }); -config({ path: path.join(repoRoot, ".env.local"), override: true }); +// Launched by the desktop shell: all configuration arrives via the environment +// and model keys come from the AI Control Center — never read a developer .env. +if (!process.env.CAPSCREEN_DESKTOP_TOKEN) { + /** Monorepo root `.env` — single source of truth for `pnpm dev`. */ + config({ path: path.join(repoRoot, ".env") }); + config({ path: path.join(repoRoot, ".env.local"), override: true }); -/** Optional per-package overrides (legacy); prefer editing root `.env`. */ -config({ path: path.join(backendRoot, ".env"), override: true }); -config({ path: path.join(backendRoot, ".env.local"), override: true }); + /** Optional per-package overrides (legacy); prefer editing root `.env`. */ + config({ path: path.join(backendRoot, ".env"), override: true }); + config({ path: path.join(backendRoot, ".env.local"), override: true }); +} diff --git a/backend/src/middleware/desktopToken.ts b/backend/src/middleware/desktopToken.ts new file mode 100644 index 0000000..0aa9756 --- /dev/null +++ b/backend/src/middleware/desktopToken.ts @@ -0,0 +1,26 @@ +import crypto from "crypto"; +import type { NextFunction, Request, Response } from "express"; + +/** + * The Electron shell generates a random token per launch, passes it to the + * backend as CAPSCREEN_DESKTOP_TOKEN, and attaches it to every renderer request + * as `x-capscreen-token`. Other local processes and web pages can reach + * 127.0.0.1 but cannot know the token. + * + * Unset in `pnpm dev` (no Electron) — the API is then loopback-only but open. + */ +export const DESKTOP_TOKEN_HEADER = "x-capscreen-token"; + +export function desktopToken(req: Request, res: Response, next: NextFunction): void { + const expected = process.env.CAPSCREEN_DESKTOP_TOKEN; + if (!expected || req.method === "OPTIONS" || req.path === "/health" || req.path.startsWith("/api/files/")) { + next(); + return; + } + const got = req.header(DESKTOP_TOKEN_HEADER) ?? ""; + if (got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { + next(); + return; + } + res.status(403).json({ detail: "Forbidden" }); +} diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 2caf9bc..8a80d09 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -1,56 +1,53 @@ +import fs from "fs"; import type { NextFunction, Request, Response } from "express"; -import { jwtConfigured, previewModeEnabled } from "../lib/auth/config"; -import { verifyAccessToken } from "../lib/auth/jwt"; -import { connectDb } from "../lib/infra/db"; -import { findUserById } from "../models"; +import { dataFile } from "../lib/infra/dataDir"; -const PREVIEW_USER_ID = "preview-user"; -const PREVIEW_USER_EMAIL = "admin@capscreen.local"; +/** + * Desktop app: one local data owner per installation. Requests are already + * gated by the per-launch desktop token (middleware/desktopToken.ts). + * + * The signed-in CapScreen account (written to identity.json by the desktop + * shell) is used for attribution — audit entries and review decisions record + * who acted — while data ownership stays `local-user`, so records don't vanish + * when a different person signs in on the same machine. + */ +export const LOCAL_USER_ID = "local-user"; +export const LOCAL_USER_EMAIL = "you@capscreen.local"; -function setPreviewUser(res: Response): void { - res.locals.userId = PREVIEW_USER_ID; - res.locals.userEmail = PREVIEW_USER_EMAIL; +type Identity = { email: string; displayName: string | null; organisation: string | null }; +let cached: { mtimeMs: number; identity: Identity | null } | null = null; + +export function signedInIdentity(): Identity | null { + const file = dataFile("identity.json"); + let mtimeMs: number; + try { + mtimeMs = fs.statSync(file).mtimeMs; + } catch { + cached = null; + return null; + } + if (cached?.mtimeMs === mtimeMs) return cached.identity; + let identity: Identity | null = null; + try { + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial; + if (typeof raw.email === "string" && raw.email) { + identity = { email: raw.email.toLowerCase(), displayName: raw.displayName ?? null, organisation: raw.organisation ?? null }; + } + } catch { + identity = null; + } + cached = { mtimeMs, identity }; + return identity; } export async function requireAuth( - req: Request, + _req: Request, res: Response, next: NextFunction, ): Promise { - const header = req.headers.authorization; - if (header?.startsWith("Bearer ")) { - const token = header.slice(7).trim(); - if (token) { - if (!jwtConfigured()) { - res.status(500).json({ detail: "Server auth is not configured" }); - return; - } - - try { - await connectDb(); - const claims = verifyAccessToken(token); - const user = await findUserById(claims.sub); - if (!user) { - res.status(401).json({ detail: "Invalid or expired token" }); - return; - } - res.locals.userId = user._id.toString(); - res.locals.userEmail = user.email.toLowerCase(); - res.locals.token = token; - next(); - return; - } catch { - res.status(401).json({ detail: "Invalid or expired token" }); - return; - } - } - } - - if (previewModeEnabled()) { - setPreviewUser(res); - next(); - return; - } - - res.status(401).json({ detail: "Authentication required" }); + const identity = signedInIdentity(); + res.locals.userId = LOCAL_USER_ID; + res.locals.userEmail = identity?.email ?? LOCAL_USER_EMAIL; + res.locals.userName = identity?.displayName ?? null; + next(); } diff --git a/backend/src/middleware/requireJwt.ts b/backend/src/middleware/requireJwt.ts new file mode 100644 index 0000000..689ddcd --- /dev/null +++ b/backend/src/middleware/requireJwt.ts @@ -0,0 +1,37 @@ +import type { NextFunction, Request, Response } from "express"; +import { jwtConfigured } from "../lib/auth/config"; +import { verifyAccessToken } from "../lib/auth/jwt"; +import { connectDb } from "../lib/infra/db"; +import { findUserById } from "../models"; + +/** + * Cloud auth service: require a valid RS256 access token (Authorization: Bearer). + * The desktop app uses middleware/requireAuth.ts (single local user) instead. + */ +export async function requireJwt(req: Request, res: Response, next: NextFunction): Promise { + const header = req.headers.authorization; + const token = header?.startsWith("Bearer ") ? header.slice(7).trim() : ""; + if (!token) { + res.status(401).json({ detail: "Authentication required" }); + return; + } + if (!jwtConfigured()) { + res.status(500).json({ detail: "Server auth is not configured" }); + return; + } + try { + await connectDb(); + const claims = verifyAccessToken(token); + const user = await findUserById(claims.sub); + if (!user) { + res.status(401).json({ detail: "Invalid or expired token" }); + return; + } + res.locals.userId = user._id.toString(); + res.locals.userEmail = user.email.toLowerCase(); + res.locals.token = token; + next(); + } catch { + res.status(401).json({ detail: "Invalid or expired token" }); + } +} diff --git a/backend/src/models/auditLog.ts b/backend/src/models/auditLog.ts deleted file mode 100644 index f93c6a7..0000000 --- a/backend/src/models/auditLog.ts +++ /dev/null @@ -1,37 +0,0 @@ -import mongoose from "mongoose"; - -export type AuditEventType = - | "screening_completed" - | "csv_uploaded" - | "csv_updated" - | "entity_reviewed" - | "portfolio_reviewed" - | "ic_memo_generated"; - -const schema = new mongoose.Schema({ - startupId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Startup", - index: true, - default: null, - }, - eventType: { - type: String, - enum: [ - "screening_completed", - "csv_uploaded", - "csv_updated", - "entity_reviewed", - "portfolio_reviewed", - "ic_memo_generated", - ], - required: true, - }, - performedBy: { type: String, required: true }, - performedByEmail: { type: String, required: true }, - details: { type: mongoose.Schema.Types.Mixed, default: {} }, - createdAt: { type: Date, default: Date.now, index: true }, -}); - -export const AuditLog = - mongoose.models["AuditLog"] ?? mongoose.model("AuditLog", schema); diff --git a/backend/src/models/auth/desktopAuthCode.ts b/backend/src/models/auth/desktopAuthCode.ts new file mode 100644 index 0000000..ba2191c --- /dev/null +++ b/backend/src/models/auth/desktopAuthCode.ts @@ -0,0 +1,24 @@ +import crypto from "crypto"; +import mongoose from "mongoose"; + +/** + * One-time authorization codes for the desktop sign-in flow (RFC 8252 + PKCE). + * Stored hashed; valid for 2 minutes; single use. + */ +const schema = new mongoose.Schema({ + codeHash: { type: String, required: true, unique: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, + codeChallenge: { type: String, required: true }, + redirectUri: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + usedAt: { type: Date, default: null }, +}); + +schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const DesktopAuthCode = + mongoose.models["DesktopAuthCode"] ?? mongoose.model("DesktopAuthCode", schema); + +export function hashAuthCode(code: string): string { + return crypto.createHash("sha256").update(code).digest("hex"); +} diff --git a/backend/src/models/capTableCsv.ts b/backend/src/models/capTableCsv.ts deleted file mode 100644 index 16a497f..0000000 --- a/backend/src/models/capTableCsv.ts +++ /dev/null @@ -1,50 +0,0 @@ -import mongoose from "mongoose"; -import { parseErrorSchema } from "../models/screening/schemas/screening"; - -const schema = new mongoose.Schema({ - startupId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Startup", - required: true, - index: true, - }, - filename: { type: String, required: true }, - content: { type: String, required: true }, - uploadedAt: { type: Date, default: Date.now }, - parseStatus: { - type: String, - enum: ["pending", "valid", "invalid", "needs_review"], - default: "pending", - }, - parseErrors: { type: [parseErrorSchema], default: [] }, - recordCount: { type: Number, default: 0 }, - csvKind: { - type: String, - enum: [ - "ownership_cap_table", - "entity_roster", - "reference_metadata", - "unknown", - ], - default: null, - }, - parseSource: { - type: String, - enum: ["strict", "heuristic", "ai"], - default: null, - }, - confidence: { type: Number, default: null }, - columnMapping: { type: mongoose.Schema.Types.Mixed, default: null }, - normalizedContent: { type: String, default: null }, - ingestWarnings: { type: [String], default: [] }, - rosterPurpose: { - type: String, - enum: ["cap_table", "co_investor", "vendor", "entity_roster"], - default: "cap_table", - }, -}); - -schema.index({ startupId: 1, uploadedAt: -1 }); - -export const CapTableCsv = - mongoose.models["CapTableCsv"] ?? mongoose.model("CapTableCsv", schema); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a97eb4d..c73e30c 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -46,3 +46,4 @@ export { } from "./sample/hiddenSampleAsset"; export { RagDocument, type IRagDocument, type RagDocumentStatus } from "./rag/ragDocument"; export { DocChunk, type IDocChunk } from "./rag/docChunk"; +export { DesktopAuthCode, hashAuthCode } from "./auth/desktopAuthCode"; diff --git a/backend/src/models/rag/docChunk.ts b/backend/src/models/rag/docChunk.ts index 0722609..c89788c 100644 --- a/backend/src/models/rag/docChunk.ts +++ b/backend/src/models/rag/docChunk.ts @@ -19,7 +19,7 @@ const DocChunkSchema = new Schema({ mimeType: { type: String, required: true }, chunkIndex: { type: Number, required: true }, chunkText: { type: String, required: true }, - embedding: { type: [Number], required: true }, + embedding: { type: [Number], default: [] }, // empty when no embedding model is configured pageNumber: { type: Number, default: null }, createdAt: { type: Date, default: () => new Date() }, }); @@ -27,7 +27,7 @@ const DocChunkSchema = new Schema({ // Compound index for bulk deletes and ownership queries DocChunkSchema.index({ startupId: 1, documentId: 1 }); -// Text index as keyword-search fallback when Atlas Vector Search is not enabled +// Text index (ignored by the SQLite store; keyword fallback scans chunks) DocChunkSchema.index({ chunkText: "text" }); export const DocChunk = mongoose.model("DocChunk", DocChunkSchema, "doc_chunks"); diff --git a/backend/src/models/schemas/screening.ts b/backend/src/models/schemas/screening.ts deleted file mode 100644 index ec9e990..0000000 --- a/backend/src/models/schemas/screening.ts +++ /dev/null @@ -1,110 +0,0 @@ -import mongoose from "mongoose"; - -export const watchmanMatchSchema = new mongoose.Schema( - { - sdnName: { type: String, required: true }, - sdnType: { type: String, required: true }, - match: { type: Number, required: true }, - programs: { type: [String], default: [] }, - remarks: { type: String, default: null }, - hitSource: { type: String, enum: ["sdn", "altName"], default: "sdn" }, - entityID: { type: String, default: null }, - matchedName: { type: String, default: null }, - title: { type: String, default: null }, - alternateID: { type: String, default: null }, - alternateType: { type: String, default: null }, - alternateName: { type: String, default: null }, - alternateRemarks: { type: String, default: null }, - callSign: { type: String, default: null }, - vesselType: { type: String, default: null }, - tonnage: { type: String, default: null }, - grossRegisteredTonnage: { type: String, default: null }, - vesselFlag: { type: String, default: null }, - vesselOwner: { type: String, default: null }, - }, - { _id: false }, -); - -const ownershipPathStepSchema = new mongoose.Schema( - { - name: { type: String, required: true }, - edgePct: { type: Number, default: null }, - }, - { _id: false }, -); - -export const entityResultSchema = new mongoose.Schema( - { - name: { type: String, required: true }, - type: { type: String, enum: ["person", "company"], required: true }, - riskLevel: { - type: String, - enum: ["clear", "review", "flagged"], - required: true, - }, - topScore: { type: Number, default: null }, - matches: { type: [watchmanMatchSchema], default: [] }, - ownershipPath: { type: [String], default: [] }, - indirectOwnershipPct: { type: Number, default: null }, - ownershipPathSteps: { type: [ownershipPathStepSchema], default: [] }, - ownershipRuleFlags: { type: [String], default: [] }, - exposureStatement: { type: String, default: null }, - role: { type: String, default: null }, - isUltimateOwner: { type: Boolean, default: false }, - ultimateOwner: { type: String, default: null }, - explanation: { type: String, default: null }, - }, - { _id: false }, -); - -export const ownershipEdgeSchema = new mongoose.Schema( - { - from: { type: String, required: true }, - to: { type: String, required: true }, - pct: { type: Number, required: true }, - }, - { _id: false }, -); - -export const screeningResultSchema = new mongoose.Schema( - { - totalEntities: { type: Number, required: true }, - flaggedCount: { type: Number, required: true }, - reviewCount: { type: Number, required: true }, - clearCount: { type: Number, required: true }, - entities: { type: [entityResultSchema], default: [] }, - edges: { type: [ownershipEdgeSchema], default: [] }, - startupName: { type: String, default: null }, - maxSanctionedExposurePct: { type: Number, default: null }, - sanctionedExposureSummary: { type: String, default: null }, - screenedAt: { type: String, default: null }, - csvId: { type: String, default: null }, - screeningMode: { - type: String, - enum: ["ownership_graph", "entity_roster"], - default: null, - }, - csvKind: { type: String, default: null }, - }, - { _id: false }, -); - -export const parseErrorSchema = new mongoose.Schema( - { - row: { type: Number, required: true }, - field: { type: String, default: null }, - message: { type: String, required: true }, - }, - { _id: false }, -); - -export const ownershipRecordSchema = new mongoose.Schema( - { - entity: { type: String, required: true }, - entityType: { type: String, enum: ["person", "company"], required: true }, - owner: { type: String, required: true }, - ownerType: { type: String, enum: ["person", "company"], required: true }, - ownershipPct: { type: Number, required: true }, - }, - { _id: false }, -); diff --git a/backend/src/models/screening/schemas/screening.ts b/backend/src/models/screening/schemas/screening.ts index ec9e990..2a53aee 100644 --- a/backend/src/models/screening/schemas/screening.ts +++ b/backend/src/models/screening/schemas/screening.ts @@ -85,6 +85,9 @@ export const screeningResultSchema = new mongoose.Schema( default: null, }, csvKind: { type: String, default: null }, + // Which sanctions-list versions this result was checked against (counts, + // SHA-256 per list, load time) — audit evidence for "as of" screening. + watchmanListInfo: { type: mongoose.Schema.Types.Mixed, default: null }, }, { _id: false }, ); diff --git a/backend/src/models/startup.ts b/backend/src/models/startup.ts deleted file mode 100644 index 80b4a64..0000000 --- a/backend/src/models/startup.ts +++ /dev/null @@ -1,40 +0,0 @@ -import mongoose from "mongoose"; -import { screeningResultSchema } from "../models/screening/schemas/screening"; - -const schema = new mongoose.Schema({ - name: { type: String, required: true, trim: true }, - ownerId: { type: String, index: true, default: null }, - createdAt: { type: Date, default: Date.now }, - latestCsvId: { - type: mongoose.Schema.Types.ObjectId, - ref: "CapTableCsv", - default: null, - }, - lastScreeningResult: { type: screeningResultSchema, default: null }, - lastScreenedAt: { type: Date, default: null }, - lastScreenedCsvId: { - type: mongoose.Schema.Types.ObjectId, - ref: "CapTableCsv", - default: null, - }, - lastCoInvestorScreeningResult: { - type: screeningResultSchema, - default: null, - }, - lastCoInvestorScreenedAt: { type: Date, default: null }, - lastVendorScreeningResult: { - type: screeningResultSchema, - default: null, - }, - lastVendorScreenedAt: { type: Date, default: null }, - latestCsvByPurpose: { type: mongoose.Schema.Types.Mixed, default: {} }, - portfolioReviewStatus: { - type: String, - enum: ["pending", "cleared", "escalated", "blocked"], - default: "pending", - }, - portfolioReviewNotes: { type: String, default: null }, -}); - -export const Startup = - mongoose.models["Startup"] ?? mongoose.model("Startup", schema); diff --git a/backend/src/models/workflowShare.ts b/backend/src/models/workflowShare.ts deleted file mode 100644 index 1fac64c..0000000 --- a/backend/src/models/workflowShare.ts +++ /dev/null @@ -1,14 +0,0 @@ -import mongoose from "mongoose"; - -const schema = new mongoose.Schema({ - _id: { type: String, required: true }, - workflow_id: { type: String, required: true, index: true }, - shared_by_user_id: { type: String, required: true }, - shared_with_email: { type: String, required: true, index: true }, - allow_edit: { type: Boolean, default: false }, - created_at: { type: String, required: true }, -}); - -export const WorkflowShareModel = - mongoose.models["WorkflowShare"] ?? - mongoose.model("WorkflowShare", schema); diff --git a/backend/src/routes/aiControlCenter.ts b/backend/src/routes/aiControlCenter.ts new file mode 100644 index 0000000..ad66e23 --- /dev/null +++ b/backend/src/routes/aiControlCenter.ts @@ -0,0 +1,373 @@ +import express from "express"; +import { + buildProvider, + describeLlmConfig, + mergeWithPreset, + isProviderLocal, + PROVIDER_PRESETS, + responseText, + type LlmRole, + type ProviderConfig, +} from "../lib/llm/providers"; +import { + deleteProviderKey, + getProviderKey, + providerKeyInfo, + readAiConfig, + setProviderKey, + updateAiConfig, + type StoredProvider, +} from "../lib/llm/controlCenter/store"; +import { + deleteOllamaModel, + detectHardware, + lmStudioStatus, + MODEL_CATALOG, + modelFit, + ollamaCapabilities, + ollamaStatus, + pullOllamaModel, + recommendedChatModel, +} from "../lib/llm/controlCenter/localRuntime"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; + +/** + * AI Control Center — the user's own model providers, keys, and assignments. + * Keys go in, never out: responses carry only "configured" + a 4-char hint. + */ +export const aiControlCenterRouter = express.Router(); + +const ROLES: LlmRole[] = ["agent", "utility", "embedding"]; +const ID_RE = /^[a-z0-9][a-z0-9-]{1,39}$/; + +/** Providers that need no key (local servers). */ +const KEYLESS = new Set(["ollama", "lmstudio", "llamacpp"]); + +function storedProviders(): StoredProvider[] { + return readAiConfig()?.providers ?? []; +} + +/** Full provider config for `id`: stored overrides on top of the preset, plus the decrypted key. */ +function resolveProvider(id: string): ProviderConfig | null { + const stored = storedProviders().find((p) => p.id === id); + const preset = PROVIDER_PRESETS[id]; + if (!stored && !preset) return null; + const merged = mergeWithPreset({ id, ...(preset ?? {}), ...(stored ?? {}) } as ProviderConfig); + const key = getProviderKey(id); + return key ? { ...merged, apiKey: key } : merged; +} + +function providerView(p: ProviderConfig, enabled: boolean) { + const key = providerKeyInfo(p.id); + const keyless = KEYLESS.has(p.id) || isProviderLocal(p); + return { + id: p.id, + label: p.label ?? p.id, + kind: p.kind, + baseUrl: p.baseUrl ?? null, + local: isProviderLocal(p), + preset: p.id in PROVIDER_PRESETS, + enabled, + needsKey: !keyless, + key: { configured: key.configured, hint: key.hint, updatedAt: key.updatedAt }, + // Dev only: an env var can stand in for an in-app key. + envKey: !key.configured && !!p.apiKeyEnv && !!process.env[p.apiKeyEnv]?.trim(), + }; +} + +aiControlCenterRouter.get("/state", (_req, res) => { + const cfg = readAiConfig(); + const stored = storedProviders(); + const ids = [...new Set([...Object.keys(PROVIDER_PRESETS), ...stored.map((p) => p.id)])]; + res.json({ + configured: !!cfg, + localOnly: cfg?.localOnly ?? false, + roles: cfg?.roles ?? {}, + effective: describeLlmConfig(), + providers: ids.map((id) => providerView(resolveProvider(id)!, stored.some((p) => p.id === id))), + }); +}); + +aiControlCenterRouter.put("/providers/:id", (req, res) => { + const id = req.params.id; + if (!ID_RE.test(id)) { + res.status(400).json({ detail: "Provider id must be 2–40 lowercase letters, digits or dashes" }); + return; + } + const { label, baseUrl, kind, local } = req.body as Partial; + const preset = PROVIDER_PRESETS[id]; + if (!preset && !baseUrl) { + res.status(400).json({ detail: "Custom providers need a base URL" }); + return; + } + if (baseUrl && !/^https?:\/\//.test(baseUrl)) { + res.status(400).json({ detail: "Base URL must start with http:// or https://" }); + return; + } + updateAiConfig((cfg) => { + const next: StoredProvider = { + id, + kind: preset?.kind ?? (kind === "anthropic" ? "anthropic" : "openai-compatible"), + ...(label ? { label: String(label).slice(0, 60) } : {}), + ...(baseUrl ? { baseUrl: baseUrl.replace(/\/$/, "") } : {}), + ...(typeof local === "boolean" ? { local } : {}), + }; + cfg.providers = [...cfg.providers.filter((p) => p.id !== id), next]; + }); + res.json({ ok: true }); +}); + +aiControlCenterRouter.delete("/providers/:id", (req, res) => { + const id = req.params.id; + deleteProviderKey(id); + updateAiConfig((cfg) => { + cfg.providers = cfg.providers.filter((p) => p.id !== id); + for (const role of ROLES) if (cfg.roles[role]?.provider === id) delete cfg.roles[role]; + }); + res.json({ ok: true }); +}); + +aiControlCenterRouter.put("/providers/:id/key", (req, res) => { + const id = req.params.id; + const apiKey = String((req.body as { apiKey?: unknown }).apiKey ?? "").trim(); + if (!resolveProvider(id)) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + if (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey)) { + res.status(400).json({ detail: "That does not look like an API key" }); + return; + } + setProviderKey(id, apiKey); + updateAiConfig((cfg) => { + if (!cfg.providers.some((p) => p.id === id)) { + const preset = PROVIDER_PRESETS[id]; + cfg.providers.push({ id, kind: preset?.kind ?? "openai-compatible" }); + } + }); + res.json({ ok: true, key: providerKeyInfo(id) }); +}); + +aiControlCenterRouter.delete("/providers/:id/key", (req, res) => { + deleteProviderKey(req.params.id); + res.json({ ok: true }); +}); + +aiControlCenterRouter.get("/providers/:id/models", async (req, res) => { + const pc = resolveProvider(req.params.id); + if (!pc) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + try { + const provider = buildProvider(pc); + const models = (await provider.listModels?.()) ?? []; + res.json({ models: models.sort() }); + } catch (err) { + res.status(502).json({ detail: friendlyLlmError(err) }); + } +}); + +/** One-shot connectivity + latency check, optionally against a specific model. */ +aiControlCenterRouter.post("/providers/:id/test", async (req, res) => { + const pc = resolveProvider(req.params.id); + if (!pc) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + const model = (req.body as { model?: string }).model; + const started = Date.now(); + try { + const provider = buildProvider(pc); + if (model) { + const reply = await provider.complete({ + model, + maxTokens: 16, + messages: [{ role: "user", content: "Reply with the single word: ready" }], + }); + res.json({ ok: true, latencyMs: Date.now() - started, reply: responseText(reply).trim().slice(0, 80) }); + } else { + const models = (await provider.listModels?.()) ?? []; + res.json({ ok: true, latencyMs: Date.now() - started, modelCount: models.length }); + } + } catch (err) { + res.json({ ok: false, latencyMs: Date.now() - started, error: friendlyLlmError(err) }); + } +}); + +/** Does this model actually call tools? Forces a trivial tool call and checks. */ +aiControlCenterRouter.post("/probe-tools", async (req, res) => { + const { provider: id, model } = req.body as { provider?: string; model?: string }; + const pc = id ? resolveProvider(id) : null; + if (!pc || !model) { + res.status(400).json({ detail: "provider and model are required" }); + return; + } + if (pc.id === "ollama") { + const caps = await ollamaCapabilities(model); + if (caps) { + res.json({ supportsTools: caps.includes("tools"), source: "ollama" }); + return; + } + } + try { + const reply = await buildProvider(pc).complete({ + model, + maxTokens: 64, + messages: [{ role: "user", content: "What time is it? Use the tool." }], + tools: [{ name: "get_time", description: "Returns the current time", input_schema: { type: "object", properties: {} } }], + toolChoice: { type: "auto" }, + }); + res.json({ supportsTools: reply.content.some((b) => b.type === "tool_use"), source: "probe" }); + } catch (err) { + const msg = friendlyLlmError(err); + if (/tool/i.test(msg)) res.json({ supportsTools: false, source: "probe", detail: msg }); + else res.status(502).json({ detail: msg }); + } +}); + +aiControlCenterRouter.put("/roles", async (req, res) => { + const body = req.body as Partial>; + for (const role of Object.keys(body)) { + if (!ROLES.includes(role as LlmRole)) { + res.status(400).json({ detail: `Unknown role "${role}"` }); + return; + } + } + for (const role of ROLES) { + const b = body[role]; + if (b && (!resolveProvider(b.provider) || !b.model?.trim())) { + res.status(400).json({ detail: `Role "${role}" needs a known provider and a model` }); + return; + } + } + + // Fill in tool support for Ollama models when the caller did not say. + for (const role of ["agent", "utility"] as const) { + const b = body[role]; + if (b && b.supportsTools === undefined && b.provider === "ollama") { + const caps = await ollamaCapabilities(b.model); + if (caps) b.supportsTools = caps.includes("tools"); + } + } + + updateAiConfig((cfg) => { + for (const role of ROLES) { + if (!(role in body)) continue; + const b = body[role]; + if (!b) { + delete cfg.roles[role]; + continue; + } + cfg.roles[role] = { provider: b.provider, model: b.model.trim(), ...(b.supportsTools !== undefined ? { supportsTools: b.supportsTools } : {}) }; + if (!cfg.providers.some((p) => p.id === b.provider)) { + const preset = PROVIDER_PRESETS[b.provider]; + cfg.providers.push({ id: b.provider, kind: preset?.kind ?? "openai-compatible" }); + } + } + }); + res.json({ ok: true, effective: describeLlmConfig() }); +}); + +aiControlCenterRouter.put("/policy", (req, res) => { + const { localOnly } = req.body as { localOnly?: boolean }; + updateAiConfig((cfg) => { + if (typeof localOnly === "boolean") cfg.localOnly = localOnly; + }); + res.json({ ok: true, effective: describeLlmConfig() }); +}); + +// ── On-device runtimes ─────────────────────────────────────────────────────── + +aiControlCenterRouter.get("/local", async (_req, res) => { + const hw = detectHardware(); + const [ollama, lmstudio] = await Promise.all([ollamaStatus(), lmStudioStatus()]); + const installed = new Set(ollama.models.map((m) => m.name.replace(/:latest$/, ""))); + res.json({ + hardware: hw, + ollama, + lmstudio, + recommended: recommendedChatModel(hw), + catalog: MODEL_CATALOG.map((m) => ({ ...m, fit: modelFit(m, hw), installed: installed.has(m.id) })), + }); +}); + +/** SSE: streams `ollama pull` progress as { status, completed, total, percent }. */ +aiControlCenterRouter.post("/local/pull", async (req, res) => { + const model = String((req.body as { model?: unknown }).model ?? "").trim(); + if (!/^[\w.\-/]+(:[\w.\-]+)?$/.test(model)) { + res.status(400).json({ detail: "Invalid model name" }); + return; + } + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + const abort = new AbortController(); + res.on("close", () => abort.abort()); + const send = (o: unknown) => res.write(`data: ${JSON.stringify(o)}\n\n`); + + try { + await pullOllamaModel( + model, + (p) => { + const percent = p.total ? Math.round(((p.completed ?? 0) / p.total) * 1000) / 10 : null; + send({ type: "progress", status: p.status, completed: p.completed ?? null, total: p.total ?? null, percent }); + }, + abort.signal, + ); + send({ type: "done", model }); + } catch (err) { + if (!abort.signal.aborted) send({ type: "error", detail: friendlyLlmError(err) }); + } + res.end(); +}); + +aiControlCenterRouter.delete("/local/models/:name", async (req, res) => { + try { + await deleteOllamaModel(req.params.name); + res.json({ ok: true }); + } catch (err) { + res.status(502).json({ detail: friendlyLlmError(err) }); + } +}); + +/** + * One-click on-device setup: Ollama for chat (best installed tool-capable model) + * and embeddings, Local-only mode on. + */ +aiControlCenterRouter.post("/quick-setup/local", async (_req, res) => { + const ollama = await ollamaStatus(); + if (!ollama.running) { + res.status(409).json({ detail: "Ollama is not running. Install it from ollama.com and open it, then try again." }); + return; + } + const names = ollama.models.map((m) => m.name); + const withTools: Array<{ name: string; sizeGB: number }> = []; + for (const m of ollama.models) { + const caps = await ollamaCapabilities(m.name); + if (caps?.includes("tools")) withTools.push(m); + } + const hw = detectHardware(); + // Largest tool-capable model that still fits in memory. + const chat = withTools + .filter((m) => m.sizeGB + 2.5 <= hw.totalMemGB) + .sort((a, b) => b.sizeGB - a.sizeGB)[0]?.name; + if (!chat) { + res.status(409).json({ + detail: `No tool-capable chat model is installed. Download ${recommendedChatModel(hw)} from the Local models section first.`, + }); + return; + } + const embed = names.find((n) => /embed/.test(n)); + + updateAiConfig((cfg) => { + if (!cfg.providers.some((p) => p.id === "ollama")) cfg.providers.push({ id: "ollama", kind: "ollama" }); + cfg.roles.agent = { provider: "ollama", model: chat, supportsTools: true }; + cfg.roles.utility = { provider: "ollama", model: chat, supportsTools: true }; + if (embed) cfg.roles.embedding = { provider: "ollama", model: embed }; + cfg.localOnly = true; + }); + res.json({ ok: true, chat, embed: embed ?? null, effective: describeLlmConfig() }); +}); diff --git a/backend/src/routes/assistantChat.ts b/backend/src/routes/assistantChat.ts index 36e52f5..41549ef 100644 --- a/backend/src/routes/assistantChat.ts +++ b/backend/src/routes/assistantChat.ts @@ -1,10 +1,10 @@ import { randomUUID } from "crypto"; import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage } from "../lib/llm/providers"; import { assistantRegistry, type ToolContext } from "../lib/tools"; import { buildSystemPrompt } from "../lib/chat/buildSystemPrompt"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; import { connectDb } from "../lib/infra/db"; import { buildWorkflowStore } from "../lib/workflows/workflowMemory"; import { @@ -76,7 +76,7 @@ function parseAssistantMessages( return { ok: true, messages: out }; } -function toAnthropicMessages(messages: ChatMessage[]): Anthropic.MessageParam[] { +function toLlmMessages(messages: ChatMessage[]): LlmMessage[] { return messages.map((m) => { let content = m.content; if (m.role === "user" && m.workflow?.id) { @@ -238,9 +238,8 @@ assistantChatRouter.post("/", async (req, res) => { try { await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(validMessages), + messages: toLlmMessages(validMessages), tools: assistantRegistry.getTools(), write, executeTool: async (name, input) => { @@ -267,11 +266,7 @@ assistantChatRouter.post("/", async (req, res) => { write("data: [DONE]\n\n"); } catch (err) { console.error("[assistantChat] error:", err); - let message = err instanceof Error ? err.message : "Stream error"; - if (message.includes("authentication_error") || message.includes("401")) { - message = - "Anthropic API key invalid. Set ANTHROPIC_API_KEY in backend/.env, then restart (pnpm dev)."; - } + const message = friendlyLlmError(err); write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } finally { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1df5b49..560c997 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from "express"; import rateLimit from "express-rate-limit"; import { connectDb } from "../lib/infra/db"; -import { requireAuth } from "../middleware/requireAuth"; +import { requireJwt } from "../middleware/requireJwt"; import { generateAuthTokens, serializeAuthTokens, @@ -325,7 +325,7 @@ authRouter.post("/reset-password", emailActionLimiter, async (req, res) => { } }); -authRouter.post("/signout", signoutLimiter, requireAuth, async (req, res) => { +authRouter.post("/signout", signoutLimiter, requireJwt, async (req, res) => { const refreshToken = readRefreshToken(req); if (!refreshToken) { clearRefreshTokenCookie(res); @@ -385,7 +385,7 @@ authRouter.post("/refresh-tokens", refreshLimiter, async (req, res) => { } }); -authRouter.get("/me", requireAuth, async (req, res) => { +authRouter.get("/me", requireJwt, async (req, res) => { const userId = res.locals.userId as string | undefined; if (!userId) { res.status(401).json({ detail: "Authentication required" }); diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 4c5a8d8..62f0cae 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -1,7 +1,6 @@ import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage, LlmToolChoice } from "../lib/llm/providers"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; import { screenerRegistry, SCREEN_CAP_TABLE_TOOL_NAME, @@ -37,9 +36,9 @@ const BASE_SYSTEM = `- When you call web_search, you MUST include every source URL from the results as a markdown hyperlink [title](url) in your response. Never summarise web results without citing links.\n\n` + `CSV ingest: any CSV format is accepted — the system auto-detects cap tables (entity + owner + ownership %) or flat entity lists.`; -function toAnthropicMessages( +function toLlmMessages( messages: Array<{ role: "user" | "assistant"; content: string }>, -): Anthropic.MessageParam[] { +): LlmMessage[] { return messages.map((m) => ({ role: m.role, content: m.content, @@ -136,16 +135,15 @@ chatRouter.post("/", async (req, res) => { // Force screen_cap_table on the first turn when a CSV is attached and no // prior result exists — makes the critical routing case deterministic. - const toolChoice: Anthropic.ToolChoiceTool | undefined = + const toolChoice: LlmToolChoice | undefined = ctx.csvContent && !ctx.screeningResult ? { type: "tool", name: SCREEN_CAP_TABLE_TOOL_NAME } : undefined; try { const { toolActivity } = await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(validMessages), + messages: toLlmMessages(validMessages), tools: screenerRegistry.getTools(), executeTool: wrappedExecute, write, diff --git a/backend/src/routes/config.ts b/backend/src/routes/config.ts index c2f3e09..bce909b 100644 --- a/backend/src/routes/config.ts +++ b/backend/src/routes/config.ts @@ -1,18 +1,15 @@ import express from "express"; -import { getAnthropicModel } from "../lib/llm/models"; +import { describeLlmConfig } from "../lib/llm/providers"; import { getScreeningThresholds } from "../lib/screening/screeningConfig"; import { isEmbeddingEnabled } from "../lib/rag/embed"; import { isWebSearchEnabled } from "../lib/infra/webSearchConfig"; export const configRouter = express.Router(); -/** Public runtime config — model selection lives on the server only. */ +/** Public runtime config — provider/model names only, never keys. */ configRouter.get("/", (_req, res) => { res.json({ - llm: { - provider: "anthropic", - model: getAnthropicModel(), - }, + llm: describeLlmConfig(), screening: getScreeningThresholds(), ragEnabled: isEmbeddingEnabled(), webSearchEnabled: isWebSearchEnabled(), diff --git a/backend/src/routes/desktopData.ts b/backend/src/routes/desktopData.ts new file mode 100644 index 0000000..b5486e2 --- /dev/null +++ b/backend/src/routes/desktopData.ts @@ -0,0 +1,155 @@ +import crypto from "crypto"; +import fs from "fs"; +import { dataFile } from "../lib/infra/dataDir"; +import express from "express"; +import { createBackup, stageRestore } from "../lib/infra/backup"; +import { getWatchFolder, scanWatchedFolders, setWatchFolder } from "../lib/infra/folderWatch"; +import { AVAILABLE_SANCTIONS_LISTS, readSettings, writeSettings } from "../lib/infra/settings"; +import { databaseFile } from "../lib/infra/db"; +import { fetchWatchmanListInfo, getWatchmanBaseUrl } from "../lib/screening/watchman"; +import { AuditLog } from "../models"; +import { ownerFilter } from "./startups/middleware"; +import { Startup } from "../models"; + +/** Desktop-only: settings, sanctions data, backup/restore, watched folders, verified audit export. */ +export const desktopDataRouter = express.Router(); + +/** Exit code the Electron supervisor treats as "restart me" (not a crash). */ +export const RESTART_EXIT_CODE = 75; + +desktopDataRouter.get("/settings", (_req, res) => { + res.json({ settings: readSettings(), availableLists: AVAILABLE_SANCTIONS_LISTS, databaseFile: databaseFile() }); +}); + +desktopDataRouter.put("/settings", (req, res) => { + const body = req.body as { backgroundScreening?: { enabled?: unknown; intervalHours?: unknown }; sanctionsLists?: unknown }; + const patch: Parameters[0] = {}; + if (body.backgroundScreening) { + const cur = readSettings().backgroundScreening; + const hours = Number(body.backgroundScreening.intervalHours ?? cur.intervalHours); + patch.backgroundScreening = { + enabled: typeof body.backgroundScreening.enabled === "boolean" ? body.backgroundScreening.enabled : cur.enabled, + intervalHours: Number.isFinite(hours) ? Math.min(24 * 30, Math.max(1, Math.round(hours))) : cur.intervalHours, + }; + } + if (body.sanctionsLists !== undefined) { + const valid = new Set(AVAILABLE_SANCTIONS_LISTS.map((l) => l.id)); + const lists = Array.isArray(body.sanctionsLists) ? body.sanctionsLists.map(String).filter((l) => valid.has(l)) : []; + if (!lists.length) { + res.status(400).json({ detail: "Choose at least one sanctions list" }); + return; + } + patch.sanctionsLists = lists; + } + // The desktop shell watches settings.json and reloads Watchman when lists change. + res.json({ settings: writeSettings(patch) }); +}); + +/** Written by the desktop shell: "online", or "offline" when using its list snapshot. */ +function engineStatus(): { mode: "online" | "offline"; cached: Record } { + try { + const raw = JSON.parse(fs.readFileSync(dataFile("watchman", "status.json"), "utf8")) as { mode?: string; cached?: Record }; + return { mode: raw.mode === "offline" ? "offline" : "online", cached: raw.cached ?? {} }; + } catch { + return { mode: "online", cached: {} }; + } +} + +desktopDataRouter.get("/sanctions", async (_req, res) => { + const info = await fetchWatchmanListInfo(); + res.json({ + engineUrl: getWatchmanBaseUrl(), + available: !!info, + info, + configured: readSettings().sanctionsLists, + ...engineStatus(), + }); +}); + +desktopDataRouter.post("/backup", (req, res) => { + const password = String((req.body as { password?: unknown }).password ?? ""); + try { + const { file, manifest } = createBackup(password); + const stamp = manifest.createdAt.slice(0, 10); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="capscreen-${stamp}.capscreen-backup"`); + res.setHeader("X-Backup-Entries", String(manifest.entries.length)); + res.send(file); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Backup failed" }); + } +}); + +desktopDataRouter.post( + "/restore", + express.raw({ type: "application/octet-stream", limit: "2gb" }), + (req, res) => { + const password = req.header("x-backup-password") ?? ""; + try { + const manifest = stageRestore(req.body as Buffer, decodeURIComponent(password)); + res.json({ ok: true, createdAt: manifest.createdAt, entries: manifest.entries.length, restarting: true }); + // Swap happens before the database opens on the next start. + setTimeout(() => process.exit(RESTART_EXIT_CODE), 300); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Restore failed" }); + } + }, +); + +desktopDataRouter.get("/watch-folders/:startupId", (req, res) => { + res.json({ watch: getWatchFolder(req.params.startupId) }); +}); + +desktopDataRouter.put("/watch-folders/:startupId", async (req, res) => { + const startup = await Startup.findOne({ _id: req.params.startupId, ...ownerFilter(res.locals.userId as string) }).lean(); + if (!startup) { + res.status(404).json({ detail: "Startup not found" }); + return; + } + const folder = (req.body as { path?: string | null }).path ?? null; + try { + setWatchFolder(req.params.startupId, folder); + if (folder) void scanWatchedFolders(); + res.json({ watch: getWatchFolder(req.params.startupId) }); + } catch (err) { + res.status(400).json({ detail: err instanceof Error ? err.message : "Could not watch folder" }); + } +}); + +/** + * Tamper-evident audit export: every entry carries sha256(prev_hash + entry), + * and the final hash seals the file. Changing, removing or reordering any line + * breaks the chain from that point on. + */ +desktopDataRouter.get("/audit-export", async (req, res) => { + const { startupId } = req.query as { startupId?: string }; + const query = startupId ? { startupId } : {}; + const logs = (await AuditLog.find(query).sort({ createdAt: 1 }).lean()) as Array>; + + let prev = "0".repeat(64); + const entries = logs.map((d) => { + const entry = { + id: String(d._id), + startupId: d.startupId ? String(d.startupId) : null, + eventType: d.eventType, + performedBy: d.performedBy, + createdAt: new Date(d.createdAt as Date).toISOString(), + details: d.details ?? {}, + }; + const hash = crypto.createHash("sha256").update(prev + JSON.stringify(entry)).digest("hex"); + prev = hash; + return { ...entry, hash }; + }); + + const doc = { + format: "capscreen-audit-v1", + exportedAt: new Date().toISOString(), + scope: startupId ? { startupId } : "all", + count: entries.length, + chain: { algorithm: "sha256(prevHash + JSON(entry))", genesis: "0".repeat(64), finalHash: prev }, + entries, + }; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Disposition", `attachment; filename="capscreen-audit-${doc.exportedAt.slice(0, 10)}.json"`); + res.send(JSON.stringify(doc, null, 2)); +}); diff --git a/backend/src/routes/desktopOAuth.ts b/backend/src/routes/desktopOAuth.ts new file mode 100644 index 0000000..ade16f2 --- /dev/null +++ b/backend/src/routes/desktopOAuth.ts @@ -0,0 +1,166 @@ +import crypto from "crypto"; +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { connectDb } from "../lib/infra/db"; +import { generateAuthTokens } from "../lib/auth/tokens"; +import { requireJwt } from "../middleware/requireJwt"; +import { + DesktopAuthCode, + findUserById, + findValidRefreshToken, + hashAuthCode, + revokeRefreshToken, + serializePublicUser, +} from "../models"; + +/** + * Desktop sign-in, the way native apps (and Claude Code) do it — RFC 8252: + * + * 1. The app opens the browser at /desktop/authorize with a loopback + * redirect_uri, a random state, and a PKCE S256 code_challenge. + * 2. The user signs in on the portal; the portal calls POST /authorize-code + * with their access token and gets a one-time code. + * 3. The browser is sent to http://127.0.0.1:/callback?code&state. + * 4. The app exchanges code + code_verifier at POST /token for a session. + */ +export const desktopOAuthRouter = Router(); + +const CODE_TTL_MS = 2 * 60 * 1000; +const DESKTOP_REFRESH_DAYS = Number.parseInt(process.env.DESKTOP_REFRESH_TOKEN_EXPIRATION_DAYS ?? "30", 10); + +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { detail: "Too many requests. Please try again later." }, +}); + +desktopOAuthRouter.use(limiter); +desktopOAuthRouter.use(async (_req, _res, next) => { + try { + await connectDb(); + next(); + } catch (err) { + next(err); + } +}); + +/** Only loopback HTTP callbacks — the code can never be sent to another host. */ +export function isLoopbackRedirect(uri: unknown): uri is string { + if (typeof uri !== "string") return false; + try { + const u = new URL(uri); + return ( + u.protocol === "http:" && + (u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]") && + u.port !== "" && + u.pathname === "/callback" && + !u.username && + !u.password + ); + } catch { + return false; + } +} + +const B64URL = /^[A-Za-z0-9_-]{43,128}$/; + +desktopOAuthRouter.post("/authorize-code", requireJwt, async (req, res) => { + const { redirect_uri, state, code_challenge, code_challenge_method } = req.body as Record; + if (!isLoopbackRedirect(redirect_uri)) { + res.status(400).json({ detail: "redirect_uri must be a loopback /callback URL" }); + return; + } + if (code_challenge_method !== "S256" || typeof code_challenge !== "string" || !B64URL.test(code_challenge)) { + res.status(400).json({ detail: "A PKCE S256 code_challenge is required" }); + return; + } + if (typeof state !== "string" || state.length < 16 || state.length > 256) { + res.status(400).json({ detail: "Invalid state" }); + return; + } + + const code = crypto.randomBytes(32).toString("base64url"); + await DesktopAuthCode.create({ + codeHash: hashAuthCode(code), + userId: res.locals.userId, + codeChallenge: code_challenge, + redirectUri: redirect_uri, + expiresAt: new Date(Date.now() + CODE_TTL_MS), + }); + + const target = new URL(redirect_uri); + target.searchParams.set("code", code); + target.searchParams.set("state", state); + res.json({ redirect: target.toString() }); +}); + +async function sessionResponse(user: Parameters[0]) { + const issued = await generateAuthTokens(user, DESKTOP_REFRESH_DAYS); + return { + user: serializePublicUser(user), + accessToken: issued.accessToken.token, + accessExpires: issued.accessToken.expires, + refreshToken: issued.refreshTokenValue, + refreshExpires: issued.refreshExpires.toISOString(), + }; +} + +desktopOAuthRouter.post("/token", async (req, res) => { + const { code, code_verifier, redirect_uri } = req.body as Record; + if (typeof code !== "string" || typeof code_verifier !== "string" || typeof redirect_uri !== "string") { + res.status(400).json({ detail: "code, code_verifier and redirect_uri are required" }); + return; + } + // Atomically claim the code so it can only ever be redeemed once. + const record = (await DesktopAuthCode.findOneAndUpdate( + { codeHash: hashAuthCode(code), usedAt: null, expiresAt: { $gt: new Date() } }, + { $set: { usedAt: new Date() } }, + { new: false }, + ).lean()) as { userId: unknown; codeChallenge: string; redirectUri: string } | null; + if (!record || record.redirectUri !== redirect_uri) { + res.status(400).json({ detail: "Invalid or expired code" }); + return; + } + const challenge = crypto.createHash("sha256").update(code_verifier).digest("base64url"); + const a = Buffer.from(challenge); + const b = Buffer.from(record.codeChallenge); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + res.status(400).json({ detail: "PKCE verification failed" }); + return; + } + const user = await findUserById(String(record.userId)); + if (!user) { + res.status(400).json({ detail: "Account no longer exists" }); + return; + } + res.json(await sessionResponse(user)); +}); + +/** Rotate a desktop refresh token (sent in the body; desktops have no cookie jar). */ +desktopOAuthRouter.post("/refresh", async (req, res) => { + const refreshToken = (req.body as { refreshToken?: unknown }).refreshToken; + if (typeof refreshToken !== "string" || !refreshToken) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + const tokenDoc = await findValidRefreshToken(refreshToken); + if (!tokenDoc) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + const user = await findUserById(tokenDoc.userId.toString()); + await tokenDoc.deleteOne(); + if (!user) { + res.status(401).json({ detail: "Invalid or expired session" }); + return; + } + res.json(await sessionResponse(user)); +}); + +desktopOAuthRouter.post("/revoke", async (req, res) => { + const refreshToken = (req.body as { refreshToken?: unknown }).refreshToken; + if (typeof refreshToken === "string" && refreshToken) await revokeRefreshToken(refreshToken).catch(() => {}); + res.json({ ok: true }); +}); diff --git a/backend/src/routes/documents/singleDocuments.ts b/backend/src/routes/documents/singleDocuments.ts index 6994eb0..eb91276 100644 --- a/backend/src/routes/documents/singleDocuments.ts +++ b/backend/src/routes/documents/singleDocuments.ts @@ -11,9 +11,9 @@ import { deleteObject, detectFileType, getObjectDownloadUrl, - r2Configured, + storageConfigured, uploadObject, -} from "../../lib/infra/r2Storage"; +} from "../../lib/infra/fileStorage"; import { toAppDocument } from "./serializers"; export const singleDocumentsRouter = Router(); @@ -44,7 +44,7 @@ singleDocumentsRouter.get("/", async (req, res) => { }); singleDocumentsRouter.post("/", upload.single("file"), async (req, res) => { - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } @@ -153,7 +153,7 @@ projectDocumentsRouter.post( "/:projectId/documents", upload.single("file"), async (req, res) => { - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } diff --git a/backend/src/routes/startups.ts b/backend/src/routes/startups.ts deleted file mode 100644 index 1730488..0000000 --- a/backend/src/routes/startups.ts +++ /dev/null @@ -1,805 +0,0 @@ -import express from "express"; -import mongoose from "mongoose"; -import { connectDb } from "../lib/infra/db"; -import { deriveParseStatus, ingestCsv, toIngestApiResponse } from "../lib/screening/csvIngest"; -import { runScreening } from "../lib/screening/runScreening"; -import { syncPortfolioMonitoringReview } from "../lib/portfolio/portfolioGrid"; -import { detectRosterPurpose } from "../lib/screening/rosterPurpose"; -import { runPortfolioRescreen } from "../lib/portfolio/rescreenScheduler"; -import { extractCsvTable } from "../lib/screening/parseCapTable"; -import { - computeScreeningDelta, - getStartupScreeningDelta, - saveScreeningSnapshot, -} from "../lib/screening/screeningDelta"; -import { syncEntityReviewStatus } from "../lib/tabular/reviewStatusSync"; -import { Startup } from "../models/startup"; -import { CapTableCsv } from "../models/capTableCsv"; -import { StartupChat } from "../models/chat/startupChat"; -import { EntityReview } from "../models/screening/entityReview"; -import { AuditLog } from "../models/audit/auditLog"; -import { StartupDocument } from "../models/documents/startupDocument"; -import { - createIcMemoDocument, - getLatestIcMemo, - getStartupDocument, - listIcMemos, -} from "../lib/documents/icMemoDocument"; -import { requireAuth } from "../middleware/requireAuth"; -import type { ScreeningSummary, ScreeningResult, RosterPurpose } from "../types/screening"; - -export const startupsRouter = express.Router(); - -startupsRouter.use(requireAuth); -startupsRouter.use(async (_req, _res, next) => { - try { - await connectDb(); - next(); - } catch (err) { - next(err); - } -}); - -function ownerFilter(userId: string) { - return { ownerId: userId }; -} - -async function findOwnedStartup(id: string, userId: string) { - if (!mongoose.Types.ObjectId.isValid(id)) return null; - const s = await Startup.findOne({ _id: id, ...ownerFilter(userId) }).lean(); - return s; -} - -startupsRouter.param("id", async (req, res, next, id) => { - if (!mongoose.Types.ObjectId.isValid(id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const userId = res.locals.userId as string; - const owned = await findOwnedStartup(id, userId); - if (!owned) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - next(); -}); - -// ── Portfolio summary (must be before /:id) ───────────────────────────────── - -startupsRouter.get("/screening-summary", async (req, res) => { - const userId = res.locals.userId as string; - const list = await Startup.find(ownerFilter(userId)) - .sort({ createdAt: -1 }) - .lean(); - res.json(list.map(toScreeningSummary)); -}); - -// ── Startups ──────────────────────────────────────────────────────────────── - -startupsRouter.get("/", async (req, res) => { - const userId = res.locals.userId as string; - const list = await Startup.find(ownerFilter(userId)) - .sort({ createdAt: -1 }) - .lean(); - res.json(list.map(toStartup)); -}); - -startupsRouter.post("/", async (req, res) => { - const { name } = req.body as { name?: string }; - if (!name?.trim()) { - res.status(400).json({ detail: "name is required" }); - return; - } - const s = await Startup.create({ - name: name.trim(), - ownerId: res.locals.userId as string, - }); - res.status(201).json(toStartup(s.toObject())); -}); - -startupsRouter.get("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const s = await findOwnedStartup(req.params.id, userId); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -startupsRouter.patch("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const { name } = req.body as { name?: string }; - const s = await Startup.findOneAndUpdate( - { _id: req.params.id, ...ownerFilter(userId) }, - { ...(name?.trim() && { name: name.trim() }) }, - { new: true }, - ).lean(); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -startupsRouter.delete("/:id", async (req, res) => { - const userId = res.locals.userId as string; - const s = await findOwnedStartup(req.params.id, userId); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - await Startup.findByIdAndDelete(req.params.id); - await CapTableCsv.deleteMany({ startupId: req.params.id }); - await StartupChat.deleteOne({ startupId: req.params.id }); - await StartupDocument.deleteMany({ startupId: req.params.id }); - res.status(204).send(); -}); - -// ── Cap-table CSVs ────────────────────────────────────────────────────────── - -startupsRouter.post("/analyze-csv", async (req, res) => { - const { filename, content } = req.body as { - filename?: string; - content?: string; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - const ingest = await ingestCsv(content, filename?.trim()); - res.json(toIngestApiResponse(ingest)); -}); - -startupsRouter.get("/:id/csvs", async (req, res) => { - const list = await CapTableCsv.find({ startupId: req.params.id }) - .sort({ uploadedAt: -1 }) - .lean(); - res.json(list.map(toCsv)); -}); - -startupsRouter.post("/:id/csvs/analyze", async (req, res) => { - const { filename, content } = req.body as { - filename?: string; - content?: string; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - const ingest = await ingestCsv(content, filename?.trim()); - res.json(toIngestApiResponse(ingest)); -}); - -startupsRouter.post("/:id/csvs", async (req, res) => { - const { filename, content, confirmMapping } = req.body as { - filename?: string; - content?: string; - confirmMapping?: boolean; - }; - if (!filename?.trim() || !content?.trim()) { - res.status(400).json({ detail: "filename and content are required" }); - return; - } - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const ingest = await ingestCsv(content, filename.trim()); - const parseStatus = deriveParseStatus(ingest, confirmMapping); - const table = extractCsvTable(content); - const rosterPurpose = resolveRosterPurpose( - filename.trim(), - table?.headers ?? [], - ingest.csvKind, - ); - const recordCount = - ingest.csvKind === "entity_roster" - ? ingest.rosterEntities.length - : ingest.records.length; - - const csv = await CapTableCsv.create({ - startupId: req.params.id, - filename: filename.trim(), - content, - parseStatus, - parseErrors: ingest.errors, - recordCount, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - confidence: ingest.confidence, - columnMapping: ingest.columnMapping, - normalizedContent: ingest.normalizedContent, - ingestWarnings: ingest.warnings, - rosterPurpose, - }); - await Startup.findByIdAndUpdate(req.params.id, { - latestCsvId: csv._id, - [`latestCsvByPurpose.${purposeStorageKey(rosterPurpose)}`]: csv._id, - }); - - // Audit: csv_uploaded - await AuditLog.create({ - startupId: req.params.id, - eventType: "csv_uploaded", - performedBy: res.locals.userId ?? "preview-user", - performedByEmail: res.locals.userEmail ?? "admin@capscreen.local", - details: { - filename: filename.trim(), - recordCount, - parseStatus, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - canScreen: ingest.canScreen, - rosterPurpose, - }, - }); - - res.status(201).json(toCsv(csv.toObject())); -}); - -startupsRouter.patch("/:id/csvs/:csvId", async (req, res) => { - const { content, confirmMapping } = req.body as { - content?: string; - confirmMapping?: boolean; - }; - if (!content?.trim()) { - res.status(400).json({ detail: "content is required" }); - return; - } - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const ingest = await ingestCsv(content); - const parseStatus = deriveParseStatus(ingest, confirmMapping); - const recordCount = - ingest.csvKind === "entity_roster" - ? ingest.rosterEntities.length - : ingest.records.length; - - const csv = await CapTableCsv.findOneAndUpdate( - { _id: req.params.csvId, startupId: req.params.id }, - { - content, - parseStatus, - parseErrors: ingest.errors, - recordCount, - csvKind: ingest.csvKind, - parseSource: ingest.parseSource, - confidence: ingest.confidence, - columnMapping: ingest.columnMapping, - normalizedContent: ingest.normalizedContent, - ingestWarnings: ingest.warnings, - }, - { new: true }, - ).lean(); - - if (!csv) { - res.status(404).json({ detail: "CSV not found" }); - return; - } - - await AuditLog.create({ - startupId: req.params.id, - eventType: "csv_updated", - performedBy: res.locals.userId ?? "preview-user", - performedByEmail: res.locals.userEmail ?? "admin@capscreen.local", - details: { - csvId: req.params.csvId, - recordCount, - parseStatus, - csvKind: ingest.csvKind, - }, - }); - - res.json(toCsv(csv)); -}); - -startupsRouter.delete("/:id/csvs/:csvId", async (req, res) => { - const deleted = await CapTableCsv.findOneAndDelete({ - _id: req.params.csvId, - startupId: req.params.id, - }); - if (!deleted) { - res.status(404).json({ detail: "CSV not found" }); - return; - } - - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (startup?.latestCsvId?.toString() === req.params.csvId) { - const latest = (await CapTableCsv.findOne({ startupId: req.params.id }) - .sort({ uploadedAt: -1 }) - .lean()) as Record | null; - await Startup.findByIdAndUpdate(req.params.id, { - latestCsvId: latest?._id ?? null, - }); - } - - res.status(204).send(); -}); - -// ── Portfolio re-screen (must be before /:id routes) ──────────────────────── - -startupsRouter.post("/rescreen-all", async (req, res) => { - const result = await runPortfolioRescreen(); - res.json({ - rescreened: result.rescreened, - total: result.total, - digestId: result.digestId, - results: [], - }); -}); - -// ── Screening ──────────────────────────────────────────────────────────────── - -startupsRouter.post("/:id/screen", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const { csvId, purpose: purposeBody } = (req.body ?? {}) as { - csvId?: string; - purpose?: string; - }; - - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - const purposeKey = - purposeBody === "co_investor" || purposeBody === "vendor" - ? purposeBody - : "cap_table"; - - let csvDoc = null as Record | null; - - if (csvId && mongoose.Types.ObjectId.isValid(csvId)) { - csvDoc = (await CapTableCsv.findOne({ - _id: csvId, - startupId: req.params.id, - }).lean()) as Record | null; - } else { - const byPurpose = startup.latestCsvByPurpose as - | Record - | undefined; - const mappedId = byPurpose?.[purposeKey]; - if (mappedId) { - csvDoc = (await CapTableCsv.findById(mappedId).lean()) as Record< - string, - unknown - > | null; - } - } - if (!csvDoc && purposeKey === "cap_table" && startup.latestCsvId) { - csvDoc = (await CapTableCsv.findById(startup.latestCsvId as string).lean()) as - | Record - | null; - } - if (!csvDoc) { - csvDoc = (await CapTableCsv.findOne({ - startupId: req.params.id, - rosterPurpose: - purposeKey === "cap_table" - ? { $in: ["cap_table", "entity_roster"] } - : purposeKey, - }) - .sort({ uploadedAt: -1 }) - .lean()) as Record | null; - } - if (!csvDoc) { - res.status(400).json({ detail: "No cap-table CSV uploaded for this startup" }); - return; - } - if (csvDoc.parseStatus === "invalid") { - res.status(400).json({ - detail: "Latest CSV has parse errors — fix and re-upload before screening", - parseErrors: csvDoc.parseErrors ?? [], - }); - return; - } - if (csvDoc.parseStatus === "needs_review") { - res.status(400).json({ - detail: - "Latest CSV needs review — confirm the detected column mapping before screening", - parseStatus: "needs_review", - }); - return; - } - - try { - const result = await runScreening(csvDoc.content as string, { - csvId: String(csvDoc._id), - filename: csvDoc.filename as string | undefined, - }); - const purpose = (csvDoc.rosterPurpose as RosterPurpose) ?? "cap_table"; - const storagePurpose = - purpose === "co_investor" - ? "co_investor" - : purpose === "vendor" - ? "vendor" - : "cap_table"; - - let delta = null; - if (storagePurpose === "cap_table") { - delta = await computeScreeningDelta(req.params.id, result); - await saveScreeningSnapshot(req.params.id, "cap_table", result); - } - - const update = screeningUpdateForPurpose(purpose, result, csvDoc._id); - const updated = await Startup.findByIdAndUpdate( - req.params.id, - update, - { new: true }, - ).lean(); - if (!updated) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - - await syncPortfolioMonitoringReview({ - userId: res.locals.userId ?? "preview-user", - userEmail: res.locals.userEmail ?? "admin@capscreen.local", - }).catch(() => {}); - res.json({ - screeningResult: result, - startup: toStartup(updated), - delta, - purpose: storagePurpose, - }); - } catch (err) { - const detail = err instanceof Error ? err.message : "Screening failed"; - res.status(400).json({ detail }); - } -}); - -startupsRouter.post("/:id/ic-memo", async (req, res) => { - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const result = startup.lastScreeningResult as ScreeningResult | null; - if (!result) { - res.status(400).json({ detail: "No screening result — run a screen first" }); - return; - } - - try { - const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string; - const doc = await createIcMemoDocument({ - startupId: req.params.id, - screeningResult: result, - performedBy: userId, - performedByEmail: userEmail, - }); - res.status(201).json(doc); - } catch (err) { - const detail = err instanceof Error ? err.message : "Failed to generate IC memo"; - res.status(500).json({ detail }); - } -}); - -startupsRouter.get("/:id/ic-memo", async (req, res) => { - const docs = await listIcMemos(req.params.id); - res.json(docs); -}); - -startupsRouter.get("/:id/ic-memo/latest", async (req, res) => { - const doc = await getLatestIcMemo(req.params.id); - if (!doc) { - res.status(404).json({ detail: "No IC memo found" }); - return; - } - res.json(doc); -}); - -startupsRouter.get("/:id/ic-memo/:docId", async (req, res) => { - const doc = await getStartupDocument(req.params.id, req.params.docId); - if (!doc) { - res.status(404).json({ detail: "Document not found" }); - return; - } - res.json(doc); -}); - -startupsRouter.get("/:id/screening-delta", async (req, res) => { - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup?.lastScreeningResult) { - res.json({ - hasPrevious: false, - previousScreenedAt: null, - changes: [], - summary: "No screening on file.", - }); - return; - } - const delta = await getStartupScreeningDelta( - req.params.id, - startup.lastScreeningResult as ScreeningResult, - ); - res.json(delta); -}); - -startupsRouter.get("/:id/screening-report", async (req, res) => { - const format = (req.query.format as string) ?? "json"; - const startup = (await Startup.findById(req.params.id).lean()) as - | Record - | null; - if (!startup) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const result = startup.lastScreeningResult as ScreeningResult | null; - if (!result) { - res.status(400).json({ detail: "No screening result" }); - return; - } - - if (format === "csv") { - const header = - "entity,type,risk,match_score,ultimate_owner,ownership_path,sanctions_match\n"; - const rows = result.entities.map((e) => { - const match = e.matches[0]?.sdnName ?? ""; - const path = e.ownershipPath.join(" → "); - const score = e.topScore != null ? String(Math.round(e.topScore * 100)) : ""; - return [ - `"${e.name.replace(/"/g, '""')}"`, - e.type, - e.riskLevel, - score, - `"${(e.ultimateOwner ?? "").replace(/"/g, '""')}"`, - `"${path.replace(/"/g, '""')}"`, - `"${match.replace(/"/g, '""')}"`, - ].join(","); - }); - res.setHeader("Content-Type", "text/csv; charset=utf-8"); - res.setHeader( - "Content-Disposition", - `attachment; filename="${(startup.name as string).replace(/\s+/g, "-")}-screening.csv"`, - ); - res.send(header + rows.join("\n")); - return; - } - - res.json({ - startupName: startup.name, - screenedAt: startup.lastScreenedAt, - screeningResult: result, - }); -}); - -startupsRouter.patch("/:id/screening-result", async (req, res) => { - const { screeningResult } = req.body as { screeningResult?: unknown }; - if (!screeningResult || typeof screeningResult !== "object") { - res.status(400).json({ detail: "screeningResult is required" }); - return; - } - const s = await Startup.findByIdAndUpdate( - req.params.id, - { lastScreeningResult: screeningResult, lastScreenedAt: new Date() }, - { new: true }, - ).lean(); - if (!s) { res.status(404).json({ detail: "Startup not found" }); return; } - res.json(toStartup(s)); -}); - -// ── Entity reviews ─────────────────────────────────────────────────────────── - -// GET /api/startups/:id/entity-reviews -startupsRouter.get("/:id/entity-reviews", async (req, res) => { - const reviews = await EntityReview.find({ startupId: req.params.id }) - .sort({ reviewedAt: -1 }) - .lean(); - res.json(reviews.map(toReview)); -}); - -// POST /api/startups/:id/entity-reviews -startupsRouter.post("/:id/entity-reviews", async (req, res) => { - const { entityName, status, notes } = req.body as { - entityName?: string; - status?: string; - notes?: string; - }; - const validStatuses = ["pending", "cleared", "escalated", "blocked"]; - if (!entityName?.trim()) { - res.status(400).json({ detail: "entityName is required" }); - return; - } - if (!status || !validStatuses.includes(status)) { - res.status(400).json({ detail: "status must be one of: " + validStatuses.join(", ") }); - return; - } - - const reviewedBy = res.locals.userId ?? "preview-user"; - const reviewedByEmail = res.locals.userEmail ?? "admin@capscreen.local"; - - await syncEntityReviewStatus({ - startupId: req.params.id, - entityName: entityName.trim(), - status: status as "pending" | "cleared" | "escalated" | "blocked", - notes: notes?.trim() ?? null, - performedBy: reviewedBy, - performedByEmail: reviewedByEmail, - }); - - const review = await EntityReview.findOne({ - startupId: req.params.id, - entityName: entityName.trim(), - }).lean(); - - res.status(201).json(toReview(review as Record)); -}); - -// ── Screener chat ─────────────────────────────────────────────────────────── - -startupsRouter.get("/:id/chat", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const doc = (await StartupChat.findOne({ startupId: req.params.id }).lean()) as - | { messages?: unknown[] } - | null; - res.json({ messages: doc?.messages ?? [] }); -}); - -startupsRouter.put("/:id/chat", async (req, res) => { - if (!mongoose.Types.ObjectId.isValid(req.params.id)) { - res.status(404).json({ detail: "Startup not found" }); - return; - } - const { messages } = req.body as { messages?: unknown[] }; - if (!Array.isArray(messages)) { - res.status(400).json({ detail: "messages array is required" }); - return; - } - await StartupChat.findOneAndUpdate( - { startupId: req.params.id }, - { $set: { messages, updatedAt: new Date() } }, - { upsert: true }, - ); - res.json({ ok: true }); -}); - -// ── Helpers ───────────────────────────────────────────────────────────────── - -function toStartup(raw: unknown) { - const s = raw as Record; - return { - id: String(s._id), - name: s.name, - createdAt: s.createdAt, - latestCsvId: s.latestCsvId ? String(s.latestCsvId) : null, - latestCsvByPurpose: s.latestCsvByPurpose ?? {}, - lastScreeningResult: s.lastScreeningResult ?? null, - lastScreenedAt: s.lastScreenedAt ?? null, - lastScreenedCsvId: s.lastScreenedCsvId ? String(s.lastScreenedCsvId) : null, - lastCoInvestorScreeningResult: s.lastCoInvestorScreeningResult ?? null, - lastCoInvestorScreenedAt: s.lastCoInvestorScreenedAt ?? null, - lastVendorScreeningResult: s.lastVendorScreeningResult ?? null, - lastVendorScreenedAt: s.lastVendorScreenedAt ?? null, - portfolioReviewStatus: s.portfolioReviewStatus ?? "pending", - portfolioReviewNotes: s.portfolioReviewNotes ?? null, - }; -} - -function toCsv(raw: unknown) { - const c = raw as Record; - return { - id: String(c._id), - startupId: String(c.startupId), - filename: c.filename, - content: c.content, - uploadedAt: c.uploadedAt, - parseStatus: c.parseStatus ?? "pending", - parseErrors: c.parseErrors ?? [], - recordCount: c.recordCount ?? 0, - csvKind: c.csvKind ?? null, - parseSource: c.parseSource ?? null, - confidence: c.confidence ?? null, - columnMapping: c.columnMapping ?? null, - normalizedContent: c.normalizedContent ?? null, - ingestWarnings: c.ingestWarnings ?? [], - rosterPurpose: c.rosterPurpose ?? "cap_table", - }; -} - -function purposeStorageKey(purpose: RosterPurpose): string { - if (purpose === "co_investor") return "co_investor"; - if (purpose === "vendor") return "vendor"; - return "cap_table"; -} - -function resolveRosterPurpose( - filename: string, - headers: string[], - csvKind: string, -): RosterPurpose { - const detected = detectRosterPurpose(filename, headers); - if (detected !== "cap_table") return detected; - if (csvKind === "entity_roster") return "entity_roster"; - return "cap_table"; -} - -function screeningUpdateForPurpose( - purpose: RosterPurpose, - result: ScreeningResult, - csvId: unknown, -): Record { - if (purpose === "co_investor") { - return { - lastCoInvestorScreeningResult: result, - lastCoInvestorScreenedAt: new Date(), - }; - } - if (purpose === "vendor") { - return { - lastVendorScreeningResult: result, - lastVendorScreenedAt: new Date(), - }; - } - return { - lastScreeningResult: result, - lastScreenedAt: new Date(), - lastScreenedCsvId: csvId, - }; -} - -function toReview(raw: Record | null) { - if (!raw) return null; - return { - id: String(raw._id), - startupId: String(raw.startupId), - entityName: raw.entityName, - status: raw.status, - notes: raw.notes ?? null, - reviewedBy: raw.reviewedBy, - reviewedByEmail: raw.reviewedByEmail, - reviewedAt: raw.reviewedAt, - }; -} - -function toScreeningSummary(raw: unknown): ScreeningSummary { - const s = raw as Record; - const result = s.lastScreeningResult as - | { - totalEntities?: number; - flaggedCount?: number; - reviewCount?: number; - entities?: Array<{ name: string; riskLevel: string }>; - } - | null - | undefined; - - let highestRiskEntity: string | null = null; - let highestRiskLevel: ScreeningSummary["highestRiskLevel"] = null; - - if (result?.entities?.length) { - const flagged = result.entities.find((e) => e.riskLevel === "flagged"); - const review = result.entities.find((e) => e.riskLevel === "review"); - const top = flagged ?? review ?? null; - if (top) { - highestRiskEntity = top.name; - highestRiskLevel = top.riskLevel as ScreeningSummary["highestRiskLevel"]; - } - } - - return { - startupId: String(s._id), - startupName: s.name as string, - lastScreenedAt: s.lastScreenedAt - ? new Date(s.lastScreenedAt as string).toISOString() - : null, - totalEntities: result?.totalEntities ?? 0, - flaggedCount: result?.flaggedCount ?? 0, - reviewCount: result?.reviewCount ?? 0, - highestRiskEntity, - highestRiskLevel, - }; -} diff --git a/backend/src/routes/startups/ragDocuments.ts b/backend/src/routes/startups/ragDocuments.ts index 3480492..bea109e 100644 --- a/backend/src/routes/startups/ragDocuments.ts +++ b/backend/src/routes/startups/ragDocuments.ts @@ -9,9 +9,9 @@ import { authIdentityOr500 } from "../../middleware/userIdentity"; import { buildRagDocumentKey, deleteObject, - r2Configured, + storageConfigured, uploadObject, -} from "../../lib/infra/r2Storage"; +} from "../../lib/infra/fileStorage"; export const ragDocumentsRouter = Router(); @@ -55,7 +55,7 @@ ragDocumentsRouter.post( res.status(400).json({ detail: "No file uploaded. Send file as multipart field 'file'." }); return; } - if (!r2Configured()) { + if (!storageConfigured()) { res.status(503).json({ detail: "Object storage is not configured" }); return; } diff --git a/backend/src/routes/tabularReview.ts b/backend/src/routes/tabularReview.ts index 1d8ac3b..ce86330 100644 --- a/backend/src/routes/tabularReview.ts +++ b/backend/src/routes/tabularReview.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; import express from "express"; -import type Anthropic from "@anthropic-ai/sdk"; +import type { LlmMessage } from "../lib/llm/providers"; import mongoose from "mongoose"; import { requireAuth } from "../middleware/requireAuth"; import { connectDb } from "../lib/infra/db"; @@ -24,7 +24,7 @@ import { } from "../lib/chat/startupMentions"; import { assistantRegistry, type ToolContext } from "../lib/tools"; import { runAgentStream } from "../lib/llm/streamAgent"; -import { getAnthropicModel } from "../lib/llm/models"; +import { friendlyLlmError } from "../lib/llm/friendlyError"; import { buildWorkflowStore } from "../lib/workflows/workflowMemory"; import type { ColumnConfig, TabularRowDoc } from "../types/tabular"; import { sendEmailSafe, sendReviewInviteEmail } from "../lib/auth/email"; @@ -483,9 +483,9 @@ function parseTabularChatMessages( return { ok: true, messages: out }; } -function toAnthropicMessages( +function toLlmMessages( messages: TabularChatMessage[], -): Anthropic.MessageParam[] { +): LlmMessage[] { return messages.map((m) => ({ role: m.role, content: m.content })); } @@ -617,9 +617,8 @@ tabularReviewRouter.post("/:id/chat", async (req, res) => { try { await runAgentStream({ - model: getAnthropicModel(), systemPrompt: system, - messages: toAnthropicMessages(parsed.messages), + messages: toLlmMessages(parsed.messages), tools: assistantRegistry.getTools(), write, executeTool: async (name, input) => { @@ -646,11 +645,7 @@ tabularReviewRouter.post("/:id/chat", async (req, res) => { write("data: [DONE]\n\n"); } catch (err) { console.error("[tabularReview/chat] error:", err); - let message = err instanceof Error ? err.message : "Stream error"; - if (message.includes("authentication_error") || message.includes("401")) { - message = - "Anthropic API key invalid. Set ANTHROPIC_API_KEY in .env, then restart (pnpm dev)."; - } + const message = friendlyLlmError(err); write(`data: ${JSON.stringify({ type: "error", message })}\n\n`); write("data: [DONE]\n\n"); } finally { diff --git a/backend/vercel.json b/backend/vercel.json new file mode 100644 index 0000000..7485f58 --- /dev/null +++ b/backend/vercel.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "npm install --no-audit --no-fund", + "buildCommand": "echo 'auth service: no build step (api/index.ts is compiled by Vercel)'", + "outputDirectory": "public", + "functions": { + "api/index.ts": { "maxDuration": 30 } + }, + "rewrites": [{ "source": "/(.*)", "destination": "/api" }] +} diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml new file mode 100644 index 0000000..492c271 --- /dev/null +++ b/desktop/electron-builder.yml @@ -0,0 +1,65 @@ +appId: com.capscreen.desktop +productName: CapScreen +copyright: CapScreen +directories: + output: release + buildResources: resources/icons + +# The main process has no runtime npm dependencies — only Electron built-ins. +files: + - dist/**/* + - package.json +asar: true + +# Sidecars live outside the asar so they can be executed/forked directly. +extraResources: + - from: resources/backend + to: backend + - from: resources/frontend + to: frontend + # Standalone Next server needs its traced node_modules (excluded by default). + filter: + - "**/*" + - "**/node_modules/**" + - "**/.pnpm/**" + - from: resources/icons + to: icons + +mac: + category: public.app-category.finance + icon: resources/icons/icon.png + target: + - target: dmg + arch: [arm64, x64] + hardenedRuntime: true + gatekeeperAssess: false + extraResources: + - from: resources/bin/watchman-darwin-${arch} + to: bin/watchman-darwin-${arch} + +dmg: + title: CapScreen ${version} + +win: + icon: resources/icons/icon.png + target: + - target: nsis + arch: [x64] + extraResources: + - from: resources/bin/watchman-win32-x64.exe + to: bin/watchman-win32-x64.exe + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + +linux: + icon: resources/icons/icon.png + category: Office + target: + - target: AppImage + arch: [x64] + extraResources: + - from: resources/bin/watchman-linux-${arch} + to: bin/watchman-linux-${arch} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..ef46275 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,32 @@ +{ + "name": "capscreen-desktop", + "productName": "CapScreen", + "version": "1.0.0", + "private": true, + "description": "CapScreen \u2014 on-device cap-table sanctions screening", + "main": "dist/main.js", + "scripts": { + "build:main": "tsc -p tsconfig.json", + "build:backend": "node scripts/bundle-backend.mjs", + "build:frontend": "node scripts/build-frontend.mjs", + "build": "pnpm run build:main && pnpm run build:backend && pnpm run build:frontend", + "dev": "pnpm run build:main && cross-env CAPSCREEN_DEV=1 electron .", + "start": "pnpm run build:main && electron .", + "dist": "pnpm run build && electron-builder --config electron-builder.yml", + "dist:dir": "pnpm run build && electron-builder --config electron-builder.yml --dir", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build:watchman": "bash scripts/build-watchman.sh", + "icons": "electron scripts/make-icons.js", + "dist:mac": "pnpm run build && electron-builder --config electron-builder.yml --mac" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "cross-env": "7.0.3", + "electron": "43.7.9", + "electron-builder": "26.17.0", + "esbuild": "0.25.4", + "typescript": "^5.8.3" + }, + "license": "AGPL-3.0-only", + "capscreenAuthUrl": "https://capscreen-auth.vercel.app" +} diff --git a/desktop/resources/icons/icon.png b/desktop/resources/icons/icon.png new file mode 100644 index 0000000..e735aed Binary files /dev/null and b/desktop/resources/icons/icon.png differ diff --git a/desktop/resources/icons/trayTemplate.png b/desktop/resources/icons/trayTemplate.png new file mode 100644 index 0000000..a9d854c Binary files /dev/null and b/desktop/resources/icons/trayTemplate.png differ diff --git a/desktop/resources/icons/trayTemplate@2x.png b/desktop/resources/icons/trayTemplate@2x.png new file mode 100644 index 0000000..89e44fa Binary files /dev/null and b/desktop/resources/icons/trayTemplate@2x.png differ diff --git a/desktop/scripts/build-frontend.mjs b/desktop/scripts/build-frontend.mjs new file mode 100644 index 0000000..8bb07b2 --- /dev/null +++ b/desktop/scripts/build-frontend.mjs @@ -0,0 +1,63 @@ +// Builds the Next.js UI in standalone mode and stages it for packaging: +// desktop/resources/frontend/frontend/server.js (+ traced node_modules, static, public). +import { execFileSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "..", ".."); +const fe = path.join(root, "frontend"); +const out = path.join(root, "desktop", "resources", "frontend"); + +execFileSync(path.join(fe, "node_modules", ".bin", process.platform === "win32" ? "next.cmd" : "next"), ["build"], { + cwd: fe, + stdio: "inherit", + env: { ...process.env, CAPSCREEN_DESKTOP_BUILD: "1", NEXT_TELEMETRY_DISABLED: "1" }, +}); + +/** Recursive copy that replaces every symlink with the real file/dir (pnpm layout). */ +function copyResolved(src, dest) { + const real = fs.realpathSync(src); + const st = fs.statSync(real); + if (st.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }); + for (const name of fs.readdirSync(real)) copyResolved(path.join(src, name), path.join(dest, name)); + } else { + fs.copyFileSync(real, dest); + fs.chmodSync(dest, st.mode); + } +} + +fs.rmSync(out, { recursive: true, force: true }); +// pnpm symlinks don't survive packaging (fs.cpSync's dereference is top-level only). +copyResolved(path.join(fe, ".next", "standalone"), out); +// Flatten pnpm's layout into the server's own node_modules. electron-builder +// drops a top-level node_modules from extraResources (it treats it as app +// deps) and skips the hidden .pnpm store, so lift the hoisted packages into +// frontend/frontend/node_modules — the first place Node resolves from. +const rootModules = path.join(out, "node_modules"); +const serverModules = path.join(out, "frontend", "node_modules"); +const hoisted = path.join(rootModules, ".pnpm", "node_modules"); +fs.mkdirSync(serverModules, { recursive: true }); +for (const dir of [hoisted, rootModules]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (name === ".pnpm") continue; + const target = path.join(serverModules, name); + if (!fs.existsSync(target)) fs.renameSync(path.join(dir, name), target); + } +} +fs.rmSync(rootModules, { recursive: true, force: true }); + +// Next expects static assets and public/ next to the server; standalone omits them. +fs.cpSync(path.join(fe, ".next", "static"), path.join(out, "frontend", ".next", "static"), { recursive: true }); +if (fs.existsSync(path.join(fe, "public"))) { + fs.cpSync(path.join(fe, "public"), path.join(out, "frontend", "public"), { recursive: true }); +} +// No secrets in the shipped UI: drop any env files the trace picked up. +for (const f of fs.readdirSync(path.join(out, "frontend"))) { + if (f.startsWith(".env")) fs.rmSync(path.join(out, "frontend", f)); +} +if (!fs.existsSync(path.join(out, "frontend", "server.js"))) throw new Error("standalone server.js missing"); +console.log(`frontend staged → ${path.relative(root, out)}`); diff --git a/desktop/scripts/build-watchman.sh b/desktop/scripts/build-watchman.sh new file mode 100755 index 0000000..379f738 --- /dev/null +++ b/desktop/scripts/build-watchman.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds self-contained Watchman binaries (pure Go, no libpostal/CGO) for every +# desktop target. The official macOS release links /usr/local/lib/libpostal and +# can't be shipped; the default build tag uses a pure-Go address parser instead. +# Requires Go 1.24+. Output names follow Node's platform/arch naming. +set -euo pipefail +VERSION="${WATCHMAN_VERSION:-v1.2.1}" +OUT="$(cd "$(dirname "$0")/.." && pwd)/resources/bin" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +curl -fsSL "https://github.com/moov-io/watchman/archive/refs/tags/${VERSION}.tar.gz" | tar xz -C "$WORK" +cd "$WORK/watchman-${VERSION#v}" +mkdir -p "$OUT" +for target in darwin/arm64:darwin-arm64 darwin/amd64:darwin-x64 windows/amd64:win32-x64.exe linux/amd64:linux-x64 linux/arm64:linux-arm64; do + goos_arch="${target%%:*}"; name="${target#*:}" + CGO_ENABLED=0 GOOS="${goos_arch%/*}" GOARCH="${goos_arch#*/}" \ + go build -trimpath -ldflags "-s -w -X github.com/moov-io/watchman.Version=${VERSION}" \ + -o "$OUT/watchman-${name}" ./cmd/server + echo "built watchman-${name}" +done diff --git a/desktop/scripts/bundle-backend.mjs b/desktop/scripts/bundle-backend.mjs new file mode 100644 index 0000000..dece5a9 --- /dev/null +++ b/desktop/scripts/bundle-backend.mjs @@ -0,0 +1,46 @@ +// Bundles the Express backend into one file for the packaged app: +// desktop/resources/backend/index.js (+ compliance corpus). +import { build } from "esbuild"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "..", ".."); +const out = path.join(root, "desktop", "resources", "backend"); +fs.rmSync(out, { recursive: true, force: true }); + +await build({ + entryPoints: [path.join(root, "backend", "src", "index.ts")], + outfile: path.join(out, "index.js"), + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + sourcemap: "linked", + minify: false, + legalComments: "linked", + // Optional native/cloud add-ons of the MongoDB driver and SDKs — loaded in + // try/catch by their owners and unused by the desktop app. + external: [ + "kerberos", + "snappy", + "@mongodb-js/zstd", + "mongodb-client-encryption", + "gcp-metadata", + "socks", + "aws4", + "@aws-sdk/credential-providers", + "@napi-rs/canvas", + "canvas", + ], + logLevel: "warning", +}); + +fs.cpSync(path.join(root, "backend", "compliance-corpus"), path.join(out, "compliance-corpus"), { recursive: true }); + +// pdf.js loads its worker from next to the running module (our index.js). +const pdfParseDir = fs.realpathSync(path.join(root, "backend", "node_modules", "pdf-parse")); +fs.copyFileSync(path.join(pdfParseDir, "dist", "pdf-parse", "cjs", "pdf.worker.mjs"), path.join(out, "pdf.worker.mjs")); +const size = fs.statSync(path.join(out, "index.js")).size; +console.log(`backend bundle: ${(size / 1024 / 1024).toFixed(1)} MB → ${path.relative(root, out)}`); diff --git a/desktop/scripts/make-icons.js b/desktop/scripts/make-icons.js new file mode 100644 index 0000000..77900f9 --- /dev/null +++ b/desktop/scripts/make-icons.js @@ -0,0 +1,52 @@ +// Renders CapScreen icons with Chromium from the brand mark (frontend/public/capscreen-mark.svg): +// resources/icons/icon.png 1024px app icon (white rounded square + mark) +// resources/icons/trayTemplate(@2x).png macOS menu-bar template (letters knocked out) +// Run: electron scripts/make-icons.js +const { app, BrowserWindow } = require("electron"); +const fs = require("fs"); +const path = require("path"); + +const root = path.join(__dirname, ".."); +const markSvg = fs.readFileSync(path.join(root, "..", "frontend", "public", "capscreen-mark.svg"), "utf8"); +const lettersPath = /${html}`)); + await new Promise((r) => setTimeout(r, 300)); + const img = await win.webContents.capturePage({ x: 0, y: 0, width: canvas, height: canvas }); + win.destroy(); + return img.resize({ width: size, height: size, quality: "best" }).toPNG(); +} + +// Each render closes its window; don't let "all windows closed" quit mid-run. +app.on("window-all-closed", () => {}); + +app.whenReady().then(async () => { + const out = path.join(root, "resources", "icons"); + const S = 1024; + // macOS icon grid: 824px rounded square inside 1024 canvas; mark at ~66% of the tile. + const tile = 824; + const mark = Math.round(tile * 0.66); + const appIcon = ` +
+
+ ${markSvg.replace(" +
`; + fs.writeFileSync(path.join(out, "icon.png"), await render(appIcon, S)); + + // Template image: opaque circle, transparent letters (macOS tints it). + const template = (size) => ` + + + + `; + fs.writeFileSync(path.join(out, "trayTemplate.png"), await render(template(256), 18)); + fs.writeFileSync(path.join(out, "trayTemplate@2x.png"), await render(template(256), 36)); + console.log("icons written to resources/icons"); + app.quit(); +}); diff --git a/desktop/src/authFlow.ts b/desktop/src/authFlow.ts new file mode 100644 index 0000000..7045603 --- /dev/null +++ b/desktop/src/authFlow.ts @@ -0,0 +1,150 @@ +import crypto from "crypto"; +import http from "http"; +import type { AddressInfo } from "net"; + +/** + * Desktop sign-in protocol (RFC 8252 loopback redirect + PKCE), independent of + * Electron so it can be exercised from plain Node: + * + * 1. listen on 127.0.0.1:/callback + * 2. open the browser at /desktop/authorize?redirect_uri&state&code_challenge + * 3. receive ?code&state on the loopback, check state + * 4. exchange code + code_verifier at /api/auth/desktop/token + */ + +export type CloudUser = { + id: string; + email: string; + displayName: string | null; + organisation: string | null; + emailVerified: boolean; +}; + +export type CloudSession = { + user: CloudUser; + accessToken: string; + accessExpires: string; + refreshToken: string; + refreshExpires: string; +}; + +export class AuthHttpError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "AuthHttpError"; + } +} + +const b64url = (buf: Buffer) => buf.toString("base64url"); + +export function createPkce(): { verifier: string; challenge: string } { + const verifier = b64url(crypto.randomBytes(48)); + return { verifier, challenge: b64url(crypto.createHash("sha256").update(verifier).digest()) }; +} + +async function postJson(authUrl: string, path: string, body: unknown): Promise { + const resp = await fetch(`${authUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + const data = (await resp.json().catch(() => ({}))) as T & { detail?: string }; + if (!resp.ok) throw new AuthHttpError(data.detail ?? `Auth service returned ${resp.status}`, resp.status); + return data; +} + +export type SignInHandle = { + /** The URL to open in the user's browser. */ + authorizeUrl: string; + /** Resolves with a session once the browser hands back, or rejects. */ + result: Promise; + cancel(): void; +}; + +export async function startSignIn(opts: { + authUrl: string; + /** HTML shown in the browser tab after the redirect lands. */ + donePage: (ok: boolean, message: string) => string; + timeoutMs?: number; +}): Promise { + const { verifier, challenge } = createPkce(); + const state = b64url(crypto.randomBytes(24)); + + let settle!: { resolve: (s: CloudSession) => void; reject: (e: Error) => void }; + const result = new Promise((resolve, reject) => (settle = { resolve, reject })); + + const server = http.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as AddressInfo).port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + + let finished = false; + const finish = (err: Error | null, session?: CloudSession) => { + if (finished) return; + finished = true; + clearTimeout(timer); + server.close(); + if (err) settle.reject(err); + else settle.resolve(session!); + }; + const timer = setTimeout(() => finish(new Error("Sign-in timed out. Try again.")), opts.timeoutMs ?? 5 * 60_000); + + server.on("request", (req, res) => { + const url = new URL(req.url ?? "/", redirectUri); + if (url.pathname !== "/callback") { + res.writeHead(404).end(); + return; + } + const reply = (ok: boolean, message: string) => { + res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); + res.end(opts.donePage(ok, message)); + }; + const code = url.searchParams.get("code"); + if (url.searchParams.get("state") !== state || !code) { + // Don't finish: a stray/forged request must not cancel the real sign-in. + reply(false, "This sign-in link doesn't match the request from CapScreen. Start again from the app."); + return; + } + postJson(opts.authUrl, "/api/auth/desktop/token", { + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }).then( + (session) => { + reply(true, "You can close this tab and return to CapScreen."); + finish(null, session); + }, + (err: Error) => { + reply(false, err.message); + finish(err); + }, + ); + }); + + const authorize = new URL("/desktop/authorize", opts.authUrl); + authorize.searchParams.set("redirect_uri", redirectUri); + authorize.searchParams.set("state", state); + authorize.searchParams.set("code_challenge", challenge); + authorize.searchParams.set("code_challenge_method", "S256"); + + return { + authorizeUrl: authorize.toString(), + result, + cancel: () => finish(new Error("Sign-in cancelled")), + }; +} + +export function refreshSession(authUrl: string, refreshToken: string): Promise { + return postJson(authUrl, "/api/auth/desktop/refresh", { refreshToken }); +} + +export async function revokeSession(authUrl: string, refreshToken: string): Promise { + await postJson(authUrl, "/api/auth/desktop/revoke", { refreshToken }).catch(() => {}); +} diff --git a/desktop/src/ipc.ts b/desktop/src/ipc.ts new file mode 100644 index 0000000..aecf639 --- /dev/null +++ b/desktop/src/ipc.ts @@ -0,0 +1,119 @@ +import fs from "fs/promises"; +import { app, BrowserWindow, dialog, ipcMain, Notification, shell, type IpcMainInvokeEvent } from "electron"; +import { userData } from "./paths"; + +type FileFilter = { name: string; extensions: string[] }; + +function win(e: IpcMainInvokeEvent): BrowserWindow | undefined { + return BrowserWindow.fromWebContents(e.sender) ?? undefined; +} + +function filters(v: unknown): FileFilter[] | undefined { + if (!Array.isArray(v)) return undefined; + return v + .filter((f): f is FileFilter => !!f && typeof f.name === "string" && Array.isArray(f.extensions)) + .map((f) => ({ name: f.name.slice(0, 60), extensions: f.extensions.map(String).slice(0, 10) })); +} + +type SessionInfo = { user: { email: string; displayName: string | null; organisation: string | null }; offline: boolean } | null; + +export function registerIpc(opts: { + apiBase: () => string; + navigate: (route: string) => void; + session: () => SessionInfo; + signOut: () => Promise; + accountUrl: () => string; +}): void { + const navigate = opts.navigate; + ipcMain.on("capscreen:bootstrap", (e) => { + const s = opts.session(); + e.returnValue = { + apiBase: opts.apiBase(), + platform: process.platform, + appVersion: app.getVersion(), + dataDir: userData(), + user: s ? { email: s.user.email, displayName: s.user.displayName, organisation: s.user.organisation } : null, + offline: s?.offline ?? false, + }; + }); + + // Fire-and-forget: the window navigates to the sign-in gate. + ipcMain.handle("capscreen:sign-out", () => { + void opts.signOut(); + }); + + ipcMain.handle("capscreen:open-account", async () => { + const url = opts.accountUrl(); + if (url) await shell.openExternal(url); + }); + + ipcMain.handle("capscreen:open-external", async (_e, url: unknown) => { + const u = new URL(String(url)); + if (!["https:", "http:", "mailto:"].includes(u.protocol)) throw new Error("Blocked URL scheme"); + await shell.openExternal(u.toString()); + }); + + ipcMain.handle("capscreen:reveal", (_e, p: unknown) => { + shell.showItemInFolder(String(p)); + }); + + ipcMain.handle("capscreen:save-file", async (e, opts: { defaultName?: unknown; data?: unknown; filters?: unknown }) => { + const res = await dialog.showSaveDialog(win(e)!, { + defaultPath: String(opts?.defaultName ?? "export"), + filters: filters(opts?.filters), + }); + if (res.canceled || !res.filePath) return null; + const raw = opts.data; + const data = + raw instanceof ArrayBuffer + ? Buffer.from(raw) + : ArrayBuffer.isView(raw) + ? Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength) + : Buffer.from(String(raw ?? ""), "utf8"); + await fs.writeFile(res.filePath, data); + return res.filePath; + }); + + ipcMain.handle("capscreen:pick-folder", async (e, opts: { title?: unknown } | undefined) => { + const res = await dialog.showOpenDialog(win(e)!, { + title: opts?.title ? String(opts.title) : undefined, + properties: ["openDirectory", "createDirectory"], + }); + return res.canceled ? null : (res.filePaths[0] ?? null); + }); + + ipcMain.handle("capscreen:pick-file", async (e, opts: { title?: unknown; filters?: unknown } | undefined) => { + const res = await dialog.showOpenDialog(win(e)!, { + title: opts?.title ? String(opts.title) : undefined, + properties: ["openFile"], + filters: filters(opts?.filters), + }); + return res.canceled ? null : (res.filePaths[0] ?? null); + }); + + ipcMain.handle("capscreen:notify", (_e, opts: { title?: unknown; body?: unknown; route?: unknown }) => { + if (!Notification.isSupported()) return; + const n = new Notification({ title: String(opts?.title ?? "CapScreen").slice(0, 120), body: String(opts?.body ?? "").slice(0, 400) }); + const route = typeof opts?.route === "string" && opts.route.startsWith("/") ? opts.route : null; + if (route) n.on("click", () => navigate(route)); + n.show(); + }); + + /** Render HTML off-screen and save it as a PDF (IC memos, screening reports). */ + ipcMain.handle("capscreen:print-pdf", async (e, opts: { html?: unknown; defaultName?: unknown }) => { + const res = await dialog.showSaveDialog(win(e)!, { + defaultPath: String(opts?.defaultName ?? "report.pdf"), + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + if (res.canceled || !res.filePath) return null; + const off = new BrowserWindow({ show: false, webPreferences: { sandbox: true, javascript: false } }); + try { + await off.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(String(opts?.html ?? ""))}`); + const pdf = await off.webContents.printToPDF({ printBackground: true, pageSize: "A4" }); + await fs.writeFile(res.filePath, pdf); + return res.filePath; + } finally { + off.destroy(); + } + }); +} diff --git a/desktop/src/listCache.ts b/desktop/src/listCache.ts new file mode 100644 index 0000000..e78da5a --- /dev/null +++ b/desktop/src/listCache.ts @@ -0,0 +1,73 @@ +import fs from "fs"; +import path from "path"; +import { userData } from "./paths"; + +/** + * Offline snapshot of the public sanctions lists, so CapScreen can start and + * screen without internet. Watchman only keeps lists in memory and, when given + * an initial-data directory, uses it for every refresh — so the shell keeps its + * own cache and points Watchman at it only when an online start fails. + * + * Sources mirror Watchman v1.2.1 (pkg/sources/ofac, pkg/sources/csl_eu). + */ +const OFAC = "https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/"; +const EU = "https://webgate.ec.europa.eu/fsd/fsf/public/files/csvFullSanctionsList_1_1/content?token=dG9rZW4tMjAxNw"; + +export const CACHEABLE_LISTS: Record> = { + us_ofac: ["add.csv", "alt.csv", "sdn.csv", "sdn_comments.csv"].map((file) => ({ file, url: OFAC + file })), + eu_csl: [{ file: "eu_csl.csv", url: EU }], +}; + +export type CacheManifest = { lists: Record }; // list id → ISO time cached + +export const cacheDir = () => userData("watchman", "cache"); +const manifestFile = () => path.join(cacheDir(), "manifest.json"); + +export function readManifest(): CacheManifest { + try { + return JSON.parse(fs.readFileSync(manifestFile(), "utf8")) as CacheManifest; + } catch { + return { lists: {} }; + } +} + +/** Lists from `wanted` that are fully present in the cache. */ +export function cachedLists(wanted: string[]): string[] { + const manifest = readManifest(); + return wanted.filter( + (id) => manifest.lists[id] && CACHEABLE_LISTS[id]?.every((s) => fs.existsSync(path.join(cacheDir(), s.file))), + ); +} + +/** + * Download fresh copies. Each list is written atomically (all files or none), + * so a failed download never replaces a good snapshot. Returns lists refreshed. + */ +export async function refreshListCache(wanted: string[]): Promise { + fs.mkdirSync(cacheDir(), { recursive: true }); + const manifest = readManifest(); + const refreshed: string[] = []; + for (const id of wanted) { + const sources = CACHEABLE_LISTS[id]; + if (!sources) continue; + const tmp = fs.mkdtempSync(path.join(cacheDir(), `.${id}-`)); + try { + for (const { file, url } of sources) { + const resp = await fetch(url, { signal: AbortSignal.timeout(120_000), headers: { "User-Agent": "CapScreen" } }); + if (!resp.ok) throw new Error(`${file}: HTTP ${resp.status}`); + const body = Buffer.from(await resp.arrayBuffer()); + if (body.length < 1024) throw new Error(`${file}: unexpectedly small (${body.length} bytes)`); + fs.writeFileSync(path.join(tmp, file), body); + } + for (const { file } of sources) fs.renameSync(path.join(tmp, file), path.join(cacheDir(), file)); + manifest.lists[id] = new Date().toISOString(); + refreshed.push(id); + } catch (err) { + console.warn(`[lists] could not refresh ${id} snapshot: ${err instanceof Error ? err.message : String(err)}`); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + fs.writeFileSync(manifestFile(), JSON.stringify(manifest, null, 2)); + return refreshed; +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts new file mode 100644 index 0000000..b518af4 --- /dev/null +++ b/desktop/src/main.ts @@ -0,0 +1,372 @@ +import crypto from "crypto"; +import fs from "fs"; +import { app, BrowserWindow, Menu, nativeImage, shell, Tray, type MenuItemConstructorOptions } from "electron"; +import path from "path"; +import { registerIpc } from "./ipc"; +import { isDev, resourcesDir, userData } from "./paths"; +import { API_PREFIX, APP_ORIGIN, handleAppProtocol, injectTokenForDev, registerSchemes } from "./protocol"; +import { onSidecarEvent, startBackend, startFrontend, startWatchman, stopAll, watchSanctionsSettings, type RuntimeInfo } from "./sidecars"; +import { startNotificationPoller } from "./notifications"; +import { loadMasterKey } from "./vault"; +import { splashHtml, errorHtml, signInHtml, type SignInView } from "./splash"; +import { AUTH_URL, beginSignIn, currentSession, onSessionExpired, restoreSession, signOut } from "./session"; + +let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let runtime: RuntimeInfo | null = null; +let quitting = false; + +// Dev-only: run without the cloud auth service. +const skipAuth = isDev && process.env.CAPSCREEN_SKIP_AUTH === "1"; +let gateResolve: (() => void) | null = null; +let pendingSignIn: Awaited> | null = null; + +const appUrl = () => (isDev ? runtime!.frontendUrl : `${APP_ORIGIN}/`); +// Empty until boot completes (the splash page's preload asks too). +const apiBase = () => (!runtime ? "" : isDev ? runtime.backendUrl : `${APP_ORIGIN}${API_PREFIX}`); + +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on("second-instance", () => showWindow()); +} + +registerSchemes(); +app.setName("CapScreen"); + +// Log main-process failures to logs/main.log instead of a blocking modal dialog. +const rawConsoleError = console.error.bind(console); +function logMainError(kind: string, err: unknown): void { + const line = `${new Date().toISOString()} ${kind}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`; + rawConsoleError(line); + try { + fs.mkdirSync(userData("logs"), { recursive: true }); + fs.appendFileSync(userData("logs", "main.log"), line); + } catch { + /* logging must never throw */ + } +} +process.on("uncaughtException", (err) => logMainError("uncaughtException", err)); +if (!isDev) { + // Packaged builds have no terminal: mirror console.error into main.log. + console.error = (...args: unknown[]) => + logMainError("error", args.map((a) => (a instanceof Error ? (a.stack ?? a.message) : String(a))).join(" ")); +} +process.on("unhandledRejection", (err) => logMainError("unhandledRejection", err)); + +// Dev-only automation hook (CDP on loopback) for end-to-end testing. +if (isDev && process.env.CAPSCREEN_REMOTE_DEBUG) { + app.commandLine.appendSwitch("remote-debugging-port", process.env.CAPSCREEN_REMOTE_DEBUG); + app.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); +} + +function navigate(route: string): void { + showWindow(); + mainWindow?.webContents.send("capscreen:navigate", route); +} + +function showWindow(): void { + if (!mainWindow) { + createWindow(); + return; + } + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1360, + height: 880, + minWidth: 960, + minHeight: 640, + title: "CapScreen", + backgroundColor: "#ffffff", + show: false, + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + trafficLightPosition: { x: 14, y: 14 }, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: true, + }, + }); + + // Everything external opens in the user's browser; the app never navigates away. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (/^https?:\/\//.test(url) && !url.startsWith(runtime?.frontendUrl ?? "\0")) void shell.openExternal(url); + return { action: "deny" }; + }); + mainWindow.webContents.on("will-navigate", (e, url) => { + // Buttons on the sign-in gate. + const gateAction = /^about:blank#(signin|signup|reopen|cancel)$/.exec(url)?.[1]; + if (gateAction) { + e.preventDefault(); + void handleGateAction(gateAction); + return; + } + // Buttons on the boot error page. + if (url === "about:blank#retry" || url === "about:blank#logs") { + e.preventDefault(); + if (url.endsWith("#logs")) void shell.openPath(userData("logs")); + else void mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(splashHtml())}`).then(start); + return; + } + const allowed = url.startsWith(APP_ORIGIN) || (runtime && url.startsWith(runtime.frontendUrl)) || url.startsWith("data:"); + if (!allowed) { + e.preventDefault(); + if (/^https?:\/\//.test(url)) void shell.openExternal(url); + } + }); + + mainWindow.once("ready-to-show", () => mainWindow?.show()); + + // Closing the window keeps CapScreen in the tray so background re-screening runs. + mainWindow.on("close", (e) => { + if (!quitting) { + e.preventDefault(); + mainWindow?.hide(); + if (process.platform === "darwin") app.dock?.hide(); + } + }); + mainWindow.on("show", () => { + if (process.platform === "darwin") void app.dock?.show(); + }); + mainWindow.on("closed", () => { + mainWindow = null; + }); + + if (runtime) void mainWindow.loadURL(appUrl()); + else void mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(splashHtml())}`); +} + +function trayIcon(): Electron.NativeImage { + const img = nativeImage.createFromPath(resourcesDir("icons", "trayTemplate.png")); + if (process.platform === "darwin") img.setTemplateImage(true); + return img; +} + +function buildTray(): void { + tray = new Tray(trayIcon()); + tray.setToolTip("CapScreen — background screening active"); + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: "Open CapScreen", click: () => showWindow() }, + { label: "AI Control Center", click: () => navigate("/ai") }, + { label: "Portfolio monitoring", click: () => navigate("/startups") }, + { type: "separator" }, + { label: "Open logs folder", click: () => void shell.openPath(userData("logs")) }, + { type: "separator" }, + { label: "Sign out", click: () => void signOutAndGate() }, + { label: "Quit CapScreen", click: () => quit() }, + ]), + ); + tray.on("click", () => showWindow()); +} + +function buildMenu(): void { + const isMac = process.platform === "darwin"; + const template: MenuItemConstructorOptions[] = [ + ...(isMac + ? [ + { + label: "CapScreen", + submenu: [ + { role: "about" }, + { type: "separator" }, + { label: "AI Control Center…", accelerator: "Cmd+,", click: () => navigate("/ai") }, + { label: "Data & Backup…", click: () => navigate("/settings/data") }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { type: "separator" }, + { label: "Quit CapScreen", accelerator: "Cmd+Q", click: () => quit() }, + ], + } as MenuItemConstructorOptions, + ] + : []), + { + label: "File", + submenu: [ + { label: "New Startup", accelerator: "CmdOrCtrl+N", click: () => navigate("/startups?new=1") }, + { type: "separator" }, + ...(!isMac + ? ([ + { label: "AI Control Center…", accelerator: "Ctrl+,", click: () => navigate("/ai") }, + { label: "Data & Backup…", click: () => navigate("/settings/data") }, + { type: "separator" }, + { label: "Quit", accelerator: "Ctrl+Q", click: () => quit() }, + ] as MenuItemConstructorOptions[]) + : [{ role: "close" } as MenuItemConstructorOptions]), + ], + }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { role: "reload" }, + ...(isDev ? ([{ role: "toggleDevTools" }] as MenuItemConstructorOptions[]) : []), + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { label: "Open logs folder", click: () => void shell.openPath(userData("logs")) }, + { label: "Open data folder", click: () => void shell.openPath(userData()) }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +async function boot(): Promise { + const token = crypto.randomBytes(32).toString("base64url"); + const masterKey = loadMasterKey(); + + const watchmanUrl = await startWatchman(); + const [frontendUrl, backendUrl] = await Promise.all([ + startFrontend(), + startBackend({ + watchmanUrl, + token, + masterKey, + frontendOrigin: APP_ORIGIN, + apiOrigin: (port) => (isDev ? `http://127.0.0.1:${port}` : `${APP_ORIGIN}${API_PREFIX}`), + }), + ]); + runtime = { watchmanUrl, backendUrl, frontendUrl, token }; + + if (isDev) injectTokenForDev(runtime); + else handleAppProtocol(runtime); + + startNotificationPoller(runtime, navigate); + watchSanctionsSettings(); +} + +function showPage(html: string): Promise { + return mainWindow?.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) ?? Promise.resolve(); +} + +function showGate(view: SignInView): void { + void showPage(signInHtml(view)); +} + +/** Show the sign-in gate and resolve once the user is signed in. */ +function requireSignIn(): Promise { + showGate({ state: "idle" }); + return new Promise((resolve) => (gateResolve = resolve)); +} + +async function handleGateAction(action: string): Promise { + if (action === "reopen" && pendingSignIn) { + await shell.openExternal(pendingSignIn.handle.authorizeUrl); + return; + } + if (action === "cancel") { + pendingSignIn?.handle.cancel(); + return; + } + // signin / signup: start a fresh browser round-trip. + pendingSignIn?.handle.cancel(); + try { + const flow = await beginSignIn(); + pendingSignIn = flow; + await shell.openExternal(flow.handle.authorizeUrl + (action === "signup" ? "#signup" : "")); + showGate({ state: "waiting" }); + await flow.done; + pendingSignIn = null; + showWindow(); + gateResolve?.(); + gateResolve = null; + } catch (err) { + pendingSignIn = null; + const message = err instanceof Error ? err.message : String(err); + showGate(message === "Sign-in cancelled" ? { state: "idle" } : { state: "error", message }); + } +} + +async function start(): Promise { + // Local services start while the user signs in. + const bootTask = boot(); + bootTask.catch(() => {}); // surfaced below + try { + if (!skipAuth && !(await restoreSession())) { + await requireSignIn(); + await showPage(splashHtml()); + } + await bootTask; + if (!mainWindow) createWindow(); + else await mainWindow.loadURL(appUrl()); + } catch (err) { + console.error("[boot]", err); + stopAll(); + const message = err instanceof Error ? err.message : String(err); + await showPage(errorHtml(message, userData("logs"))); + } +} + +/** Sign out (menu, tray, or account page) → gate → back into the app. */ +async function signOutAndGate(): Promise { + await signOut(); + await requireSignIn(); + if (runtime) await mainWindow?.loadURL(appUrl()); +} + +onSessionExpired(() => { + void (async () => { + showWindow(); + await requireSignIn(); + if (runtime) await mainWindow?.loadURL(appUrl()); + })(); +}); + +function quit(): void { + quitting = true; + app.quit(); +} + +app.on("before-quit", () => { + quitting = true; + stopAll(); +}); + +// kill / OS shutdown: quit through Electron so sidecars are stopped too. +for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + process.on(signal, () => quit()); +} + +app.on("activate", () => showWindow()); +app.on("window-all-closed", () => { + /* stay alive in the tray */ +}); + +onSidecarEvent((name, event, detail) => { + console.warn(`[sidecar] ${name} ${event}: ${detail}`); +}); + +void app.whenReady().then(() => { + registerIpc({ + apiBase, + navigate, + session: () => { + const s = currentSession(); + return s ? { user: s.user, offline: s.offline } : null; + }, + signOut: signOutAndGate, + accountUrl: () => AUTH_URL, + }); + buildMenu(); + buildTray(); + createWindow(); + void start(); +}); diff --git a/desktop/src/notifications.ts b/desktop/src/notifications.ts new file mode 100644 index 0000000..30931d9 --- /dev/null +++ b/desktop/src/notifications.ts @@ -0,0 +1,55 @@ +import fs from "fs"; +import { Notification } from "electron"; +import { userData } from "./paths"; +import type { RuntimeInfo } from "./sidecars"; + +/** + * Watches the backend's latest re-screen digest and raises a native + * notification when background screening finds new flags or review items. + */ +type Digest = { id: string; generatedAt: string; newFlagCount: number; newReviewCount: number; rescreenedCount: number }; + +const stateFile = () => userData("notify-state.json"); + +function lastNotified(): string | null { + try { + return (JSON.parse(fs.readFileSync(stateFile(), "utf8")) as { digestId?: string }).digestId ?? null; + } catch { + return null; + } +} + +export function startNotificationPoller(rt: RuntimeInfo, navigate: (route: string) => void): void { + let seen = lastNotified(); + const check = async () => { + try { + const resp = await fetch(`${rt.backendUrl}/api/portfolio/digest`, { + headers: { "x-capscreen-token": rt.token }, + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) return; + const d = (await resp.json()) as Partial | null; + if (!d?.id || d.id === seen) return; + const first = seen === null; + seen = d.id; + fs.writeFileSync(stateFile(), JSON.stringify({ digestId: d.id })); + // Don't replay an old digest the first time the app runs. + if (first && Date.now() - new Date(d.generatedAt ?? 0).getTime() > 3_600_000) return; + + const flags = d.newFlagCount ?? 0; + const reviews = d.newReviewCount ?? 0; + if (!flags && !reviews || !Notification.isSupported()) return; + const parts = [flags && `${flags} new flagged`, reviews && `${reviews} new for review`].filter(Boolean); + const n = new Notification({ + title: "Portfolio re-screen: action needed", + body: `${parts.join(", ")} across ${d.rescreenedCount ?? 0} startups. Click to review.`, + }); + n.on("click", () => navigate("/startups")); + n.show(); + } catch { + /* backend restarting — try next tick */ + } + }; + setTimeout(() => void check(), 30_000); + setInterval(() => void check(), 2 * 60_000); +} diff --git a/desktop/src/paths.ts b/desktop/src/paths.ts new file mode 100644 index 0000000..6e42a90 --- /dev/null +++ b/desktop/src/paths.ts @@ -0,0 +1,22 @@ +import { app } from "electron"; +import path from "path"; + +/** `pnpm desktop` runs against the source tree; packaged builds use bundled resources. */ +export const isDev = !app.isPackaged || process.env.CAPSCREEN_DEV === "1"; + +/** Monorepo root (dev only). */ +export const repoRoot = path.resolve(__dirname, "..", ".."); + +export function resourcesDir(...p: string[]): string { + const base = app.isPackaged ? process.resourcesPath : path.join(repoRoot, "desktop", "resources"); + return path.join(base, ...p); +} + +export function userData(...p: string[]): string { + return path.join(app.getPath("userData"), ...p); +} + +export function watchmanBinary(): string { + const ext = process.platform === "win32" ? ".exe" : ""; + return resourcesDir("bin", `watchman-${process.platform}-${process.arch}${ext}`); +} diff --git a/desktop/src/ports.ts b/desktop/src/ports.ts new file mode 100644 index 0000000..13a5266 --- /dev/null +++ b/desktop/src/ports.ts @@ -0,0 +1,32 @@ +import net from "net"; + +/** Ask the OS for a free loopback port. */ +export function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address() as net.AddressInfo; + srv.close(() => resolve(port)); + }); + }); +} + +/** Poll `url` until it answers 2xx or the deadline passes. */ +export async function waitForHttp(url: string, timeoutMs: number, isAlive: () => boolean = () => true): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = ""; + while (Date.now() < deadline) { + if (!isAlive()) throw new Error(`process exited before ${url} came up`); + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (resp.ok) return; + lastError = `HTTP ${resp.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + await new Promise((r) => setTimeout(r, 400)); + } + throw new Error(`${url} did not become ready within ${Math.round(timeoutMs / 1000)}s (${lastError})`); +} diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts new file mode 100644 index 0000000..f7b40ee --- /dev/null +++ b/desktop/src/preload.ts @@ -0,0 +1,32 @@ +import { contextBridge, ipcRenderer } from "electron"; + +/** + * window.capscreenDesktop — keep in sync with frontend/src/lib/desktop.ts. + * Sandboxed preload: everything goes through validated IPC in ipc.ts. + */ +const boot = ipcRenderer.sendSync("capscreen:bootstrap") as { + apiBase: string; + platform: NodeJS.Platform; + appVersion: string; + dataDir: string; + user: { email: string; displayName: string | null; organisation: string | null } | null; + offline: boolean; +}; + +contextBridge.exposeInMainWorld("capscreenDesktop", { + ...boot, + openExternal: (url: string) => ipcRenderer.invoke("capscreen:open-external", url), + revealInFolder: (p: string) => ipcRenderer.invoke("capscreen:reveal", p), + saveFile: (opts: unknown) => ipcRenderer.invoke("capscreen:save-file", opts), + pickFolder: (opts?: unknown) => ipcRenderer.invoke("capscreen:pick-folder", opts), + pickFile: (opts?: unknown) => ipcRenderer.invoke("capscreen:pick-file", opts), + notify: (opts: unknown) => ipcRenderer.invoke("capscreen:notify", opts), + printToPdf: (opts: unknown) => ipcRenderer.invoke("capscreen:print-pdf", opts), + signOut: () => ipcRenderer.invoke("capscreen:sign-out"), + openAccount: () => ipcRenderer.invoke("capscreen:open-account"), + onNavigate: (cb: (route: string) => void) => { + const listener = (_e: unknown, route: string) => cb(route); + ipcRenderer.on("capscreen:navigate", listener); + return () => ipcRenderer.removeListener("capscreen:navigate", listener); + }, +}); diff --git a/desktop/src/protocol.ts b/desktop/src/protocol.ts new file mode 100644 index 0000000..48caef8 --- /dev/null +++ b/desktop/src/protocol.ts @@ -0,0 +1,98 @@ +import http from "http"; +import { Readable } from "stream"; +import type { ReadableStream as WebReadableStream } from "stream/web"; +import { protocol, session } from "electron"; +import type { RuntimeInfo } from "./sidecars"; + +/** + * capscreen://app/… → Next.js server (127.0.0.1:) + * capscreen://app/__api/… → backend (127.0.0.1:), token attached here + * + * A fixed origin keeps localStorage stable across launches (ports change), and + * routing the API through the same origin keeps the desktop token out of the + * renderer entirely. + */ +export const SCHEME = "capscreen"; +export const APP_ORIGIN = `${SCHEME}://app`; +export const API_PREFIX = "/__api"; +const TOKEN_HEADER = "x-capscreen-token"; + +/** Must run before app "ready". */ +export function registerSchemes(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true, codeCache: true }, + }, + ]); +} + +const HOP_BY_HOP = ["host", "connection", "origin", "referer", "content-length"]; + +export function handleAppProtocol(rt: RuntimeInfo): void { + protocol.handle(SCHEME, async (req) => { + try { + return await proxy(rt, req); + } catch (err) { + console.error(`[protocol] ${req.method} ${req.url} failed:`, err); + return new Response(`CapScreen could not reach a local service: ${err instanceof Error ? err.message : String(err)}`, { + status: 502, + headers: { "content-type": "text/plain; charset=utf-8" }, + }); + } + }); +} + +/** + * Forward with node:http rather than net.fetch: net.fetch cannot hand back a + * redirect untouched (redirect:"manual" throws), and we need to rewrite Next's + * redirects onto the app origin. Bodies stream both ways (SSE, uploads). + */ +async function proxy(rt: RuntimeInfo, req: Request): Promise { + const url = new URL(req.url); + const isApi = url.pathname === API_PREFIX || url.pathname.startsWith(`${API_PREFIX}/`); + const upstream = isApi ? rt.backendUrl : rt.frontendUrl; + const target = new URL(`${upstream}${isApi ? url.pathname.slice(API_PREFIX.length) || "/" : url.pathname}${url.search}`); + + const headers: Record = {}; + req.headers.forEach((value, key) => { + if (!HOP_BY_HOP.includes(key)) headers[key] = value; + }); + if (isApi) headers[TOKEN_HEADER] = rt.token; + + const res = await new Promise((resolve, reject) => { + const out = http.request(target, { method: req.method, headers }, resolve); + out.on("error", reject); + if (req.body && req.method !== "GET" && req.method !== "HEAD") { + Readable.fromWeb(req.body as unknown as WebReadableStream).on("error", reject).pipe(out); + } else { + out.end(); + } + }); + + const respHeaders = new Headers(); + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined || key === "transfer-encoding" || key === "connection") continue; + for (const v of Array.isArray(value) ? value : [value]) respHeaders.append(key, v); + } + const status = res.statusCode ?? 502; + + // Keep redirects inside the app origin. + const location = respHeaders.get("location"); + if (location && status >= 300 && status < 400) { + if (location.startsWith(upstream)) respHeaders.set("location", APP_ORIGIN + location.slice(upstream.length)); + res.resume(); + return new Response(null, { status, headers: respHeaders }); + } + + const noBody = req.method === "HEAD" || status === 204 || status === 304; + if (noBody) res.resume(); + return new Response(noBody ? null : (Readable.toWeb(res) as unknown as ReadableStream), { status, headers: respHeaders }); +} + +/** Dev mode: the page loads Next directly, so attach the token on the wire instead. */ +export function injectTokenForDev(rt: RuntimeInfo): void { + session.defaultSession.webRequest.onBeforeSendHeaders({ urls: [`${rt.backendUrl}/*`] }, (details, cb) => { + cb({ requestHeaders: { ...details.requestHeaders, [TOKEN_HEADER]: rt.token } }); + }); +} diff --git a/desktop/src/session.ts b/desktop/src/session.ts new file mode 100644 index 0000000..f2fe1e9 --- /dev/null +++ b/desktop/src/session.ts @@ -0,0 +1,156 @@ +import fs from "fs"; +import { safeStorage } from "electron"; +import { AuthHttpError, refreshSession, revokeSession, startSignIn, type CloudSession, type CloudUser, type SignInHandle } from "./authFlow"; +import { isDev, userData } from "./paths"; +import { authDonePage } from "./splash"; + +/** + * Signed-in CapScreen account, Claude-Code style: sign in through the browser, + * keep the refresh token sealed by the OS keychain, work offline for a grace + * period. The account gates access only — screening data never leaves the machine. + */ + +const OFFLINE_GRACE_MS = 14 * 24 * 60 * 60 * 1000; +const REFRESH_EVERY_MS = 12 * 60 * 60 * 1000; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const pkg = require("../package.json") as { capscreenAuthUrl?: string }; + +/** Hosted auth service (backend/src/authService on Vercel). */ +export const AUTH_URL = ( + process.env.CAPSCREEN_AUTH_URL?.trim() || + (isDev ? "http://127.0.0.1:4100" : pkg.capscreenAuthUrl) || + "" +).replace(/\/$/, ""); + +type StoredSession = { + user: CloudUser; + refreshToken: string; + refreshExpires: string; + lastValidatedAt: string; +}; + +export type ActiveSession = { user: CloudUser; offline: boolean }; + +const sessionFile = () => userData("vault", "session.sealed"); +const identityFile = () => userData("identity.json"); + +function load(): StoredSession | null { + try { + const raw = fs.readFileSync(sessionFile()); + const json = safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(raw) : raw.toString("utf8"); + return JSON.parse(json) as StoredSession; + } catch { + return null; + } +} + +function save(s: CloudSession): StoredSession { + const stored: StoredSession = { + user: s.user, + refreshToken: s.refreshToken, + refreshExpires: s.refreshExpires, + lastValidatedAt: new Date().toISOString(), + }; + fs.mkdirSync(userData("vault"), { recursive: true, mode: 0o700 }); + const json = JSON.stringify(stored); + fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json, { mode: 0o600 }); + return stored; +} + +/** Non-secret identity for the local backend (audit "performed by"). */ +function writeIdentity(user: CloudUser | null): void { + if (!user) { + fs.rmSync(identityFile(), { force: true }); + return; + } + fs.writeFileSync( + identityFile(), + JSON.stringify({ email: user.email, displayName: user.displayName, organisation: user.organisation }, null, 2), + { mode: 0o600 }, + ); +} + +function clear(): void { + fs.rmSync(sessionFile(), { force: true }); + writeIdentity(null); +} + +let current: ActiveSession | null = null; +let refreshTimer: NodeJS.Timeout | null = null; +let onExpired: (() => void) | null = null; + +export function currentSession(): ActiveSession | null { + return current; +} + +/** Called when the server rejects the session mid-run (revoked, account deleted). */ +export function onSessionExpired(fn: () => void): void { + onExpired = fn; +} + +function activate(user: CloudUser, offline: boolean): ActiveSession { + current = { user, offline }; + writeIdentity(user); + if (!refreshTimer) { + refreshTimer = setInterval(() => void backgroundRefresh(), REFRESH_EVERY_MS); + refreshTimer.unref(); + } + return current; +} + +async function backgroundRefresh(): Promise { + const stored = load(); + if (!stored || !AUTH_URL) return; + try { + const s = save(await refreshSession(AUTH_URL, stored.refreshToken)); + activate(s.user, false); + } catch (err) { + if (err instanceof AuthHttpError && (err.status === 400 || err.status === 401)) { + clear(); + current = null; + onExpired?.(); + } + // Network errors: keep working within the offline grace window. + } +} + +/** Restore at launch: refresh online, fall back to the offline grace window. */ +export async function restoreSession(): Promise { + const stored = load(); + if (!stored) return null; + if (Date.parse(stored.refreshExpires) <= Date.now()) { + clear(); + return null; + } + try { + const s = save(await refreshSession(AUTH_URL, stored.refreshToken)); + return activate(s.user, false); + } catch (err) { + if (err instanceof AuthHttpError && (err.status === 400 || err.status === 401)) { + clear(); + return null; + } + const lastOk = Date.parse(stored.lastValidatedAt); + if (Date.now() - lastOk < OFFLINE_GRACE_MS) return activate(stored.user, true); + return null; + } +} + +/** Begin browser sign-in. The caller opens handle.authorizeUrl and awaits completion. */ +export async function beginSignIn(): Promise<{ handle: SignInHandle; done: Promise }> { + if (!AUTH_URL) throw new Error("This build has no sign-in service configured (capscreenAuthUrl)."); + const handle = await startSignIn({ authUrl: AUTH_URL, donePage: authDonePage }); + const done = handle.result.then((s) => { + save(s); + return activate(s.user, false); + }); + return { handle, done }; +} + +export async function signOut(): Promise { + const stored = load(); + clear(); + current = null; + if (stored && AUTH_URL) await revokeSession(AUTH_URL, stored.refreshToken); +} diff --git a/desktop/src/sidecars.ts b/desktop/src/sidecars.ts new file mode 100644 index 0000000..bb8e2d1 --- /dev/null +++ b/desktop/src/sidecars.ts @@ -0,0 +1,369 @@ +import { spawn, type ChildProcess } from "child_process"; +import fs from "fs"; +import path from "path"; +import { utilityProcess, type UtilityProcess } from "electron"; +import { isDev, repoRoot, resourcesDir, userData, watchmanBinary } from "./paths"; +import { freePort, waitForHttp } from "./ports"; +import { cacheDir, cachedLists, readManifest, refreshListCache } from "./listCache"; + +type Proc = ChildProcess | UtilityProcess; + +const RESTART_EXIT_CODE = 75; + +export type SidecarName = "watchman" | "backend" | "frontend"; + +export type RuntimeInfo = { + watchmanUrl: string; + backendUrl: string; + frontendUrl: string; + token: string; +}; + +type Sidecar = { + name: SidecarName; + proc: Proc | null; + alive: boolean; + stopping: boolean; + restarts: number; +}; + +const sidecars = new Map(); +const listeners: Array<(name: SidecarName, event: "exit" | "restart", detail: string) => void> = []; + +export function onSidecarEvent(fn: (name: SidecarName, event: "exit" | "restart", detail: string) => void): void { + listeners.push(fn); +} + +function logStream(name: string): fs.WriteStream { + const dir = userData("logs"); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${name}.log`); + // Keep logs bounded: start fresh when a file grows past 10 MB. + try { + if (fs.statSync(file).size > 10 * 1024 * 1024) fs.rmSync(file); + } catch { + /* no previous log */ + } + const stream = fs.createWriteStream(file, { flags: "a" }); + stream.write(`\n=== ${new Date().toISOString()} ${name} starting ===\n`); + return stream; +} + +function pipeLogs(proc: Proc, name: string): void { + const out = logStream(name); + const forward = (prefix: string) => (chunk: Buffer) => { + out.write(chunk); + if (isDev) process.stdout.write(`[${prefix}] ${chunk}`); + }; + proc.stdout?.on("data", forward(name)); + proc.stderr?.on("data", forward(name)); +} + +function track(name: SidecarName, proc: Proc, restart?: () => Promise): Sidecar { + const sc: Sidecar = sidecars.get(name) ?? { name, proc: null, alive: false, stopping: false, restarts: 0 }; + sc.proc = proc; + sc.alive = true; + sc.stopping = false; + sidecars.set(name, sc); + // ChildProcess and UtilityProcess both emit "exit"; their typings don't share overloads. + (proc as ChildProcess).on("exit", (code: number | null) => { + sc.alive = false; + if (sc.stopping) return; + // 75 = the backend asked to be restarted (e.g. after restoring a backup). + if (code === RESTART_EXIT_CODE && restart) { + void restart().then(() => listeners.forEach((l) => l(name, "restart", "requested"))); + return; + } + const detail = `exited with code ${code}`; + listeners.forEach((l) => l(name, "exit", detail)); + // Crash recovery with backoff; give up after 5 quick failures. + if (restart && sc.restarts < 5) { + sc.restarts++; + setTimeout(() => { + restart() + .then(() => listeners.forEach((l) => l(name, "restart", `restart #${sc.restarts}`))) + .catch((err) => console.error(`[sidecar] ${name} restart failed:`, err)); + }, 1000 * sc.restarts); + } + }); + return sc; +} + +const isAlive = (name: SidecarName) => () => sidecars.get(name)?.alive ?? false; + +// ── Watchman (Go binary) ───────────────────────────────────────────────────── + +function configuredLists(): string[] { + try { + const s = JSON.parse(fs.readFileSync(userData("settings.json"), "utf8")) as { sanctionsLists?: string[] }; + if (Array.isArray(s.sanctionsLists) && s.sanctionsLists.length) return s.sanctionsLists; + } catch { + /* defaults */ + } + return ["us_ofac", "eu_csl"]; +} + +type WatchmanMode = "online" | "offline"; +let watchmanMode: WatchmanMode = "online"; + +/** Online: download lists. Offline: load the shell's snapshot (cached lists only). */ +function writeWatchmanConfig(port: number, adminPort: number, mode: WatchmanMode): string { + const dir = userData("watchman"); + const lists = mode === "offline" ? cachedLists(configuredLists()) : configuredLists(); + const yaml = [ + "Watchman:", + " Servers:", + ` BindAddress: "127.0.0.1:${port}"`, + ` AdminAddress: "127.0.0.1:${adminPort}"`, + " Download:", + ' RefreshInterval: "12h"', + ` InitialDataDirectory: ${mode === "offline" ? JSON.stringify(cacheDir()) : '""'}`, + " IncludedLists:", + ...lists.map((l) => ` - ${l}`), + "", + ].join("\n"); + const file = path.join(dir, "config.yml"); + fs.writeFileSync(file, yaml); + writeWatchmanStatus(); + return file; +} + +/** Read by the backend (Data & Backup page): online vs cached lists, and when cached. */ +function writeWatchmanStatus(): void { + fs.writeFileSync( + userData("watchman", "status.json"), + JSON.stringify({ mode: watchmanMode, cached: readManifest().lists, updatedAt: new Date().toISOString() }, null, 2), + ); +} + +let watchmanPorts: { port: number; adminPort: number } | null = null; + +const watchmanPidFile = () => userData("watchman", "watchman.pid"); + +/** + * Watchman is a plain child process (not an Electron utility process), so a + * crash or force-quit of the app can orphan it. Reap the previous instance. + */ +function killStaleWatchman(): void { + try { + const pid = Number(fs.readFileSync(watchmanPidFile(), "utf8")); + if (pid > 0) process.kill(pid, "SIGTERM"); + } catch { + /* no pid file, or already gone */ + } + fs.rmSync(watchmanPidFile(), { force: true }); +} + +export async function startWatchman(): Promise { + // Ports stay fixed for the app's lifetime: the backend holds this URL. + watchmanPorts ??= { port: await freePort(), adminPort: await freePort() }; + const { port, adminPort } = watchmanPorts; + const bin = watchmanBinary(); + if (!fs.existsSync(bin)) { + throw new Error(`Watchman binary missing at ${bin}. Run: pnpm --filter capscreen-desktop run build:watchman`); + } + const url = `http://127.0.0.1:${port}`; + killStaleWatchman(); + const launch = async () => { + const config = writeWatchmanConfig(port, adminPort, watchmanMode); + // Own empty cwd: Watchman treats its working directory as a data source + // when no InitialDataDirectory is configured. + const cwd = userData("watchman", "work"); + fs.mkdirSync(cwd, { recursive: true }); + const proc = spawn(bin, [], { + cwd, + env: { ...process.env, APP_CONFIG: config }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + pipeLogs(proc, "watchman"); + if (proc.pid) fs.writeFileSync(watchmanPidFile(), String(proc.pid)); + track("watchman", proc, relaunch); + }; + // Watchman exits if it can't download lists at start (no/slow internet). + // Fall back to the snapshot when there is one; otherwise retry online. + const relaunch = async () => { + if (watchmanMode === "online" && cachedLists(configuredLists()).length > 0) { + console.warn("[sidecar] watchman could not load lists online — starting from the offline snapshot"); + watchmanMode = "offline"; + } + await launch(); + }; + await launch(); + // Watchman binds its port only after the first list load (download + index, + // ~15 s on a good connection). Wait across supervised restarts, not just one. + const sc = () => sidecars.get("watchman"); + try { + await waitForHttp(`${url}/ping`, 240_000, () => (sc()?.alive ?? false) || (sc()?.restarts ?? 0) < 5); + } catch (err) { + if (cachedLists(configuredLists()).length === 0) { + throw new Error( + "CapScreen couldn't download the sanctions lists. An internet connection is needed the first time " + + "CapScreen starts; after that it keeps an offline copy.", + ); + } + throw err; + } + sc()!.restarts = 0; + scheduleListMaintenance(); + return url; +} + +let listTimer: NodeJS.Timeout | null = null; + +/** + * Keep the offline snapshot fresh while online (every 12 h, like Watchman), and + * while running from the snapshot try to get back online every 30 minutes. + */ +function scheduleListMaintenance(): void { + if (listTimer) return; + let lastRefresh = 0; + const tick = async () => { + // Online: refresh the snapshot twice a day. Offline: probe every tick. + if (watchmanMode === "online" && Date.now() - lastRefresh < 12 * 60 * 60_000) return; + const refreshed = await refreshListCache(configuredLists()); + if (refreshed.length > 0) lastRefresh = Date.now(); + writeWatchmanStatus(); + if (watchmanMode === "offline" && refreshed.length > 0) { + console.log("[sidecar] sanctions sources reachable again — reloading Watchman online"); + watchmanMode = "online"; + await restartWatchman().catch((err) => console.error("[sidecar] watchman online reload failed:", err)); + } + }; + setTimeout(() => void tick(), 60_000).unref(); + listTimer = setInterval(() => void tick(), 30 * 60_000); + listTimer.unref(); +} + +/** For the Data page via the backend: current mode is also in status.json. */ +export function sanctionsMode(): WatchmanMode { + return watchmanMode; +} + +// ── Backend (Express) ──────────────────────────────────────────────────────── + +export async function startBackend(opts: { + watchmanUrl: string; + token: string; + masterKey: string; + frontendOrigin: string; + apiOrigin: (port: number) => string; +}): Promise { + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + const env: Record = { + ...(process.env as Record), + NODE_ENV: isDev ? "development" : "production", + HOST: "127.0.0.1", + PORT: String(port), + WATCHMAN_URL: opts.watchmanUrl, + CAPSCREEN_DATA_DIR: userData(), + CAPSCREEN_DESKTOP_TOKEN: opts.token, + CAPSCREEN_MASTER_KEY: opts.masterKey, + CAPSCREEN_API_ORIGIN: opts.apiOrigin(port), + CAPSCREEN_CORPUS_DIR: resourcesDir("backend", "compliance-corpus"), + FRONTEND_URL: opts.frontendOrigin, + }; + // Never let a developer's cloud .env leak into the desktop runtime. + for (const k of ["MONGODB_URI", "UPSTASH_REDIS_REST_URL", "R2_ACCESS_KEY_ID", "RESEND_API_KEY"]) delete env[k]; + + const launch = async () => { + let proc: Proc; + if (isDev) { + const tsx = path.join(repoRoot, "backend", "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx"); + proc = spawn(tsx, ["src/index.ts"], { cwd: path.join(repoRoot, "backend"), env, stdio: ["ignore", "pipe", "pipe"] }); + } else { + proc = utilityProcess.fork(resourcesDir("backend", "index.js"), [], { + env, + stdio: "pipe", + serviceName: "CapScreen Backend", + }); + } + pipeLogs(proc, "backend"); + track("backend", proc, launch); + }; + await launch(); + await waitForHttp(`${url}/health`, isDev ? 90_000 : 30_000, isAlive("backend")); + return url; +} + +// ── Frontend (Next.js) ─────────────────────────────────────────────────────── + +export async function startFrontend(): Promise { + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + const env: Record = { + ...(process.env as Record), + PORT: String(port), + HOSTNAME: "127.0.0.1", + NEXT_TELEMETRY_DISABLED: "1", + }; + + const launch = async () => { + let proc: Proc; + if (isDev) { + const next = path.join(repoRoot, "frontend", "node_modules", ".bin", process.platform === "win32" ? "next.cmd" : "next"); + proc = spawn(next, ["dev", "-p", String(port), "-H", "127.0.0.1"], { + cwd: path.join(repoRoot, "frontend"), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + } else { + // Standalone output mirrors the monorepo layout: frontend/frontend/server.js. + proc = utilityProcess.fork(resourcesDir("frontend", "frontend", "server.js"), [], { + env: { ...env, NODE_ENV: "production" }, + cwd: resourcesDir("frontend", "frontend"), + stdio: "pipe", + serviceName: "CapScreen UI", + }); + } + pipeLogs(proc, "frontend"); + track("frontend", proc, launch); + }; + await launch(); + await waitForHttp(url, isDev ? 180_000 : 30_000, isAlive("frontend")); + return url; +} + +/** Reload Watchman when the user changes which sanctions lists to screen against. */ +export function watchSanctionsSettings(): void { + const file = userData("settings.json"); + const read = () => { + try { + return JSON.stringify((JSON.parse(fs.readFileSync(file, "utf8")) as { sanctionsLists?: string[] }).sanctionsLists ?? null); + } catch { + return "null"; + } + }; + let last = read(); + fs.watchFile(file, { interval: 2000 }, () => { + const next = read(); + if (next === last) return; + last = next; + console.log("[sidecar] sanctions lists changed — reloading Watchman"); + restartWatchman().catch((err) => console.error("[sidecar] watchman reload failed:", err)); + }); +} + +export function stopAll(): void { + for (const sc of sidecars.values()) { + sc.stopping = true; + try { + sc.proc?.kill(); + } catch { + /* already gone */ + } + } +} + +/** Restart Watchman on the same port (e.g. after a list snapshot import). */ +export async function restartWatchman(): Promise { + const sc = sidecars.get("watchman"); + if (sc?.proc && sc.alive) { + sc.stopping = true; + const exited = new Promise((r) => (sc.proc as ChildProcess).once("exit", r)); + sc.proc.kill(); + await exited; + } + return startWatchman(); +} diff --git a/desktop/src/splash.ts b/desktop/src/splash.ts new file mode 100644 index 0000000..a9d554c --- /dev/null +++ b/desktop/src/splash.ts @@ -0,0 +1,87 @@ +import fs from "fs"; +import { app } from "electron"; +import path from "path"; +import { resourcesDir } from "./paths"; + +/** Inline brand mark; recoloured via CSS so it inverts in dark mode. */ +function markSvg(): string { + const file = app.isPackaged + ? resourcesDir("frontend", "frontend", "public", "capscreen-mark.svg") + : path.join(__dirname, "..", "..", "frontend", "public", "capscreen-mark.svg"); + try { + return fs.readFileSync(file, "utf8").replace(/<\?xml[^>]*>/, "").replace("CapScreen +
${markSvg()}

CapScreen

+

Starting the on-device screening engine…

+

Your data stays on this computer.

`; +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +} + +export function errorHtml(message: string, logsDir: string): string { + return `CapScreen +
${markSvg()}

CapScreen couldn't start

+

One of the local services failed to launch.

+
${escapeHtml(message)}
+

Logs: ${escapeHtml(logsDir)}

+ +
`; +} + +export type SignInView = { state: "idle" } | { state: "waiting" } | { state: "error"; message: string }; + +/** + * Gate shown until the user signs in. Buttons navigate to about:blank#… and + * main.ts intercepts them (the page itself has no privileges). + */ +export function signInHtml(view: SignInView): string { + const body = + view.state === "waiting" + ? `
+

Finish signing in in your browser.

+

Didn't see a browser window?

+ ` + : `

Sign in with your CapScreen account to continue.

+

Your account controls access only. Cap tables, documents and screenings stay on this computer.

+ ${view.state === "error" ? `
${escapeHtml(view.message)}
` : ""} + `; + return `CapScreen +
${markSvg()}

Welcome to CapScreen

${body}
`; +} + +/** Shown in the browser tab after it hands the sign-in back to the app. */ +export function authDonePage(ok: boolean, message: string): string { + return `CapScreen +
${markSvg()}

${ok ? "You're signed in" : "Sign-in didn't complete"}

+

${escapeHtml(message)}

`; +} diff --git a/desktop/src/vault.ts b/desktop/src/vault.ts new file mode 100644 index 0000000..f0c255d --- /dev/null +++ b/desktop/src/vault.ts @@ -0,0 +1,37 @@ +import crypto from "crypto"; +import fs from "fs"; +import { safeStorage } from "electron"; +import { userData } from "./paths"; + +/** + * 32-byte master key for encrypting API keys at rest (backend: lib/llm/controlCenter/store.ts). + * Sealed with the OS keychain (macOS Keychain, Windows DPAPI, libsecret on Linux). + */ +export function loadMasterKey(): string { + fs.mkdirSync(userData("vault"), { recursive: true, mode: 0o700 }); + const sealedFile = userData("vault", "master.key.sealed"); + const plainFile = userData("vault", "master.key"); + const canSeal = safeStorage.isEncryptionAvailable(); + + if (fs.existsSync(sealedFile) && canSeal) { + return safeStorage.decryptString(fs.readFileSync(sealedFile)); + } + if (fs.existsSync(plainFile)) { + const key = fs.readFileSync(plainFile, "utf8").trim(); + if (canSeal) { + // Upgrade: keychain became available (e.g. Linux keyring unlocked). + fs.writeFileSync(sealedFile, safeStorage.encryptString(key), { mode: 0o600 }); + fs.rmSync(plainFile); + } + return key; + } + + const key = crypto.randomBytes(32).toString("base64"); + if (canSeal) { + fs.writeFileSync(sealedFile, safeStorage.encryptString(key), { mode: 0o600 }); + } else { + console.warn("[vault] OS keychain unavailable — master key stored with file permissions only"); + fs.writeFileSync(plainFile, key, { mode: 0o600 }); + } + return key; +} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 0000000..df2f23a --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/docs/DESKTOP_PLAN.md b/docs/DESKTOP_PLAN.md new file mode 100644 index 0000000..1346f37 --- /dev/null +++ b/docs/DESKTOP_PLAN.md @@ -0,0 +1,113 @@ +# CapScreen Desktop — Conversion Plan + +Goal: ship CapScreen as a native desktop app (Electron) where **no cap-table, +document, or screening data leaves the machine** unless the user explicitly +opts into a cloud model. The hosted web build (Vercel + EC2) keeps working from +the same codebase. + +## Decisions (2026-10-25) + +| Question | Decision | +|---|---| +| Local database | **SQLite** — implemented as a MongoDB-driver-compatible store under mongoose (`node:sqlite`), so no call sites changed. | +| Web version | **Desktop only** (revised 2026-10-25: no server costs). Cloud services removed from the runtime. | +| Feature priority | Local model onboarding, background re-screening, exports & backup, folder watch + sanctions-list versioning. | + +## Runtime topology (desktop) + +``` +Electron main + ├─ Watchman sidecar 127.0.0.1: (Go binary, bundled per-OS) + ├─ Express backend 127.0.0.1: (utilityProcess; per-launch bearer token) + │ └─ SQLite file /capscreen.db + │ └─ Documents /documents/ + ├─ Next standalone 127.0.0.1: → BrowserWindow + └─ Secrets OS keychain via safeStorage → injected as env to backend +``` + +Network egress in desktop mode is limited to: sanctions-list downloads (can be +disabled via offline import) and cloud LLM/embedding calls **only** when the +user selects a non-local provider. The UI shows an egress indicator. + +## Cloud dependency → desktop replacement + +| Server mode | Desktop mode | +|---|---| +| MongoDB Atlas (mongoose, ~170 live call sites) | SQLite via repositories | +| Atlas `$vectorSearch` (`lib/rag/retrieveChunks.ts`) | In-process cosine over per-startup chunks | +| Cloudflare R2 (`lib/infra/r2Storage.ts`) | Local filesystem | +| Upstash Redis (`lib/infra/cache.ts`) | In-memory (already the fallback) | +| JWT cookies + Resend email auth | Single local user; optional OS-auth app lock | +| Watchman in Docker | Bundled Watchman binary | +| Anthropic-only LLM | Pluggable providers (see Phase 1) | +| Web search tool | Off by default; flagged as egress | + +## Phases + +### Phase 1 — Pluggable model providers ✅ done +`backend/src/lib/llm/providers/`: provider-neutral message/tool/stream types, +an Anthropic adapter, and one OpenAI-compatible adapter covering Ollama, +LM Studio, llama.cpp, vLLM, OpenAI, OpenRouter, Groq, Gemini (OpenAI endpoint). +Model roles: `agent` (tool-calling chat) and `utility` (narratives, CSV schema +inference, entity extraction). Config from env or `LLM_CONFIG_FILE` (JSON). +Data-residency checks become per-provider: local providers are always allowed. + +### Phase 2 — SQLite ✅ done (mongoose driver shim instead of a repository layer) +1. Delete dead code (`routes/startups.ts`, duplicate root-level models). +2. Introduce `backend/src/data/` repositories per aggregate (startups, csvs, + chats, documents, RAG, screening snapshots/digests/reviews, tabular, + workflows, audit, users). Mongo implementation first — pure refactor. +3. SQLite implementation + migrations; vector search in-process. +4. Embeddings through the provider layer (local `nomic-embed-text` via Ollama). + +### Phase 3 — Desktop runtime ✅ done (local files, memory cache, local user, token gate) +Storage (fs vs R2), cache, auth (local user vs JWT), CORS/loopback token, +rate limiting relaxed for loopback. + +### Phase 4 — Electron shell ✅ done (signing/notarization + auto-update still TODO) +`desktop/` workspace: main process, sidecar supervisor (Watchman, backend, +Next standalone), safeStorage key vault, IPC for native dialogs, tray, +notifications, auto-update (electron-updater), electron-builder targets +(dmg, nsis, AppImage), code signing. + +### Phase 5 — Desktop features ✅ done +- **Local model onboarding:** detect Ollama/LM Studio, read RAM/GPU, recommend + a model tier, pull with progress, custom endpoint form, capability probe + (tool-calling support) with a guided no-tools fallback. +- **Background re-screening:** tray-resident scheduler (`rescreenScheduler.ts`), + native notifications on risk-level delta. +- **Exports & backup:** IC memo PDF, signed audit-log export, encrypted DB + backup/restore via native dialogs. +- **Folder watch + list versioning:** watch a data-room folder for ingest; + stamp every screening with Watchman list versions/hashes; offline list import. + +## Status (2026-10-25) + +Verified end to end in the packaged macOS build (`desktop/release/CapScreen-1.0.0-arm64.dmg`): +on-device screening chat with qwen3:4b (tool call → Watchman → SQLite, ~70 s on an 8 GB M2), +real OFAC match + OFAC 50% rule propagation, encrypted backup → restore, hash-chained audit +export with tamper detection, watched-folder ingest (CSV + documents), crash restart of the +backend, clean shutdown (no orphaned sidecars). + +### Open items +- Code signing + notarization (macOS), Authenticode (Windows); `electron-updater` feeds. +- Windows/Linux builds must be produced on those OSes (platform-specific `sharp` in the UI bundle). +- Database is not encrypted at rest (API keys are); rely on FileVault/BitLocker or add SQLCipher. +- `sample-data/DEMO.md` expects "Ivan Petrovich Kozlov" to be flagged, but no such entity exists in + current OFAC/EU lists — the demo data needs a real or fixture-backed match. +- Deploy the auth service to Vercel and set `capscreenAuthUrl` in `desktop/package.json` before + building installers (until then packaged builds stop at the sign-in screen). +- Unused server-era code still in the tree: frontend login/signup pages (sign-in now happens on the + hosted portal), `docker-compose.yml`, `backend/Dockerfile`, Cloudflare/OpenNext config. + +## Accounts (2026-10-25) + +Claude-Code-style sign-in added: hosted auth service (existing account system, Vercel + Atlas), +browser hand-off via RFC 8252 loopback + PKCE, session sealed in the OS keychain, 14-day offline +grace, identity stamped on audit entries/reviews. Verified: 17-check protocol suite (replayed codes, +wrong PKCE verifier, non-loopback redirects, forged callbacks, rotation, revocation), real portal UI +driven in Chromium, gate → restore → offline → sign-out in the dev app. + +Offline sanctions data: the shell keeps a snapshot of the OFAC/EU source files and starts Watchman +from it when an online start fails; it returns to live lists automatically (verified with network +blocked for Watchman: boot in ~12 s, identical screening results). diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 4ccd2db..9d2743f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -7,6 +7,8 @@ const monorepoRoot = path.join(__dirname, ".."); loadEnvConfig(monorepoRoot); const nextConfig: NextConfig = { + // Desktop app: self-contained server the Electron shell runs on loopback. + ...(process.env.CAPSCREEN_DESKTOP_BUILD === "1" ? { output: "standalone" as const } : {}), // pnpm workspace: pin Turbopack root so it resolves `next` correctly. turbopack: { root: monorepoRoot, diff --git a/frontend/public/VC Fund Research and Tool Building.svg b/frontend/public/VC Fund Research and Tool Building.svg deleted file mode 100644 index b21cf30..0000000 --- a/frontend/public/VC Fund Research and Tool Building.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..1323d2c Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/capscreen-logo-reversed.svg b/frontend/public/capscreen-logo-reversed.svg new file mode 100644 index 0000000..eeb6c9f --- /dev/null +++ b/frontend/public/capscreen-logo-reversed.svg @@ -0,0 +1,6 @@ + + CapScreen + + + + diff --git a/frontend/public/capscreen-logo.svg b/frontend/public/capscreen-logo.svg new file mode 100644 index 0000000..7a9a22a --- /dev/null +++ b/frontend/public/capscreen-logo.svg @@ -0,0 +1,6 @@ + + CapScreen + + + + diff --git a/frontend/public/capscreen-mark.svg b/frontend/public/capscreen-mark.svg new file mode 100644 index 0000000..9754286 --- /dev/null +++ b/frontend/public/capscreen-mark.svg @@ -0,0 +1,5 @@ + + CapScreen + + + diff --git a/frontend/src/app/(pages)/account/page.tsx b/frontend/src/app/(pages)/account/page.tsx index ddad8f1..ff6c093 100644 --- a/frontend/src/app/(pages)/account/page.tsx +++ b/frontend/src/app/(pages)/account/page.tsx @@ -9,6 +9,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { useOnboardingTour } from "@/app/contexts/OnboardingTourContext"; import { deleteAccount } from "@/app/lib/capScreenApi"; +import { useDesktop } from "@/lib/desktop"; export default function AccountPage() { const router = useRouter(); @@ -23,6 +24,7 @@ export default function AccountPage() { const [orgSaved, setOrgSaved] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const bridge = useDesktop(); useEffect(() => { if (profile?.displayName) { @@ -79,6 +81,52 @@ export default function AccountPage() { if (!user) return null; + if (bridge?.user) { + const account = bridge.user; + return ( +
+
+

Account

+
+
+
Name
+
{account.displayName ?? "—"}
+
+
+
Email
+
{account.email}
+
+
+
Organisation
+
{account.organisation ?? "—"}
+
+
+

+ Your CapScreen account controls access to this app. Cap tables, documents and screenings + stay on this computer. + {bridge.offline && " You're working offline; CapScreen will reconnect automatically."} +

+
+
+

Actions

+
+ + + +
+
+
+ ); + } + return (
{/* Profile Settings */} diff --git a/frontend/src/app/(pages)/ai/page.tsx b/frontend/src/app/(pages)/ai/page.tsx new file mode 100644 index 0000000..e4041a4 --- /dev/null +++ b/frontend/src/app/(pages)/ai/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { aiApi, type AiState, type LocalState } from "@/lib/aiControlCenterApi"; +import { PrivacyCard } from "@/app/components/ai/PrivacyCard"; +import { RoleAssignments } from "@/app/components/ai/RoleAssignments"; +import { LocalModels } from "@/app/components/ai/LocalModels"; +import { ProviderList } from "@/app/components/ai/ProviderList"; + +export default function AiControlCenterPage() { + const [state, setState] = useState(null); + const [local, setLocal] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const [s, l] = await Promise.all([aiApi.state(), aiApi.local()]); + setState(s); + setLocal(l); + setError(null); + } catch (e) { + setError((e as Error).message); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // While Ollama is missing, keep checking so installing it "just works". + useEffect(() => { + if (!local || local.ollama.running) return; + const t = setInterval(() => void aiApi.local().then(setLocal).catch(() => {}), 5000); + return () => clearInterval(t); + }, [local]); + + return ( +
+
+

AI Control Center

+

+ Choose where CapScreen's AI runs — on this computer, or with your own cloud keys. +

+
+
+ {error && ( +
{error}
+ )} + {!state ? ( +
+ Loading… +
+ ) : ( + <> + + + + + + )} +
+
+ ); +} diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 3fb205c..f6d59a0 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -9,6 +9,7 @@ import { SidebarContext } from "@/app/contexts/SidebarContext"; import { OnboardingTourProvider } from "@/app/contexts/OnboardingTourContext"; import { AppSidebar } from "@/app/components/shared/AppSidebar"; import { UI_PREVIEW_MODE } from "@/lib/uiPreview"; +import { DesktopShell } from "@/app/components/shared/DesktopShell"; export default function CapScreenLayout({ children, @@ -99,12 +100,13 @@ export default function CapScreenLayout({
+
-
+
{/* Mobile header */}
+
diff --git a/frontend/src/app/(pages)/settings/data/page.tsx b/frontend/src/app/(pages)/settings/data/page.tsx new file mode 100644 index 0000000..bae7a38 --- /dev/null +++ b/frontend/src/app/(pages)/settings/data/page.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Archive, Check, Download, FolderOpen, Loader2, RefreshCw, ShieldCheck, Upload } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PasswordInput } from "@/components/ui/password-input"; +import { desktopDataApi, saveDownload, type AppSettings, type SanctionsInfo } from "@/lib/desktopDataApi"; +import { useDesktop } from "@/lib/desktop"; + +type Status = { ok: boolean; text: string } | null; + +function StatusLine({ status }: { status: Status }) { + if (!status) return null; + return ( +

+ {status.ok && } + {status.text} +

+ ); +} + +function SanctionsSection() { + const [info, setInfo] = useState(null); + const [settings, setSettings] = useState(null); + const [lists, setLists] = useState>([]); + const [status, setStatus] = useState(null); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + const [s, i] = await Promise.all([desktopDataApi.settings(), desktopDataApi.sanctions()]); + setSettings(s.settings); + setLists(s.availableLists); + setInfo(i); + }, []); + + useEffect(() => { + void load().catch((e: Error) => setStatus({ ok: false, text: e.message })); + }, [load]); + + const toggleList = async (id: string) => { + if (!settings) return; + const next = settings.sanctionsLists.includes(id) + ? settings.sanctionsLists.filter((l) => l !== id) + : [...settings.sanctionsLists, id]; + setSaving(true); + setStatus(null); + try { + const r = await desktopDataApi.saveSettings({ sanctionsLists: next }); + setSettings(r.settings); + setStatus({ ok: true, text: "Saved — reloading sanctions data (about 30 seconds)…" }); + setTimeout(() => void load(), 30_000); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + } finally { + setSaving(false); + } + }; + + const saveSchedule = async (patch: Partial) => { + if (!settings) return; + const r = await desktopDataApi.saveSettings({ backgroundScreening: { ...settings.backgroundScreening, ...patch } }); + setSettings(r.settings); + }; + + return ( +
+
+

Sanctions data

+

+ Lists are downloaded from official public sources and matched on this computer. Every screening + records the exact list versions it used. +

+
+ +
+
+
+ + {info?.available ? "Screening engine ready" : "Screening engine loading…"} + {info?.info?.version && Watchman {info.info.version}} +
+ +
+ {info?.info?.lists && ( + + + + + + + + + + {Object.entries(info.info.lists).map(([id, count]) => ( + + + + + + ))} + +
ListEntriesVersion (SHA-256)
{lists.find((l) => l.id === id)?.label ?? id}{count.toLocaleString()} + {info.info?.listHashes?.[id]?.slice(0, 16) ?? "—"}… +
+ )} + {info?.mode === "offline" && ( +

+ Offline: screening against the copy saved on this computer + {Object.values(info.cached).length > 0 && + ` (${new Date(Math.min(...Object.values(info.cached).map((d) => Date.parse(d)))).toLocaleString()})`} + . CapScreen switches back to live lists automatically when the sources are reachable. +

+ )} + {info?.mode === "online" && Object.keys(info.cached ?? {}).length > 0 && ( +

+ Offline copy saved {new Date(Math.max(...Object.values(info.cached).map((d) => Date.parse(d)))).toLocaleString()} — used automatically if the internet is unavailable. +

+ )} + {info?.info?.endedAt && ( +

+ Loaded {new Date(info.info.endedAt).toLocaleString()} · refreshes every 12 hours while CapScreen runs +

+ )} +
+ +
+

Lists to screen against

+
+ {lists.map((l) => ( + + ))} +
+ +
+ + {settings && ( +
+ +

+ While CapScreen is open or in the menu bar, every startup's latest cap table is re-checked + against fresh lists. You get a notification when something new needs review. +

+
+ Every + +
+
+ )} +
+ ); +} + +function BackupSection() { + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState<"backup" | "restore" | "audit" | null>(null); + const [status, setStatus] = useState(null); + const [restorePassword, setRestorePassword] = useState(""); + const [restoreFile, setRestoreFile] = useState(null); + const [dbFile, setDbFile] = useState(null); + const fileRef = useRef(null); + const bridge = useDesktop(); + + useEffect(() => { + void desktopDataApi.settings().then((s) => setDbFile(s.databaseFile)).catch(() => {}); + }, []); + + const backup = async () => { + setBusy("backup"); + setStatus(null); + try { + const { data, filename } = await desktopDataApi.backup(password); + const saved = await saveDownload(data, filename); + setStatus(saved ? { ok: true, text: `Encrypted backup saved (${(data.byteLength / 1024 / 1024).toFixed(1)} MB)` } : null); + setPassword(""); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + } finally { + setBusy(null); + } + }; + + const restore = async () => { + if (!restoreFile) return; + if (!confirm("Restoring replaces everything currently in CapScreen with the backup. Continue?")) return; + setBusy("restore"); + setStatus(null); + try { + const r = await desktopDataApi.restore(await restoreFile.arrayBuffer(), restorePassword); + setStatus({ ok: true, text: `Restored backup from ${new Date(r.createdAt).toLocaleString()}. Reloading…` }); + setTimeout(() => window.location.assign("/startups"), 6000); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + setBusy(null); + } + }; + + const exportAudit = async () => { + setBusy("audit"); + setStatus(null); + try { + const { data, filename } = await desktopDataApi.auditExport(); + const saved = await saveDownload(data, filename); + if (saved) setStatus({ ok: true, text: "Tamper-evident audit log exported" }); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + } finally { + setBusy(null); + } + }; + + return ( +
+
+

Backup & export

+

+ Everything lives in one folder on this computer (API keys are additionally sealed by your OS + keychain; keep disk encryption such as FileVault or BitLocker on). Back it up to an external drive + or move it to a new machine. +

+
+ + {dbFile && ( +
+ {dbFile} + {bridge && ( + + )} +
+ )} + +
+

+ Create encrypted backup +

+

+ Startups, cap tables, screenings, documents, chats, and settings — sealed with your password + (AES-256). API keys are not included; re-enter them after restoring on another computer. +

+
+
+ setPassword(e.target.value)} placeholder="Backup password (8+ characters)" autoComplete="new-password" /> +
+ +
+
+ +
+

+ Restore from backup +

+ setRestoreFile(e.target.files?.[0] ?? null)} + /> +
+ + {restoreFile?.name ?? "No file chosen"} +
+ {restoreFile && ( +
+
+ setRestorePassword(e.target.value)} placeholder="Backup password" autoComplete="off" /> +
+ +
+ )} +
+ +
+

Audit log

+

+ Exports every screening, review decision and document event as JSON with a SHA-256 hash chain, so + any later edit to the file is detectable. +

+ +
+ + +
+ ); +} + +export default function DataSettingsPage() { + return ( +
+
+

Data & Backup

+

Your data never leaves this computer unless you export it.

+
+
+ + +
+
+ ); +} diff --git a/frontend/src/app/components/ai/LocalModels.tsx b/frontend/src/app/components/ai/LocalModels.tsx new file mode 100644 index 0000000..214edf8 --- /dev/null +++ b/frontend/src/app/components/ai/LocalModels.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Cpu, Download, ExternalLink, HardDrive, Loader2, Sparkles, Trash2, Wrench, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { aiApi, type CatalogModel, type Fit, type LocalState } from "@/lib/aiControlCenterApi"; +import { desktop } from "@/lib/desktop"; + +const FIT_STYLE: Record = { + great: { label: "Runs well", className: "bg-emerald-50 text-emerald-700" }, + ok: { label: "Fits", className: "bg-sky-50 text-sky-700" }, + tight: { label: "Tight — slow", className: "bg-amber-50 text-amber-700" }, + "too-large": { label: "Too large", className: "bg-gray-100 text-gray-500" }, +}; + +type PullState = { model: string; percent: number | null; status: string; abort: AbortController }; + +function openExternal(url: string) { + const bridge = desktop(); + if (bridge) void bridge.openExternal(url); + else window.open(url, "_blank", "noopener,noreferrer"); +} + +export function LocalModels({ + local, + onChange, +}: { + local: LocalState | null; + onChange: () => void; +}) { + const [pull, setPull] = useState(null); + const [error, setError] = useState(null); + const [setupBusy, setSetupBusy] = useState(false); + const [setupMsg, setSetupMsg] = useState(null); + const pullRef = useRef(null); + + if (!local) { + return ( +
+

Local models

+
+ Checking this computer… +
+
+ ); + } + + const { hardware: hw, ollama } = local; + + const startPull = async (model: string) => { + setError(null); + const state: PullState = { model, percent: null, status: "Starting…", abort: new AbortController() }; + pullRef.current = state; + setPull(state); + try { + await aiApi.pull( + model, + (e) => { + if (e.type === "progress") { + const next = { ...pullRef.current!, status: e.status, percent: e.percent ?? pullRef.current!.percent }; + pullRef.current = next; + setPull(next); + } else if (e.type === "error") { + setError(e.detail); + } + }, + state.abort.signal, + ); + } catch (e) { + if (!state.abort.signal.aborted) setError((e as Error).message); + } finally { + pullRef.current = null; + setPull(null); + onChange(); + } + }; + + const remove = async (name: string) => { + if (!confirm(`Delete ${name} from this computer?`)) return; + try { + await aiApi.deleteLocalModel(name); + onChange(); + } catch (e) { + setError((e as Error).message); + } + }; + + const quickSetup = async () => { + setSetupBusy(true); + setSetupMsg(null); + setError(null); + try { + const r = await aiApi.quickSetupLocal(); + setSetupMsg( + `Done — ${r.chat} handles chat and background tasks${r.embed ? `, ${r.embed} handles document search` : ""}. Local-only mode is on.`, + ); + onChange(); + } catch (e) { + setError((e as Error).message); + } finally { + setSetupBusy(false); + } + }; + + const chatModels = local.catalog.filter((m) => m.kind === "chat"); + const embedModels = local.catalog.filter((m) => m.kind === "embedding"); + + const renderModel = (m: CatalogModel) => { + const fit = FIT_STYLE[m.fit]; + const pulling = pull?.model === m.id; + return ( +
  • +
    +
    +
    + {m.label} + {m.id === local.recommended && ( + + Recommended + + )} +
    +
    {m.id}
    +
    + + {fit.label} + +
    +

    {m.note}

    +
    + + {m.sizeGB} GB + {m.tools && ( + + tools + + )} + + {m.installed ? ( + Installed + ) : pulling ? ( + + ) : ( + + )} +
    + {pulling && ( +
    +
    +
    +
    +
    + {pull?.status} + {pull?.percent != null ? ` · ${pull.percent}%` : ""} +
    +
    + )} +
  • + ); + }; + + return ( +
    +
    +
    +

    Local models

    +

    + Run models on this computer with Ollama — nothing is sent anywhere. +

    +
    + {ollama.running && ( + + )} +
    + +
    +
    +
    Runtime
    + {ollama.running ? ( +
    + + Ollama {ollama.version} running · {ollama.models.length} model + {ollama.models.length === 1 ? "" : "s"} +
    + ) : ( +
    +
    + + Ollama is not running +
    +

    + Install Ollama (free), open it, then come back — this page refreshes automatically. +

    + +
    + )} + {local.lmstudio.running && ( +
    + + LM Studio running · {local.lmstudio.models.length} models +
    + )} +
    +
    +
    This computer
    +
    + + {hw.cpu} · {hw.cores} cores +
    +
    + {hw.totalMemGB} GB {hw.unifiedMemory ? "unified memory (shared with GPU)" : "RAM"} +
    + {hw.totalMemGB < 12 && ( +

    + With {hw.totalMemGB} GB, 4B-parameter models run best. Larger ones work but respond + slowly while CapScreen is open. +

    + )} +
    +
    + + {setupMsg &&

    {setupMsg}

    } + {error &&

    {error}

    } + + {ollama.models.length > 0 && ( +
    +

    Installed

    +
      + {ollama.models.map((m) => ( +
    • + {m.name} + + {m.parameterSize && {m.parameterSize}} + {m.sizeGB} GB + + +
    • + ))} +
    +
    + )} + +

    Chat models

    +
      {chatModels.map(renderModel)}
    +

    Document search models

    +
      {embedModels.map(renderModel)}
    +
    + ); +} diff --git a/frontend/src/app/components/ai/PrivacyCard.tsx b/frontend/src/app/components/ai/PrivacyCard.tsx new file mode 100644 index 0000000..941da86 --- /dev/null +++ b/frontend/src/app/components/ai/PrivacyCard.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Cloud, HardDrive, Loader2, ShieldCheck, ShieldAlert } from "lucide-react"; +import { useState } from "react"; +import { aiApi, ROLE_INFO, type AiState, type LlmRole } from "@/lib/aiControlCenterApi"; + +export function PrivacyCard({ state, onChange }: { state: AiState; onChange: () => void }) { + const [saving, setSaving] = useState(false); + const roles = (["agent", "utility", "embedding"] as LlmRole[]) + .map((r) => [r, state.effective.roles[r]] as const) + .filter(([, s]) => !!s); + const cloudRoles = roles.filter(([, s]) => s && !s.local); + const allLocal = roles.length > 0 && cloudRoles.length === 0; + + const toggle = async () => { + setSaving(true); + try { + await aiApi.setLocalOnly(!state.localOnly); + onChange(); + } finally { + setSaving(false); + } + }; + + return ( +
    +
    +
    + {allLocal ? : } +
    +
    +

    + {roles.length === 0 + ? "No AI model configured yet" + : allLocal + ? "Everything stays on this computer" + : `${cloudRoles.length} task${cloudRoles.length > 1 ? "s" : ""} send data to a cloud provider`} +

    +

    + Cap tables, documents and screening results are stored only on this device. Sanctions + matching always runs locally. Only the AI tasks below can reach the internet — and only + when you assign them a cloud model. +

    + + {roles.length > 0 && ( +
      + {roles.map(([role, s]) => ( +
    • + {s!.local ? ( + + ) : ( + + )} + + {ROLE_INFO[role].short}:{" "} + {s!.local ? "on-device" : s!.label} + +
    • + ))} +
    + )} +
    + + +
    + {state.localOnly && ( +

    + Local-only mode is on: any cloud model is blocked, even if one is assigned. +

    + )} +
    + ); +} diff --git a/frontend/src/app/components/ai/ProviderList.tsx b/frontend/src/app/components/ai/ProviderList.tsx new file mode 100644 index 0000000..d6f83bb --- /dev/null +++ b/frontend/src/app/components/ai/ProviderList.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState } from "react"; +import { Check, ExternalLink, KeyRound, Loader2, Plus, Server, Trash2, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { aiApi, type AiState, type ProviderView } from "@/lib/aiControlCenterApi"; +import { desktop } from "@/lib/desktop"; + +const KEY_LINKS: Record = { + anthropic: "https://console.anthropic.com/settings/keys", + openai: "https://platform.openai.com/api-keys", + gemini: "https://aistudio.google.com/apikey", + openrouter: "https://openrouter.ai/keys", + groq: "https://console.groq.com/keys", +}; + +function openExternal(url: string) { + const bridge = desktop(); + if (bridge) void bridge.openExternal(url); + else window.open(url, "_blank", "noopener,noreferrer"); +} + +function useStatus() { + const [status, setStatus] = useState<{ ok: boolean; text: string } | null>(null); + return [status, setStatus] as const; +} + +function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => void }) { + const [key, setKey] = useState(""); + const [busy, setBusy] = useState<"save" | "test" | "remove" | null>(null); + const [status, setStatus] = useStatus(); + + const run = async (kind: "save" | "test" | "remove", fn: () => Promise<{ ok: boolean; text: string } | void>) => { + setBusy(kind); + setStatus(null); + try { + const r = await fn(); + if (r) setStatus(r); + onChange(); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + } finally { + setBusy(null); + } + }; + + return ( +
  • +
    +
    + + {p.label} + {p.key.configured ? ( + + Key saved {p.key.hint} + + ) : p.envKey ? ( + From .env (dev) + ) : null} +
    + {KEY_LINKS[p.id] && ( + + )} +
    + +
    +
    + setKey(e.target.value)} + placeholder={p.key.configured ? "Paste a new key to replace" : "Paste API key"} + autoComplete="off" + spellCheck={false} + aria-label={`${p.label} API key`} + /> +
    + +
    + +
    + {(p.key.configured || p.envKey) && ( + + )} + {p.key.configured && ( + + )} + {status && ( + + {status.ok && } + {status.text} + + )} +
    +
  • + ); +} + +function ServerRow({ p, onChange }: { p: ProviderView; onChange: () => void }) { + const [baseUrl, setBaseUrl] = useState(p.baseUrl ?? ""); + const [status, setStatus] = useStatus(); + const [busy, setBusy] = useState(false); + + const test = async () => { + setBusy(true); + setStatus(null); + try { + if (baseUrl !== (p.baseUrl ?? "")) await aiApi.saveProvider(p.id, { baseUrl }); + const t = await aiApi.test(p.id); + setStatus(t.ok ? { ok: true, text: `Connected · ${t.modelCount} models` } : { ok: false, text: t.error ?? "Failed" }); + onChange(); + } catch (e) { + setStatus({ ok: false, text: (e as Error).message }); + } finally { + setBusy(false); + } + }; + + return ( +
  • +
    + + {p.label} + + {p.local ? "on this computer" : "remote server"} + + {!p.preset && ( + + )} +
    +
    + setBaseUrl(e.target.value)} className="font-mono text-xs" aria-label={`${p.label} base URL`} /> + +
    + {status && {status.text}} +
  • + ); +} + +function AddCustomProvider({ onChange }: { onChange: () => void }) { + const [open, setOpen] = useState(false); + const [label, setLabel] = useState(""); + const [baseUrl, setBaseUrl] = useState("http://127.0.0.1:8000/v1"); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!open) { + return ( + + ); + } + + const id = + label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "custom"; + + const add = async () => { + setBusy(true); + setError(null); + try { + await aiApi.saveProvider(id, { label: label.trim(), baseUrl: baseUrl.trim() }); + if (apiKey.trim()) await aiApi.saveKey(id, apiKey.trim()); + setOpen(false); + setLabel(""); + setApiKey(""); + onChange(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
    +

    + Any server that speaks the OpenAI chat-completions API — vLLM, LocalAI, Jan, a company gateway, Azure + OpenAI proxy, etc. Loopback URLs (127.0.0.1 / localhost) count as on-device. +

    + setLabel(e.target.value)} placeholder="Name (e.g. Team vLLM)" /> + setBaseUrl(e.target.value)} placeholder="https://host/v1" className="font-mono text-xs" /> + setApiKey(e.target.value)} placeholder="API key (optional)" autoComplete="off" /> + {error &&

    {error}

    } +
    + + +
    +
    + ); +} + +export function ProviderList({ state, onChange }: { state: AiState; onChange: () => void }) { + const cloud = state.providers.filter((p) => p.needsKey && p.preset); + const servers = state.providers.filter((p) => !p.preset || (!p.needsKey && p.id !== "ollama")); + + return ( + <> +
    +

    Cloud providers

    +

    + Bring your own API key. Keys are encrypted with your operating system's keychain and never + leave this computer except to authenticate with that provider. +

    +
      + {cloud.map((p) => ( + + ))} +
    +
    + +
    +

    Other servers

    +

    LM Studio, llama.cpp, or your own OpenAI-compatible endpoint.

    +
      + {servers.map((p) => ( + + ))} +
    + +
    + + ); +} diff --git a/frontend/src/app/components/ai/RoleAssignments.tsx b/frontend/src/app/components/ai/RoleAssignments.tsx new file mode 100644 index 0000000..8ee92a8 --- /dev/null +++ b/frontend/src/app/components/ai/RoleAssignments.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, Cloud, HardDrive, Loader2, Wrench, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { aiApi, ROLE_INFO, type AiState, type LlmRole, type ProviderView } from "@/lib/aiControlCenterApi"; + +/** Providers a role can use: local servers, plus cloud ones that have a key. */ +function usableProviders(state: AiState): ProviderView[] { + return state.providers.filter((p) => !p.needsKey || p.key.configured || p.envKey); +} + +function RoleRow({ role, state, onSaved }: { role: LlmRole; state: AiState; onSaved: () => void }) { + const current = state.roles[role] ?? (role === "utility" ? state.roles.agent : undefined); + const effective = state.effective.roles[role]; + const providers = usableProviders(state); + + const [provider, setProvider] = useState(current?.provider ?? ""); + const [model, setModel] = useState(current?.model ?? ""); + const [supportsTools, setSupportsTools] = useState(current?.supportsTools); + const [models, setModels] = useState([]); + const [modelsError, setModelsError] = useState(null); + const [busy, setBusy] = useState<"save" | "test" | null>(null); + const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + if (!provider) return; + let cancelled = false; + aiApi + .models(provider) + .then((r) => { + if (cancelled) return; + setModels(r.models); + setModelsError(null); + }) + .catch((e: Error) => { + if (cancelled) return; + setModels([]); + setModelsError(e.message); + }); + return () => { + cancelled = true; + }; + }, [provider]); + + const filteredModels = + role === "embedding" ? models.filter((m) => /embed/i.test(m)) : models.filter((m) => !/embed/i.test(m)); + + const probe = async (p: string, m: string) => { + if (role === "embedding" || !p || !m) return; + try { + const r = await aiApi.probeTools(p, m); + setSupportsTools(r.supportsTools); + } catch { + setSupportsTools(undefined); + } + }; + + const dirty = + provider !== (current?.provider ?? "") || + model !== (current?.model ?? "") || + supportsTools !== current?.supportsTools; + + const save = async () => { + setBusy("save"); + setResult(null); + try { + await aiApi.saveRoles({ + [role]: provider && model ? { provider, model, ...(role !== "embedding" ? { supportsTools } : {}) } : null, + }); + setResult({ ok: true, text: "Saved" }); + onSaved(); + } catch (e) { + setResult({ ok: false, text: (e as Error).message }); + } finally { + setBusy(null); + } + }; + + const test = async () => { + if (!provider || !model) return; + setBusy("test"); + setResult(null); + try { + if (role === "embedding") { + const r = await aiApi.test(provider); + setResult({ ok: r.ok, text: r.ok ? `Reachable · ${r.latencyMs} ms` : (r.error ?? "Failed") }); + } else { + const r = await aiApi.test(provider, model); + setResult({ + ok: r.ok, + text: r.ok ? `Replied “${r.reply}” in ${(r.latencyMs / 1000).toFixed(1)} s` : (r.error ?? "Failed"), + }); + } + } catch (e) { + setResult({ ok: false, text: (e as Error).message }); + } finally { + setBusy(null); + } + }; + + const selected = providers.find((p) => p.id === provider); + const listId = `models-${role}`; + + return ( +
    +
    +
    +

    {ROLE_INFO[role].title}

    +

    {ROLE_INFO[role].description}

    +
    + {effective && ( + + {effective.local ? : } + {effective.error ? "Blocked" : effective.local ? "On-device" : "Cloud"} + + )} +
    + +
    + +
    + setModel(e.target.value)} + onBlur={() => probe(provider, model)} + aria-label={`${ROLE_INFO[role].title} model`} + /> + + {filteredModels.map((m) => ( + +
    +
    + + +
    +
    + +
    + {role !== "embedding" && provider && model && ( + + )} + {selected && !selected.local && ( + Requests to this model leave your computer. + )} + {modelsError && Couldn't list models: {modelsError}} + {result && ( + + {result.ok && } + {result.text} + + )} + {effective?.error && !result && {effective.error}} +
    +
    + ); +} + +export function RoleAssignments({ state, onChange }: { state: AiState; onChange: () => void }) { + return ( +
    +

    Model assignments

    +

    Choose which model handles each kind of work.

    +
    + {(["agent", "utility", "embedding"] as LlmRole[]).map((role) => { + const b = state.roles[role]; + // Remount when the saved binding changes so the form resets to it. + return ( + + ); + })} +
    +
    + ); +} diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index de69d1c..f8195ca 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -20,6 +20,7 @@ import { getAuthHeaders } from "@/lib/apiAuth"; import type { ScreeningResult } from "@/lib/screenerTypes"; import { parseMessageOptions } from "@/lib/parseMessageOptions"; import { MessageOptionChips } from "@/app/components/shared/MessageOptionChips"; +import { apiBase } from "@/lib/apiBase"; function toolCallLabel(name: string): string { if (name === "generate_docx") return "Creating document..."; @@ -112,8 +113,7 @@ function BulkEditActions({ setBusy(verb); setProgress({ done: 0, total: pending.length }); try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const base = apiBase(); // Sequential so the per-document version counter advances in a // predictable order and the viewer doesn't race between bumps. @@ -137,7 +137,7 @@ function BulkEditActions({ } try { const resp = await fetch( - `${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`, + `${base}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`, { method: "POST", headers: {}, @@ -641,7 +641,7 @@ function DocDownloadBlock({ // the user's bearer token, so any absolute URL from tool output is // refused to keep the token from leaking off-origin. const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + apiBase(); const normalizedUrl = (() => { const legacy = download_url.match( /^\/api\/startups\/[^/]+\/documents\/([^/]+)\/download$/, diff --git a/frontend/src/app/components/assistant/EditCard.tsx b/frontend/src/app/components/assistant/EditCard.tsx index eb8a343..c4ddbdb 100644 --- a/frontend/src/app/components/assistant/EditCard.tsx +++ b/frontend/src/app/components/assistant/EditCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { AppEditAnnotation } from "../shared/types"; +import { apiBase } from "@/lib/apiBase"; function normalizeText(s: string) { return s.replace(/\s+/g, " ").trim(); @@ -239,10 +240,9 @@ export function EditCard({ console.error("[EditCard] optimistic update threw", e); } try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const base = apiBase(); const resp = await fetch( - `${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`, + `${base}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`, { method: "POST", headers: {} }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index c06fcb8..97ea99f 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -10,6 +10,8 @@ import { User, ChevronsUpDown, ChevronDown, + Cpu, + Database, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; @@ -46,6 +48,12 @@ const NAV_ITEMS = [ icon: Library, tourId: "nav-workflows", }, + { + href: "/ai", + label: "AI Control Center", + icon: Cpu, + tourId: "nav-ai", + }, ] as const; interface AppSidebarProps { @@ -155,8 +163,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
    {/* Toggle + Logo */} @@ -452,6 +460,16 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { Account Settings +
    )}
    diff --git a/frontend/src/app/components/shared/DesktopShell.tsx b/frontend/src/app/components/shared/DesktopShell.tsx new file mode 100644 index 0000000..5651db6 --- /dev/null +++ b/frontend/src/app/components/shared/DesktopShell.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { Cpu, X } from "lucide-react"; +import { useDesktop } from "@/lib/desktop"; +import { aiApi } from "@/lib/aiControlCenterApi"; + +/** + * Desktop chrome: routes menu/tray/notification clicks, provides the macOS + * title-bar drag strip, and nudges first-run users to configure a model. + */ +export function DesktopShell({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const bridge = useDesktop(); + const isMac = bridge?.platform === "darwin"; + const [needsModel, setNeedsModel] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (!bridge) return; + return bridge.onNavigate((route) => router.push(route)); + }, [bridge, router]); + + useEffect(() => { + let cancelled = false; + aiApi + .state() + .then((s) => { + if (!cancelled) setNeedsModel(!s.effective.roles.agent || !!s.effective.roles.agent.error); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [pathname]); + + const showBanner = needsModel && !dismissed && !pathname.startsWith("/ai"); + + return ( + <> + {isMac &&
    } + {bridge?.offline && ( +
    + Working offline — your sign-in is cached on this computer. CapScreen will reconnect automatically. +
    + )} + {showBanner && ( +
    + + + No AI model is set up yet. Screening works, but the assistant needs a model — run one on this + computer or add your own API key. + + + Open AI Control Center + + +
    + )} + {children} + + ); +} diff --git a/frontend/src/app/components/shared/DocPanel.tsx b/frontend/src/app/components/shared/DocPanel.tsx index c430e55..963c9c5 100644 --- a/frontend/src/app/components/shared/DocPanel.tsx +++ b/frontend/src/app/components/shared/DocPanel.tsx @@ -17,6 +17,7 @@ import type { AppCitationAnnotation, AppEditAnnotation, } from "./types"; +import { apiBase } from "@/lib/apiBase"; function isDocxFilename(name: string): boolean { const ext = name.split(".").pop()?.toLowerCase(); @@ -368,11 +369,9 @@ function EditResolveButtons({ ); } try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - "http://localhost:3001"; + const base = apiBase(); const resp = await fetch( - `${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`, + `${base}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`, { method: "POST", headers: {} }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); @@ -463,8 +462,7 @@ function DownloadButton({ if (busy || isReloading) return; setBusy(true); try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const base = apiBase(); const path = isStartupDocument ? `/api/documents/${documentId}/download` : `/single-documents/${documentId}/docx${ @@ -472,7 +470,7 @@ function DownloadButton({ ? `?version_id=${encodeURIComponent(versionId)}` : "" }`; - const resp = await fetch(`${apiBase}${path}`, { + const resp = await fetch(`${base}${path}`, { headers: await getAuthHeaders(), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); diff --git a/frontend/src/app/components/shared/DocxView.tsx b/frontend/src/app/components/shared/DocxView.tsx index fa12228..6a72b08 100644 --- a/frontend/src/app/components/shared/DocxView.tsx +++ b/frontend/src/app/components/shared/DocxView.tsx @@ -9,6 +9,7 @@ import { highlightDocxQuote, } from "./highlightDocxQuote"; import type { CitationQuote } from "./types"; +import { apiBase } from "@/lib/apiBase"; interface Props { documentId: string; @@ -144,13 +145,12 @@ async function tagWIdsOnRenderedDom( versionId: string | null | undefined, ): Promise { try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const base = apiBase(); const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const resp = await fetch( - `${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`, + `${base}/single-documents/${documentId}/tracked-change-ids${qs}`, { headers: await getAuthHeaders() }, ); if (!resp.ok) { diff --git a/frontend/src/app/components/shared/StartupDocumentView.tsx b/frontend/src/app/components/shared/StartupDocumentView.tsx index a2e478c..8d99fbe 100644 --- a/frontend/src/app/components/shared/StartupDocumentView.tsx +++ b/frontend/src/app/components/shared/StartupDocumentView.tsx @@ -5,6 +5,7 @@ import { Loader2 } from "lucide-react"; import { getAuthHeaders } from "@/lib/apiAuth"; import { getStartupGeneratedDocument } from "@/lib/startupsApi"; import { ChatMarkdown } from "./ChatMarkdown"; +import { apiBase } from "@/lib/apiBase"; interface Props { documentId: string; @@ -22,9 +23,8 @@ async function fetchDocumentContent( return doc.content; } - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; - const resp = await fetch(`${apiBase}/api/documents/${documentId}/view`, { + const base = apiBase(); + const resp = await fetch(`${base}/api/documents/${documentId}/view`, { headers: await getAuthHeaders(), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); diff --git a/frontend/src/app/components/startups/IcMemoDownloadCard.tsx b/frontend/src/app/components/startups/IcMemoDownloadCard.tsx index 9c98183..0958ea2 100644 --- a/frontend/src/app/components/startups/IcMemoDownloadCard.tsx +++ b/frontend/src/app/components/startups/IcMemoDownloadCard.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { Download, FileText, Loader2 } from "lucide-react"; import { getIcMemoDocument } from "@/lib/startupsApi"; -import { downloadIcMemoAsDoc } from "@/lib/downloadIcMemo"; +import { downloadIcMemoAsDoc, downloadIcMemoAsPdf } from "@/lib/downloadIcMemo"; +import { useDesktop } from "@/lib/desktop"; interface Props { startupId: string; @@ -12,19 +13,21 @@ interface Props { } export function IcMemoDownloadCard({ startupId, documentId, title }: Props) { - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState<"doc" | "pdf" | null>(null); const [error, setError] = useState(null); + const desktopApp = useDesktop() !== null; - async function handleDownload() { - setLoading(true); + async function handleDownload(format: "doc" | "pdf") { + setLoading(format); setError(null); try { const doc = await getIcMemoDocument(startupId, documentId); - downloadIcMemoAsDoc(doc); + if (format === "pdf") await downloadIcMemoAsPdf(doc); + else downloadIcMemoAsDoc(doc); } catch (err) { setError(err instanceof Error ? err.message : "Download failed"); } finally { - setLoading(false); + setLoading(null); } } @@ -38,19 +41,22 @@ export function IcMemoDownloadCard({ startupId, documentId, title }: Props) { IC compliance memo ready

    - + {(desktopApp ? (["pdf", "doc"] as const) : (["doc"] as const)).map((format) => ( + + ))}
    {error &&

    {error}

    }
    diff --git a/frontend/src/app/components/startups/RagDocumentsStrip.tsx b/frontend/src/app/components/startups/RagDocumentsStrip.tsx index 1e3e498..2d677ea 100644 --- a/frontend/src/app/components/startups/RagDocumentsStrip.tsx +++ b/frontend/src/app/components/startups/RagDocumentsStrip.tsx @@ -8,10 +8,9 @@ import { type RagDocumentRecord, uploadRagDocument, } from "@/lib/startupsApi"; +import { apiBase } from "@/lib/apiBase"; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); interface Props { startupId: string; diff --git a/frontend/src/app/components/startups/StartupScreenerPage.tsx b/frontend/src/app/components/startups/StartupScreenerPage.tsx index 192084d..bb567e6 100644 --- a/frontend/src/app/components/startups/StartupScreenerPage.tsx +++ b/frontend/src/app/components/startups/StartupScreenerPage.tsx @@ -17,6 +17,7 @@ import { StartupChatPanel } from "@/app/components/startups/StartupChatPanel"; import { StartupAnalysisDock } from "@/app/components/startups/StartupAnalysisDock"; import { ScreeningFollowUpActions } from "@/app/components/startups/ScreeningFollowUpActions"; import { ScreeningDeltaBanner } from "@/app/components/startups/ScreeningDeltaBanner"; +import { WatchFolderButton } from "./WatchFolderButton"; import { StartupAuditMenu } from "@/app/components/startups/StartupAuditMenu"; import { getStartup, @@ -591,6 +592,7 @@ export function StartupScreenerPage({ startupId }: Props) { />
    + diff --git a/frontend/src/app/components/startups/WatchFolderButton.tsx b/frontend/src/app/components/startups/WatchFolderButton.tsx new file mode 100644 index 0000000..4efc637 --- /dev/null +++ b/frontend/src/app/components/startups/WatchFolderButton.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { FolderSync, Loader2 } from "lucide-react"; +import { useDesktop } from "@/lib/desktop"; +import { desktopDataApi, type WatchFolder } from "@/lib/desktopDataApi"; + +/** + * Link a data-room folder to this startup: new CSVs become cap-table uploads, + * PDFs/DOCX go into document search. Desktop only. + */ +export function WatchFolderButton({ startupId }: { startupId: string }) { + const [watch, setWatch] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const ref = useRef(null); + const bridge = useDesktop(); + + useEffect(() => { + if (!bridge) return; + void desktopDataApi.watchFolder(startupId).then((r) => setWatch(r.watch)).catch(() => {}); + }, [startupId, bridge]); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, [open]); + + if (!bridge) return null; + + const choose = async () => { + setError(null); + const folder = await bridge.pickFolder({ title: "Choose a data-room folder to watch" }); + if (!folder) return; + setBusy(true); + try { + setWatch((await desktopDataApi.setWatchFolder(startupId, folder)).watch); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + + const stop = async () => { + setBusy(true); + try { + setWatch((await desktopDataApi.setWatchFolder(startupId, null)).watch); + setOpen(false); + } finally { + setBusy(false); + } + }; + + return ( +
    + + {open && watch && ( +
    +
    {watch.path}
    +
    + {watch.ingestedCount} file{watch.ingestedCount === 1 ? "" : "s"} imported + {watch.lastScanAt && ` · checked ${new Date(watch.lastScanAt).toLocaleTimeString()}`} +
    +

    + New .csv files are added as cap tables; .pdf / .docx / .txt go into document search. +

    + {watch.lastError &&

    {watch.lastError}

    } +
    + + + +
    +
    + )} + {error &&

    {error}

    } +
    + ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico index 8c51e6a..c64fd31 100644 Binary files a/frontend/src/app/favicon.ico and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/hooks/useDocumentVersions.ts b/frontend/src/app/hooks/useDocumentVersions.ts index 6244f02..d76f20b 100644 --- a/frontend/src/app/hooks/useDocumentVersions.ts +++ b/frontend/src/app/hooks/useDocumentVersions.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { getAuthHeaders } from "@/lib/apiAuth"; +import { apiBase } from "@/lib/apiBase"; export interface DocumentVersionRow { id: string; @@ -52,11 +53,9 @@ export function useDocumentVersions( (async () => { try { - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - "http://localhost:3001"; + const base = apiBase(); const resp = await fetch( - `${apiBase}/single-documents/${documentId}/versions`, + `${base}/single-documents/${documentId}/versions`, { headers: await getAuthHeaders() }, ); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); diff --git a/frontend/src/app/hooks/useFetchDocxBytes.ts b/frontend/src/app/hooks/useFetchDocxBytes.ts index 79363ef..709db06 100644 --- a/frontend/src/app/hooks/useFetchDocxBytes.ts +++ b/frontend/src/app/hooks/useFetchDocxBytes.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { getAuthHeaders } from "@/lib/apiAuth"; +import { apiBase } from "@/lib/apiBase"; export interface FetchDocxResult { bytes: ArrayBuffer | null; @@ -63,12 +64,11 @@ export function useFetchDocxBytes( } const key = cacheKey(documentId, versionId, refetchKey); - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + const base = apiBase(); const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; - const url = `${apiBase}/single-documents/${documentId}/docx${qs}`; + const url = `${base}/single-documents/${documentId}/docx${qs}`; // Cache hit: reuse bytes synchronously, no network, no spinner. const cached = bytesCache.get(key); diff --git a/frontend/src/app/hooks/useFetchSingleDoc.ts b/frontend/src/app/hooks/useFetchSingleDoc.ts index 9be9b18..dcf5172 100644 --- a/frontend/src/app/hooks/useFetchSingleDoc.ts +++ b/frontend/src/app/hooks/useFetchSingleDoc.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { getAuthHeaders } from "@/lib/apiAuth"; +import { apiBase } from "@/lib/apiBase"; /** * /display returns either PDF bytes (when the active version has a PDF @@ -39,14 +40,12 @@ export function useFetchSingleDoc( try { if (cancelled) return; - const apiBase = - process.env.NEXT_PUBLIC_API_BASE_URL ?? - "http://localhost:3001"; + const base = apiBase(); const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const response = await fetch( - `${apiBase}/single-documents/${documentId}/display${qs}`, + `${base}/single-documents/${documentId}/display${qs}`, { headers: await getAuthHeaders(), }, diff --git a/frontend/src/app/icon.svg b/frontend/src/app/icon.svg index 9a1cb72..9754286 100644 --- a/frontend/src/app/icon.svg +++ b/frontend/src/app/icon.svg @@ -1,128 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + CapScreen + + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 16cf1b7..8001779 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -24,7 +24,7 @@ export const metadata: Metadata = { icons: { icon: [ { - url: "/VC%20Fund%20Research%20and%20Tool%20Building.svg", + url: "/capscreen-mark.svg", type: "image/svg+xml", }, { url: "/favicon.ico" }, diff --git a/frontend/src/app/lib/auditLogApi.ts b/frontend/src/app/lib/auditLogApi.ts index b20ccfb..529d5a2 100644 --- a/frontend/src/app/lib/auditLogApi.ts +++ b/frontend/src/app/lib/auditLogApi.ts @@ -1,8 +1,7 @@ import { getAuthHeaders } from "@/lib/apiAuth"; +import { apiBase } from "@/lib/apiBase"; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); export interface AuditLogEntry { id: string; diff --git a/frontend/src/app/lib/capScreenApi.ts b/frontend/src/app/lib/capScreenApi.ts index 944832e..2eaa7a5 100644 --- a/frontend/src/app/lib/capScreenApi.ts +++ b/frontend/src/app/lib/capScreenApi.ts @@ -39,9 +39,10 @@ import type { TabularReview, TabularReviewDetailOut, } from "@/app/components/shared/types"; +import { apiBase } from "@/lib/apiBase"; const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; + apiBase(); async function apiRequest(path: string, init?: RequestInit): Promise { const authHeaders = await getAuthHeaders(); diff --git a/frontend/src/app/lib/screenerApi.ts b/frontend/src/app/lib/screenerApi.ts index 2a73e0d..dff0e6e 100644 --- a/frontend/src/app/lib/screenerApi.ts +++ b/frontend/src/app/lib/screenerApi.ts @@ -3,6 +3,7 @@ import type { ScreeningProgressEvent, ScreeningResult, } from "@/lib/screenerTypes"; +import { apiBase } from "@/lib/apiBase"; export type ChatMessage = { role: "user" | "assistant"; content: string }; @@ -12,9 +13,7 @@ export type ToolActivity = { summary?: string; }; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); export async function screenCapTable( file: File, diff --git a/frontend/src/components/app-logo.tsx b/frontend/src/components/app-logo.tsx index 0f9ec61..a46f9f5 100644 --- a/frontend/src/components/app-logo.tsx +++ b/frontend/src/components/app-logo.tsx @@ -1,10 +1,12 @@ -/** Logo asset in /public */ -export const APP_LOGO_SRC = "/VC%20Fund%20Research%20and%20Tool%20Building.svg"; +/** CapScreen brand assets in /public (vector outlines, no font dependency). */ +export const APP_LOGO_SRC = "/capscreen-logo.svg"; +export const APP_MARK_SRC = "/capscreen-mark.svg"; -const ASPECT = 1000 / 391; +/** Lockup artboard: 1104 × 391 (circle diameter = height). */ +const ASPECT = 1104 / 391; type AppLogoProps = { - /** Full wordmark vs circular mark (left crop) */ + /** Full lockup (circle + "screen") vs circular "cap" mark */ variant?: "full" | "mark"; height?: number; className?: string; @@ -16,24 +18,16 @@ export function AppLogo({ className = "", }: AppLogoProps) { if (variant === "mark") { - const size = height; return ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - - + // eslint-disable-next-line @next/next/no-img-element + CapScreen ); } @@ -42,7 +36,7 @@ export function AppLogo({ // eslint-disable-next-line @next/next/no-img-element VC Fund Research and Tool Building { if (UI_PREVIEW_MODE) { - setUser(PREVIEW_USER); + // Desktop: the signed-in account's email; data stays owned by the local user. + const account = desktop()?.user; + setUser(account ? { id: PREVIEW_USER.id, email: account.email } : PREVIEW_USER); setAuthLoading(false); return; } @@ -55,6 +58,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const signOut = async () => { + const bridge = desktop(); + if (bridge) { + // The desktop shell revokes the session and shows the sign-in screen. + await bridge.signOut(); + return; + } if (UI_PREVIEW_MODE) return; await apiSignOut(); setUser(null); diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index 4a65617..93a2806 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -18,6 +18,7 @@ import { updateUserProfile, } from "@/app/lib/capScreenApi"; import { PREVIEW_PROFILE, UI_PREVIEW_MODE } from "@/lib/uiPreview"; +import { desktop } from "@/lib/desktop"; interface UserProfile { displayName: string | null; @@ -91,7 +92,16 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { useEffect(() => { if (UI_PREVIEW_MODE && isAuthenticated) { - setProfile(PREVIEW_PROFILE); + const account = desktop()?.user; + setProfile( + account + ? { + ...PREVIEW_PROFILE, + displayName: account.displayName ?? account.email.split("@")[0], + organisation: account.organisation ?? PREVIEW_PROFILE.organisation, + } + : PREVIEW_PROFILE, + ); setLoading(false); return; } diff --git a/frontend/src/lib/aiControlCenterApi.ts b/frontend/src/lib/aiControlCenterApi.ts new file mode 100644 index 0000000..0d4b3b2 --- /dev/null +++ b/frontend/src/lib/aiControlCenterApi.ts @@ -0,0 +1,169 @@ +import { apiBase } from "@/lib/apiBase"; + +export type LlmRole = "agent" | "utility" | "embedding"; + +export type RoleBinding = { provider: string; model: string; supportsTools?: boolean }; + +export type RoleSummary = { + provider: string; + label: string; + model: string; + local: boolean; + supportsTools: boolean; + error?: string; +}; + +export type ProviderView = { + id: string; + label: string; + kind: "anthropic" | "openai-compatible" | "ollama"; + baseUrl: string | null; + local: boolean; + preset: boolean; + enabled: boolean; + needsKey: boolean; + key: { configured: boolean; hint: string | null; updatedAt: string | null }; + envKey: boolean; +}; + +export type AiState = { + configured: boolean; + localOnly: boolean; + roles: Partial>; + effective: { + roles: Partial>; + localOnly: boolean; + source: "control-center" | "env"; + error?: string; + }; + providers: ProviderView[]; +}; + +export type Fit = "great" | "ok" | "tight" | "too-large"; + +export type CatalogModel = { + id: string; + label: string; + family: string; + sizeGB: number; + minRamGB: number; + tools: boolean; + kind: "chat" | "embedding"; + note: string; + fit: Fit; + installed: boolean; +}; + +export type LocalState = { + hardware: { + platform: string; + arch: string; + cpu: string; + cores: number; + totalMemGB: number; + freeMemGB: number; + unifiedMemory: boolean; + }; + ollama: { + running: boolean; + version: string | null; + models: Array<{ name: string; sizeGB: number; family: string | null; parameterSize: string | null }>; + }; + lmstudio: { running: boolean; models: string[] }; + recommended: string; + catalog: CatalogModel[]; +}; + +export type PullEvent = + | { type: "progress"; status: string; completed: number | null; total: number | null; percent: number | null } + | { type: "done"; model: string } + | { type: "error"; detail: string }; + +async function request(path: string, init?: RequestInit): Promise { + const resp = await fetch(`${apiBase()}/api/ai${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + const body = (await resp.json().catch(() => ({}))) as T & { detail?: string }; + if (!resp.ok) throw new Error(body.detail ?? `Request failed (${resp.status})`); + return body; +} + +const json = (method: string, body: unknown): RequestInit => ({ method, body: JSON.stringify(body) }); + +export const aiApi = { + state: () => request("/state"), + local: () => request("/local"), + + saveProvider: (id: string, p: { label?: string; baseUrl?: string; kind?: string; local?: boolean }) => + request<{ ok: true }>(`/providers/${encodeURIComponent(id)}`, json("PUT", p)), + removeProvider: (id: string) => request<{ ok: true }>(`/providers/${encodeURIComponent(id)}`, { method: "DELETE" }), + + saveKey: (id: string, apiKey: string) => + request<{ ok: true }>(`/providers/${encodeURIComponent(id)}/key`, json("PUT", { apiKey })), + removeKey: (id: string) => request<{ ok: true }>(`/providers/${encodeURIComponent(id)}/key`, { method: "DELETE" }), + + models: (id: string) => request<{ models: string[] }>(`/providers/${encodeURIComponent(id)}/models`), + test: (id: string, model?: string) => + request<{ ok: boolean; latencyMs: number; reply?: string; modelCount?: number; error?: string }>( + `/providers/${encodeURIComponent(id)}/test`, + json("POST", { model }), + ), + probeTools: (provider: string, model: string) => + request<{ supportsTools: boolean; source: string; detail?: string }>("/probe-tools", json("POST", { provider, model })), + + saveRoles: (roles: Partial>) => + request<{ ok: true; effective: AiState["effective"] }>("/roles", json("PUT", roles)), + setLocalOnly: (localOnly: boolean) => request<{ ok: true }>("/policy", json("PUT", { localOnly })), + quickSetupLocal: () => + request<{ ok: true; chat: string; embed: string | null }>("/quick-setup/local", { method: "POST" }), + + deleteLocalModel: (name: string) => + request<{ ok: true }>(`/local/models/${encodeURIComponent(name)}`, { method: "DELETE" }), + + /** Stream an Ollama download. Abort via the signal to cancel. */ + async pull(model: string, onEvent: (e: PullEvent) => void, signal?: AbortSignal): Promise { + const resp = await fetch(`${apiBase()}/api/ai/local/pull`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + signal, + }); + if (!resp.ok || !resp.body) { + const body = (await resp.json().catch(() => ({}))) as { detail?: string }; + throw new Error(body.detail ?? `Download failed (${resp.status})`); + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const line = buf.slice(0, idx).replace(/^data: /, "").trim(); + buf = buf.slice(idx + 2); + if (line) onEvent(JSON.parse(line) as PullEvent); + } + } + }, +}; + +export const ROLE_INFO: Record = { + agent: { + title: "Assistant & screening chat", + short: "Chat", + description: "Runs the compliance assistant and calls screening tools. Needs a tool-capable model.", + }, + utility: { + title: "Background tasks", + short: "Background tasks", + description: "Match narratives, CSV column mapping, entity extraction, IC memo drafts.", + }, + embedding: { + title: "Document search", + short: "Document search", + description: "Embeds uploaded documents so the assistant can cite them. Optional.", + }, +}; diff --git a/frontend/src/lib/apiBase.ts b/frontend/src/lib/apiBase.ts new file mode 100644 index 0000000..236b915 --- /dev/null +++ b/frontend/src/lib/apiBase.ts @@ -0,0 +1,10 @@ +import { desktop } from "./desktop"; + +/** + * Backend origin. The desktop shell picks a free loopback port at launch and + * hands it over through the preload bridge; `pnpm dev` uses the env var. + */ +export function apiBase(): string { + const fromDesktop = desktop()?.apiBase; + return (fromDesktop ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001").replace(/\/$/, ""); +} diff --git a/frontend/src/lib/authApi.ts b/frontend/src/lib/authApi.ts index 3675975..ff322b5 100644 --- a/frontend/src/lib/authApi.ts +++ b/frontend/src/lib/authApi.ts @@ -1,5 +1,6 @@ -const API_BASE = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; +import { apiBase } from "@/lib/apiBase"; + +const API_BASE = apiBase(); const ACCESS_TOKEN_KEY = "vc_screener_access_token"; const TOKEN_EXPIRES_KEY = "vc_screener_token_expires"; diff --git a/frontend/src/lib/desktop.ts b/frontend/src/lib/desktop.ts new file mode 100644 index 0000000..3b759fc --- /dev/null +++ b/frontend/src/lib/desktop.ts @@ -0,0 +1,51 @@ +import { useSyncExternalStore } from "react"; + +/** + * Bridge exposed by the Electron preload script (desktop/src/preload.ts). + * Absent under plain `pnpm dev` in a browser — every helper degrades gracefully. + */ +export type DesktopBridge = { + apiBase: string; + platform: NodeJS.Platform; + appVersion: string; + dataDir: string; + /** Signed-in CapScreen account (cloud identity; gates access only). */ + user: { email: string; displayName: string | null; organisation: string | null } | null; + /** True when working within the offline grace period. */ + offline: boolean; + signOut(): Promise; + /** Open the account portal (password, profile) in the browser. */ + openAccount(): Promise; + openExternal(url: string): Promise; + revealInFolder(path: string): Promise; + saveFile(opts: { defaultName: string; data: ArrayBuffer | string; filters?: Array<{ name: string; extensions: string[] }> }): Promise; + pickFolder(opts?: { title?: string }): Promise; + pickFile(opts?: { title?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise; + notify(opts: { title: string; body: string; route?: string }): Promise; + printToPdf(opts: { html: string; defaultName: string }): Promise; + onNavigate(cb: (route: string) => void): () => void; +}; + +declare global { + interface Window { + capscreenDesktop?: DesktopBridge; + } +} + +export function desktop(): DesktopBridge | null { + return typeof window !== "undefined" ? (window.capscreenDesktop ?? null) : null; +} + +export function isDesktop(): boolean { + return desktop() !== null; +} + +const noopSubscribe = () => () => {}; + +/** + * The bridge for use during render. The server snapshot is always null, so the + * hydration pass matches the server HTML and React re-renders with the bridge. + */ +export function useDesktop(): DesktopBridge | null { + return useSyncExternalStore(noopSubscribe, desktop, () => null); +} diff --git a/frontend/src/lib/desktopDataApi.ts b/frontend/src/lib/desktopDataApi.ts new file mode 100644 index 0000000..03a12a2 --- /dev/null +++ b/frontend/src/lib/desktopDataApi.ts @@ -0,0 +1,81 @@ +import { apiBase } from "@/lib/apiBase"; + +export type AppSettings = { + backgroundScreening: { enabled: boolean; intervalHours: number }; + sanctionsLists: string[]; +}; + +export type SanctionsInfo = { + engineUrl: string; + available: boolean; + configured: string[]; + /** "offline" when screening from the on-device list snapshot. */ + mode: "online" | "offline"; + cached: Record; + info: { + lists?: Record; + listHashes?: Record; + startedAt?: string; + endedAt?: string; + version?: string; + } | null; +}; + +export type WatchFolder = { path: string; lastScanAt?: string; lastError: string | null; ingestedCount: number } | null; + +async function request(path: string, init?: RequestInit): Promise { + const resp = await fetch(`${apiBase()}/api/desktop${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + const body = (await resp.json().catch(() => ({}))) as T & { detail?: string }; + if (!resp.ok) throw new Error(body.detail ?? `Request failed (${resp.status})`); + return body; +} + +async function download(path: string, init?: RequestInit): Promise<{ data: ArrayBuffer; filename: string }> { + const resp = await fetch(`${apiBase()}/api/desktop${path}`, init); + if (!resp.ok) { + const body = (await resp.json().catch(() => ({}))) as { detail?: string }; + throw new Error(body.detail ?? `Request failed (${resp.status})`); + } + const disposition = resp.headers.get("content-disposition") ?? ""; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? "download"; + return { data: await resp.arrayBuffer(), filename }; +} + +export const desktopDataApi = { + settings: () => + request<{ settings: AppSettings; availableLists: Array<{ id: string; label: string }>; databaseFile: string }>("/settings"), + saveSettings: (patch: Partial) => + request<{ settings: AppSettings }>("/settings", { method: "PUT", body: JSON.stringify(patch) }), + sanctions: () => request("/sanctions"), + + backup: (password: string) => + download("/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) }), + restore: (file: ArrayBuffer, password: string) => + request<{ ok: true; createdAt: string; entries: number }>("/restore", { + method: "POST", + headers: { "Content-Type": "application/octet-stream", "x-backup-password": encodeURIComponent(password) }, + body: file, + }), + + auditExport: (startupId?: string) => download(`/audit-export${startupId ? `?startupId=${encodeURIComponent(startupId)}` : ""}`), + + watchFolder: (startupId: string) => request<{ watch: WatchFolder }>(`/watch-folders/${startupId}`), + setWatchFolder: (startupId: string, path: string | null) => + request<{ watch: WatchFolder }>(`/watch-folders/${startupId}`, { method: "PUT", body: JSON.stringify({ path }) }), +}; + +/** Save bytes through the native dialog on desktop, or a browser download otherwise. */ +export async function saveDownload(data: ArrayBuffer | string, filename: string): Promise { + const bridge = typeof window !== "undefined" ? window.capscreenDesktop : undefined; + if (bridge) return bridge.saveFile({ defaultName: filename, data }); + const url = URL.createObjectURL(new Blob([data])); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + return filename; +} diff --git a/frontend/src/lib/downloadIcMemo.ts b/frontend/src/lib/downloadIcMemo.ts index 579b45d..cd894bd 100644 --- a/frontend/src/lib/downloadIcMemo.ts +++ b/frontend/src/lib/downloadIcMemo.ts @@ -77,3 +77,26 @@ ${body} a.click(); URL.revokeObjectURL(url); } + +/** Desktop: render the memo with print styling and save it as a PDF. */ +export async function downloadIcMemoAsPdf(doc: StartupDocument): Promise { + const bridge = typeof window !== "undefined" ? window.capscreenDesktop : undefined; + if (!bridge) throw new Error("PDF export is available in the desktop app"); + const html = `${escapeHtml(doc.title)} + +${markdownToSimpleHtml(doc.content)} + +`; + return bridge.printToPdf({ html, defaultName: `${doc.title.replace(/[^\w\s-]/g, "").replace(/\s+/g, "-")}.pdf` }); +} diff --git a/frontend/src/lib/portfolioApi.ts b/frontend/src/lib/portfolioApi.ts index 1c36c78..b30d6af 100644 --- a/frontend/src/lib/portfolioApi.ts +++ b/frontend/src/lib/portfolioApi.ts @@ -1,8 +1,7 @@ import { getAuthHeaders } from "@/lib/apiAuth"; +import { apiBase } from "@/lib/apiBase"; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); async function api(path: string, init?: RequestInit): Promise { const authHeaders = await getAuthHeaders(); diff --git a/frontend/src/lib/screeningExplainApi.ts b/frontend/src/lib/screeningExplainApi.ts index a0a3db2..e724f77 100644 --- a/frontend/src/lib/screeningExplainApi.ts +++ b/frontend/src/lib/screeningExplainApi.ts @@ -1,9 +1,8 @@ import { getAuthHeaders } from "@/lib/apiAuth"; import type { EntityResult } from "@/lib/screenerTypes"; +import { apiBase } from "@/lib/apiBase"; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); export async function fetchEntityExplanation( entity: EntityResult, diff --git a/frontend/src/lib/serverConfig.ts b/frontend/src/lib/serverConfig.ts index 0390fa6..a53642a 100644 --- a/frontend/src/lib/serverConfig.ts +++ b/frontend/src/lib/serverConfig.ts @@ -1,22 +1,31 @@ -/** Runtime config from the backend — model ids live server-side only. */ +/** Runtime config from the backend — provider/model names only, never keys. */ +import { apiBase } from "@/lib/apiBase"; -export type ServerLlmConfig = { - provider: "anthropic"; +export type LlmRole = "agent" | "utility" | "embedding"; + +export type ServerLlmRole = { + provider: string; + label: string; model: string; + /** True when this model runs on the user's machine (no data egress). */ + local: boolean; +}; + +export type ServerLlmConfig = { + roles: Partial>; + error?: string; }; let cached: ServerLlmConfig | null = null; export async function getServerLlmConfig(): Promise { if (cached) return cached; - const base = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" - ).replace(/\/$/, ""); + const base = apiBase(); const resp = await fetch(`${base}/api/config`, { cache: "no-store" }); if (!resp.ok) { - return { provider: "anthropic", model: "" }; + return { roles: {} }; } const data = (await resp.json()) as { llm?: ServerLlmConfig }; - cached = data.llm ?? { provider: "anthropic", model: "" }; + cached = data.llm ?? { roles: {} }; return cached; } diff --git a/frontend/src/lib/startupsApi.ts b/frontend/src/lib/startupsApi.ts index 14c0f42..29670dd 100644 --- a/frontend/src/lib/startupsApi.ts +++ b/frontend/src/lib/startupsApi.ts @@ -17,10 +17,9 @@ import { CLIENT_TTL, } from "./apiCache"; import { dedup } from "./requestDedup"; +import { apiBase } from "@/lib/apiBase"; -const API_BASE = ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001" -).replace(/\/$/, ""); +const API_BASE = apiBase(); export interface StartupRecord { id: string; diff --git a/frontend/src/lib/uiPreview.ts b/frontend/src/lib/uiPreview.ts index 642fa90..cf4fe42 100644 --- a/frontend/src/lib/uiPreview.ts +++ b/frontend/src/lib/uiPreview.ts @@ -1,15 +1,18 @@ -/** Explicit opt-in only — never enable in production for a compliance deployment. */ -export const UI_PREVIEW_MODE = - process.env.NEXT_PUBLIC_ALLOW_PREVIEW_MODE === "true"; +/** + * Desktop app: a single local user, no sign-in. The flag keeps its historical + * name because the no-auth code paths were first built as "preview mode". + */ +export const UI_PREVIEW_MODE = true; export const PREVIEW_USER = { - id: "preview-user", - email: "admin@capscreen.local", + id: "local-user", + email: "you@capscreen.local", }; export const PREVIEW_PROFILE = { - displayName: "CapScreen Admin", - organisation: "CapScreen", + displayName: "You", + organisation: "This computer", + // Model keys live in the AI Control Center; the legacy per-user key UI is unused. apiKeys: { claude: { configured: true, source: "user" as const }, gemini: { configured: false, source: null }, diff --git a/package.json b/package.json index dd1f032..9dcfc5b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "dev": "pnpm --parallel --filter vc-screener-backend --filter capscreen run dev", "build": "pnpm --filter vc-screener-backend run build && pnpm --filter capscreen run build", "typecheck": "pnpm --filter vc-screener-backend run typecheck", - "lint": "pnpm --filter capscreen run lint" + "lint": "pnpm --filter capscreen run lint", + "desktop": "pnpm --filter capscreen-desktop run dev", + "desktop:dist": "pnpm --filter capscreen-desktop run dist" }, "devDependencies": { "husky": "^9.1.7" @@ -17,6 +19,7 @@ "pnpm": { "onlyBuiltDependencies": [ "@openrouter/sdk", + "electron", "esbuild", "sharp", "unrs-resolver", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 024ac26..00feb2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.1062.0 version: 3.1063.0 + '@thednp/dommatrix': + specifier: 3.1.2 + version: 3.1.2 '@upstash/redis': specifier: ^1.38.0 version: 1.38.0 @@ -106,6 +109,27 @@ importers: specifier: ^5.8.3 version: 5.9.3 + desktop: + devDependencies: + '@types/node': + specifier: ^22.14.1 + version: 22.19.20 + cross-env: + specifier: 7.0.3 + version: 7.0.3 + electron: + specifier: 43.7.9 + version: 43.7.9 + electron-builder: + specifier: 26.17.0 + version: 26.17.0(electron-builder-squirrel-windows@26.17.0) + esbuild: + specifier: 0.25.4 + version: 0.25.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + frontend: dependencies: '@aws-sdk/client-s3': @@ -661,6 +685,50 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@electron-internal/extract-zip@1.0.6': + resolution: {integrity: sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.1': + resolution: {integrity: sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1352,6 +1420,10 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1374,6 +1446,14 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + '@mongodb-js/saslprep@1.4.11': resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} @@ -1630,6 +1710,21 @@ packages: '@openrouter/sdk@0.3.16': resolution: {integrity: sha512-WEBlrXdc5R8I7o2temkMV65tIRGkzg9ct4DMNZ/FHS/hiRscLRS5EpcuORnAgjzZAh9X2dSsBpgygM8T7KiNAw==} + '@peculiar/asn1-schema@2.11.0': + resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==} + engines: {node: '>=14'} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -1924,6 +2019,10 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2095,6 +2194,10 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -2183,6 +2286,9 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@thednp/dommatrix@3.1.2': + resolution: {integrity: sha512-wxRH2/Qdqd+JxFpjb5vw+ZMaij8Hqa/9Hgl41n2Jb+3WNRhdemrosK0FxEo/yEvhvHGW8D1tZ3oPH9N0m1QG+A==} + '@tiptap/core@3.26.0': resolution: {integrity: sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==} peerDependencies: @@ -2344,6 +2450,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2409,9 +2518,15 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -2430,6 +2545,9 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@3.0.5': resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} @@ -2478,6 +2596,9 @@ packages: '@types/node@22.19.20': resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + '@types/node@25.9.2': resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} @@ -2501,6 +2622,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -2724,6 +2848,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xyflow/react@12.11.0': resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} @@ -2741,6 +2866,10 @@ packages: '@xyflow/system@0.0.77': resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -2763,6 +2892,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -2770,6 +2903,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2790,6 +2926,13 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + app-builder-lib@26.17.0: + resolution: {integrity: sha512-wfL7EhujuODDW3uMSD8RPcr/MLzmT0dmpVfWV0wFUWXI0rIdaukzAJ7EFUSyHXOpxuBZqaIGlQt6VeQhtHhetw==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.17.0 + electron-builder-squirrel-windows: 26.17.0 + append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} @@ -2857,9 +3000,17 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1js@3.1.0: + resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} + engines: {node: '>=12.0.0'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2870,10 +3021,17 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} @@ -2929,6 +3087,9 @@ packages: bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + body-parser@1.20.5: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2940,6 +3101,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2986,6 +3151,14 @@ packages: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.16.0: + resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==} + engines: {node: '>=14.0.0'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2994,6 +3167,18 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -3039,6 +3224,17 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -3052,10 +3248,17 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + cloudflare@4.5.0: resolution: {integrity: sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ==} @@ -3084,14 +3287,26 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + comment-json@4.6.2: resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} engines: {node: '>= 6'} + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + compress-commons@4.1.2: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} @@ -3160,6 +3375,14 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3285,9 +3508,17 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3319,16 +3550,25 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + direction@2.0.1: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dmg-builder@26.17.0: + resolution: {integrity: sha512-EAw1f2iGkICmUvAVBOjm7v5mxJdB7/+OZSycHFhKBimiTkCXtvkCC6wBKzHo7ZQYi58C2MmwIleRbgRnROlK2Q==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3340,6 +3580,10 @@ packages: resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==} engines: {node: '>=10'} + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3374,12 +3618,40 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.17.0: + resolution: {integrity: sha512-uyh+D+B0m12qCRp9c/7dwBirHTh7JcvAzj5eTEbb4dXPPGAhYPx1nozzbu1fC6AdULfat6ZNqgWhipTFpb82lQ==} + + electron-builder@26.17.0: + resolution: {integrity: sha512-iYHBRiagS9sDIbZx1ZD113f5rEGQvtpvTvHf70ovHKJ8mRvxwIkWX7JCwfmVQmVS4eX735p7TZmoxHnBPwF3vA==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.16.0: + resolution: {integrity: sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==} + electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@43.7.9: + resolution: {integrity: sha512-wZkQ/R9F5Bv8dM5vX6vWboNo5ZCBC/IYQV195dtG9JvFAlNbebzlkWWSE5esgpCVy3rVYQ/g09GeknJwvQnS9A==} + engines: {node: '>= 22.12.0'} + hasBin: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -3406,6 +3678,17 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -3444,6 +3727,9 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.25.4: resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} engines: {node: '>=18'} @@ -3567,6 +3853,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3625,6 +3912,9 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -3666,6 +3956,9 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -3689,6 +3982,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3746,6 +4042,30 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3797,6 +4117,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -3833,6 +4157,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3849,6 +4177,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3955,16 +4287,35 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -4091,6 +4442,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -4171,6 +4526,14 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4178,6 +4541,10 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -4186,6 +4553,11 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -4212,9 +4584,15 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -4224,6 +4602,12 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -4263,6 +4647,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -4415,6 +4802,9 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4425,6 +4815,10 @@ packages: lop@0.4.2: resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4435,6 +4829,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lucide-react@0.553.0: resolution: {integrity: sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==} peerDependencies: @@ -4463,6 +4861,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4659,10 +5061,23 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + miniflare@4.20260603.0: resolution: {integrity: sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==} engines: {node: '>=22.0.0'} @@ -4686,6 +5101,10 @@ packages: resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -4697,6 +5116,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -4816,6 +5239,13 @@ packages: react: '>= 16.0.0' react-dom: '>= 16.0.0' + node-abi@4.40.0: + resolution: {integrity: sha512-GuvqvtrOk+AC27RidBxBsMzVNkwNl7PjHgc4NXbmdvSVLgIUjpaqcG1FjNRYnt2Yn9Ytq+Ln37XsBaglUcte/w==} + engines: {node: '>=22.12.0'} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -4834,14 +5264,31 @@ packages: encoding: optional: true + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4927,6 +5374,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5010,6 +5461,10 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5021,6 +5476,14 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkijs@3.4.3: + resolution: {integrity: sha512-V9kOzaItwDgqlryhXysQsAXUEKi5O7kqWUaP3dXI7MLvkPqWWMb4wVb1QAtmEDCKI+XIAINsZoNKMFF7sZb1eg==} + engines: {node: '>=16.0.0'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5036,6 +5499,11 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5045,12 +5513,27 @@ packages: engines: {node: '>=14'} hasBin: true + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -5100,6 +5583,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -5108,6 +5594,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -5115,6 +5608,10 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5187,6 +5684,10 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -5282,6 +5783,18 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -5294,6 +5807,9 @@ packages: '@react-email/render': optional: true + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5306,15 +5822,31 @@ packages: engines: {node: '>= 0.4'} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -5346,6 +5878,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -5357,10 +5892,22 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.2: resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} engines: {node: '>=10'} @@ -5374,6 +5921,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -5438,6 +5989,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5458,12 +6013,19 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5476,6 +6038,10 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -5554,6 +6120,10 @@ packages: babel-plugin-macros: optional: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -5580,11 +6150,25 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + terser@5.16.9: resolution: {integrity: sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==} engines: {node: '>=10'} hasBin: true + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5597,6 +6181,9 @@ packages: peerDependencies: '@tiptap/core': ^3.0.1 + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -5625,6 +6212,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -5655,6 +6245,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -5716,6 +6310,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@6.29.1: + resolution: {integrity: sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==} + engines: {node: '>=18.17'} + undici@7.24.8: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} @@ -5753,6 +6351,14 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -5763,6 +6369,9 @@ packages: unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5800,6 +6409,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5838,6 +6450,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5878,6 +6493,16 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5897,6 +6522,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -5931,6 +6560,10 @@ packages: resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -5945,15 +6578,30 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yargs@18.0.0: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -6769,6 +7417,100 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@electron-internal/extract-zip@1.0.6': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.1.0': + dependencies: + debug: 4.4.3 + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.2 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.24.8 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.1': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + node-abi: 4.40.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.4.1 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3 + fs-extra: 11.4.1 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -7212,6 +7954,10 @@ snapshots: '@isaacs/cliui@9.0.0': {} + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7241,6 +7987,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + '@mongodb-js/saslprep@1.4.11': dependencies: sparse-bitfield: 3.0.3 @@ -7457,6 +8216,28 @@ snapshots: dependencies: zod: 4.4.3 + '@peculiar/asn1-schema@2.11.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -7726,6 +8507,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} '@smithy/config-resolver@4.5.6': @@ -7934,6 +8717,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -8003,6 +8790,8 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.0 + '@thednp/dommatrix@3.1.2': {} + '@tiptap/core@3.26.0(@tiptap/pm@3.26.0)': dependencies: '@tiptap/pm': 3.26.0 @@ -8189,6 +8978,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.20 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.20 + '@types/responselike': 1.0.3 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.20 @@ -8264,15 +9060,21 @@ snapshots: '@types/qs': 6.15.1 '@types/serve-static': 1.15.10 + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.19.20 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/jsdom@27.0.0': dependencies: - '@types/node': 20.19.42 + '@types/node': 22.19.20 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -8287,6 +9089,10 @@ snapshots: '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.20 + '@types/linkify-it@3.0.5': {} '@types/linkify-it@5.0.0': {} @@ -8321,7 +9127,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 20.19.42 + '@types/node': 22.19.20 form-data: 4.0.5 '@types/node@14.18.63': {} @@ -8338,6 +9144,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + '@types/node@25.9.2': dependencies: undici-types: 7.24.6 @@ -8360,6 +9170,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.20 + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -8620,6 +9434,8 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 + abbrev@4.0.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -8640,6 +9456,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -8651,6 +9469,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -8663,6 +9488,54 @@ snapshots: ansi-styles@6.2.3: {} + app-builder-lib@26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.2.1 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 1.8.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.1.0 + async-exit-hook: 2.0.1 + builder-util: 26.16.0 + builder-util-runtime: 9.7.0 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3 + dmg-builder: 26.17.0(electron-builder-squirrel-windows@26.17.0) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.17.0(dmg-builder@26.17.0) + electron-publish: 26.16.0 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.2.0 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + pkijs: 3.4.3 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.22 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + append-field@1.0.0: {} archiver-utils@2.1.0: @@ -8784,18 +9657,30 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1js@3.1.0: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + ast-types-flow@0.0.8: {} + async-exit-hook@2.0.1: {} + async-function@1.0.0: {} async@3.2.6: {} asynckit@0.4.0: {} + at-least-node@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + aws4@1.13.2: {} + aws4fetch@1.0.20: {} axe-core@4.12.0: {} @@ -8837,6 +9722,8 @@ snapshots: bluebird@3.4.7: {} + bluebird@3.7.2: {} + body-parser@1.20.5: dependencies: bytes: 3.1.2 @@ -8870,6 +9757,9 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: + optional: true + bowser@2.14.1: {} brace-expansion@1.1.15: @@ -8914,12 +9804,52 @@ snapshots: buffers@0.1.1: {} + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.16.0: + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.2.0 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + busboy@1.6.0: dependencies: streamsearch: 1.1.0 bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.3.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -8962,6 +9892,12 @@ snapshots: character-reference-invalid@2.0.1: {} + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + ci-info@4.4.0: {} class-variance-authority@0.7.1: @@ -8972,12 +9908,22 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@9.0.1: dependencies: string-width: 7.2.0 strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + cloudflare@4.5.0: dependencies: '@types/node': 18.19.130 @@ -9008,13 +9954,20 @@ snapshots: commander@2.20.3: {} + commander@5.1.0: {} + commander@8.3.0: {} + commander@9.5.0: + optional: true + comment-json@4.6.2: dependencies: array-timsort: 1.0.3 esprima: 4.0.1 + compare-version@0.1.2: {} + compress-commons@4.1.2: dependencies: buffer-crc32: 0.2.13 @@ -9072,6 +10025,13 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 + cross-dirname@0.1.0: + optional: true + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9186,8 +10146,14 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-is@0.1.4: {} + defer-to-connect@2.0.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -9212,14 +10178,32 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 dingbat-to-unicode@1.0.1: {} + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + direction@2.0.1: {} + dmg-builder@26.17.0(electron-builder-squirrel-windows@26.17.0): + dependencies: + app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0) + builder-util: 26.16.0 + fs-extra: 10.1.0 + js-yaml: 4.2.0 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9237,6 +10221,10 @@ snapshots: xml: 1.0.1 xml-js: 1.6.11 + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + dotenv@16.6.1: {} dotenv@17.4.2: {} @@ -9272,10 +10260,75 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.17.0(dmg-builder@26.17.0): + dependencies: + app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0) + builder-util: 26.16.0 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.17.0(electron-builder-squirrel-windows@26.17.0): + dependencies: + app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0) + builder-util: 26.16.0 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.17.0(electron-builder-squirrel-windows@26.17.0) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.16.0: + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.16.0 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + form-data: 4.0.5 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.368: {} + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3 + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@43.7.9: + dependencies: + '@electron-internal/extract-zip': 1.0.6 + '@electron/get': 5.1.0 + '@types/node': 24.19.2 + transitivePeerDependencies: + - supports-color + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encodeurl@2.0.0: {} @@ -9298,6 +10351,12 @@ snapshots: entities@6.0.1: {} + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + err-code@2.0.3: {} + error-stack-parser-es@1.0.5: {} es-abstract@1.24.2: @@ -9403,6 +10462,9 @@ snapshots: es-toolkit@1.47.0: {} + es6-error@4.1.1: + optional: true + esbuild@0.25.4: optionalDependencies: '@esbuild/aix-ppc64': 0.25.4 @@ -9502,7 +10564,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.7 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -9525,7 +10587,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -9540,14 +10602,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -9562,7 +10624,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -9738,6 +10800,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@4.22.2): dependencies: express: 4.22.2 @@ -9837,6 +10901,8 @@ snapshots: fast-sha256@1.3.0: {} + fast-uri@3.1.9: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -9861,6 +10927,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -9932,6 +11002,43 @@ snapshots: fs-constants@1.0.0: {} + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -9985,6 +11092,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@6.0.1: {} get-symbol-description@1.1.0: @@ -10032,6 +11143,16 @@ snapshots: minipass: 4.2.8 path-scurry: 1.11.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.2 + serialize-error: 7.0.1 + optional: true + globals@14.0.0: {} globals@16.4.0: {} @@ -10043,6 +11164,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} graphology-types@0.24.8: {} @@ -10241,10 +11376,16 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + http-cache-semantics@4.3.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10253,6 +11394,25 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} humanize-ms@1.2.1: @@ -10368,6 +11528,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -10442,10 +11604,16 @@ snapshots: isarray@2.0.5: {} + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + isexe@2.0.0: {} isexe@3.1.5: {} + isexe@4.0.0: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -10459,6 +11627,12 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -10478,14 +11652,29 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@1.0.2: dependencies: minimist: 1.2.8 json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -10542,6 +11731,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + lazy-val@1.0.5: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -10654,6 +11845,8 @@ snapshots: lodash.uniq@4.5.0: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -10666,6 +11859,8 @@ snapshots: option: 0.2.4 underscore: 1.13.8 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.5.1: {} @@ -10674,6 +11869,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lucide-react@0.553.0(react@19.2.0): dependencies: react: 19.2.0 @@ -10710,6 +11909,11 @@ snapshots: marked@17.0.6: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -11115,8 +12319,14 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mimic-fn@2.1.0: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + miniflare@4.20260603.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -11147,12 +12357,20 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + minimist@1.2.8: {} minipass@4.2.8: {} minipass@7.1.3: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -11260,6 +12478,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + node-abi@4.40.0: + dependencies: + semver: 7.8.2 + + node-api-version@0.2.1: + dependencies: + semver: 7.8.2 + node-domexception@1.0.0: {} node-exports-info@1.6.0: @@ -11273,10 +12499,31 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.2 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.29.1 + which: 6.0.1 + + node-int64@0.4.0: {} + node-releases@2.0.47: {} + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -11369,6 +12616,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-cancelable@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11444,12 +12693,29 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.80 + pe-library@0.4.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} + pkijs@3.4.3: + dependencies: + '@noble/hashes': 1.8.0 + asn1js: 3.1.0 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + possible-typed-array-names@1.1.0: {} postal-mime@2.7.4: {} @@ -11466,18 +12732,38 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + prelude-ls@1.2.1: {} prettier@3.8.3: {} + proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} prosemirror-changeset@2.4.1: @@ -11565,16 +12851,29 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 queue-microtask@1.2.3: {} + quick-lru@5.1.1: {} + range-parser@1.2.1: {} raw-body@2.5.3: @@ -11654,6 +12953,12 @@ snapshots: react@19.2.0: {} + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -11872,6 +13177,14 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + reselect@5.1.1: {} resend@6.12.4: @@ -11879,6 +13192,8 @@ snapshots: postal-mime: 2.7.4 standardwebhooks: 1.0.0 + resolve-alpn@1.2.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11892,12 +13207,32 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + reusify@1.1.0: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + rimraf@2.7.1: dependencies: glob: 7.2.3 + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rope-sequence@1.3.4: {} router@2.2.0: @@ -11939,6 +13274,10 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + sax@1.6.0: {} saxes@5.0.1: @@ -11947,8 +13286,15 @@ snapshots: scheduler@0.27.0: {} + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.2: {} send@0.19.2: @@ -11985,6 +13331,11 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -12100,6 +13451,10 @@ snapshots: signal-exit@4.1.0: {} + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.2 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -12117,6 +13472,9 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: + optional: true + stable-hash@0.0.5: {} standardwebhooks@1.0.0: @@ -12124,6 +13482,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + stat-mode@1.0.0: {} + statuses@2.0.2: {} stop-iteration-iterator@1.1.0: @@ -12133,6 +13493,12 @@ snapshots: streamsearch@1.1.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -12234,6 +13600,12 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + supports-color@10.2.2: {} supports-color@7.2.0: @@ -12256,6 +13628,24 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + terser@5.16.9: dependencies: '@jridgewell/source-map': 0.3.11 @@ -12263,6 +13653,10 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + tiny-invariant@1.3.3: {} tinyglobby@0.2.17: @@ -12278,6 +13672,10 @@ snapshots: markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.4 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + tmp@0.2.7: {} to-regex-range@5.0.1: @@ -12298,6 +13696,10 @@ snapshots: trough@2.2.0: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -12327,6 +13729,9 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: + optional: true + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -12405,6 +13810,8 @@ snapshots: undici-types@7.24.6: {} + undici@6.29.1: {} + undici@7.24.8: {} unenv@2.0.0-rc.24: @@ -12466,6 +13873,10 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} + + universalify@2.0.1: {} + unpipe@1.0.0: {} unrs-resolver@1.12.2: @@ -12508,6 +13919,14 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -12539,6 +13958,8 @@ snapshots: dependencies: react: 19.2.0 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -12585,6 +14006,14 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} @@ -12648,6 +14077,14 @@ snapshots: dependencies: isexe: 3.1.5 + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + word-wrap@1.2.5: {} workerd@1.20260603.1: @@ -12674,6 +14111,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -12694,6 +14137,8 @@ snapshots: xmlbuilder@10.1.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} xtend@4.0.2: {} @@ -12702,10 +14147,26 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + + yallist@5.0.0: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yargs@18.0.0: dependencies: cliui: 9.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 507e28e..2c18a5e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - backend - frontend + - desktop