diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..cb31444afc433f9d92a8be9cc85929a354372d24 --- /dev/null +++ b/.env.example @@ -0,0 +1,93 @@ +# ============================================================ +# 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 --- +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/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4d68e8f99e51bf93fea5ff4975444364fcd9400e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +out/ +.env +.env.local +*.tsbuildinfo 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/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..f5ad723e455d362aff898a033c4f454c7fc97c3e --- /dev/null +++ b/Makefile @@ -0,0 +1,195 @@ +# ============================================================================= +# MedOS Global - Makefile +# Free AI Medical Assistant for Hugging Face Spaces +# ============================================================================= + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +# Directories +APP_DIR := 9-HuggingFace-Global +NODE_MODULES := $(APP_DIR)/node_modules + +# Colors +GREEN := \033[0;32m +YELLOW := \033[0;33m +RED := \033[0;31m +NC := \033[0m + +# ============================================================================= +# Help +# ============================================================================= + +.PHONY: help +help: ## Show this help message + @echo "" + @echo " MedOS Global - Development Commands" + @echo " ====================================" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}' + @echo "" + +# ============================================================================= +# Installation +# ============================================================================= + +.PHONY: install +install: ## Install all dependencies + @echo "$(GREEN)Installing dependencies...$(NC)" + cd $(APP_DIR) && npm install + @echo "$(GREEN)Dependencies installed successfully.$(NC)" + +.PHONY: install-ci +install-ci: ## Install dependencies for CI (clean, reproducible) + @echo "$(GREEN)Installing dependencies (CI mode)...$(NC)" + cd $(APP_DIR) && npm ci + @echo "$(GREEN)CI dependencies installed.$(NC)" + +.PHONY: clean +clean: ## Remove node_modules and build artifacts + @echo "$(YELLOW)Cleaning build artifacts...$(NC)" + rm -rf $(APP_DIR)/node_modules + rm -rf $(APP_DIR)/.next + rm -rf $(APP_DIR)/out + @echo "$(GREEN)Clean complete.$(NC)" + +# ============================================================================= +# Quality Checks +# ============================================================================= + +.PHONY: lint +lint: ## Run ESLint + @echo "$(GREEN)Running linter...$(NC)" + cd $(APP_DIR) && npx next lint + +.PHONY: type-check +type-check: ## Run TypeScript type checking + @echo "$(GREEN)Running type checker...$(NC)" + cd $(APP_DIR) && npx tsc --noEmit + +.PHONY: format-check +format-check: ## Check code formatting + @echo "$(GREEN)Checking code formatting...$(NC)" + cd $(APP_DIR) && npx prettier --check "**/*.{ts,tsx,js,json,css}" 2>/dev/null || echo "Prettier not configured - skipping" + +# ============================================================================= +# Testing +# ============================================================================= + +.PHONY: test +test: test-unit test-structure test-providers test-i18n test-safety ## Run all tests + @echo "$(GREEN)All tests passed!$(NC)" + +.PHONY: test-unit +test-unit: ## Run unit tests + @echo "$(GREEN)Running unit tests...$(NC)" + cd $(APP_DIR) && node --experimental-vm-modules ../tests/9-hf-global/run-tests.js + +.PHONY: test-structure +test-structure: ## Verify folder structure and required files exist + @echo "$(GREEN)Verifying folder structure...$(NC)" + @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1) + @test -f $(APP_DIR)/package.json || (echo "$(RED)FAIL: package.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/next.config.js || (echo "$(RED)FAIL: next.config.js missing$(NC)" && exit 1) + @test -f $(APP_DIR)/tsconfig.json || (echo "$(RED)FAIL: tsconfig.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/tailwind.config.ts || (echo "$(RED)FAIL: tailwind.config.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/README.md || (echo "$(RED)FAIL: README.md missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/layout.tsx || (echo "$(RED)FAIL: app/layout.tsx missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/page.tsx || (echo "$(RED)FAIL: app/page.tsx missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/globals.css || (echo "$(RED)FAIL: app/globals.css missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/chat/route.ts || (echo "$(RED)FAIL: chat API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/triage/route.ts || (echo "$(RED)FAIL: triage API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/app/api/health/route.ts || (echo "$(RED)FAIL: health API route missing$(NC)" && exit 1) + @test -f $(APP_DIR)/public/manifest.json || (echo "$(RED)FAIL: manifest.json missing$(NC)" && exit 1) + @test -f $(APP_DIR)/public/sw.js || (echo "$(RED)FAIL: sw.js missing$(NC)" && exit 1) + @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1) + @echo "$(GREEN) Structure check passed (15/15 files verified).$(NC)" + +.PHONY: test-providers +test-providers: ## Test provider modules exist and export correctly + @echo "$(GREEN)Verifying provider modules...$(NC)" + @test -f $(APP_DIR)/lib/providers/index.ts || (echo "$(RED)FAIL: providers/index.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/ollabridge.ts || (echo "$(RED)FAIL: ollabridge.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/huggingface-direct.ts || (echo "$(RED)FAIL: huggingface-direct.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/cached-faq.ts || (echo "$(RED)FAIL: cached-faq.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/providers/ollabridge-models.ts || (echo "$(RED)FAIL: ollabridge-models.ts missing$(NC)" && exit 1) + @echo "$(GREEN) Provider modules verified (5/5).$(NC)" + +.PHONY: test-i18n +test-i18n: ## Verify i18n translations exist (synced or in web/ source) + @echo "$(GREEN)Verifying i18n...$(NC)" + @if test -f $(APP_DIR)/lib/i18n.ts; then \ + echo "$(GREEN) Found synced lib/i18n.ts$(NC)"; \ + elif test -f web/lib/i18n.ts; then \ + echo "$(GREEN) Found web/lib/i18n.ts (source of truth)$(NC)"; \ + else \ + echo "$(RED)FAIL: i18n.ts not found in lib/ or web/lib/$(NC)" && exit 1; \ + fi + +.PHONY: test-safety +test-safety: ## Verify safety modules exist + @echo "$(GREEN)Verifying safety modules...$(NC)" + @test -f $(APP_DIR)/lib/safety/triage.ts || (echo "$(RED)FAIL: triage.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/safety/emergency-numbers.ts || (echo "$(RED)FAIL: emergency-numbers.ts missing$(NC)" && exit 1) + @test -f $(APP_DIR)/lib/safety/disclaimer.ts || (echo "$(RED)FAIL: disclaimer.ts missing$(NC)" && exit 1) + @echo "$(GREEN) Safety modules verified (3/3).$(NC)" + +# ============================================================================= +# Build +# ============================================================================= + +.PHONY: build +build: ## Build the Next.js application + @echo "$(GREEN)Building application...$(NC)" + cd $(APP_DIR) && npm run build + @echo "$(GREEN)Build complete.$(NC)" + +.PHONY: docker-build +docker-build: ## Build Docker image + @echo "$(GREEN)Building Docker image...$(NC)" + cd $(APP_DIR) && docker build -t medos-global . + @echo "$(GREEN)Docker image built successfully.$(NC)" + +.PHONY: docker-run +docker-run: ## Run Docker container locally + @echo "$(GREEN)Starting MedOS on http://localhost:7860...$(NC)" + cd $(APP_DIR) && docker run -p 7860:7860 \ + -e OLLABRIDGE_URL=$${OLLABRIDGE_URL:-https://ruslanmv-ollabridge-cloud.hf.space} \ + -e HF_TOKEN=$${HF_TOKEN:-} \ + medos-global + +# ============================================================================= +# Development +# ============================================================================= + +.PHONY: dev +dev: ## Start development server on port 7860 + @echo "$(GREEN)Starting dev server on http://localhost:7860...$(NC)" + cd $(APP_DIR) && npm run dev + +.PHONY: start +start: ## Start production server on port 7860 + cd $(APP_DIR) && npm run start + +# ============================================================================= +# Deployment +# ============================================================================= + +.PHONY: deploy-hf +deploy-hf: ## Deploy to Hugging Face Spaces (requires HF_TOKEN) + @test -n "$(HF_TOKEN)" || (echo "$(RED)ERROR: HF_TOKEN is required. Set it with: make deploy-hf HF_TOKEN=hf_...$(NC)" && exit 1) + @echo "$(GREEN)Deploying to Hugging Face Spaces...$(NC)" + @bash scripts/deploy-hf.sh $(HF_TOKEN) + +# ============================================================================= +# CI Pipeline (combines all checks) +# ============================================================================= + +.PHONY: ci +ci: install-ci test build ## Full CI pipeline: install, test, build + @echo "$(GREEN)CI pipeline passed successfully!$(NC)" + +.PHONY: check +check: test-structure test-providers test-i18n test-safety ## Quick check (no install needed) + @echo "$(GREEN)All structural checks passed!$(NC)" 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..8d565eab6868e71289d41ee8e788cab0d2c7e3aa --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + Users, + Activity, + Database, + MessageCircle, + Shield, + Search, + Trash2, + ChevronLeft, + ChevronRight, + RefreshCw, + LogIn, + Lock, +} 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; +} + +/** + * 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 [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; } + // Verify this user is actually an admin. + const meRes = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${data.token}` }, + }); + const me = await meRes.json(); + if (!me.user) { setLoginError('Auth failed'); return; } + // Check admin flag by trying the admin API. + 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

