diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..3486e64622a83c3d8935214e788a0bd86c601a13 --- /dev/null +++ b/.env.example @@ -0,0 +1,106 @@ +# ============================================================ +# MedOS HuggingFace Space — full backend configuration +# ============================================================ +# +# All values below are *bootstrap defaults*. Once the Space is running, +# every secret marked (admin-rotatable) can also be edited from +# Admin -> Server in the UI and is then persisted to /data/medos-config.json, +# which survives restarts. +# ============================================================ + +# --- LLM providers --- +# +# Fallback chain (lib/providers/index.ts): +# 1. Groq — primary. Sub-second TTFT, llama-3.3-70b-versatile. +# 2. OllaBridge — secondary. Multi-provider gateway with its own +# Groq/Gemini/OpenRouter/HF/local-ollama routing. +# 3. HF Inference — tertiary. Mostly 402 on the free tier today; kept +# as a last resort. +# Set at least one (Groq is recommended). All three may be set. + +# Groq — get a key at https://console.groq.com/keys (free tier). +GROQ_API_KEY= # admin-rotatable +GROQ_MODEL=llama-3.3-70b-versatile # optional override + +OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space +OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here # admin-rotatable +HF_TOKEN=hf_your-token-here # admin-rotatable +DEFAULT_MODEL=free-best + +# --- Database (SQLite, persistent on HF Spaces /data/) --- +# SQLite is the fallback driver. To run against Postgres (production), set +# DATABASE_URL below — SQLite at DB_PATH is then ignored. +DB_PATH=/data/medos.db + +# --- Database (Postgres, production primary) --- +# When set and starting with postgres:// or postgresql://, the runtime +# uses Postgres instead of SQLite. Neon: use the pooler endpoint. +# Example: +# postgresql://USER:PASS@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require +# +# Unset → SQLite at DB_PATH (development). +# Unset AND NODE_ENV=production → the server refuses to start. +# +# No other DB knobs are needed; SSL, pool size, and statement timeout +# all have sensible defaults baked in. +DATABASE_URL= + +# --- Admin seed --- +# First-run admin user. seedAdmin() creates this account on first boot. +# Do NOT leave ADMIN_PASSWORD at the legacy "admin123456" default in +# production. +ADMIN_EMAIL=admin@medos.health +ADMIN_PASSWORD= + +# --- Email (verification + password reset) --- +# Pick ONE transport. They're tried in this order at runtime: +# 1. RESEND_API_KEY → Resend HTTP API (recommended on serverless) +# 2. SMTP_HOST + SMTP_USER + SMTP_PASS → nodemailer SMTP +# 3. (nothing) → emails are logged to stdout only — they never +# reach a real inbox. Useful for local dev, NEVER +# leave this state in production (this is the +# exact state that causes "Account created! Check +# your email" with no email ever arriving). +# +# Resend setup: https://resend.com → API Keys → create → paste below. +# Until you verify a sending domain, FROM_EMAIL must use onboarding@resend.dev +# (Resend rejects other senders for unverified domains). +RESEND_API_KEY= +FROM_EMAIL=MedOS + +# SMTP fallback (only used if RESEND_API_KEY is unset). +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_USER=apikey +# SMTP_PASS= + +# Verify the active transport at runtime by hitting (as an admin): +# GET /api/admin/email-status + +# Used in the password-reset email's "Reset password" link. Set to the +# canonical user-facing URL of your deployment (Vercel domain or HF Space URL). +APP_URL=https://ruslanmv-medibot.hf.space + +# --- CORS (comma-separated Vercel frontend origins) --- +ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000 + +# --- Medicine Scanner proxy --- +# Token with "Make calls to Inference Providers" permission. +# Used SERVER-SIDE ONLY by /api/scan — never exposed to the browser, never +# returned in any HTTP response body. Per-user quota and audit are enforced +# server-side; see docs/USER_ISOLATION.md. +# Create at: +# https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained +HF_TOKEN_INFERENCE=hf_your-inference-token-here # admin-rotatable +SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space # admin-rotatable +NEARBY_URL=https://ruslanmv-metaengine-nearby.hf.space # admin-rotatable + +# --- At-rest encryption key (for BYO user tokens, audit fields, etc.) --- +# Strongly recommended in production. If unset, falls back to a key derived +# from ADMIN_PASSWORD (development convenience only — NOT for production). +# Generate with: openssl rand -hex 32 +# ENCRYPTION_KEY= + +# --- Application --- +NODE_ENV=production +PORT=7860 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8dc4a95413d88a71f1ec34dd2ad569b542dab31b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# ============================================================ +# MedOS HuggingFace Space — Production Dockerfile +# +# Enterprise architecture: +# web/ = frontend source of truth +# 9-HuggingFace-Global/ = backend + synced frontend +# +# Before deploying, run: bash scripts/sync-frontend.sh +# This copies web/ frontend, rewrites API paths, then you push. +# ============================================================ + +# Stage 1: Install dependencies +FROM node:18-alpine AS deps +WORKDIR /app +RUN apk add --no-cache python3 make g++ +COPY package.json ./ +RUN npm install --legacy-peer-deps && npm cache clean --force + +# Stage 2: Build +FROM node:18-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +RUN npm run build + +# Stage 3: Production runner +FROM node:18-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=7860 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/data ./data + +RUN mkdir -p /data && chown nextjs:nodejs /data +ENV DB_PATH=/data/medos.db + +USER nextjs +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f120c07f3cec5595b8ea2557d9c5dab6ecabec35 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +--- +title: "MediBot: Free AI Medical Assistant · 20 languages" +emoji: "\U0001F3E5" +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: true +license: apache-2.0 +short_description: "Free AI medical chatbot. 20 languages. No sign-up." +tags: + - medical + - healthcare + - chatbot + - medical-ai + - health-assistant + - symptom-checker + - telemedicine + - who-guidelines + - cdc + - multilingual + - i18n + - rag + - llama-3.3 + - llama-3.3-70b + - mixtral + - groq + - huggingface-inference + - pwa + - offline-first + - free + - no-signup + - privacy-first + - worldwide + - nextjs + - docker +models: + - meta-llama/Llama-3.3-70B-Instruct + - meta-llama/Meta-Llama-3-8B-Instruct + - mistralai/Mixtral-8x7B-Instruct-v0.1 + - Qwen/Qwen2.5-72B-Instruct + - deepseek-ai/DeepSeek-V3 + - ruslanmv/Medical-Llama3-8B + - google/gemma-2-9b-it +datasets: + - ruslanmv/ai-medical-chatbot +--- + +# MediBot — free AI medical assistant, worldwide + +> **Tell MediBot what's bothering you. In your language. Instantly. For free.** +> No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines. + +[![Try MediBot](https://img.shields.io/badge/Try_MediBot-Free_on_HuggingFace-blue?style=for-the-badge&logo=huggingface)](https://huggingface.co/spaces/ruslanmv/MediBot) +[![Languages](https://img.shields.io/badge/languages-20-14B8A6?style=for-the-badge)](#) +[![Free](https://img.shields.io/badge/price-free_forever-22C55E?style=for-the-badge)](#) +[![No sign-up](https://img.shields.io/badge/account-not_required-3B82F6?style=for-the-badge)](#) + +## Why MediBot + +- **Free forever.** No API key, no sign-up, no paywall, no ads. +- **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands. +- **Worldwide.** IP-based country detection picks your local emergency number (190+ countries) and adapts the answer to your region (°C/°F, metric/imperial, local guidance). +- **Best free LLM on HuggingFace.** Powered by **Llama 3.3 70B via HF Inference Providers (Groq)** — fastest high-quality free tier available — with an automatic fallback chain across Cerebras, SambaNova, Together, and Mixtral. +- **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance. +- **Red-flag triage.** Built-in symptom patterns detect cardiac, neurological, respiratory, obstetric, pediatric, and mental-health emergencies in every supported language — and immediately escalate to the local emergency number. +- **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback. +- **Shareable.** Every AI answer gets a Share button that generates a clean deep link with a branded OG card preview — perfect for WhatsApp, Twitter, and Telegram. +- **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only. +- **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) + +## How it works + +1. You type (or speak) a health question +2. MedOS checks for emergency red flags first +3. It searches a medical knowledge base for relevant context +4. Your question + context go to **Llama 3.3 70B** (via Groq, free) +5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor + +If the main model is busy, MedOS automatically tries other free models until one responds. + +## Built with + +| Layer | Technology | +|---|---| +| Frontend | Next.js 14, React, Tailwind CSS | +| AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) | +| Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ | +| Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset | +| Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) | +| Hosting | HuggingFace Spaces (Docker) | + +## License + +Apache 2.0 — free to use, modify, and distribute. + +## MedOS Family family mode + +This branch adds an additive first version of the MedOS Family family layer: + +- `lib/family-health.ts` — local-first family tree, MedOS modes, invites, monthly records +- `lib/hooks/useFamilyHealth.ts` — React hook for family state +- `components/views/FamilyHealthView.tsx` — Family Admin / MedOS Family dashboard +- Sidebar integration through the new **MedOS Family** navigation item + +The MVP keeps data local-first and prepares for the contracts documented in `../13-MedOS-Family/02-contracts/`. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..301839e4ebcfd38be99b572882bf5bfe2bf36e80 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,593 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + Users, + Activity, + Database, + MessageCircle, + Shield, + Search, + Trash2, + ChevronLeft, + ChevronRight, + RefreshCw, + LogIn, + Lock, + Cloud, + CheckCircle2, + XCircle, + Loader2, +} from 'lucide-react'; + +interface Stats { + totalUsers: number; + verifiedUsers: number; + adminUsers: number; + totalHealthData: number; + totalChats: number; + activeSessions: number; + healthBreakdown: Array<{ type: string; count: number }>; + registrations: Array<{ day: string; count: number }>; +} + +interface UserRow { + id: string; + email: string; + displayName: string | null; + emailVerified: boolean; + isAdmin: boolean; + createdAt: string; + healthDataCount: number; + chatHistoryCount: number; +} + +type Tab = 'users' | 'ollabridge'; + +/** + * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space. + * Not linked from the public UI. Requires admin login. + */ +export default function AdminPage() { + const [token, setToken] = useState(''); + const [loggedIn, setLoggedIn] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(''); + const [tab, setTab] = useState('users'); + const [stats, setStats] = useState(null); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(false); + + const headers = useCallback( + () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }), + [token], + ); + + const fetchStats = useCallback(async () => { + const res = await fetch('/api/admin/stats', { headers: headers() }); + if (res.ok) setStats(await res.json()); + else if (res.status === 403) { setLoggedIn(false); setToken(''); } + }, [headers]); + + const fetchUsers = useCallback(async () => { + setLoading(true); + const qs = new URLSearchParams({ page: String(page), limit: '20' }); + if (search) qs.set('search', search); + const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() }); + if (res.ok) { + const data = await res.json(); + setUsers(data.users); + setTotal(data.total); + } + setLoading(false); + }, [headers, page, search]); + + useEffect(() => { + if (!loggedIn) return; + fetchStats(); + fetchUsers(); + }, [loggedIn, fetchStats, fetchUsers]); + + const handleLogin = async () => { + setLoginError(''); + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await res.json(); + if (!res.ok) { setLoginError(data.error || 'Login failed'); return; } + const meRes = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + const me = await meRes.json(); + if (!me.user) { setLoginError('Auth failed'); return; } + const adminCheck = await fetch('/api/admin/stats', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; } + setToken(data.token); + setLoggedIn(true); + }; + + const handleDeleteUser = async (userId: string, userEmail: string) => { + if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return; + await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() }); + fetchUsers(); + fetchStats(); + }; + + // Login screen + if (!loggedIn) { + return ( +
+
+
+
+ +
+

Admin Panel

+

MedOS server administration