+
+ +
+ +
+ {/* Stats grid */} + {stats && ( +
+ + + + + + +
+ )} + + {/* Health data breakdown */} + {stats && stats.healthBreakdown.length > 0 && ( +
+

Health data by type

+
+ {stats.healthBreakdown.map((b) => ( + + {b.type}: {b.count} + + ))} +
+
+ )} + + {/* User management */} +
+
+

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'} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} +
+
+
+ ); +} + +function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) { + return ( +
+ +
{value.toLocaleString()}
+
{label}
+
+ ); +} 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..86cb04c9befae34a319b823b260b2a43e198031b --- /dev/null +++ b/app/api/admin/llm-health/route.ts @@ -0,0 +1,127 @@ +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. + */ + +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', +]; + +async function testModel(model: string, token: string): Promise<{ + model: string; + status: 'ok' | 'error'; + latencyMs: number; + response?: string; + error?: string; + httpStatus?: number; +}> { + 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), + }; + } +} + +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 || ''; + + if (!token) { + // Still return a well-formed response so the UI can render an empty-state + // Provider Status panel with a helpful error banner. + return NextResponse.json({ + error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.', + models: MODELS_TO_TEST.map((model) => ({ + model, + status: 'error' as const, + latencyMs: 0, + error: 'No HF token configured', + })), + summary: { + total: MODELS_TO_TEST.length, + ok: 0, + error: MODELS_TO_TEST.length, + testedAt: new Date().toISOString(), + }, + }); + } + + // Test all models in parallel for speed. + const results = await Promise.all( + MODELS_TO_TEST.map((model) => testModel(model, token)) + ); + + 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/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..323f65449be86114da466c318d46deb73251f5d6 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,320 @@ +import { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { chatWithFallback, type ChatMessage } from '@/lib/providers'; +import { getEmergencyInfo } from '@/lib/safety/emergency-numbers'; +import { preCheck, postCheck } from '@/lib/safety/safety-engine'; +import { snapshotFlags } from '@/lib/feature-flags'; + +// 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 || ''; + const cleanUserContent = stripInjectedPatientContext(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, + })}`, + ); + + if (safetyDecision.kind === 'emergency_template') { + // Capture the narrowed values before entering the ReadableStream + // callback — discriminated-union narrowing on `safetyDecision` + // does not survive into the inner closure under strict TS. + const emergencyTemplate = safetyDecision.template; + const emergencyRuleFires = safetyDecision.audit.ruleFires; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const data = JSON.stringify({ + choices: [{ delta: { content: emergencyTemplate } }], + provider: 'safety-engine', + model: 'emergency-template', + isEmergency: true, + riskClass: 'R5', + }); + 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: { + riskClass: 'R5', + ruleFires: emergencyRuleFires, + countryCode, + model: 'emergency-template', + }, + }); + } + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); + } + } + + // 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); + const baseSystemPrompt = buildMedicalSystemPrompt({ + country: countryCode, + language, + emergencyNumber: emergencyInfo.emergency, + }); + const systemPrompt = + safetyDecision && safetyDecision.kind === 'allow_llm' + ? `${baseSystemPrompt}\n\n${safetyDecision.systemInstructions}` + : baseSystemPrompt; + + // 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 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. + // + // This adds end-to-end latency relative to mid-stream display, but it is + // the only honest way to enforce the safety contract (SAFETY.md). UX + // optimisations (server-side chunking of the filtered output) can + // happen in a follow-up without changing the safety guarantee. + const providerResponse = await chatWithFallback(augmentedMessages, model); + + const riskClass = safetyDecision?.kind === 'allow_llm' + ? safetyDecision.riskClass + : 'R0'; + + const post = postCheck({ + response: providerResponse.content, + riskClass, + emergency: emergencyInfo, + }); + + 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: post.filtered } }], + provider: providerResponse.provider, + model: providerResponse.model, + riskClass, + filtered: post.audit.modified, + }); + 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' } } + ); + } + + 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..0dea31359bbab2177f1579406d75ffc131b33a80 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,378 @@ +@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, + /* CJK fallbacks (Chinese, Japanese, Korean) */ + "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans", "MS Gothic", + "Malgun Gothic", "Apple SD Gothic Neo", + /* Arabic */ + "Noto Sans Arabic", "Geeza Pro", "Tahoma", + /* Devanagari (Hindi) */ + "Noto Sans Devanagari", + /* Thai */ + "Noto Sans Thai", + /* System fallbacks */ + 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); } + } + + /* Medicine scanner viewfinder — pulsing green border */ + @keyframes scannerPulse { + 0%, 100% { border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } + 50% { border-color: rgba(34, 197, 94, 1); box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.15); } + } + @keyframes scanLine { + 0% { top: 8%; } + 50% { top: 88%; } + 100% { top: 8%; } + } + @keyframes scanSuccess { + 0% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); } + 50% { transform: scale(1.02); border-color: rgba(34, 197, 94, 1); } + 100% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); } + } + @keyframes scanProgress { + 0% { width: 0%; } + 100% { width: 100%; } + } + .animate-scanner-pulse { animation: scannerPulse 2s ease-in-out infinite; } + .animate-scan-line { animation: scanLine 2.5s ease-in-out infinite; } + .animate-scan-success { animation: scanSuccess 0.6s ease-out; } + .animate-scan-progress { animation: scanProgress 3s ease-out forwards; } +} + +/* ------------------------------------------------------------ + * 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 utilities for notched phones (iPhone X+, Android gesture nav) */ +.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } +.pb-safe-area { padding-bottom: env(safe-area-inset-bottom, 0px); } +.safe-area-top { padding-top: env(safe-area-inset-top, 0px); } +.px-safe-area { + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); +} + +/* ============================================================ + * RTL (Right-to-Left) support for Arabic (ar) and Urdu (ur) + * Set via document.documentElement.dir = "rtl" in MedOSApp.tsx + * ============================================================ */ +[dir="rtl"] { + /* Base direction */ + direction: rtl; + text-align: right; +} + +/* Flex containers reverse in RTL */ +[dir="rtl"] .flex { direction: rtl; } + +/* Fix drawer slide direction (left → right for RTL) */ +[dir="rtl"] aside { left: auto; right: 0; } +[dir="rtl"] .-translate-x-full { transform: translateX(100%); } +[dir="rtl"] .translate-x-0 { transform: translateX(0); } + +/* Flip margins and paddings for common patterns */ +[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; } +[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; } +[dir="rtl"] .text-left { text-align: right; } +[dir="rtl"] .text-right { text-align: left; } + +/* Fix icon + text alignment in nav items */ +[dir="rtl"] .gap-2 { gap: 0.5rem; } +[dir="rtl"] .gap-3 { gap: 0.75rem; } + +/* Scrollbar on left side for RTL */ +[dir="rtl"] ::-webkit-scrollbar { direction: rtl; } + +/* Fix rounded corners (swap left/right) */ +[dir="rtl"] .rounded-tl-sm { border-top-left-radius: 0; border-top-right-radius: 0.125rem; } +[dir="rtl"] .rounded-tr-sm { border-top-right-radius: 0; border-top-left-radius: 0.125rem; } + +/* 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; + } +} + +/* CJK word-break — prevents overflow for Chinese/Japanese/Korean text */ +:lang(zh), :lang(ja), :lang(ko) { + word-break: break-all; + overflow-wrap: break-word; +} + +/* 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; + } +} + +/* ============================================================ + * Print styles — professional PDF export via window.print() + * Hides navigation, maximizes content, clean typography + * ============================================================ */ +@media print { + /* Hide navigation, headers, footers */ + aside, nav, header, footer, + .safe-area-bottom, .pb-mobile-nav, + button, [role="navigation"], + .md\:hidden, .lg\:flex { display: none !important; } + + /* Full width, no sidebar */ + body { background: white !important; color: black !important; font-size: 12pt; } + .flex-1 { width: 100% !important; max-width: 100% !important; } + .overflow-hidden { overflow: visible !important; } + .overflow-y-auto { overflow: visible !important; } + + /* Clean card borders for print */ + .bg-surface-1, .bg-surface-0 { background: white !important; } + .border { border-color: #ddd !important; } + .shadow-soft, .shadow-card, .shadow-glow { box-shadow: none !important; } + .rounded-2xl { border-radius: 8px !important; } + + /* Keep colors readable */ + .text-ink-base { color: #111 !important; } + .text-ink-muted { color: #555 !important; } + .text-brand-500, .text-brand-600 { color: #2563eb !important; } + .text-danger-500 { color: #dc2626 !important; } + .text-success-500 { color: #16a34a !important; } + + /* Page breaks */ + .stat-card, .rounded-2xl { break-inside: avoid; } + h2, h3 { break-after: avoid; } + + /* Print header */ + @page { margin: 1.5cm; size: A4; } +} 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 ( + + +