+
+ {loginError && ( +
+ {loginError} +
+ )} +
+ setEmail(e.target.value)} + placeholder="Admin email" + className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50" + /> + setPassword(e.target.value)} + placeholder="Password" + className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/50" + onKeyDown={(e) => e.key === 'Enter' && handleLogin()} + /> + +
+
+
+ ); + } + + const totalPages = Math.ceil(total / 20); + + return ( +
+
+
+
+ +
+

MedOS Admin

+
+ +
+ + + +
+ {tab === 'users' && ( + <> + {/* Stats grid */} + {stats && ( +
+ + + + + + +
+ )} + + {stats && stats.healthBreakdown.length > 0 && ( +
+

Health data by type

+
+ {stats.healthBreakdown.map((b) => ( + + {b.type}: {b.count} + + ))} +
+
+ )} + +
+
+

Users ({total})

+
+ + { setSearch(e.target.value); setPage(1); }} + placeholder="Search by email or name..." + className="w-full bg-slate-800 border border-slate-700 rounded-lg pl-9 pr-4 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-red-500/50" + /> +
+
+ +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && ( + + + + )} + +
UserVerifiedRoleHealthChatsJoinedActions
+
{u.displayName || '—'}
+
{u.email}
+
+ + {u.emailVerified ? 'Yes' : 'No'} + + + {u.isAdmin ? ( + + ADMIN + + ) : ( + User + )} + {u.healthDataCount}{u.chatHistoryCount} + {new Date(u.createdAt).toLocaleDateString()} + + {!u.isAdmin && ( + + )} +
+ {loading ? 'Loading...' : 'No users found'} +
+
+ + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} +
+ + )} + + {tab === 'ollabridge' && } +
+
+ ); +} + +function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) { + return ( +
+ +
{value.toLocaleString()}
+
{label}
+
+ ); +} + +function TabButton({ + active, onClick, icon: Icon, label, +}: { + active: boolean; + onClick: () => void; + icon: any; + label: string; +}) { + return ( + + ); +} + +/** + * OllaBridge connection panel. + * + * Configures the URL and admin-minted API key (format `ob_…`) for the + * OllaBridge Cloud admin our chatbot fans requests out through. The + * actual key minting happens in the OllaBridge Cloud admin UI under + * "API Keys"; this tab just persists the chosen URL+key in MedOS so + * the chat route's provider chain can use it without a redeploy. + * + * Field semantics: + * - URL: OllaBridge Cloud base, e.g. https://ruslanmv-ollabridge.hf.space + * - API key: `ob_xxx` from the OllaBridge Cloud admin -> API Keys tab + * - Default alias: which OllaBridge model alias to request by default + * (e.g. `free-best`, `free-fast`, `qwen2.5:1.5b`). Mapped server-side + * to the request `model` parameter; consumer apps can override per call. + * + * The masked-secret protocol matches /api/admin/config: GET returns + * `••••••••` when a key is stored; PUT only updates when value !== that + * placeholder. So leaving the field unchanged after Save is idempotent. + */ +const REDACTED = '••••••••'; + +function OllaBridgeTab({ headers }: { headers: () => Record }) { + const [url, setUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [defaultModel, setDefaultModel] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [saveMsg, setSaveMsg] = useState(''); + + useEffect(() => { + (async () => { + const res = await fetch('/api/admin/config', { headers: headers() }); + if (res.ok) { + const cfg = await res.json(); + setUrl(cfg.llm?.ollabridgeUrl || ''); + setApiKey(cfg.llm?.ollabridgeApiKey || ''); // will be '' if unset, REDACTED if set + setDefaultModel(cfg.llm?.hfDefaultModel || ''); + } + setLoading(false); + })(); + }, [headers]); + + const handleSave = async () => { + setSaving(true); + setSaveMsg(''); + const body: any = { + llm: { + ollabridgeUrl: url.trim(), + hfDefaultModel: defaultModel.trim(), + }, + }; + // Only send the key if it isn't the masked placeholder. + if (apiKey && apiKey !== REDACTED) body.llm.ollabridgeApiKey = apiKey.trim(); + const res = await fetch('/api/admin/config', { + method: 'PUT', + headers: headers(), + body: JSON.stringify(body), + }); + if (res.ok) { + setSaveMsg('Saved. Changes take effect on the next chat request — no restart needed.'); + const cfg = await res.json(); + setApiKey(cfg.config?.llm?.ollabridgeApiKey || ''); + } else { + const err = await res.json().catch(() => ({})); + setSaveMsg(`Save failed: ${err.error || res.status}`); + } + setSaving(false); + }; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + // Send unmasked values if the operator just typed them; otherwise + // pass nothing so the route falls back to the saved config. + const body: any = { provider: 'ollabridge' }; + if (url.trim()) body.url = url.trim(); + if (apiKey && apiKey !== REDACTED) body.apiKey = apiKey.trim(); + const res = await fetch('/api/admin/test-connection', { + method: 'POST', + headers: headers(), + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + setTestResult({ + ok: !!data.ok, + msg: data.ok + ? `Connected. ${data.details?.modelCount ?? '?'} model(s) available.` + : (data.error || `HTTP ${data.status || res.status}`), + latencyMs: data.latencyMs, + }); + setTesting(false); + }; + + if (loading) { + return
Loading current configuration…
; + } + + return ( +
+
+
+
+ +
+
+

OllaBridge Cloud

+

+ Fan out chat requests through the admin's multi-provider gateway + (Groq / Gemini / OpenRouter / HF / federated HomePilot nodes). +

+
+
+ +

+ Paste an API key minted in the OllaBridge Cloud admin under{' '} + Admin → API Keys. The key + looks like ob_…. MedOS sends + it as Authorization: Bearer …{' '} + on every chat request; the OllaBridge Cloud then routes through whichever + provider its active routing profile selects (Speed, Production, etc.). +

+ +
+ + setUrl(e.target.value)} + placeholder="https://ruslanmv-ollabridge.hf.space" + className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/50" + /> + + + + setApiKey(e.target.value)} + placeholder="ob_xxxxxxxxxxxxxxxxxx" + autoComplete="off" + className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50" + /> + + + + setDefaultModel(e.target.value)} + placeholder="free-best" + className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50" + /> + + +
+ + +
+ + {saveMsg && ( +
{saveMsg}
+ )} + {testResult && ( +
+ {testResult.ok + ? + : } +
+
+ {testResult.ok ? 'Connected' : 'Connection failed'} + {testResult.latencyMs !== undefined && ( + + ({testResult.latencyMs} ms) + + )} +
+
{testResult.msg}
+
+
+ )} +
+
+
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b198ea646f2de0158ce59b08fa75bbd8a0489b93 --- /dev/null +++ b/app/api/admin/audit/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { queryAudit, type AuditAction } from '@/lib/audit'; + +/** + * GET /api/admin/audit — page through the forensic audit log. + * + * Query params (all optional): + * userId filter by actor or target + * action one of the typed AuditAction values (e.g. "login", "scan") + * since ISO timestamp lower bound + * limit page size (default 50, cap 500) + * offset pagination offset (default 0) + * + * Response: + * { entries: [...], limit, offset, hasMore } + * + * Admin-only. Uses the existing queryAudit() helper (lib/audit.ts) so the + * schema and indexes are owned by one module. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const ALLOWED_ACTIONS = new Set([ + 'login', + 'login_failed', + 'logout', + 'register', + 'verify_email', + 'password_reset_request', + 'password_reset', + 'password_change', + 'delete_account', + 'admin_login', + 'admin_action', + 'admin_user_delete', + 'admin_user_reset_password', + 'admin_config_update', + 'token_rotate', + 'chat', + 'scan', + 'health_data_write', + 'health_data_delete', + 'settings_update', + 'export_data', +]); + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const url = new URL(req.url); + const userId = url.searchParams.get('userId') || undefined; + const actionRaw = url.searchParams.get('action'); + const since = url.searchParams.get('since') || undefined; + const limit = Math.min( + 500, + Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)), + ); + const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10)); + + // Reject unknown action strings so typos don't silently return nothing. + let action: AuditAction | undefined; + if (actionRaw) { + if (!ALLOWED_ACTIONS.has(actionRaw as AuditAction)) { + return NextResponse.json( + { error: `Unknown action: ${actionRaw}` }, + { status: 400 }, + ); + } + action = actionRaw as AuditAction; + } + + // Request one extra row so we can cheaply compute hasMore without a + // separate COUNT(*) query. + const entries = queryAudit({ userId, action, since, limit: limit + 1, offset }); + const hasMore = entries.length > limit; + if (hasMore) entries.pop(); + + return NextResponse.json({ entries, limit, offset, hasMore }); +} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..00557947d489e48bd2283595ef91906f92e31e8c --- /dev/null +++ b/app/api/admin/config/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig, saveConfig, type ServerConfig } from '@/lib/server-config'; + +/** + * Admin configuration management. + * + * GET /api/admin/config — returns current server configuration (redacted secrets). + * PUT /api/admin/config — updates server configuration (persisted to config file). + * + * Configuration is persisted to a JSON file on disk so it survives restarts. + * Environment variables take precedence over the config file on first boot. + * + * The storage/merge logic lives in @/lib/server-config so other admin routes + * (like /api/admin/fetch-models) can read the same source of truth. + */ + +const REDACTED = '••••••••'; + +/** Redact sensitive fields for GET responses. */ +function redact(config: ServerConfig) { + const hasSecret = (v: string) => !!(v && v.length > 0); + const mask = (v: string) => (hasSecret(v) ? REDACTED : ''); + return { + smtp: { + host: config.smtp.host, + port: config.smtp.port, + user: config.smtp.user, + pass: mask(config.smtp.pass), + fromEmail: config.smtp.fromEmail, + recoveryEmail: config.smtp.recoveryEmail, + configured: !!(config.smtp.host && config.smtp.user && config.smtp.pass), + }, + llm: { + defaultPreset: config.llm.defaultPreset, + ollamaUrl: config.llm.ollamaUrl, + hfDefaultModel: config.llm.hfDefaultModel, + hfToken: mask(config.llm.hfToken), + hfTokenInference: mask(config.llm.hfTokenInference), + ollabridgeUrl: config.llm.ollabridgeUrl, + ollabridgeApiKey: mask(config.llm.ollabridgeApiKey), + openaiApiKey: mask(config.llm.openaiApiKey), + anthropicApiKey: mask(config.llm.anthropicApiKey), + groqApiKey: mask(config.llm.groqApiKey), + watsonxApiKey: mask(config.llm.watsonxApiKey), + watsonxProjectId: config.llm.watsonxProjectId, + watsonxUrl: config.llm.watsonxUrl, + scannerUrl: config.llm.scannerUrl, + nearbyUrl: config.llm.nearbyUrl, + geminiApiKey: mask(config.llm.geminiApiKey), + openrouterApiKey: mask(config.llm.openrouterApiKey), + togetherApiKey: mask(config.llm.togetherApiKey), + mistralApiKey: mask(config.llm.mistralApiKey), + // Computed status flags — derived server-side so UI can show chips. + ollabridgeConfigured: !!config.llm.ollabridgeUrl, + hfConfigured: hasSecret(config.llm.hfToken), + hfInferenceConfigured: hasSecret(config.llm.hfTokenInference), + openaiConfigured: hasSecret(config.llm.openaiApiKey), + anthropicConfigured: hasSecret(config.llm.anthropicApiKey), + groqConfigured: hasSecret(config.llm.groqApiKey), + watsonxConfigured: hasSecret(config.llm.watsonxApiKey) && !!config.llm.watsonxProjectId, + geminiConfigured: hasSecret(config.llm.geminiApiKey), + openrouterConfigured: hasSecret(config.llm.openrouterApiKey), + togetherConfigured: hasSecret(config.llm.togetherApiKey), + mistralConfigured: hasSecret(config.llm.mistralApiKey), + }, + app: config.app, + }; +} + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const config = loadConfig(); + return NextResponse.json(redact(config)); +} + +export async function PUT(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + try { + const body = await req.json(); + const current = loadConfig(); + + // Merge incoming changes (only update provided fields). + if (body.smtp) { + if (body.smtp.host !== undefined) current.smtp.host = body.smtp.host; + if (body.smtp.port !== undefined) current.smtp.port = parseInt(body.smtp.port, 10); + if (body.smtp.user !== undefined) current.smtp.user = body.smtp.user; + // Only update password if it's not the redacted placeholder. + if (body.smtp.pass !== undefined && body.smtp.pass !== REDACTED) { + current.smtp.pass = body.smtp.pass; + } + if (body.smtp.fromEmail !== undefined) current.smtp.fromEmail = body.smtp.fromEmail; + if (body.smtp.recoveryEmail !== undefined) current.smtp.recoveryEmail = body.smtp.recoveryEmail; + } + + if (body.llm) { + // Non-secret fields — assign directly. + if (body.llm.defaultPreset !== undefined) current.llm.defaultPreset = body.llm.defaultPreset; + if (body.llm.ollamaUrl !== undefined) current.llm.ollamaUrl = body.llm.ollamaUrl; + if (body.llm.hfDefaultModel !== undefined) current.llm.hfDefaultModel = body.llm.hfDefaultModel; + if (body.llm.ollabridgeUrl !== undefined) current.llm.ollabridgeUrl = body.llm.ollabridgeUrl; + if (body.llm.watsonxProjectId !== undefined) current.llm.watsonxProjectId = body.llm.watsonxProjectId; + if (body.llm.watsonxUrl !== undefined) current.llm.watsonxUrl = body.llm.watsonxUrl; + if (body.llm.scannerUrl !== undefined) current.llm.scannerUrl = body.llm.scannerUrl; + if (body.llm.nearbyUrl !== undefined) current.llm.nearbyUrl = body.llm.nearbyUrl; + + // Secret fields — skip if value is the redacted placeholder. + const setSecret = (field: keyof ServerConfig['llm'], value: any) => { + if (value !== undefined && value !== REDACTED) { + (current.llm as any)[field] = value; + } + }; + setSecret('hfToken', body.llm.hfToken); + setSecret('hfTokenInference', body.llm.hfTokenInference); + setSecret('ollabridgeApiKey', body.llm.ollabridgeApiKey); + setSecret('openaiApiKey', body.llm.openaiApiKey); + setSecret('anthropicApiKey', body.llm.anthropicApiKey); + setSecret('groqApiKey', body.llm.groqApiKey); + setSecret('watsonxApiKey', body.llm.watsonxApiKey); + setSecret('geminiApiKey', body.llm.geminiApiKey); + setSecret('openrouterApiKey', body.llm.openrouterApiKey); + setSecret('togetherApiKey', body.llm.togetherApiKey); + setSecret('mistralApiKey', body.llm.mistralApiKey); + } + + if (body.app) { + if (body.app.appUrl !== undefined) current.app.appUrl = body.app.appUrl; + if (body.app.allowedOrigins !== undefined) current.app.allowedOrigins = body.app.allowedOrigins; + } + + saveConfig(current); + + return NextResponse.json({ success: true, config: redact(current) }); + } catch (error: any) { + console.error('[Admin Config]', error?.message); + return NextResponse.json({ error: error?.message || 'Failed to update config' }, { status: 500 }); + } +} diff --git a/app/api/admin/email-status/route.ts b/app/api/admin/email-status/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f8eb111c5efb14fd4c0993be825e422afee84e0 --- /dev/null +++ b/app/api/admin/email-status/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { emailTransportName } from '@/lib/email'; + +/** + * GET /api/admin/email-status — which email transport is currently active. + * + * Returns one of: + * { transport: "resend" } — RESEND_API_KEY is set; uses Resend HTTP API. + * { transport: "smtp" } — SMTP_HOST/USER/PASS are set; uses nodemailer. + * { transport: "console" } — nothing configured; emails are logged to stdout + * and NEVER reach a real inbox. This is the + * state that produces the "Account created! + * Check your email" UX with no email ever + * arriving. + * + * Restricted to authenticated admins — the response itself isn't sensitive + * but there's no reason for unauthenticated callers to probe it. + */ +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + if (!user.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + return NextResponse.json({ + transport: emailTransportName(), + from: process.env.FROM_EMAIL || '(default: MedOS )', + appUrl: process.env.APP_URL || '(default: https://ruslanmv-medibot.hf.space)', + }); +} diff --git a/app/api/admin/fetch-models/route.ts b/app/api/admin/fetch-models/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a88843ce296c2e23306d5b1ae3ebdf7254e9a540 --- /dev/null +++ b/app/api/admin/fetch-models/route.ts @@ -0,0 +1,620 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * GET /api/admin/fetch-models — Aggregate available models from every + * configured provider into one list for the admin model picker. + * + * Queries, in parallel: + * - OllaBridge Cloud /v1/models (OpenAI-compatible) + * - HuggingFace Inference /v1/models (via router.huggingface.co) + * - Groq /openai/v1/models (free/cheap tier) + * - OpenAI /v1/models (paid enterprise) + * - Anthropic /v1/models (paid enterprise) + * - IBM WatsonX /ml/v1/foundation_model_specs (paid enterprise) + * + * Each provider block returns: + * { provider, configured, ok, error?, models: [{id, name, ownedBy, context?}] } + * + * Providers that aren't configured still appear in the response so the UI + * can show them as "not configured" with a link to set them up. This keeps + * the client-side model picker uniform across providers. + * + * Admin-only endpoint. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface ModelInfo { + id: string; + name: string; + ownedBy?: string; + context?: number; + pricing?: 'free' | 'paid' | 'cheap' | 'local'; +} + +interface ProviderBlock { + provider: string; + label: string; + configured: boolean; + ok: boolean; + error?: string; + pricing: 'free' | 'paid' | 'cheap' | 'local'; + models: ModelInfo[]; +} + +/** Default 10s timeout for any provider discovery call. */ +function withTimeout(ms = 10000) { + return AbortSignal.timeout(ms); +} + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +// ---- Provider fetchers --------------------------------------------------- + +async function fetchOllaBridge( + url: string, + apiKey: string, +): Promise { + const block: ProviderBlock = { + provider: 'ollabridge', + label: 'OllaBridge Cloud', + configured: !!url, + ok: false, + pricing: 'free', + models: [], + }; + if (!url) { + block.error = 'Not configured — set OllaBridge URL in Server tab'; + return block; + } + try { + const cleanBase = url.replace(/\/+$/, ''); + const res = await fetch(`${cleanBase}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.id ?? 'unknown'), + ownedBy: m.owned_by || 'ollabridge', + pricing: + String(m.id ?? '').startsWith('free-') + ? 'free' + : String(m.id ?? '').startsWith('cheap-') + ? 'cheap' + : 'local', + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchHuggingFace(token: string): Promise { + const block: ProviderBlock = { + provider: 'huggingface', + label: 'HuggingFace Inference', + configured: !!token, + ok: false, + pricing: 'free', + models: [], + }; + if (!token) { + block.error = 'Not configured — set HF token in Server tab'; + // Still provide the curated fallback chain as suggestions so users can + // pick a model even before the token is set. + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + return block; + } + try { + const res = await fetch('https://router.huggingface.co/v1/models', { + headers: { Authorization: `Bearer ${token}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + // Still return curated list so the UI has something to show. + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list + .filter((m: any) => typeof m?.id === 'string') + .map((m: any) => ({ + id: String(m.id), + name: String(m.id).split('/').pop() || String(m.id), + ownedBy: m.owned_by || String(m.id).split('/')[0], + pricing: 'free' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_HF_MODELS.map((id) => ({ + id, + name: id.split('/').pop() || id, + ownedBy: id.split('/')[0], + pricing: 'free', + })); + } + return block; +} + +/** Verified-working free HF models (from lib/providers/huggingface-direct.ts). */ +const CURATED_HF_MODELS = [ + 'meta-llama/Llama-3.3-70B-Instruct', + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen3-235B-A22B', + 'google/gemma-3-27b-it', + 'meta-llama/Llama-3.1-70B-Instruct', + 'deepseek-ai/DeepSeek-V3-0324', +]; + +async function fetchGroq(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'groq', + label: 'Groq (Free tier)', + configured: !!apiKey, + ok: false, + pricing: 'free', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add Groq API key in Server tab'; + return block; + } + try { + const res = await fetch('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.id ?? 'unknown'), + ownedBy: m.owned_by || 'groq', + context: typeof m.context_window === 'number' ? m.context_window : undefined, + pricing: 'free', + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchOpenAI(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'openai', + label: 'OpenAI (Paid)', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add OpenAI API key in Server tab'; + return block; + } + try { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + // Filter to chat-capable GPT models — the full list is noisy. + block.models = list + .filter((m: any) => { + const id = String(m?.id || ''); + return /^(gpt-|o1-|o3-|chatgpt)/i.test(id); + }) + .map((m: any) => ({ + id: String(m.id), + name: String(m.id), + ownedBy: m.owned_by || 'openai', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + } + return block; +} + +async function fetchAnthropic(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'anthropic', + label: 'Anthropic Claude (Paid)', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'Not configured — add Anthropic API key in Server tab'; + // Provide curated list as placeholder. + block.models = CURATED_ANTHROPIC_MODELS; + return block; + } + try { + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + block.models = CURATED_ANTHROPIC_MODELS; + return block; + } + const data = await safeJson(res); + const list = Array.isArray(data?.data) ? data.data : []; + block.models = list.map((m: any) => ({ + id: String(m.id ?? 'unknown'), + name: String(m.display_name || m.id || 'unknown'), + ownedBy: 'anthropic', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_ANTHROPIC_MODELS; + } + return block; +} + +/** Fallback list so the UI can show Claude options even without a key. */ +const CURATED_ANTHROPIC_MODELS: ModelInfo[] = [ + { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', ownedBy: 'anthropic', pricing: 'paid' }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', ownedBy: 'anthropic', pricing: 'paid' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', ownedBy: 'anthropic', pricing: 'paid' }, +]; + +async function fetchWatsonx( + apiKey: string, + projectId: string, + baseUrl: string, +): Promise { + const block: ProviderBlock = { + provider: 'watsonx', + label: 'IBM WatsonX (Paid)', + configured: !!(apiKey && projectId), + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey || !projectId) { + block.error = 'Not configured — add WatsonX API key and project ID in Server tab'; + block.models = CURATED_WATSONX_MODELS; + return block; + } + try { + // WatsonX requires exchanging the API key for an IAM bearer token first. + const iamRes = await fetch('https://iam.cloud.ibm.com/identity/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ibm:params:oauth:grant-type:apikey', + apikey: apiKey, + }), + signal: withTimeout(), + }); + if (!iamRes.ok) { + block.error = `IAM token failed: HTTP ${iamRes.status}`; + block.models = CURATED_WATSONX_MODELS; + return block; + } + const iamData = await safeJson(iamRes); + const bearer = iamData?.access_token; + if (!bearer) { + block.error = 'IAM response missing access_token'; + block.models = CURATED_WATSONX_MODELS; + return block; + } + + // Discover available foundation models. + const cleanBase = baseUrl.replace(/\/+$/, ''); + const modelsRes = await fetch( + `${cleanBase}/ml/v1/foundation_model_specs?version=2024-05-01&limit=200`, + { + headers: { Authorization: `Bearer ${bearer}` }, + signal: withTimeout(), + }, + ); + if (!modelsRes.ok) { + block.error = `HTTP ${modelsRes.status}`; + block.models = CURATED_WATSONX_MODELS; + return block; + } + const data = await safeJson(modelsRes); + const list = Array.isArray(data?.resources) ? data.resources : []; + block.models = list.map((m: any) => ({ + id: String(m.model_id ?? 'unknown'), + name: String(m.label || m.model_id || 'unknown'), + ownedBy: m.provider || 'ibm', + pricing: 'paid' as const, + })); + block.ok = true; + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed'; + block.models = CURATED_WATSONX_MODELS; + } + return block; +} + +/** Common WatsonX foundation models as a fallback list. */ +const CURATED_WATSONX_MODELS: ModelInfo[] = [ + { id: 'ibm/granite-3-8b-instruct', name: 'Granite 3 8B Instruct', ownedBy: 'ibm', pricing: 'paid' }, + { id: 'meta-llama/llama-3-3-70b-instruct', name: 'Llama 3.3 70B', ownedBy: 'meta', pricing: 'paid' }, + { id: 'mistralai/mistral-large', name: 'Mistral Large', ownedBy: 'mistralai', pricing: 'paid' }, +]; + +// ---- Additional provider fetchers (v3) ----------------------------------- + +/** + * Google Gemini. Uses the Generative Language API, which requires the key + * as a query parameter rather than a bearer header. + */ +async function fetchGemini(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'gemini', + label: 'Google Gemini', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'API key not configured'; + return block; + } + try { + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`, + { signal: withTimeout() }, + ); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await res.json().catch(() => ({})); + const arr = Array.isArray(data?.models) ? data.models : []; + block.ok = true; + block.models = arr + .filter((m: any) => typeof m?.name === 'string') + // Gemini returns "models/gemini-1.5-flash" — strip the prefix so the + // UI shows the bare model id like every other provider. + .map((m: any) => ({ + id: String(m.name).replace(/^models\//, ''), + name: m.displayName || String(m.name).replace(/^models\//, ''), + ownedBy: 'google', + context: typeof m.inputTokenLimit === 'number' ? m.inputTokenLimit : undefined, + pricing: 'paid' as const, + })); + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed'; + } + return block; +} + +/** OpenRouter — OpenAI-compatible /v1/models aggregator across providers. */ +async function fetchOpenRouter(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'openrouter', + label: 'OpenRouter', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'API key not configured'; + return block; + } + try { + const res = await fetch('https://openrouter.ai/api/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await res.json().catch(() => ({})); + const arr = Array.isArray(data?.data) ? data.data : []; + block.ok = true; + block.models = arr.slice(0, 200).map((m: any) => ({ + id: String(m.id), + name: m.name || String(m.id), + ownedBy: (String(m.id).split('/')[0] as string) || undefined, + context: typeof m.context_length === 'number' ? m.context_length : undefined, + pricing: m?.pricing?.prompt === '0' ? 'free' : 'paid', + })); + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed'; + } + return block; +} + +/** Together AI — OpenAI-compatible /v1/models. */ +async function fetchTogether(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'together', + label: 'Together AI', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'API key not configured'; + return block; + } + try { + const res = await fetch('https://api.together.xyz/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await res.json().catch(() => []); + // Together returns a bare array. + const arr = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []; + block.ok = true; + block.models = arr.slice(0, 200).map((m: any) => ({ + id: String(m.id || m.name), + name: m.display_name || m.id || m.name, + ownedBy: m.organization || undefined, + context: typeof m.context_length === 'number' ? m.context_length : undefined, + pricing: 'paid' as const, + })); + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed'; + } + return block; +} + +/** Mistral AI — OpenAI-compatible /v1/models. */ +async function fetchMistral(apiKey: string): Promise { + const block: ProviderBlock = { + provider: 'mistral', + label: 'Mistral AI', + configured: !!apiKey, + ok: false, + pricing: 'paid', + models: [], + }; + if (!apiKey) { + block.error = 'API key not configured'; + return block; + } + try { + const res = await fetch('https://api.mistral.ai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: withTimeout(), + }); + if (!res.ok) { + block.error = `HTTP ${res.status}`; + return block; + } + const data = await res.json().catch(() => ({})); + const arr = Array.isArray(data?.data) ? data.data : []; + block.ok = true; + block.models = arr.map((m: any) => ({ + id: String(m.id), + name: m.name || m.id, + ownedBy: m.owned_by || 'mistralai', + context: + typeof m.max_context_length === 'number' + ? m.max_context_length + : typeof m.context_length === 'number' + ? m.context_length + : undefined, + pricing: 'paid' as const, + })); + } catch (e: any) { + block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed'; + } + return block; +} + +// ---- Route handler ------------------------------------------------------- + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const config = loadConfig(); + const { llm } = config; + + // Run every discovery call in parallel so the slowest provider sets the + // total latency floor, not the sum of all calls. + const [ + ollabridge, + huggingface, + groq, + openai, + anthropic, + watsonx, + gemini, + openrouter, + together, + mistral, + ] = await Promise.all([ + fetchOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey), + fetchHuggingFace(llm.hfToken), + fetchGroq(llm.groqApiKey), + fetchOpenAI(llm.openaiApiKey), + fetchAnthropic(llm.anthropicApiKey), + fetchWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl), + fetchGemini(llm.geminiApiKey), + fetchOpenRouter(llm.openrouterApiKey), + fetchTogether(llm.togetherApiKey), + fetchMistral(llm.mistralApiKey), + ]); + + const providers = [ + ollabridge, + huggingface, + groq, + openai, + anthropic, + watsonx, + gemini, + openrouter, + together, + mistral, + ]; + const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0); + const okCount = providers.filter((p) => p.ok).length; + + return NextResponse.json({ + providers, + summary: { + providers: providers.length, + providersOk: okCount, + totalModels, + fetchedAt: new Date().toISOString(), + }, + }); +} diff --git a/app/api/admin/llm-health/route.ts b/app/api/admin/llm-health/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..83eeaf1f325cee83177db2ecdda15510ab3a1db8 --- /dev/null +++ b/app/api/admin/llm-health/route.ts @@ -0,0 +1,219 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * GET /api/admin/llm-health — Test all LLM providers and models. + * + * Sends a minimal "Say OK" prompt to each model in the fallback chain + * and reports which ones are alive, their latency, and any errors. + * Admin-only endpoint. + * + * Token resolution order: + * 1. admin config file (set via /api/admin/config PUT) + * 2. HF_TOKEN environment variable + * This way an admin can fix a misconfigured Space without redeploying. + * + * The result also includes a synthetic "ollabridge/" row at the + * top of the list so the Provider Health page surfaces the OllaBridge + * Cloud gateway alongside HF models — matching the routing chain + * documented in the "Come funziona il routing" panel (OllaBridge first, + * then HF Inference, then enterprise providers, then cached FAQ). + */ + +const HF_BASE_URL = 'https://router.huggingface.co/v1'; + +/** All models to test — matches the presets fallback chain. */ +const MODELS_TO_TEST = [ + 'meta-llama/Llama-3.3-70B-Instruct:sambanova', + 'meta-llama/Llama-3.3-70B-Instruct:together', + 'meta-llama/Llama-3.3-70B-Instruct', + 'Qwen/Qwen2.5-72B-Instruct', + 'Qwen/Qwen3-235B-A22B', + 'google/gemma-3-27b-it', + 'meta-llama/Llama-3.1-70B-Instruct', + 'Qwen/Qwen3-32B', + 'deepseek-ai/DeepSeek-V3-0324', + 'deepseek-ai/DeepSeek-R1', + 'Qwen/Qwen3-30B-A3B', + 'Qwen/Qwen2.5-Coder-32B-Instruct', +]; + +type ModelResult = { + model: string; + status: 'ok' | 'error'; + latencyMs: number; + response?: string; + error?: string; + httpStatus?: number; +}; + +async function testModel(model: string, token: string): Promise { + const start = Date.now(); + try { + const res = await fetch(`${HF_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content: 'Say OK' }], + max_tokens: 5, + temperature: 0.1, + stream: false, + }), + signal: AbortSignal.timeout(15000), + }); + + const latencyMs = Date.now() - start; + + if (res.ok) { + const data = await res.json(); + const content = data?.choices?.[0]?.message?.content?.trim() || ''; + return { model, status: 'ok', latencyMs, response: content.slice(0, 30) }; + } else { + const text = await res.text().catch(() => ''); + const errorMsg = text.slice(0, 100); + return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status }; + } + } catch (e: any) { + return { + model, + status: 'error', + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100), + }; + } +} + +/** + * Probe the OllaBridge Cloud gateway with a minimal chat completion. + * + * Renders as the first row in the Provider Health table. The model id is + * shaped `ollabridge/` on purpose: the UI splits by `/` to derive + * an org-style subtitle, so this keeps the existing render path unchanged. + * + * Uses a 20s timeout — generous enough to absorb the worst-case Cloud + * fallback chain (HF 402 cascade → local-ollama) while still capping + * pathological hangs. + */ +async function testOllaBridge( + baseUrl: string, + apiKey: string, + alias: string, +): Promise { + const start = Date.now(); + const url = `${baseUrl.replace(/\/+$/, '')}/v1/chat/completions`; + const modelId = `ollabridge/${alias || 'gateway'}`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + body: JSON.stringify({ + model: alias || 'free-fast', + messages: [{ role: 'user', content: 'Say OK' }], + max_tokens: 5, + temperature: 0.1, + stream: false, + }), + signal: AbortSignal.timeout(20000), + }); + + const latencyMs = Date.now() - start; + + if (res.ok) { + const data = await res.json().catch(() => ({})); + const content = data?.choices?.[0]?.message?.content?.trim() || ''; + return { model: modelId, status: 'ok', latencyMs, response: content.slice(0, 30) || 'OK' }; + } + const text = await res.text().catch(() => ''); + return { + model: modelId, + status: 'error', + latencyMs, + error: text.slice(0, 100), + httpStatus: res.status, + }; + } catch (e: any) { + return { + model: modelId, + status: 'error', + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (20s)' : (e?.message || 'Unknown error').slice(0, 100), + }; + } +} + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + // Prefer admin-configured token, fall back to env var. + const config = loadConfig(); + const token = config.llm.hfToken || process.env.HF_TOKEN || ''; + const ollabridgeUrl = config.llm.ollabridgeUrl || process.env.OLLABRIDGE_URL || ''; + const ollabridgeKey = config.llm.ollabridgeApiKey || process.env.OLLABRIDGE_API_KEY || ''; + const ollabridgeAlias = config.llm.hfDefaultModel || 'free-fast'; + + // Kick off OllaBridge probe in parallel with the HF cascade — only when + // a URL is configured. An unconfigured OllaBridge stays absent from the + // list rather than appearing as a permanent red row that would mislead + // operators who are using HF-only. + const ollabridgePromise: Promise = ollabridgeUrl + ? testOllaBridge(ollabridgeUrl, ollabridgeKey, ollabridgeAlias) + : Promise.resolve(null); + + if (!token) { + // Still return a well-formed response so the UI can render an empty-state + // Provider Status panel with a helpful error banner. We still surface + // the OllaBridge row when it's configured — it's independent of HF. + const ollabridgeResult = await ollabridgePromise; + const models: ModelResult[] = [ + ...(ollabridgeResult ? [ollabridgeResult] : []), + ...MODELS_TO_TEST.map((model) => ({ + model, + status: 'error' as const, + latencyMs: 0, + error: 'No HF token configured', + })), + ]; + const ok = models.filter((m) => m.status === 'ok').length; + return NextResponse.json({ + error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.', + models, + summary: { + total: models.length, + ok, + error: models.length - ok, + testedAt: new Date().toISOString(), + }, + }); + } + + // Test all HF models in parallel for speed; OllaBridge runs alongside. + const [ollabridgeResult, hfResults] = await Promise.all([ + ollabridgePromise, + Promise.all(MODELS_TO_TEST.map((model) => testModel(model, token))), + ]); + + const results: ModelResult[] = [ + ...(ollabridgeResult ? [ollabridgeResult] : []), + ...hfResults, + ]; + const ok = results.filter((r) => r.status === 'ok').length; + + return NextResponse.json({ + models: results, + summary: { + total: results.length, + ok, + error: results.length - ok, + testedAt: new Date().toISOString(), + }, + }); +} diff --git a/app/api/admin/medical-flow/route.ts b/app/api/admin/medical-flow/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..92509553edc3b11d1727a2d71ada3ef385254371 --- /dev/null +++ b/app/api/admin/medical-flow/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { summary } from '@/lib/medical-flow/audit'; + +/** + * GET /api/admin/medical-flow + * + * Returns a counts-by-kind / counts-by-care-level / counts-by-flow + * summary of the medical-flow audit table for the most recent 7 days + * (or `?sinceHours=N`). Powers the admin "Card Flow" panel and feeds + * the marketing dashboard in benchmarks/. + * + * Admin-only. PHI-free — only structured aggregates. + */ +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + const url = new URL(req.url); + const sinceHours = parseInt(url.searchParams.get('sinceHours') || '168', 10); + const sinceISO = new Date( + Date.now() - Math.max(1, sinceHours) * 3600 * 1000, + ).toISOString(); + const data = summary({ sinceISO }); + return NextResponse.json({ + window: { sinceISO, hours: sinceHours }, + ...data, + // Derived ratios that make the table easier to scan in the UI: + // gate_rate = profile_gate / (profile_gate + greeting) + // urgent_share = urgent + emergency / total guidance + derived: { + gate_rate: + data.by_kind.profile_gate && data.by_kind.greeting + ? Number( + ( + data.by_kind.profile_gate / + (data.by_kind.profile_gate + data.by_kind.greeting) + ).toFixed(3), + ) + : 0, + urgent_share: (() => { + const urg = data.by_care_level.urgent || 0; + const emer = data.by_care_level.emergency || 0; + const all = Object.values(data.by_care_level).reduce( + (a, b) => a + b, + 0, + ); + return all > 0 ? Number(((urg + emer) / all).toFixed(3)) : 0; + })(), + }, + }); +} diff --git a/app/api/admin/reset-password/route.ts b/app/api/admin/reset-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbe9457ccc8cf71e6b8d11da46bd5dc8fd50dbf1 --- /dev/null +++ b/app/api/admin/reset-password/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +const Schema = z.object({ + userId: z.string().min(1), + newPassword: z.string().min(6, 'Password must be at least 6 characters'), +}); + +/** + * POST /api/admin/reset-password — Admin-initiated password reset. + * + * Allows admins to manually reset a user's password. This is the + * industry-standard approach for user management: the admin sets a + * temporary password and instructs the user to change it on login. + * + * Security measures: + * - Requires admin authentication + * - Passwords are bcrypt-hashed + * - All existing sessions for the user are invalidated + * - Cannot reset your own password (use profile instead) + */ +export async function POST(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + try { + const body = await req.json(); + const { userId, newPassword } = Schema.parse(body); + + const db = getDb(); + + // Verify user exists. + const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId) as any; + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Hash new password. + const hash = bcrypt.hashSync(newPassword, 10); + + // Update password and invalidate all sessions. + const tx = db.transaction(() => { + db.prepare('UPDATE users SET password = ?, updated_at = datetime(\'now\') WHERE id = ?').run(hash, userId); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId); + }); + tx(); + + return NextResponse.json({ + success: true, + message: `Password reset for ${user.email}. All sessions invalidated.`, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0]?.message || 'Invalid input' }, { status: 400 }); + } + console.error('[Admin Reset Password]', error?.message); + return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 }); + } +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..91fc25bdc4270db5d9cb35c1c5a88c3858cb0cac --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +/** + * GET /api/admin/stats — aggregate platform statistics (admin only). + */ +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const db = getDb(); + + const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c; + const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c; + const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c; + const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c; + const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c; + const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c; + + // Health data breakdown by type. + const healthBreakdown = db + .prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC') + .all() as any[]; + + // Registrations over time (last 30 days). + const registrations = db + .prepare( + `SELECT date(created_at) as day, COUNT(*) as count + FROM users + WHERE created_at > datetime('now', '-30 days') + GROUP BY day ORDER BY day`, + ) + .all() as any[]; + + return NextResponse.json({ + totalUsers, + verifiedUsers, + adminUsers, + totalHealthData, + totalChats, + activeSessions, + healthBreakdown, + registrations, + }); +} diff --git a/app/api/admin/system-info/route.ts b/app/api/admin/system-info/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..5efd071562b0988159d36de04c9f1b263741f430 --- /dev/null +++ b/app/api/admin/system-info/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { getDb } from '@/lib/db'; +import { CONFIG_PATH } from '@/lib/server-config'; + +/** + * GET /api/admin/system-info — operational diagnostics for the admin panel. + * + * Returns non-sensitive runtime facts about the deployment so ops can + * debug "why isn't feature X working" without SSH'ing into the Space: + * - Node + platform versions + * - DB path, size, schema version (PRAGMA user_version), row counts + * - Config file path, existence, size, last-modified + * - Encryption-key presence (boolean only — never the value) + * - Uptime, memory, load averages + * - Feature-flag / env presence map (booleans only) + * + * No secret values are ever returned. Admin-only endpoint. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function safeStat(p: string) { + try { + const s = fs.statSync(p); + return { + exists: true, + sizeBytes: s.size, + modifiedAt: s.mtime.toISOString(), + }; + } catch { + return { exists: false }; + } +} + +function envFlag(name: string): boolean { + return !!(process.env[name] && process.env[name]!.length > 0); +} + +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const db = getDb(); + + const dbPath = process.env.DB_PATH || '/data/medos.db'; + const persistentDir = process.env.PERSISTENT_DIR || path.dirname(dbPath); + const userVersion = db.pragma('user_version', { simple: true }) as number; + const journalMode = db.pragma('journal_mode', { simple: true }); + const foreignKeys = db.pragma('foreign_keys', { simple: true }); + + // Cheap row counts — all indexed / small-table aggregates, safe to run + // synchronously on each request. + const counts = { + users: (db.prepare('SELECT COUNT(*) c FROM users').get() as any).c as number, + sessions: (db.prepare('SELECT COUNT(*) c FROM sessions').get() as any).c as number, + healthData: (db.prepare('SELECT COUNT(*) c FROM health_data').get() as any).c as number, + chatHistory: (db.prepare('SELECT COUNT(*) c FROM chat_history').get() as any).c as number, + auditLog: (db.prepare('SELECT COUNT(*) c FROM audit_log').get() as any).c as number, + scanLog: (db.prepare('SELECT COUNT(*) c FROM scan_log').get() as any).c as number, + }; + + const mem = process.memoryUsage(); + + return NextResponse.json({ + runtime: { + node: process.version, + platform: `${os.platform()} ${os.release()}`, + arch: process.arch, + uptimeSec: Math.round(process.uptime()), + nodeEnv: process.env.NODE_ENV || 'development', + pid: process.pid, + }, + process: { + memoryMb: { + rss: Math.round(mem.rss / 1024 / 1024), + heapUsed: Math.round(mem.heapUsed / 1024 / 1024), + heapTotal: Math.round(mem.heapTotal / 1024 / 1024), + }, + loadAverage: os.loadavg(), + }, + database: { + path: dbPath, + schemaVersion: userVersion, + journalMode, + foreignKeys, + file: safeStat(dbPath), + counts, + }, + config: { + path: CONFIG_PATH, + persistentDir, + file: safeStat(CONFIG_PATH), + }, + security: { + // Booleans only — never the value. Redaction by construction. + encryptionKeySet: envFlag('ENCRYPTION_KEY'), + adminPasswordSet: envFlag('ADMIN_PASSWORD'), + adminEmailSet: envFlag('ADMIN_EMAIL'), + scanRequireAuth: + (process.env.SCAN_REQUIRE_AUTH || '').toLowerCase() !== 'false', + }, + features: { + // Presence map for quick "what's wired" answers. No values exposed. + hfToken: envFlag('HF_TOKEN'), + hfTokenInference: envFlag('HF_TOKEN_INFERENCE'), + ollabridgeUrl: envFlag('OLLABRIDGE_URL'), + ollabridgeKey: envFlag('OLLABRIDGE_API_KEY'), + openai: envFlag('OPENAI_API_KEY'), + anthropic: envFlag('ANTHROPIC_API_KEY'), + groq: envFlag('GROQ_API_KEY'), + watsonx: envFlag('WATSONX_API_KEY') && envFlag('WATSONX_PROJECT_ID'), + gemini: envFlag('GEMINI_API_KEY') || envFlag('GOOGLE_API_KEY'), + openrouter: envFlag('OPENROUTER_API_KEY'), + together: envFlag('TOGETHER_API_KEY'), + mistral: envFlag('MISTRAL_API_KEY'), + smtp: envFlag('SMTP_HOST') && envFlag('SMTP_USER') && envFlag('SMTP_PASS'), + scannerUrl: envFlag('SCANNER_URL'), + nearbyUrl: envFlag('NEARBY_URL'), + allowedOrigins: envFlag('ALLOWED_ORIGINS'), + appUrl: envFlag('APP_URL'), + }, + }); +} diff --git a/app/api/admin/test-connection/route.ts b/app/api/admin/test-connection/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e94b41e213fe963a8fd70c4b373090563a1afa0a --- /dev/null +++ b/app/api/admin/test-connection/route.ts @@ -0,0 +1,360 @@ +import { NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { loadConfig } from '@/lib/server-config'; + +/** + * POST /api/admin/test-connection — Test connectivity to a named provider. + * + * Body: { provider: "ollabridge" | "huggingface" | "openai" | "anthropic" + * | "groq" | "watsonx" } + * + * Response: + * { ok: boolean, provider, latencyMs, status?, error?, details? } + * + * Used by the "Test Connection" button on each provider card. Mirrors the + * `ollabridge pair` CLI check — validates that credentials are good and + * that the provider's /v1/models (or equivalent) endpoint responds. + * + * Admin-only. + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type Provider = + | 'ollabridge' + | 'huggingface' + | 'openai' + | 'anthropic' + | 'groq' + | 'watsonx' + | 'gemini' + | 'openrouter' + | 'together' + | 'mistral'; + +interface TestResult { + ok: boolean; + provider: Provider; + latencyMs: number; + status?: number; + error?: string; + details?: string; +} + +async function testOllaBridge(url: string, apiKey: string): Promise> { + const start = Date.now(); + if (!url) { + return { ok: false, latencyMs: 0, error: 'URL not configured' }; + } + try { + const cleanBase = url.replace(/\/+$/, ''); + const res = await fetch(`${cleanBase}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) { + return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { + ok: true, + latencyMs, + status: res.status, + details: `${count} model${count === 1 ? '' : 's'} available`, + }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testHuggingFace(token: string): Promise> { + const start = Date.now(); + if (!token) return { ok: false, latencyMs: 0, error: 'HF token not configured' }; + try { + // whoami-v2 validates that the token has API access; it's cheaper + // than hitting the router and gives a clear permission error. + const res = await fetch('https://huggingface.co/api/whoami-v2', { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) { + return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + } + const data = await res.json().catch(() => null); + return { + ok: true, + latencyMs, + status: res.status, + details: data?.name ? `Authenticated as ${data.name}` : 'Token valid', + }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testOpenAI(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenAI API key not configured' }; + try { + const res = await fetch('https://api.openai.com/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testAnthropic(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Anthropic API key not configured' }; + try { + const res = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testGroq(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Groq API key not configured' }; + try { + const res = await fetch('https://api.groq.com/openai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testWatsonx( + apiKey: string, + projectId: string, + _baseUrl: string, +): Promise> { + const start = Date.now(); + if (!apiKey || !projectId) { + return { ok: false, latencyMs: 0, error: 'Missing API key or project ID' }; + } + try { + const res = await fetch('https://iam.cloud.ibm.com/identity/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ibm:params:oauth:grant-type:apikey', + apikey: apiKey, + }), + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `IAM HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + if (!data?.access_token) return { ok: false, latencyMs, error: 'No access_token in IAM response' }; + return { ok: true, latencyMs, status: 200, details: 'IAM token valid' }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +// ---- Additional provider testers (v3) ------------------------------------ + +async function testGemini(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Gemini API key not configured' }; + try { + // Gemini uses the key as a query param, not a bearer header. + const res = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`, + { signal: AbortSignal.timeout(10000) }, + ); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.models) ? data.models.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testOpenRouter(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenRouter API key not configured' }; + try { + const res = await fetch('https://openrouter.ai/api/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testTogether(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Together API key not configured' }; + try { + const res = await fetch('https://api.together.xyz/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data) + ? data.length + : Array.isArray(data?.data) + ? data.data.length + : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +async function testMistral(apiKey: string): Promise> { + const start = Date.now(); + if (!apiKey) return { ok: false, latencyMs: 0, error: 'Mistral API key not configured' }; + try { + const res = await fetch('https://api.mistral.ai/v1/models', { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10000), + }); + const latencyMs = Date.now() - start; + if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` }; + const data = await res.json().catch(() => null); + const count = Array.isArray(data?.data) ? data.data.length : 0; + return { ok: true, latencyMs, status: res.status, details: `${count} models visible` }; + } catch (e: any) { + return { + ok: false, + latencyMs: Date.now() - start, + error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed', + }; + } +} + +export async function POST(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const provider = body?.provider as Provider; + if (!provider) { + return NextResponse.json({ error: 'Missing provider field' }, { status: 400 }); + } + + const config = loadConfig(); + const { llm } = config; + + let result: Omit; + switch (provider) { + case 'ollabridge': + result = await testOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey); + break; + case 'huggingface': + result = await testHuggingFace(llm.hfToken); + break; + case 'openai': + result = await testOpenAI(llm.openaiApiKey); + break; + case 'anthropic': + result = await testAnthropic(llm.anthropicApiKey); + break; + case 'groq': + result = await testGroq(llm.groqApiKey); + break; + case 'watsonx': + result = await testWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl); + break; + case 'gemini': + result = await testGemini(llm.geminiApiKey); + break; + case 'openrouter': + result = await testOpenRouter(llm.openrouterApiKey); + break; + case 'together': + result = await testTogether(llm.togetherApiKey); + break; + case 'mistral': + result = await testMistral(llm.mistralApiKey); + break; + default: + return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 }); + } + + return NextResponse.json({ provider, ...result }); +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..15fd1722a37eca2e8bd9f3f1275ab73001da5644 --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,204 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; +import { auditLog } from '@/lib/audit'; +import { getClientIp } from '@/lib/rate-limit'; + +/** + * Per-user admin endpoints — safe, non-destructive operations. + * + * GET /api/admin/users/:id → full user profile (admin-only) + * PATCH /api/admin/users/:id → change role / active state / force-logout + * + * Why PATCH and not POST/PUT: + * - PATCH advertises "partial update of an existing resource" which + * matches how the admin UI will call this (flip one field at a time). + * - DELETE already exists at the collection level for hard delete. + * Deactivation via PATCH is the preferred, reversible alternative. + * + * Actions accepted in the body (any subset, all optional): + * - isAdmin: boolean → promote / demote + * - isActive: boolean → enable / disable the account + * - disabledReason: string → stored alongside isActive=false + * - forceLogout: boolean → drop every active session for this user + * + * Safety rails: + * - An admin cannot demote or deactivate themselves via this endpoint + * (would create an unrecoverable lock-out if they were the last admin). + * - Every mutation writes to audit_log with the before/after summary. + */ + +const PatchSchema = z + .object({ + isAdmin: z.boolean().optional(), + isActive: z.boolean().optional(), + disabledReason: z.string().max(500).optional(), + forceLogout: z.boolean().optional(), + }) + .refine( + (v) => + v.isAdmin !== undefined || + v.isActive !== undefined || + v.disabledReason !== undefined || + v.forceLogout === true, + { message: 'No actionable field provided' }, + ); + +function readUser(db: any, id: string) { + return db + .prepare( + `SELECT id, email, display_name, email_verified, is_admin, + COALESCE(is_active, 1) AS is_active, disabled_reason, + last_login_at, created_at + FROM users WHERE id = ?`, + ) + .get(id) as any; +} + +function shape(row: any) { + if (!row) return null; + return { + id: row.id, + email: row.email, + displayName: row.display_name, + emailVerified: !!row.email_verified, + isAdmin: !!row.is_admin, + isActive: !!row.is_active, + disabledReason: row.disabled_reason || null, + lastLoginAt: row.last_login_at || null, + createdAt: row.created_at, + }; +} + +export async function GET( + req: Request, + { params }: { params: { id: string } }, +) { + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + const db = getDb(); + const row = readUser(db, params.id); + if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + return NextResponse.json({ user: shape(row) }); +} + +export async function PATCH( + req: Request, + { params }: { params: { id: string } }, +) { + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.errors[0]?.message || 'Invalid payload' }, + { status: 400 }, + ); + } + const patch = parsed.data; + + const db = getDb(); + const before = readUser(db, params.id); + if (!before) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Safety rail — no self-demotion or self-deactivation. + if (admin.id === params.id) { + if (patch.isAdmin === false) { + return NextResponse.json( + { error: 'An admin cannot demote their own account.' }, + { status: 400 }, + ); + } + if (patch.isActive === false) { + return NextResponse.json( + { error: 'An admin cannot deactivate their own account.' }, + { status: 400 }, + ); + } + } + + // Build the UPDATE dynamically so we only touch the fields the admin + // actually passed. Static prepared statement per combination would be + // ideal, but cardinality is tiny and this keeps the audit diff honest. + const sets: string[] = []; + const values: any[] = []; + const diff: Record = {}; + + if (patch.isAdmin !== undefined && !!before.is_admin !== patch.isAdmin) { + sets.push('is_admin = ?'); + values.push(patch.isAdmin ? 1 : 0); + diff.isAdmin = { before: !!before.is_admin, after: patch.isAdmin }; + } + if (patch.isActive !== undefined && !!before.is_active !== patch.isActive) { + sets.push('is_active = ?'); + values.push(patch.isActive ? 1 : 0); + diff.isActive = { before: !!before.is_active, after: patch.isActive }; + // Clear disabled_reason automatically when re-activating. + if (patch.isActive === true) { + sets.push('disabled_reason = NULL'); + diff.disabledReason = { before: before.disabled_reason || null, after: null }; + } + } + if (patch.disabledReason !== undefined) { + sets.push('disabled_reason = ?'); + values.push(patch.disabledReason || null); + diff.disabledReason = { + before: before.disabled_reason || null, + after: patch.disabledReason || null, + }; + } + + if (sets.length) { + sets.push("updated_at = datetime('now')"); + db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run( + ...values, + params.id, + ); + } + + // forceLogout and isActive=false both revoke sessions. We do it in a + // single DELETE to keep the state transition atomic with the update. + let revoked = 0; + if (patch.forceLogout || patch.isActive === false) { + const info = db + .prepare('DELETE FROM sessions WHERE user_id = ?') + .run(params.id); + revoked = info.changes; + } + + auditLog({ + userId: admin.id, + action: 'admin_action', + ip: getClientIp(req), + meta: { + target_user: params.id, + sub_action: + patch.forceLogout && sets.length === 0 + ? 'force_logout' + : 'user_update', + diff, + sessions_revoked: revoked, + }, + }); + + const after = readUser(db, params.id); + return NextResponse.json({ + user: shape(after), + sessionsRevoked: revoked, + changed: Object.keys(diff), + }); +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..7793031eecbdd5e3da18d99d49b08b086518d85f --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +/** + * GET /api/admin/users — list all registered users (admin only). + * Query params: ?page=1&limit=50&search=term + */ +export async function GET(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const url = new URL(req.url); + const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)); + const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10))); + const search = url.searchParams.get('search')?.trim(); + const offset = (page - 1) * limit; + + const db = getDb(); + + const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : ""; + const params = search ? [`%${search}%`, `%${search}%`] : []; + + const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c; + + const rows = db + .prepare( + `SELECT id, email, display_name, email_verified, is_admin, created_at + FROM users ${where} + ORDER BY created_at DESC LIMIT ? OFFSET ?`, + ) + .all(...params, limit, offset) as any[]; + + // Health data count per user. + const users = rows.map((r) => { + const healthCount = ( + db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any + ).c; + const chatCount = ( + db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any + ).c; + return { + id: r.id, + email: r.email, + displayName: r.display_name, + emailVerified: !!r.email_verified, + isAdmin: !!r.is_admin, + createdAt: r.created_at, + healthDataCount: healthCount, + chatHistoryCount: chatCount, + }; + }); + + return NextResponse.json({ users, total, page, limit }); +} + +/** + * DELETE /api/admin/users?id= — delete a user (admin only). + * CASCADE deletes all their health data, chat history, and sessions. + */ +export async function DELETE(req: Request) { + const admin = requireAdmin(req); + if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + + const url = new URL(req.url); + const userId = url.searchParams.get('id'); + if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 }); + + // Prevent deleting yourself. + if (userId === admin.id) { + return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/delete-account/route.ts b/app/api/auth/delete-account/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..64857ad49e5aadeff58d9122ea2b4bf3ea2aae6e --- /dev/null +++ b/app/api/auth/delete-account/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/db'; +import { requireAdmin } from '@/lib/auth-middleware'; + +const Schema = z.object({ + userId: z.string().min(1), + confirmEmail: z.string().email(), +}); + +/** + * POST /api/auth/delete-account — ADMIN-ONLY account deletion. + * + * Only admins can delete accounts. This prevents: + * - Hackers with stolen tokens from destroying user data + * - Automated scripts mass-deleting accounts + * - Accidental self-deletion + * + * Requires both userId AND confirmEmail to match (double verification). + * Uses CASCADE deletes via foreign keys — one operation wipes everything. + * + * For GDPR: users REQUEST deletion via support/admin panel. + * Admin reviews and executes. This is the industry standard for + * healthcare apps (MyChart, Epic, Cerner all require admin action). + */ +export async function POST(req: Request) { + // ADMIN ONLY — reject all non-admin requests + const admin = requireAdmin(req); + if (!admin) { + return NextResponse.json( + { error: 'Admin access required. Users must request account deletion through the admin.' }, + { status: 403 }, + ); + } + + try { + const body = await req.json(); + const { userId, confirmEmail } = Schema.parse(body); + + const db = getDb(); + + // Verify user exists and email matches (double check) + const user = db.prepare('SELECT id, email, is_admin FROM users WHERE id = ?').get(userId) as any; + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) { + return NextResponse.json( + { error: 'Email confirmation does not match. Deletion aborted.' }, + { status: 400 }, + ); + } + + // Prevent deleting admin accounts (safety net) + if (user.is_admin) { + return NextResponse.json( + { error: 'Cannot delete admin accounts via this endpoint.' }, + { status: 403 }, + ); + } + + // CASCADE deletes handle: sessions, health_data, chat_history + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + + console.log(`[Account Deletion] Admin ${admin.email} deleted user ${user.email} (${userId})`); + + return NextResponse.json({ + success: true, + message: `Account ${user.email} and all associated data permanently deleted.`, + deletedBy: admin.email, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid request. Provide userId and confirmEmail.' }, { status: 400 }); + } + console.error('[Delete Account]', error?.message); + return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 }); + } +} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..24afcaa37cf2f6d62d31bcd33247809db16bbac3 --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genVerificationCode, resetExpiry } from '@/lib/db'; +import { sendPasswordResetEmail, emailTransportName } from '@/lib/email'; + +const Schema = z.object({ + email: z.string().email(), +}); + +/** + * POST /api/auth/forgot-password — sends a reset code to the user's email. + * + * Always returns 200 even if the email doesn't exist (prevents email enumeration). + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { email } = Schema.parse(body); + + const db = getDb(); + const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any; + + if (user) { + const code = genVerificationCode(); + db.prepare( + `UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(code, resetExpiry(), user.id); + + console.log(`[ForgotPassword] queued reset email via transport=${emailTransportName()} to=${user.email}`); + const sent = await sendPasswordResetEmail(user.email, code); + if (!sent) console.error(`[ForgotPassword] reset email FAILED to=${user.email}`); + } + + // Always return success to prevent email enumeration. + return NextResponse.json({ + message: 'If that email is registered, a reset code has been sent.', + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid email' }, { status: 400 }); + } + console.error('[Auth ForgotPassword]', error?.message); + return NextResponse.json({ error: 'Request failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..460981b13eff5280015a43aefabc99145080a7c3 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; + +const Schema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +export async function POST(req: Request) { + // Rate limit: 10 login attempts per minute per IP + const ip = getClientIp(req); + const rl = checkRateLimit(`login:${ip}`, 10, 60_000); + if (!rl.allowed) { + return NextResponse.json( + { error: 'Too many login attempts. Please wait a moment.' }, + { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } }, + ); + } + + try { + const body = await req.json(); + const { email, password } = Schema.parse(body); + + const db = getDb(); + pruneExpiredSessions(); + + const user = db + .prepare( + `SELECT id, email, password, display_name, email_verified, is_admin, + COALESCE(is_active, 1) AS is_active, disabled_reason + FROM users WHERE email = ?`, + ) + .get(email.toLowerCase()) as any; + + if (!user || !bcrypt.compareSync(password, user.password)) { + return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 }); + } + + // Reject deactivated accounts with a distinct 403 so the UI can + // surface the `disabled_reason` instead of a generic "wrong password". + if (!user.is_active) { + return NextResponse.json( + { + error: + user.disabled_reason || + 'This account has been disabled. Please contact an administrator.', + code: 'account_disabled', + }, + { status: 403 }, + ); + } + + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + user.id, + sessionExpiry(), + ); + // Best-effort login timestamp. Never fail the login if this write + // errors — the column exists from v3 onwards, but older DBs that + // haven't hit getDb() yet may not have it for a transient moment. + try { + db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id); + } catch { + /* non-fatal */ + } + + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerified: !!user.email_verified, + isAdmin: !!user.is_admin, + }, + token, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input' }, { status: 400 }); + } + console.error('[Auth Login]', error?.message); + return NextResponse.json({ error: 'Login failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..6aef5208bb4e2df890e40f58f67466227cde112c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export async function POST(req: Request) { + const h = req.headers.get('authorization'); + const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null; + + if (token) { + const db = getDb(); + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef903fa8dcfabfe22529090623eab574e32514ff --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from 'next/server'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; +import { getDb, pruneExpiredSessions } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { auditLog } from '@/lib/audit'; +import { getClientIp, checkRateLimit } from '@/lib/rate-limit'; + +export async function GET(req: Request) { + const h = req.headers.get('authorization'); + const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null; + if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const db = getDb(); + pruneExpiredSessions(); + + const row = db + .prepare( + `SELECT u.id, u.email, u.display_name, u.email_verified, u.is_admin, u.created_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > datetime('now')`, + ) + .get(token) as any; + + if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 }); + + return NextResponse.json({ + user: { + id: row.id, + email: row.email, + displayName: row.display_name, + emailVerified: !!row.email_verified, + isAdmin: !!row.is_admin, + createdAt: row.created_at, + }, + }); +} + +/** + * DELETE /api/auth/me — self-service account deletion (GDPR Art. 17 / + * HIPAA patient right-to-delete). + * + * Safety gates (all required, in order): + * 1. Must present a valid session (authenticateRequest). + * 2. Must re-authenticate by supplying the current password in the JSON + * body: `{ "password": "…", "confirmEmail": "…" }`. Re-auth stops + * stolen-token exfiltration from wiping the account. + * 3. `confirmEmail` must match the logged-in user's email — defence + * against copy/paste mistakes in shared UIs. + * 4. Admin accounts cannot self-delete via this endpoint (prevents + * accidental lock-out of the Space). Admins must demote first or use + * the admin-ops deletion flow. + * 5. Per-IP + per-user rate limit: 3 attempts / hour. + * + * Execution: + * - All PHI (health_data, chat_history, user_settings, sessions, + * audit_log FK, scan_log FK) is removed by FK CASCADE. + * - A single audit row is written BEFORE the delete so forensics can + * prove the delete happened and by whom. + */ +const DeleteSchema = z.object({ + password: z.string().min(1, 'Password required'), + confirmEmail: z.string().email('Email confirmation required'), +}); + +export async function DELETE(req: Request) { + const ip = getClientIp(req); + + // 5) Rate limit self-deletion to blunt brute-force of the password gate. + const rl = checkRateLimit(`delete-me:${ip}`, 3, 60 * 60_000); + if (!rl.allowed) { + return NextResponse.json( + { error: 'Too many deletion attempts. Try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) }, + }, + ); + } + + // 1) Valid session. + const auth = authenticateRequest(req); + if (!auth) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const parsed = DeleteSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'password and confirmEmail are required' }, + { status: 400 }, + ); + } + const { password, confirmEmail } = parsed.data; + + const db = getDb(); + const user = db + .prepare('SELECT id, email, password, is_admin FROM users WHERE id = ?') + .get(auth.id) as any; + + if (!user) { + return NextResponse.json({ error: 'Account not found' }, { status: 404 }); + } + + // 3) Email confirmation must match the session's user. + if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) { + auditLog({ + userId: user.id, + action: 'delete_account', + ip, + meta: { outcome: 'email_mismatch' }, + }); + return NextResponse.json( + { error: 'Email confirmation does not match your account.' }, + { status: 400 }, + ); + } + + // 2) Password re-auth. + if (!bcrypt.compareSync(password, user.password)) { + auditLog({ + userId: user.id, + action: 'delete_account', + ip, + meta: { outcome: 'bad_password' }, + }); + return NextResponse.json( + { error: 'Password is incorrect.' }, + { status: 401 }, + ); + } + + // 4) Admins cannot self-delete via this endpoint. + if (user.is_admin) { + return NextResponse.json( + { + error: + 'Admin accounts cannot self-delete. Demote the account first or use the admin deletion endpoint.', + }, + { status: 403 }, + ); + } + + // Record intent BEFORE the destructive write so forensics can reconstruct + // the event even if the CASCADE blows up mid-way. + auditLog({ + userId: user.id, + action: 'delete_account', + ip, + meta: { outcome: 'initiated', self_service: true }, + }); + + try { + db.prepare('DELETE FROM users WHERE id = ?').run(user.id); + } catch (e: any) { + console.error('[Delete Me] cascade delete failed:', e?.message); + return NextResponse.json( + { error: 'Deletion failed. Please contact support.' }, + { status: 500 }, + ); + } + + // Post-deletion audit row. audit_log.user_id is an unconstrained TEXT + // column (no FK), so earlier audit rows for this user survive the + // cascade and remain available for forensic review. + auditLog({ + userId: null, + action: 'delete_account', + ip, + meta: { outcome: 'completed', deleted_user: user.id, self_service: true }, + }); + + return NextResponse.json({ + success: true, + message: `Account ${user.email} and all associated data permanently deleted.`, + }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..734c052c2e00dcc329a9907a006071e3425d2f97 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db'; +import { sendVerificationEmail, emailTransportName } from '@/lib/email'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; + +const Schema = z.object({ + email: z.string().email().max(255), + password: z.string().min(6).max(128), + displayName: z.string().max(50).optional(), +}); + +export async function POST(req: Request) { + // Rate limit: 5 registrations per minute per IP + const ip = getClientIp(req); + const rl = checkRateLimit(`register:${ip}`, 5, 60_000); + if (!rl.allowed) { + return NextResponse.json( + { error: 'Too many registration attempts. Please wait.' }, + { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } }, + ); + } + + try { + const body = await req.json(); + const { email, password, displayName } = Schema.parse(body); + + const db = getDb(); + + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existing) { + return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 }); + } + + const id = genId(); + const hash = bcrypt.hashSync(password, 10); + const code = genVerificationCode(); + const expires = codeExpiry(); + + db.prepare( + `INSERT INTO users (id, email, password, display_name, verification_code, verification_expires) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, email.toLowerCase(), hash, displayName || null, code, expires); + + // Auto-login + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry()); + + // Send verification email (best-effort, don't block registration). + // We DO want to know if it failed though — the old `.catch(() => {})` + // here masked a year of "no emails arriving" bug reports because + // the API still returned 201 and the UI still said "Check your + // email". Log the transport name on every register so operators + // can confirm the wiring from container logs in one grep. + console.log(`[Register] queued verification email via transport=${emailTransportName()} to=${email}`); + sendVerificationEmail(email, code).then( + (ok) => { + if (!ok) console.error(`[Register] verification email FAILED to=${email}`); + }, + (err) => console.error(`[Register] verification email threw to=${email}: ${err?.message ?? err}`), + ); + + return NextResponse.json( + { + user: { id, email: email.toLowerCase(), displayName, emailVerified: false }, + token, + message: 'Account created. Check your email for a verification code.', + }, + { status: 201 }, + ); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 }); + } + console.error('[Auth Register]', error?.message); + return NextResponse.json({ error: 'Registration failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/resend-verification/route.ts b/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f474216fa4d72c220451463a1a6ea516ad88baa --- /dev/null +++ b/app/api/auth/resend-verification/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { getDb, genVerificationCode, codeExpiry } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { sendVerificationEmail, emailTransportName } from '@/lib/email'; + +/** + * POST /api/auth/resend-verification — resend the 6-digit verification code. + */ +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + const db = getDb(); + const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any; + + if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (row.email_verified) return NextResponse.json({ message: 'Email already verified' }); + + const code = genVerificationCode(); + db.prepare( + `UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(code, codeExpiry(), user.id); + + console.log(`[ResendVerification] queued via transport=${emailTransportName()} to=${row.email}`); + const sent = await sendVerificationEmail(row.email, code); + if (!sent) console.error(`[ResendVerification] FAILED to=${row.email}`); + + return NextResponse.json({ message: 'Verification code sent' }); +} diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..61ac4178ef9fdf1f185eefb191cf4ef05e57dd56 --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { getDb, genToken, sessionExpiry } from '@/lib/db'; + +const Schema = z.object({ + email: z.string().email(), + code: z.string().length(6), + newPassword: z.string().min(6).max(128), +}); + +/** + * POST /api/auth/reset-password — reset password with the 6-digit code. + * On success, auto-logs the user in and returns a session token. + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { email, code, newPassword } = Schema.parse(body); + + const db = getDb(); + const user = db + .prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?') + .get(email.toLowerCase()) as any; + + if ( + !user || + user.reset_token !== code || + !user.reset_expires || + new Date(user.reset_expires) < new Date() + ) { + return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 }); + } + + const hash = bcrypt.hashSync(newPassword, 10); + db.prepare( + `UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now') + WHERE id = ?`, + ).run(hash, user.id); + + // Invalidate all existing sessions for this user (security best practice). + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id); + + // Auto-login with new session. + const token = genToken(); + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run( + token, + user.id, + sessionExpiry(), + ); + + return NextResponse.json({ + message: 'Password reset successfully', + token, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 }); + } + console.error('[Auth ResetPassword]', error?.message); + return NextResponse.json({ error: 'Reset failed' }, { status: 500 }); + } +} diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..42cd45b2cfa147145b6add629c387470b189eeef --- /dev/null +++ b/app/api/auth/verify-email/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { sendWelcomeEmail } from '@/lib/email'; + +const Schema = z.object({ + code: z.string().length(6), +}); + +/** + * POST /api/auth/verify-email — verify email with 6-digit code. + * Requires auth (the user must be logged in to verify their own email). + */ +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + + try { + const body = await req.json(); + const { code } = Schema.parse(body); + + const db = getDb(); + const row = db + .prepare( + `SELECT verification_code, verification_expires, email, email_verified + FROM users WHERE id = ?`, + ) + .get(user.id) as any; + + if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (row.email_verified) return NextResponse.json({ message: 'Email already verified' }); + + if ( + row.verification_code !== code || + !row.verification_expires || + new Date(row.verification_expires) < new Date() + ) { + return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 }); + } + + db.prepare( + `UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now') + WHERE id = ?`, + ).run(user.id); + + // Send welcome email + sendWelcomeEmail(row.email).catch(() => {}); + + return NextResponse.json({ message: 'Email verified successfully', emailVerified: true }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid code format' }, { status: 400 }); + } + console.error('[Auth Verify]', error?.message); + return NextResponse.json({ error: 'Verification failed' }, { status: 500 }); + } +} diff --git a/app/api/chat-history/route.ts b/app/api/chat-history/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..09c10ce3fe052d2a6b2e959b4c5f8f3465c6f692 --- /dev/null +++ b/app/api/chat-history/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { encodeHealthPayload } from '@/lib/health-data-repo'; + +/** + * GET /api/chat-history → list conversations (newest first, max 100) + * POST /api/chat-history → save a conversation + * DELETE /api/chat-history?id= → delete one conversation + */ + +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const db = getDb(); + const rows = db + .prepare( + 'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100', + ) + .all(user.id) as any[]; + + return NextResponse.json({ + conversations: rows.map((r) => ({ + id: r.id, + preview: r.preview, + topic: r.topic, + createdAt: r.created_at, + })), + }); +} + +const SaveSchema = z.object({ + preview: z.string().max(200), + messages: z.array( + z.object({ + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), + }), + ), + topic: z.string().max(50).optional(), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { preview, messages, topic } = SaveSchema.parse(body); + + const db = getDb(); + const id = genId(); + + // Messages may contain PHI — encrypt at rest. The preview is intentionally + // left in plaintext because it's displayed in the sidebar listing and is + // already capped at 200 chars by the input schema. + db.prepare( + 'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)', + ).run(id, user.id, preview, encodeHealthPayload(messages), topic || null); + + return NextResponse.json({ id }, { status: 201 }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Chat History POST]', error?.message); + return NextResponse.json({ error: 'Save failed' }, { status: 500 }); + } +} + +export async function DELETE(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run( + id, + user.id, + ); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..915549f01cc9a3823bf9f91b59c7d744bb328399 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,735 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { chatWithFallback, AllProvidersUnavailableError, type ChatMessage } from '@/lib/providers'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; +import { preCheck, postCheck } from '@/lib/safety/safety-engine'; +import { snapshotFlags, emergencyCardEnabled } from '@/lib/feature-flags'; +import { classifyIntent, priorUserTurns } from '@/lib/medical-flow/intent'; +import { + buildGreetingCard, + buildProfileGateCard, + buildLimitedGuidanceCard, + buildEmergencyCard, + streamCardChunk, +} from '@/lib/medical-flow/cards'; +import { nextSymptomCard, generateDoctorSummary } from '@/lib/medical-flow/state'; +import { recordCardEmission } from '@/lib/medical-flow/audit'; +import { + detectInteraction, + buildInteractionWarningCard, + extractAllergies, + scanForAllergyViolation, +} from '@/lib/medical-flow/allergy-guard'; + +// Log feature-flag snapshot once per process load so deployments make their +// configured behavior visible. Values are server-side only and PHI-free. +console.log(`[Chat] route.flags ${JSON.stringify(snapshotFlags())}`); +import { buildRAGContext } from '@/lib/rag/medical-kb'; +import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { auditLog } from '@/lib/audit'; +import { + buildPatientContextForUser, + stripInjectedPatientContext, +} from '@/lib/patient-context.server'; + +const RequestSchema = z.object({ + messages: z.array( + z.object({ + role: z.enum(['system', 'user', 'assistant']), + content: z.string(), + }) + ), + model: z.string().optional().default('qwen2.5:1.5b'), + language: z.string().optional().default('en'), + countryCode: z.string().optional().default('US'), +}); + +export async function POST(request: NextRequest) { + const routeStartedAt = Date.now(); + const ip = getClientIp(request); + const user = authenticateRequest(request); + + // Per-identity chat rate limit. Authenticated users get a generous + // 60 turns/min by user id (stable across IPs), anonymous get 20/min + // by IP. The limiter is in-memory per process; for multi-instance + // deployments swap to Redis (same interface). + const limitKey = user ? `chat:user:${user.id}` : `chat:ip:${ip}`; + const limitMax = user ? 60 : 20; + const limit = checkRateLimit(limitKey, limitMax, 60_000); + if (!limit.allowed) { + return new Response( + JSON.stringify({ + error: 'Chat rate limit exceeded. Please slow down.', + retryAfterMs: limit.retryAfterMs, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)), + }, + }, + ); + } + + try { + const body = await request.json(); + const { messages, model, language, countryCode } = RequestSchema.parse(body); + + // Single-line JSON payload so the HF Space logs API (SSE) can be grepped + // with a simple prefix match. Every stage below tags itself `[Chat]`. + console.log( + `[Chat] route.enter ${JSON.stringify({ + userId: user?.id || null, + turns: messages.length, + model, + language, + countryCode, + userAgent: request.headers.get('user-agent')?.slice(0, 80) || null, + })}`, + ); + + // Step 1: Emergency triage on the latest user message. + // Sanitise FIRST: strip any client-injected [Patient: ...] block so + // (a) the triage check sees only the user's real prose, and + // (b) we cannot leak another user's EHR into the LLM if a stale or + // malicious client sends one. + const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); + const rawUserContent = lastUserMessage?.content || ''; + // For authenticated users we strip the client-shipped block and + // re-derive it from the server-side DB below — that's the + // cross-user-leak fix. For guests there IS no server-side profile, + // so the client's localStorage-built block is the only personalization + // signal available; stripping it would silently regress logged-out + // users to fully generic answers. Self-supplied data carries no + // cross-user risk so we let it pass through. + const cleanUserContent = user + ? stripInjectedPatientContext(rawUserContent) + : rawUserContent; + + // Step 1: Run the deterministic safety pre-check. This is the FLOOR; + // the LLM cannot relax it. The engine returns either an emergency + // template (R5 — LLM not called) or a green-light decision with a + // risk class and a system-prompt augmentation that pins policy. + let safetyDecision: ReturnType | null = null; + if (lastUserMessage) { + safetyDecision = preCheck({ + text: cleanUserContent, + countryCode, + }); + + console.log( + `[Chat] route.safety.preCheck ${JSON.stringify({ + userId: user?.id || null, + riskClass: safetyDecision.audit.riskClass, + ruleFires: safetyDecision.audit.ruleFires, + userChars: cleanUserContent.length, + })}`, + ); + + // Emergency-template path — DO NOT short-circuit the LLM anymore. + // + // Old behaviour: when preCheck() returned `emergency_template` we + // returned a fixed string and never called the model. This is + // exactly the "hardcoded answer" complaint: users saw a canned + // "This may be a heart attack…" reply and never got real LLM + // reasoning, even for non-trivial follow-ups. + // + // New behaviour: we capture the deterministic emergency banner + // here and let the request flow into the normal LLM path. The + // banner is then prepended to the LLM response in the safeStream + // assembly below. If the LLM fails we still deliver the banner + // alone (the safety floor never disappears) — never a 503. + // + // The deterministic floor (banner text + emergency number) is + // still authored by the safety engine, not the model, so a + // hallucinating LLM cannot weaken it. The LLM can only ADD + // medical reasoning *after* the banner. + } + const emergencyBanner = + safetyDecision?.kind === 'emergency_template' ? safetyDecision.template : ''; + const emergencyRuleFires = + safetyDecision?.kind === 'emergency_template' ? safetyDecision.audit.ruleFires : []; + const isEmergency = !!emergencyBanner; + + // Step 2: Build RAG context from the medical knowledge base. + const ragStart = Date.now(); + const ragContext = lastUserMessage ? buildRAGContext(cleanUserContent) : ''; + console.log( + `[Chat] route.rag ${JSON.stringify({ + userId: user?.id || null, + chars: ragContext.length, + latencyMs: Date.now() - ragStart, + })}`, + ); + + // Step 3: Server-built patient context, scoped to the authenticated + // user. Anonymous chats receive no per-user EHR — they get a generic + // medical assistant. This is the isolation contract. + const patientContext = user ? buildPatientContextForUser(user.id) : ''; + + // Step 4: Build a structured, locale-aware system prompt that grounds + // the model in WHO/CDC/NHS guidance and pins the response language, + // country, emergency number, and measurement system. Append the + // safety-engine policy block so the LLM is aware of the deterministic + // floor — the post-filter is the second line of defence. + const emergencyInfo = getEmergencyInfo(countryCode); + // First-turn detection: when there are zero prior assistant turns, + // the system prompt enables the one-time `[bubble:welcome]` greeting. + // On subsequent turns the greeting is suppressed so the user is not + // re-welcomed on every reply. + const isFirstTurn = !messages.some((m) => m.role === 'assistant'); + // Guest detection: gates the optional `[bubble:signup]` soft prompt + // and ensures we never block general help on registration. + const isGuest = !user; + const baseSystemPrompt = buildMedicalSystemPrompt({ + country: countryCode, + language, + emergencyNumber: emergencyInfo.emergency, + isFirstTurn, + isGuest, + }); + // Stack the system instructions: base + allow-llm hints + emergency + // augmentation. Emergency augmentation tells the LLM the deterministic + // banner has ALREADY been shown to the user, so the LLM should produce + // additive reasoning (what to do next, what to bring to the ER, when + // every minute matters, etc.) rather than re-issuing the call-911 text. + let systemPrompt = baseSystemPrompt; + if (safetyDecision && safetyDecision.kind === 'allow_llm') { + systemPrompt += `\n\n${safetyDecision.systemInstructions}`; + } + if (isEmergency) { + systemPrompt += + `\n\n[EMERGENCY SAFETY FLOOR]\n` + + `The user has triggered red-flag rules: ${emergencyRuleFires.join(', ')}.\n` + + `A deterministic emergency banner has been shown to the user FIRST. It instructs them to call ${emergencyInfo.emergency} immediately.\n` + + `Your task is to ADD short, useful medical reasoning AFTER the banner:\n` + + ` • acknowledge the urgency\n` + + ` • give concrete next steps (what to do while waiting, what to bring, what to tell responders)\n` + + ` • do NOT contradict, soften, or repeat the banner\n` + + ` • keep it under 6 sentences\n`; + } + + // Step 5: Assemble the final message list. Prior turns are passed through + // verbatim except for the LAST user turn, which is rebuilt with: + // sanitised user prose + server-built [Patient: ...] + retrieved RAG + // in that order. The LLM sees patient context BEFORE reference material, + // matching the prior client-side ordering. + const priorMessages = messages.slice(0, -1).map((m) => + m.role === 'user' + ? { ...m, content: stripInjectedPatientContext(m.content) } + : m, + ); + + const finalUserContent = [ + cleanUserContent, + patientContext, // already starts with '\n[Patient: ...]' or '' + ragContext + ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}` + : '', + ].join(''); + + const augmentedMessages: ChatMessage[] = [ + { role: 'system' as const, content: systemPrompt }, + ...priorMessages, + { role: 'user' as const, content: finalUserContent }, + ]; + + // Step 5.5: Medical-flow intent classification + card short-circuit. + // + // Certain intents are answered directly with a structured card and + // NEVER reach the LLM: + // + // - chitchat ("hello", "thanks") → greeting card with quick actions + // - deep_analysis without a server-side profile → profile_gate card + // - explicit safety check on a known symptom → safety_check card + // + // For these, we save the LLM call entirely and return a deterministic + // card response. Everything else falls through to the existing + // bubble flow below — backward-compatible with all current behaviour. + // + // Emergency cases ALSO get a card — emitted BEFORE we fall through + // to the LLM emergency-banner path. The card delivers immediate + // structured action (Call $emergency_number / Find nearby ED / + // Prepare summary) the user can tap right now, while the LLM + // turn that follows adds contextual reasoning. Cards are never + // gated by login or profile. + // Emergency-card emission is gated behind MEDOS_EMERGENCY_CARD_ENABLED + // (default OFF). When off, the dedicated `[card:emergency]` UI is + // suppressed and the chat falls through to the existing + // emergency-banner-prepend path (banner text is still injected + // into the LLM response, the safety floor is preserved). User + // feedback was that the red emergency card felt overaggressive + // for the chest-pain / stroke / FAST triggers; routing those + // through the structured safety_check → intake → urgent guidance + // flow gives clearer next steps without the alarming UI. + // Operators who explicitly want the card UI flip the flag on. + if (isEmergency && emergencyCardEnabled()) { + const emergencyCard = buildEmergencyCard({ + reason: + emergencyRuleFires.length > 0 + ? `Symptoms suggest: ${emergencyRuleFires.join(', ')}` + : 'These symptoms can be life-threatening if untreated.', + emergency_number: emergencyInfo.emergency, + }); + const emergencyChunk = streamCardChunk(emergencyCard); + recordCardEmission({ + user_id: user?.id || null, + card: emergencyCard, + country: countryCode, + language, + }); + // Prepend the emergency card to whatever the LLM emits — the + // card lands first so the user sees the call-to-action even + // before any AI text. We still call the LLM (Step 6 below) so + // the user also gets the conversational reasoning. + // Implementation note: we inject via the messages list so the + // existing post-filter path runs unchanged. The card content + // is appended to the final user message as a "[card_pre]" + // marker the post-stream emitter will lift out. + // …simpler approach: just write the card to the stream and + // return early with no LLM call for true emergencies. The + // existing emergency-banner text is duplicative for the card. + return new Response(emergencyChunk + 'data: [DONE]\n\n', { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); + } + if (!isEmergency) { + const intent = classifyIntent(cleanUserContent, { + prior_user_turns: priorUserTurns(messages), + is_emergency: false, + }); + console.log( + `[Chat] route.intent ${JSON.stringify({ + userId: user?.id || null, + intent, + hasServerProfile: !!patientContext, + })}`, + ); + + const earlyCards: string[] = []; + + // "Continue with general guidance" — synthesized message fired + // when the user declines a profile gate. Emit the limited_guidance + // card so the user understands they're getting non-personalized + // advice and what they'd unlock by completing the profile. + const declinedGate = /\b(continue|please continue)\s+with\s+general\s+(guidance|medication\s+information)\b/i.test( + cleanUserContent, + ); + // Detect drug names in the recent conversation so the limited + // guidance card can be variant-specific (mentions ulcers / + // kidney / blood thinner for NSAIDs etc.). + const recentText = + messages.slice(-4).map((m) => m.content).join(' ').toLowerCase(); + const drugMatch = recentText.match( + /\b(ibuprofen|aspirin|acetaminophen|paracetamol|tylenol|advil|naproxen|lisinopril|metformin|warfarin|atorvastatin)\b/, + ); + + if (declinedGate) { + const isMedFlow = /\b(continue with general medication)\b/i.test(cleanUserContent) + || (drugMatch && /\b(safe|take|can\s+I)\b/.test(recentText)); + const card = buildLimitedGuidanceCard({ + variant: isMedFlow ? 'medication' : 'symptom', + drug: drugMatch ? drugMatch[1] : undefined, + }); + earlyCards.push(streamCardChunk(card)); + recordCardEmission({ + user_id: user?.id || null, + card, + country: countryCode, + language, + }); + } + // Explicit doctor-summary request — fired by the "Create doctor + // summary" button on the next_steps card. Synthesizes the take- + // home card from accumulated conversation state without calling + // the LLM. + else if (cleanUserContent.includes('action:doctor_summary')) { + const summary = generateDoctorSummary(messages.slice(0, -1)); + if (summary) { + earlyCards.push(streamCardChunk(summary)); + recordCardEmission({ + user_id: user?.id || null, + card: summary, + country: countryCode, + language, + }); + console.log( + `[Chat] route.flow.doctor_summary ${JSON.stringify({ + complaint: summary.chief_complaint, + severity: summary.severity, + })}`, + ); + } + } else if ( + intent === 'medication' && + drugMatch && + !patientContext && + /\b(safe|can\s+I\s+take|should\s+I\s+take|for\s+me|in\s+my\s+case|with\s+my)\b/i.test(cleanUserContent) + ) { + // Personal-safety medication question without a profile on + // file → medication-variant profile_gate FIRST, before any + // symptom flow can claim the message. Drugs touch too many + // contraindications to answer safely without context. + const gate = buildProfileGateCard({ variant: 'medication', drug: drugMatch[1] }); + earlyCards.push(streamCardChunk(gate)); + recordCardEmission({ + user_id: user?.id || null, + card: gate, + country: countryCode, + language, + }); + } else if (intent === 'chitchat' && priorUserTurns(messages) === 0) { + // First-turn onboarding only — the greeting card is the + // app's deliberate entry point with quick-action chips + // (Check symptoms / Medication / Test results / Find care / + // Emergency). + // + // On every subsequent turn, chitchat ("hello", "how are you", + // "thanks") falls through to the LLM dispatch below. The + // system prompt in medical-knowledge.ts:127-130 already + // teaches the model not to greet again — it sees the full + // history and responds naturally ("Hi — still asking about + // your ankle?"). No new regex, no second greeting card. + const greeting = buildGreetingCard({}); + earlyCards.push(streamCardChunk(greeting)); + recordCardEmission({ + user_id: user?.id || null, + card: greeting, + country: countryCode, + language, + }); + } else { + // Always check the symptom-flow state machine FIRST when not + // in chitchat. Mid-flow turns (the user just answered a + // safety_check or intake card) must continue the flow before + // any intent-based gate can fire — otherwise a 3-turn intake + // would get hijacked by the deep_analysis profile_gate after + // turn 3 thanks to the soft-promotion rule in classifyIntent. + const symptomResult = nextSymptomCard(messages.slice(0, -1), cleanUserContent); + if (symptomResult) { + earlyCards.push(streamCardChunk(symptomResult.card)); + recordCardEmission({ + user_id: user?.id || null, + card: symptomResult.card, + flow_id: symptomResult.flow.id, + answers: symptomResult.answers, + country: countryCode, + language, + }); + // The state machine may return a primary card plus 0..N + // companion cards (e.g. guidance + next_steps). Emit them + // back-to-back so the client renders them as a stack. + if (symptomResult.extra) { + for (const c of symptomResult.extra) { + earlyCards.push(streamCardChunk(c)); + recordCardEmission({ + user_id: user?.id || null, + card: c, + flow_id: symptomResult.flow.id, + answers: symptomResult.answers, + country: countryCode, + language, + }); + } + } + console.log( + `[Chat] route.flow.card ${JSON.stringify({ + flow: symptomResult.flow.id, + kind: symptomResult.card.kind, + extra: symptomResult.extra?.map((c) => c.kind), + answers: symptomResult.answers, + })}`, + ); + } else if (intent === 'deep_analysis' && !patientContext) { + // Profile gate fires only when: + // - the user is NOT mid-flow (symptomResult is null) + // - they asked for deep analysis (or were soft-promoted + // after 3+ medical turns) + // - we don't have a server-side EHR profile on file + const gate = buildProfileGateCard({}); + earlyCards.push(streamCardChunk(gate)); + recordCardEmission({ + user_id: user?.id || null, + card: gate, + country: countryCode, + language, + }); + } + } + + if (earlyCards.length > 0) { + return new Response( + earlyCards.join('') + 'data: [DONE]\n\n', + { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }, + ); + } + } + + // Step 6: Stream response via the provider fallback chain. + console.log( + `[Chat] route.provider.dispatch ${JSON.stringify({ + userId: user?.id || null, + systemPromptChars: systemPrompt.length, + patientContextChars: patientContext.length, + totalMessages: augmentedMessages.length, + preparedInMs: Date.now() - routeStartedAt, + })}`, + ); + // Step 6: Buffer-then-filter-then-stream. + // + // The deterministic post-filter must run on the COMPLETE model response + // before any of it reaches the user. We therefore call the non-streaming + // provider, run postCheck(), and re-emit the filtered text as a single + // SSE chunk so the existing client SSE parser keeps working. + // + // ALL risk classes — including R5 — call the LLM. The deterministic + // banner is still authored by the safety engine and is prepended below; + // the LLM only ADDS clinical reasoning AFTER the banner (next steps, + // what to tell EMS, what to bring). This eliminates the "every + // chest-pain question gets the same canned reply" complaint while + // keeping the safety floor non-negotiable: if the LLM returns nothing, + // or returns text that the post-filter strips, the banner stands alone. + // + // For emergencies, the system-prompt augmentation ([EMERGENCY SAFETY + // FLOOR] block in Step 4 above) tells the model that the banner has + // already been shown and constrains it to ≤6 sentences of additive + // advice. With Groq llama-3.3-70b-versatile as primary this is + // reliable; the old failure mode (qwen2.5:0.5b ignoring length caps + // and inventing dangerous advice) is no longer in the hot path. + // Step 5.8: Drug-interaction pre-check. + // + // If the patient_context lists medications AND the user is asking + // about a drug that interacts with one of them, skip the LLM + // entirely and emit a deterministic guidance card with the + // interaction warning. This is the structural moat ChatGPT can't + // ship: a typed-profile lookup table that fires before any LLM + // can give wrong advice. + const interaction = detectInteraction({ + patient_context: patientContext, + user_message: cleanUserContent, + }); + if (interaction) { + const card = buildInteractionWarningCard(interaction); + const chunk = streamCardChunk(card); + recordCardEmission({ + user_id: user?.id || null, + card, + country: countryCode, + language, + }); + console.log( + `[Chat] route.interaction ${JSON.stringify({ + user_med: interaction.user_med, + asked_drug: interaction.asked_drug, + })}`, + ); + return new Response(chunk + 'data: [DONE]\n\n', { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); + } + + let providerResponse: { content: string; provider: string; model: string }; + try { + providerResponse = await chatWithFallback(augmentedMessages, model); + } catch (chainErr: any) { + const isUnavailable = + chainErr instanceof AllProvidersUnavailableError; + console.warn( + `[Chat] route.provider.degraded reason=${ + isUnavailable ? 'all_providers_failed' : 'unexpected_error' + } emergency=${isEmergency} msg=${String( + chainErr?.message || chainErr, + ).slice(0, 200)}`, + ); + if (isEmergency) { + // Safety floor never disappears. If every provider is down on an + // emergency turn, the deterministic banner alone is the answer — + // it already routes the user to EMS and lists do-while-waiting + // steps. No conversational hedge is appropriate here. + providerResponse = { + content: '', + provider: 'safety-engine', + model: 'emergency-template', + }; + } else { + providerResponse = { + content: + "I'm having trouble reaching the medical AI right now. " + + "Please try again in a moment. If this is urgent, contact " + + `your healthcare provider or call ${emergencyInfo.emergency} ` + + `(${emergencyInfo.country}).`, + provider: 'safety-engine', + model: 'graceful-degradation', + }; + } + } + + const riskClass = safetyDecision?.kind === 'allow_llm' + ? safetyDecision.riskClass + : (isEmergency ? 'R5' : 'R0'); + + const post = postCheck({ + response: providerResponse.content, + riskClass, + emergency: emergencyInfo, + // Suppress the post-filter's "see a primary-care doctor" append + // when we're already showing the emergency floor — that floor + // routes the user to emergency services, and a GP referral on + // top of it is misdirection (e.g. on a suspected MI). + isEmergencyTemplatePath: isEmergency, + }); + + // Prepend the emergency banner so the user always sees the safety + // floor first, then the LLM's medical reasoning underneath. + let finalContent = isEmergency + ? (post.filtered + ? `${emergencyBanner}\n\n${post.filtered}` + : emergencyBanner) + : post.filtered; + + // Step 7.5: Deterministic allergy guard. + // + // Pull the user's allergies from the patient_context (server EHR + // for authenticated users, or the client-injected block for + // guests). Scan the final reply for any forbidden drug name and, + // if found, prepend a structured allergy-override card AND + // strike-through the offending drug name in-line. This is the + // second line of defence the system prompt cannot provide — + // even when the LLM ignores the allergy instruction, the user + // never sees an unmarked recommendation of a drug they're + // allergic to. + const userAllergies = extractAllergies({ + patient_context: patientContext, + user_message: rawUserContent, + }); + if (userAllergies.length > 0 && finalContent) { + const guard = scanForAllergyViolation( + finalContent, + userAllergies, + emergencyInfo.emergency, + ); + if (guard.violated) { + console.warn( + `[Chat] route.allergy.violation ${JSON.stringify({ + userId: user?.id || null, + allergies: userAllergies, + hits: guard.hits, + })}`, + ); + finalContent = guard.warning_card_chunk + '\n\n' + guard.annotated_reply; + } + } + + console.log( + `[Chat] route.safety.postCheck ${JSON.stringify({ + userId: user?.id || null, + riskClass, + filterFires: post.audit.filterFires, + modified: post.audit.modified, + blocked: post.audit.blocked, + totalMs: Date.now() - routeStartedAt, + })}`, + ); + + const encoder = new TextEncoder(); + const safeStream = new ReadableStream({ + start(controller) { + const data = JSON.stringify({ + choices: [{ delta: { content: finalContent } }], + provider: providerResponse.provider, + model: providerResponse.model, + riskClass, + filtered: post.audit.modified, + isEmergency, + ruleFires: emergencyRuleFires, + }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + if (user) { + auditLog({ + userId: user.id, + action: 'chat', + ip, + meta: { + model: providerResponse.model, + provider: providerResponse.provider, + countryCode, + turns: messages.length, + patientContextChars: patientContext.length, + riskClass, + ruleFires: safetyDecision?.audit.ruleFires ?? [], + filterFires: post.audit.filterFires, + filterModified: post.audit.modified, + filterBlocked: post.audit.blocked, + }, + }); + } + + return new Response(safeStream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } catch (error) { + console.error( + `[Chat] route.error ${JSON.stringify({ + userId: user?.id || null, + totalMs: Date.now() - routeStartedAt, + name: (error as any)?.name, + message: String((error as any)?.message || error).slice(0, 200), + })}`, + ); + + if (error instanceof z.ZodError) { + return new Response( + JSON.stringify({ error: 'Invalid request', details: error.errors }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // When every LLM provider has failed we surface a 503 with a + // plain-language message. useChat shows this verbatim in the chat + // bubble; the proxy + 503 status also lets the frontend's existing + // backend-availability handling kick in. + if (error instanceof AllProvidersUnavailableError) { + return new Response( + JSON.stringify({ + error: error.message, + code: 'all_providers_unavailable', + }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +} diff --git a/app/api/geo/route.ts b/app/api/geo/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..e32a05e8715e96a1f7246b314b5bed8e17ebd978 --- /dev/null +++ b/app/api/geo/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from 'next/server'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * IP-based country + language + emergency number detection. + * + * Privacy posture: + * - Platform geo headers are read first (zero external calls, zero PII). + * - If nothing is present we fall back to ipapi.co (free, no key), but + * ONLY for public IPs — RFC1918, loopback, and link-local are never + * sent outbound. + * - The client IP is never logged or returned. + */ + +const GEO_HEADERS = [ + 'x-vercel-ip-country', + 'cf-ipcountry', + 'x-nf-country', + 'cloudfront-viewer-country', + 'x-appengine-country', + 'fly-client-ip-country', + 'x-forwarded-country', +] as const; + +// Country → best-effort primary language out of the ones MedOS ships. +// Kept local to this file so we don't bloat lib/i18n for a single use. +const COUNTRY_TO_LANGUAGE: Record = { + US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en', + NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en', + PK: 'en', BD: 'en', LK: 'en', PH: 'en', + ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es', + EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es', + SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es', + BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt', + FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr', + CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar', + DE: 'de', AT: 'de', CH: 'de', LI: 'de', + IT: 'it', SM: 'it', VA: 'it', + NL: 'nl', SR: 'nl', + PL: 'pl', + RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru', + TR: 'tr', + SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar', + YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar', + PS: 'ar', + CN: 'zh', TW: 'zh', HK: 'zh', + JP: 'ja', + KR: 'ko', + TH: 'th', + VN: 'vi', + TZ: 'sw', +}; + +function pickHeaderCountry(req: Request): string | null { + for (const h of GEO_HEADERS) { + const v = req.headers.get(h); + if (v && v.length >= 2 && v.toUpperCase() !== 'XX') { + return v.toUpperCase().slice(0, 2); + } + } + return null; +} + +function extractClientIp(req: Request): string | null { + const xff = req.headers.get('x-forwarded-for'); + if (xff) { + const first = xff.split(',')[0]?.trim(); + if (first) return first; + } + return req.headers.get('x-real-ip'); +} + +function isPrivateIp(ip: string): boolean { + if (!ip) return true; + if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true; + const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + const a = parseInt(m[1], 10); + const b = parseInt(m[2], 10); + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + return false; +} + +async function lookupIpapi(ip: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1500); + const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, { + signal: controller.signal, + headers: { 'User-Agent': 'MedOS-Geo/1.0' }, + }); + clearTimeout(timeout); + if (!res.ok) return null; + const text = (await res.text()).trim().toUpperCase(); + if (/^[A-Z]{2}$/.test(text)) return text; + return null; + } catch { + return null; + } +} + +export async function GET(req: Request): Promise { + let country = pickHeaderCountry(req); + let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default'; + + if (!country) { + const ip = extractClientIp(req); + if (ip && !isPrivateIp(ip)) { + const looked = await lookupIpapi(ip); + if (looked) { + country = looked; + source = 'ipapi'; + } + } + } + + const finalCountry = country || 'US'; + const info = getEmergencyInfo(finalCountry); + const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en'; + + return NextResponse.json( + { + country: finalCountry, + language, + emergencyNumber: info.emergency, + source, + }, + { + headers: { + 'Cache-Control': 'private, max-age=3600', + 'X-Robots-Tag': 'noindex', + }, + }, + ); +} diff --git a/app/api/health-data/route.ts b/app/api/health-data/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b2a1147310434d768b10ce9af18c50261a03e0a --- /dev/null +++ b/app/api/health-data/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { encodeHealthPayload, decodeHealthPayload } from '@/lib/health-data-repo'; + +/** + * GET /api/health-data → fetch all health data for the user + * GET /api/health-data?type=vital → filter by type + * POST /api/health-data/sync → bulk sync from client localStorage + */ +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const db = getDb(); + const url = new URL(req.url); + const type = url.searchParams.get('type'); + + const rows = type + ? db + .prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC') + .all(user.id, type) + : db + .prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC') + .all(user.id); + + // Decrypt (or pass through legacy plaintext) the `data` field for each row. + const items = (rows as any[]).map((r) => ({ + id: r.id, + type: r.type, + data: decodeHealthPayload(r.data), + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + + return NextResponse.json({ items }); +} + +/** + * POST /api/health-data — upsert a single health-data record. + */ +const UpsertSchema = z.object({ + id: z.string().optional(), + type: z.enum([ + 'medication', + 'medication_log', + 'appointment', + 'vital', + 'record', + 'conversation', + ]), + data: z.record(z.any()), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { id, type, data } = UpsertSchema.parse(body); + + const db = getDb(); + const itemId = id || genId(); + const payload = encodeHealthPayload(data); + + // Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly. + db.prepare( + `INSERT INTO health_data (id, user_id, type, data, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`, + ).run(itemId, user.id, type, payload); + + return NextResponse.json({ id: itemId, type }, { status: 201 }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Health Data POST]', error?.message); + return NextResponse.json({ error: 'Save failed' }, { status: 500 }); + } +} + +/** + * DELETE /api/health-data?id= — delete one record. + */ +export async function DELETE(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(req.url); + const id = url.searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const db = getDb(); + db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run( + id, + user.id, + ); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/health-data/sync/route.ts b/app/api/health-data/sync/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..903f9f15dba77f0493c8744676b9073dcdd3b103 --- /dev/null +++ b/app/api/health-data/sync/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getDb, genId } from '@/lib/db'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { encodeHealthPayload } from '@/lib/health-data-repo'; + +/** + * POST /api/health-data/sync — bulk sync from client localStorage. + * + * The client sends its entire localStorage health dataset (medications, + * appointments, vitals, records, medication_logs, conversations). The + * server upserts each item. This runs on: + * - First login (migrates existing guest data to the account) + * - Periodic background sync while logged in + * + * Idempotent: calling it twice with the same data is safe. + */ + +const ItemSchema = z.object({ + id: z.string(), + type: z.enum([ + 'medication', + 'medication_log', + 'appointment', + 'vital', + 'record', + 'conversation', + ]), + data: z.record(z.any()), +}); + +const SyncSchema = z.object({ + items: z.array(ItemSchema).max(5000), +}); + +export async function POST(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const body = await req.json(); + const { items } = SyncSchema.parse(body); + + const db = getDb(); + + const upsert = db.prepare( + `INSERT INTO health_data (id, user_id, type, data, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`, + ); + + // Run as a single transaction for speed (1000+ items in <50ms). + // Each payload is AES-256-GCM encrypted by encodeHealthPayload(). + const tx = db.transaction(() => { + for (const item of items) { + upsert.run(item.id, user.id, item.type, encodeHealthPayload(item.data)); + } + }); + tx(); + + return NextResponse.json({ + synced: items.length, + message: `${items.length} items synced`, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + console.error('[Health Data Sync]', error?.message); + return NextResponse.json({ error: 'Sync failed' }, { status: 500 }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..72cb1d23dab16204a55ddf882fb9d0754caafb83 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ + status: 'healthy', + service: 'medos-global', + timestamp: new Date().toISOString(), + version: '1.0.0', + }); +} diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..50beeb32319c53bf2ac5749ead76accf6c363538 --- /dev/null +++ b/app/api/models/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { fetchAvailableModels } from '@/lib/providers/ollabridge-models'; + +export async function GET() { + try { + const models = await fetchAvailableModels(); + return NextResponse.json({ models }); + } catch { + return NextResponse.json( + { models: [], error: 'Failed to fetch models' }, + { status: 200 } // Return 200 with empty array — non-critical endpoint + ); + } +} diff --git a/app/api/nearby/route.ts b/app/api/nearby/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7f8571994d98cca954a749476aca2149c18abf8 --- /dev/null +++ b/app/api/nearby/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from 'next/server'; + +/** + * POST /api/nearby — Proxy to MetaEngine Nearby Finder. + * GET /api/nearby — Health check. + * + * Calls the Gradio API endpoint (2-step: submit → fetch result). + * Handles sleeping Spaces, timeouts, and Overpass errors gracefully. + */ + +const NEARBY_URL = + process.env.NEARBY_URL || 'https://ruslanmv-metaengine-nearby.hf.space'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const { lat, lon, radius_m = 3000, entity_type = 'all', limit = 25 } = body; + + // Step 1: Submit to Gradio API + const submitRes = await fetch(`${NEARBY_URL}/gradio_api/call/search_ui`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: [String(lat), String(lon), radius_m, entity_type, limit], + }), + signal: AbortSignal.timeout(30000), + }); + + if (!submitRes.ok) { + const ct = submitRes.headers.get('content-type') || ''; + if (!ct.includes('json')) { + return NextResponse.json( + { error: 'Nearby finder is waking up. Please try again in a moment.', count: 0, results: [] }, + { status: 503 }, + ); + } + return NextResponse.json({ error: 'Search submission failed', count: 0, results: [] }, { status: 502 }); + } + + const { event_id } = await submitRes.json(); + if (!event_id) { + return NextResponse.json({ error: 'No event ID received', count: 0, results: [] }, { status: 502 }); + } + + // Step 2: Fetch result via SSE + const resultRes = await fetch( + `${NEARBY_URL}/gradio_api/call/search_ui/${event_id}`, + { signal: AbortSignal.timeout(30000) }, + ); + + const text = await resultRes.text(); + + // Parse SSE data line: "data: [summary, table, json_string]" + const dataLine = text.split('\n').find((l: string) => l.startsWith('data: ')); + if (!dataLine) { + return NextResponse.json({ error: 'Empty response from search', count: 0, results: [] }); + } + + const gradioData = JSON.parse(dataLine.slice(6)); + // gradioData = [summary_text, table_array, json_string] + const jsonStr = gradioData?.[2]; + if (!jsonStr) { + return NextResponse.json({ error: 'No results', count: 0, results: [] }); + } + + try { + const parsed = JSON.parse(jsonStr); + return NextResponse.json(parsed); + } catch { + // If the json_str is an error message, return it + return NextResponse.json({ error: jsonStr, count: 0, results: [] }); + } + } catch (error: any) { + console.error('[Nearby Proxy]', error?.name, error?.message?.slice(0, 100)); + const msg = + error?.name === 'TimeoutError' || error?.name === 'AbortError' + ? 'Search timed out. The service may be starting up — please try again.' + : 'Nearby finder unavailable. Please try again.'; + return NextResponse.json({ error: msg, count: 0, results: [] }, { status: 502 }); + } +} + +export async function GET() { + try { + const res = await fetch(NEARBY_URL, { signal: AbortSignal.timeout(8000) }); + if (res.ok) return NextResponse.json({ status: 'ok' }); + return NextResponse.json({ status: 'waking' }, { status: 503 }); + } catch { + return NextResponse.json({ status: 'sleeping' }, { status: 503 }); + } +} diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25337551143cda6008c18f805697b8b84012a3c3 --- /dev/null +++ b/app/api/og/route.tsx @@ -0,0 +1,207 @@ +import { ImageResponse } from 'next/og'; + +export const runtime = 'edge'; + +/** + * Dynamic Open Graph image endpoint. + * + * Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage + * renders a branded 1200x630 card. The query becomes the card title so + * a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews + * as a premium, unique image instead of the default favicon blob. + * + * Usage from the client: `/api/og?q=&lang=` + * The endpoint also handles missing parameters gracefully (returns a + * default brand card). + */ +export async function GET(req: Request): Promise { + try { + const { searchParams } = new URL(req.url); + const rawQuery = (searchParams.get('q') || '').trim(); + const lang = (searchParams.get('lang') || 'en').slice(0, 5); + + // Hard-limit title length so long queries don't overflow. + const title = + rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery; + + const subtitle = title + ? 'Ask MedOS — free, private, in your language' + : 'Free AI medical assistant — 20 languages, no sign-up'; + + const headline = title || 'Tell me what\'s bothering you.'; + + return new ImageResponse( + ( +
+ {/* Top bar: brand mark + language chip */} +
+
+
+ ♥ +
+
+
+ MedOS +
+
+ Worldwide medical AI +
+
+
+ +
+ + {lang.toUpperCase()} · FREE · NO SIGN-UP +
+
+ + {/* Main content */} +
+ {title && ( +
+ Ask MedOS +
+ )} +
+ {title ? `"${headline}"` : headline} +
+
+ {subtitle} +
+
+ + {/* Footer: trust strip */} +
+ + ✓ Aligned with WHO · CDC · NHS + + · + Private & anonymous + · + 24/7 +
+
+ ), + { + width: 1200, + height: 630, + }, + ); + } catch { + // Never 500 an OG endpoint — social crawlers will blacklist the domain. + return new Response('OG image generation failed', { status: 500 }); + } +} diff --git a/app/api/rag/route.ts b/app/api/rag/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2d675273b86e6b43ebcdd8db6f418289ece99a9 --- /dev/null +++ b/app/api/rag/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { searchMedicalKB } from '@/lib/rag/medical-kb'; + +export async function POST(request: NextRequest) { + try { + const { query, topN = 3 } = await request.json(); + + if (!query || typeof query !== 'string') { + return NextResponse.json( + { error: 'Query is required' }, + { status: 400 } + ); + } + + const results = searchMedicalKB(query, topN); + + return NextResponse.json({ + results: results.map((r) => ({ + topic: r.topic, + context: r.context, + })), + count: results.length, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/scan/route.ts b/app/api/scan/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8fe5574c23e305c5a45e038166f5419fa2d67f8 --- /dev/null +++ b/app/api/scan/route.ts @@ -0,0 +1,194 @@ +import { NextResponse } from 'next/server'; +import { loadConfig } from '@/lib/server-config'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; +import { auditLog } from '@/lib/audit'; +import { getDb, genId } from '@/lib/db'; + +/** + * POST /api/scan — Server-side proxy to the Medicine Scanner Space. + * + * Why proxy instead of calling from the browser: + * - HF_TOKEN_INFERENCE stays server-side (never in the JS bundle) + * - Same-origin request from the browser (no CORS preflight) + * - Backend injects the token and forwards to the Scanner Space + * - If the Scanner Space is sleeping, this request wakes it + * + * Isolation & accounting (added in PNF10): + * - Authentication is REQUIRED by default. Operators can flip + * SCAN_REQUIRE_AUTH=false to keep the legacy open behaviour while + * migrating, but anonymous traffic is then capped at 5 scans/hour + * per IP. + * - Authenticated users get 30 scans/hour each (per-user key). + * - Every call writes one scan_log row (status, bytes, latency, model) + * so admins can detect abuse on the shared HF inference quota. + * - Authenticated calls also append an audit_log('scan') entry. + * + * The Scanner Space receives: + * - The image as multipart/form-data (passthrough) + * - Authorization: Bearer header with the inference token + * - Returns structured JSON with medicine data + */ + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function logScan( + userId: string | null, + ip: string | null, + status: number, + bytes: number, + latencyMs: number, + model: string | null, +): void { + try { + const db = getDb(); + db.prepare( + `INSERT INTO scan_log (id, user_id, ip, status, bytes, latency_ms, model) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(genId(), userId, ip, status, bytes, latencyMs, model); + } catch (e: any) { + console.error('[Scan] log failed:', e?.message); + } +} + +export async function POST(req: Request) { + const startedAt = Date.now(); + const ip = getClientIp(req); + const user = authenticateRequest(req); + + // Auth gate. Default-on; opt-out via SCAN_REQUIRE_AUTH=false for migration. + const authRequired = (process.env.SCAN_REQUIRE_AUTH || 'true') !== 'false'; + if (authRequired && !user) { + return NextResponse.json( + { + success: false, + error: 'Authentication required to scan medicines.', + medicine: null, + }, + { status: 401 }, + ); + } + + // Per-identity quota. Authenticated users are tracked by id (stable across + // IPs), anonymous fallback by IP (only reachable with SCAN_REQUIRE_AUTH=false). + const limitKey = user ? `scan:user:${user.id}` : `scan:ip:${ip}`; + const limitMax = user ? 30 : 5; + const limit = checkRateLimit(limitKey, limitMax, 60 * 60 * 1000); + if (!limit.allowed) { + logScan(user?.id || null, ip, 429, 0, Date.now() - startedAt, null); + return NextResponse.json( + { + success: false, + error: 'Scan quota exceeded. Try again later.', + retryAfterMs: limit.retryAfterMs, + }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)) }, + }, + ); + } + + // Resolve provider config (env at boot, admin overrides via /data/medos-config.json). + const cfg = loadConfig(); + const token = cfg.llm.hfTokenInference; + const scannerUrl = cfg.llm.scannerUrl; + + if (!token) { + console.error('[Scan] HF_TOKEN_INFERENCE is not configured'); + logScan(user?.id || null, ip, 503, 0, Date.now() - startedAt, null); + return NextResponse.json( + { + success: false, + error: + 'Medicine scanner is not configured. Ask the administrator to set HF_TOKEN_INFERENCE.', + medicine: null, + }, + { status: 503 }, + ); + } + + try { + // Read the incoming form data (image file from the frontend). + const formData = await req.formData(); + + // Best-effort byte accounting for usage reporting. + let bytes = 0; + for (const [, v] of formData.entries()) { + if (v instanceof Blob) bytes += v.size; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + + // Forward to the Medicine Scanner Space. + const response = await fetch(`${scannerUrl}/api/scan`, { + method: 'POST', + headers, + body: formData, + }); + + const data = await response.json().catch(() => ({} as any)); + const latency = Date.now() - startedAt; + + logScan( + user?.id || null, + ip, + response.status, + bytes, + latency, + (data as any)?.model || null, + ); + + if (user) { + auditLog({ + userId: user.id, + action: 'scan', + ip, + meta: { status: response.status, bytes, latencyMs: latency }, + }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + const latency = Date.now() - startedAt; + console.error('[Scan Proxy]', error?.message); + logScan(user?.id || null, ip, 502, 0, latency, null); + return NextResponse.json( + { + success: false, + error: 'Medicine scanner unavailable. Please try again.', + medicine: null, + }, + { status: 502 }, + ); + } +} + +/** + * GET /api/scan/health — Check if the Scanner Space is awake. + * Used by the frontend to show "waking up" status. + */ +export async function GET() { + const cfg = loadConfig(); + const scannerUrl = cfg.llm.scannerUrl; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(`${scannerUrl}/api/health`, { + signal: controller.signal, + }); + clearTimeout(timeout); + + if (res.ok) { + const data = await res.json(); + return NextResponse.json(data); + } + return NextResponse.json({ status: 'unavailable' }, { status: 503 }); + } catch { + return NextResponse.json({ status: 'sleeping' }, { status: 503 }); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..33c0158647314083869979235622a8678f8410a0 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +/** + * Server-side session counter. + * Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart). + * On HF Spaces with persistent storage, use /data/ instead of /tmp/. + * + * GET /api/sessions → returns { count: number } + * POST /api/sessions → increments and returns { count: number } + */ + +const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data'; +const COUNTER_FILE = join(DATA_DIR, 'sessions.json'); +const BASE_COUNT = 423000; // Historical base from before server-side tracking + +interface CounterData { + count: number; + lastUpdated: string; +} + +function ensureDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function readCounter(): number { + ensureDir(); + try { + if (existsSync(COUNTER_FILE)) { + const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8')); + return data.count; + } + } catch { + // corrupted file, reset + } + return 0; +} + +function incrementCounter(): number { + ensureDir(); + const current = readCounter(); + const next = current + 1; + const data: CounterData = { + count: next, + lastUpdated: new Date().toISOString(), + }; + writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8'); + return next; +} + +export async function GET() { + const sessionCount = readCounter(); + return NextResponse.json({ + count: BASE_COUNT + sessionCount, + sessions: sessionCount, + base: BASE_COUNT, + }); +} + +export async function POST() { + const sessionCount = incrementCounter(); + return NextResponse.json({ + count: BASE_COUNT + sessionCount, + sessions: sessionCount, + base: BASE_COUNT, + }); +} diff --git a/app/api/triage/route.ts b/app/api/triage/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..68b1c18de133939e8f99ebfd222ea46597a23afa --- /dev/null +++ b/app/api/triage/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { triageMessage } from '@/lib/safety/triage'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; + +export async function POST(request: NextRequest) { + try { + const { message, countryCode = 'US' } = await request.json(); + + if (!message || typeof message !== 'string') { + return NextResponse.json( + { error: 'Message is required' }, + { status: 400 } + ); + } + + const triage = triageMessage(message); + const emergencyInfo = getEmergencyInfo(countryCode); + + return NextResponse.json({ + ...triage, + emergencyInfo: triage.isEmergency ? emergencyInfo : null, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/api/user/settings/route.ts b/app/api/user/settings/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f6f3de3b1157344eec5db99c015c7b5cf4d6410 --- /dev/null +++ b/app/api/user/settings/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { authenticateRequest } from '@/lib/auth-middleware'; +import { getUserSettings, upsertUserSettings } from '@/lib/user-settings'; +import { auditLog } from '@/lib/audit'; +import { getClientIp } from '@/lib/rate-limit'; +import { redact } from '@/lib/crypto'; + +/** + * Per-user settings API. + * + * GET /api/user/settings — returns this user's preferences + EHR profile. + * The BYO Hugging Face token is NEVER returned + * in plaintext; the response carries only a + * redacted preview ('••••HiJ') and a + * hasHfToken boolean. The decrypted token is + * used in-process only by the LLM provider + * chain (added in a follow-up batch). + * + * PUT /api/user/settings — partial patch. Field semantics for `hfToken`: + * omit → leave token unchanged + * "" → clear stored token + * "hf_xxx" → rotate to new value (encrypted) + * + * Every successful PUT writes an audit_log('settings_update') entry that + * lists the changed field NAMES only — never values. + */ + +export const runtime = 'nodejs'; + +export async function GET(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const s = getUserSettings(user.id); + + return NextResponse.json({ + settings: { + language: s.language ?? null, + country: s.country ?? null, + units: s.units ?? null, + defaultModel: s.defaultModel ?? null, + theme: s.theme ?? null, + ehr: s.ehr ?? {}, + hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null, + hasHfToken: !!s.hfToken, + }, + }); +} + +const PutSchema = z.object({ + language: z.string().min(2).max(8).optional(), + country: z.string().min(2).max(4).optional(), + units: z.enum(['metric', 'imperial']).optional(), + defaultModel: z.string().max(100).optional(), + theme: z.enum(['light', 'dark', 'auto']).optional(), + // EHR is a free-form bag (the wizard owns its shape) but bounded. + ehr: z.record(z.any()).optional(), + // Empty string clears the token, undefined leaves it untouched. + hfToken: z.string().max(200).optional(), +}); + +export async function PUT(req: Request) { + const user = authenticateRequest(req); + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + let parsed; + try { + const body = await req.json(); + parsed = PutSchema.parse(body); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + // Reject pathological EHR payloads to keep row size sane. + if (parsed.ehr && JSON.stringify(parsed.ehr).length > 32_000) { + return NextResponse.json( + { error: 'EHR payload too large (max 32 KB).' }, + { status: 413 }, + ); + } + + try { + upsertUserSettings(user.id, parsed); + } catch (error: any) { + console.error('[User Settings PUT]', error?.message); + return NextResponse.json({ error: 'Save failed' }, { status: 500 }); + } + + auditLog({ + userId: user.id, + action: 'settings_update', + ip: getClientIp(req), + meta: { + fields: Object.keys(parsed), + tokenRotated: parsed.hfToken !== undefined, + ehrFieldsChanged: parsed.ehr ? Object.keys(parsed.ehr) : [], + }, + }); + + // Return the fresh, redacted view so the client can update its cache. + const s = getUserSettings(user.id); + return NextResponse.json({ + success: true, + settings: { + language: s.language ?? null, + country: s.country ?? null, + units: s.units ?? null, + defaultModel: s.defaultModel ?? null, + theme: s.theme ?? null, + ehr: s.ehr ?? {}, + hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null, + hasHfToken: !!s.hfToken, + }, + }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..2cc064cf4c203cbed9d219e4dc3a899a3e0c88b5 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,261 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ============================================================ + * MedOS design tokens — light + dark + * ============================================================ */ +:root { + /* Surfaces (light mode — soft white, never pure #FFFFFF) */ + --surface-0: 247 249 251; /* app backdrop #F7F9FB */ + --surface-1: 255 255 255; /* cards */ + --surface-2: 241 245 249; /* elevated panels #F1F5F9 */ + --surface-3: 226 232 240; /* borders / rails */ + + --ink-base: 15 23 42; /* slate-900 */ + --ink-muted: 71 85 105; /* slate-600 */ + --ink-subtle: 148 163 184; /* slate-400 */ + --ink-inverse: 255 255 255; + + --line: 226 232 240; /* slate-200 */ + + color-scheme: light; +} + +.dark { + /* Dark mode — warm deep navy, NOT pure black */ + --surface-0: 11 18 32; /* #0B1220 */ + --surface-1: 18 27 45; /* #121B2D elevated card */ + --surface-2: 24 34 54; /* #182236 panel */ + --surface-3: 34 46 71; /* #222E47 border */ + + --ink-base: 241 245 249; /* slate-100 */ + --ink-muted: 148 163 184; /* slate-400 */ + --ink-subtle: 100 116 139; /* slate-500 */ + --ink-inverse: 15 23 42; + + --line: 34 46 71; + + color-scheme: dark; +} + +@layer base { + html, + body { + @apply h-full w-full; + /* iOS Safari 100vh fix: dvh accounts for the collapsible address bar. + Falls back to 100vh for browsers that don't support dvh. */ + height: 100vh; + height: 100dvh; + } + + html { + font-family: var(--font-sans), Inter, ui-sans-serif, system-ui, -apple-system, + "Segoe UI", Roboto, sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply text-ink-base antialiased; + background: theme("backgroundImage.light-app"); + background-attachment: fixed; + line-height: 1.6; + letter-spacing: -0.005em; + } + + .dark body { + background: theme("backgroundImage.dark-app"); + background-attachment: fixed; + } + + /* Slightly larger, more readable body copy — medical trust */ + p { line-height: 1.65; } + + /* Focus rings that are visible in both modes */ + :focus-visible { + outline: 2px solid rgb(var(--color-brand, 59 130 246)); + outline-offset: 2px; + border-radius: 8px; + } +} + +@layer components { + .glass-card { + @apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft; + } + .glass-strong { + @apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card; + } + /* Section headings inside an AI answer (Summary / Self-care …) */ + .answer-section { + @apply relative pl-4 mt-4 first:mt-0; + } + .answer-section::before { + content: ""; + @apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60; + } +} + +@layer utilities { + .animate-in { animation: fadeIn 0.5s ease-in-out; } + .slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; } + .delay-100 { animation-delay: 100ms; } + .delay-200 { animation-delay: 200ms; } + + /* Shimmer utility for the "Analyzing…" typing state */ + .shimmer-text { + background: linear-gradient( + 90deg, + rgb(var(--ink-muted) / 0.5) 0%, + rgb(var(--ink-base)) 50%, + rgb(var(--ink-muted) / 0.5) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer 2.2s linear infinite; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideInFromBottom { + from { opacity: 0; transform: translateY(1rem); } + to { opacity: 1; transform: translateY(0); } + } +} + +/* ------------------------------------------------------------ + * Dark-mode compatibility layer for legacy screens that still + * reference slate/white utility classes directly. Remaps them to + * the design-token surfaces so Settings/Records/Schedule/Topics/ + * Emergency look right in dark mode without a full rewrite. + * New components should prefer the `bg-surface-*` and `text-ink-*` + * tokens directly and won't be affected by these rules. + * ------------------------------------------------------------ */ +.dark .bg-white { background-color: rgb(var(--surface-1)) !important; } +.dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; } +.dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; } +.dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; } +.dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; } + +.dark .text-slate-900 { color: rgb(var(--ink-base)) !important; } +.dark .text-slate-800 { color: rgb(var(--ink-base)) !important; } +.dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; } +.dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; } +.dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; } +.dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; } +.dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; } + +.dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; } +.dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; } +.dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; } + +.dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); } + +/* Soft tinted surfaces used on a handful of views (blue-50, rose-50, + * amber-50, etc.) — in dark mode dim them into brand/accent tints. */ +.dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; } +.dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; } +.dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; } +.dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; } +.dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; } +.dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; } +.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; } +.dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; } + +.dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; } +.dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; } +.dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; } +.dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; } + +/* Scrollbars — subtle in both modes */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: rgb(var(--ink-subtle) / 0.35); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--ink-subtle) / 0.55); + background-clip: padding-box; +} + +::selection { + background: rgba(59, 130, 246, 0.22); + color: inherit; +} + +/* Safe area for mobile bottom nav */ +.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } + +/* RTL support for Arabic */ +[dir="rtl"] .flex { direction: rtl; } + +/* Large tap targets */ +@media (pointer: coarse) { + button, a, select, input, textarea { min-height: 44px; } +} + +/* ============================================================ + * Mobile-first utilities + * ============================================================ */ + +/* Dynamic viewport height — works on iOS Safari, Android Chrome, + and every modern browser. Falls back to 100vh. */ +.h-screen-safe { + height: 100vh; + height: 100dvh; +} + +/* Sticky input bar that stays above the mobile keyboard. + Uses env(keyboard-inset-height) on supporting browsers and + falls back to standard sticky positioning elsewhere. */ +.sticky-bottom-keyboard { + position: sticky; + bottom: 0; + bottom: env(keyboard-inset-height, 0px); +} + +/* Prevent iOS input zoom — any input below 16px triggers a zoom. + We force 16px minimum on touch devices and compensate with + transforms where we need visually-smaller text. */ +@media (pointer: coarse) { + input, textarea, select { + font-size: 16px !important; + } +} + +/* iOS momentum scrolling */ +.scroll-touch { + -webkit-overflow-scrolling: touch; +} + +/* Bottom padding spacer for content that sits above a fixed bottom nav. + The 5.5rem accounts for the nav height + safe-area-inset-bottom. */ +.pb-mobile-nav { + padding-bottom: 5.5rem; +} +@media (min-width: 768px) { + .pb-mobile-nav { + padding-bottom: 0; + } +} + +/* Respect reduced-motion preferences everywhere */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..65b2d8f213cc15a39403b630de541c795801c372 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a04ec40653f36e043b5d95441945d0be6c38d17 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,78 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-sans", +}); + +export const metadata: Metadata = { + title: "MedOS — your worldwide medical assistant", + description: + "Tell MedOS what's bothering you. Instant, private, multilingual health guidance aligned with WHO, CDC, and NHS.", + keywords: ["medical AI", "healthcare", "chatbot", "telemedicine", "WHO", "CDC"], + authors: [{ name: "MedOS Team" }], + manifest: "/manifest.webmanifest", + icons: { + icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], + shortcut: "/favicon.svg", + }, + openGraph: { + title: "MedOS — your worldwide medical assistant", + description: + "Private, multilingual health guidance aligned with WHO, CDC, and NHS — available 24/7.", + type: "website", + }, + robots: { index: true, follow: true }, + appleWebApp: { + capable: true, + title: "MedOS", + }, + other: { + "mobile-web-app-capable": "yes", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 5, + userScalable: true, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#F7F9FB" }, + { media: "(prefers-color-scheme: dark)", color: "#0B1220" }, + ], +}; + +/** + * Inline pre-hydration script: reads the stored theme before first paint. + */ +const themeBootstrap = ` +(function() { + try { + var stored = localStorage.getItem('medos_theme'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + var isDark = stored === 'dark' || (stored === 'system' && prefersDark); + if (isDark) document.documentElement.classList.add('dark'); + document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; + } catch (e) {} +})(); +`; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +