github-actions[bot] commited on
Commit
3bbe317
·
0 Parent(s):

Deploy MedOS Global from cbd72928

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +106 -0
  2. Dockerfile +56 -0
  3. README.md +106 -0
  4. app/admin/page.tsx +593 -0
  5. app/api/admin/audit/route.ts +84 -0
  6. app/api/admin/config/route.ts +142 -0
  7. app/api/admin/email-status/route.ts +30 -0
  8. app/api/admin/fetch-models/route.ts +620 -0
  9. app/api/admin/llm-health/route.ts +219 -0
  10. app/api/admin/medical-flow/route.ts +53 -0
  11. app/api/admin/reset-password/route.ts +62 -0
  12. app/api/admin/stats/route.ts +46 -0
  13. app/api/admin/system-info/route.ts +130 -0
  14. app/api/admin/test-connection/route.ts +360 -0
  15. app/api/admin/users/[id]/route.ts +204 -0
  16. app/api/admin/users/route.ts +78 -0
  17. app/api/auth/delete-account/route.ts +80 -0
  18. app/api/auth/forgot-password/route.ts +46 -0
  19. app/api/auth/login/route.ts +88 -0
  20. app/api/auth/logout/route.ts +14 -0
  21. app/api/auth/me/route.ts +184 -0
  22. app/api/auth/register/route.ts +79 -0
  23. app/api/auth/resend-verification/route.ts +30 -0
  24. app/api/auth/reset-password/route.ts +63 -0
  25. app/api/auth/verify-email/route.ts +58 -0
  26. app/api/chat-history/route.ts +99 -0
  27. app/api/chat/route.ts +735 -0
  28. app/api/geo/route.ts +142 -0
  29. app/api/health-data/route.ts +114 -0
  30. app/api/health-data/sync/route.ts +77 -0
  31. app/api/health/route.ts +10 -0
  32. app/api/models/route.ts +14 -0
  33. app/api/nearby/route.ts +94 -0
  34. app/api/og/route.tsx +207 -0
  35. app/api/rag/route.ts +30 -0
  36. app/api/scan/route.ts +194 -0
  37. app/api/sessions/route.ts +70 -0
  38. app/api/triage/route.ts +29 -0
  39. app/api/user/settings/route.ts +126 -0
  40. app/globals.css +261 -0
  41. app/icon.svg +10 -0
  42. app/layout.tsx +78 -0
  43. app/manifest.ts +43 -0
  44. app/page.tsx +2 -0
  45. app/robots.ts +23 -0
  46. app/sitemap.ts +27 -0
  47. app/stats/page.tsx +245 -0
  48. app/symptoms/[slug]/page.tsx +237 -0
  49. app/symptoms/page.tsx +86 -0
  50. components/MedOSApp.tsx +560 -0
.env.example ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # MedOS HuggingFace Space — full backend configuration
3
+ # ============================================================
4
+ #
5
+ # All values below are *bootstrap defaults*. Once the Space is running,
6
+ # every secret marked (admin-rotatable) can also be edited from
7
+ # Admin -> Server in the UI and is then persisted to /data/medos-config.json,
8
+ # which survives restarts.
9
+ # ============================================================
10
+
11
+ # --- LLM providers ---
12
+ #
13
+ # Fallback chain (lib/providers/index.ts):
14
+ # 1. Groq — primary. Sub-second TTFT, llama-3.3-70b-versatile.
15
+ # 2. OllaBridge — secondary. Multi-provider gateway with its own
16
+ # Groq/Gemini/OpenRouter/HF/local-ollama routing.
17
+ # 3. HF Inference — tertiary. Mostly 402 on the free tier today; kept
18
+ # as a last resort.
19
+ # Set at least one (Groq is recommended). All three may be set.
20
+
21
+ # Groq — get a key at https://console.groq.com/keys (free tier).
22
+ GROQ_API_KEY= # admin-rotatable
23
+ GROQ_MODEL=llama-3.3-70b-versatile # optional override
24
+
25
+ OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space
26
+ OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here # admin-rotatable
27
+ HF_TOKEN=hf_your-token-here # admin-rotatable
28
+ DEFAULT_MODEL=free-best
29
+
30
+ # --- Database (SQLite, persistent on HF Spaces /data/) ---
31
+ # SQLite is the fallback driver. To run against Postgres (production), set
32
+ # DATABASE_URL below — SQLite at DB_PATH is then ignored.
33
+ DB_PATH=/data/medos.db
34
+
35
+ # --- Database (Postgres, production primary) ---
36
+ # When set and starting with postgres:// or postgresql://, the runtime
37
+ # uses Postgres instead of SQLite. Neon: use the pooler endpoint.
38
+ # Example:
39
+ # postgresql://USER:PASS@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require
40
+ #
41
+ # Unset → SQLite at DB_PATH (development).
42
+ # Unset AND NODE_ENV=production → the server refuses to start.
43
+ #
44
+ # No other DB knobs are needed; SSL, pool size, and statement timeout
45
+ # all have sensible defaults baked in.
46
+ DATABASE_URL=
47
+
48
+ # --- Admin seed ---
49
+ # First-run admin user. seedAdmin() creates this account on first boot.
50
+ # Do NOT leave ADMIN_PASSWORD at the legacy "admin123456" default in
51
+ # production.
52
+ ADMIN_EMAIL=admin@medos.health
53
+ ADMIN_PASSWORD=
54
+
55
+ # --- Email (verification + password reset) ---
56
+ # Pick ONE transport. They're tried in this order at runtime:
57
+ # 1. RESEND_API_KEY → Resend HTTP API (recommended on serverless)
58
+ # 2. SMTP_HOST + SMTP_USER + SMTP_PASS → nodemailer SMTP
59
+ # 3. (nothing) → emails are logged to stdout only — they never
60
+ # reach a real inbox. Useful for local dev, NEVER
61
+ # leave this state in production (this is the
62
+ # exact state that causes "Account created! Check
63
+ # your email" with no email ever arriving).
64
+ #
65
+ # Resend setup: https://resend.com → API Keys → create → paste below.
66
+ # Until you verify a sending domain, FROM_EMAIL must use onboarding@resend.dev
67
+ # (Resend rejects other senders for unverified domains).
68
+ RESEND_API_KEY=
69
+ FROM_EMAIL=MedOS <onboarding@resend.dev>
70
+
71
+ # SMTP fallback (only used if RESEND_API_KEY is unset).
72
+ # SMTP_HOST=smtp.sendgrid.net
73
+ # SMTP_PORT=587
74
+ # SMTP_USER=apikey
75
+ # SMTP_PASS=
76
+
77
+ # Verify the active transport at runtime by hitting (as an admin):
78
+ # GET /api/admin/email-status
79
+
80
+ # Used in the password-reset email's "Reset password" link. Set to the
81
+ # canonical user-facing URL of your deployment (Vercel domain or HF Space URL).
82
+ APP_URL=https://ruslanmv-medibot.hf.space
83
+
84
+ # --- CORS (comma-separated Vercel frontend origins) ---
85
+ ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000
86
+
87
+ # --- Medicine Scanner proxy ---
88
+ # Token with "Make calls to Inference Providers" permission.
89
+ # Used SERVER-SIDE ONLY by /api/scan — never exposed to the browser, never
90
+ # returned in any HTTP response body. Per-user quota and audit are enforced
91
+ # server-side; see docs/USER_ISOLATION.md.
92
+ # Create at:
93
+ # https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained
94
+ HF_TOKEN_INFERENCE=hf_your-inference-token-here # admin-rotatable
95
+ SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space # admin-rotatable
96
+ NEARBY_URL=https://ruslanmv-metaengine-nearby.hf.space # admin-rotatable
97
+
98
+ # --- At-rest encryption key (for BYO user tokens, audit fields, etc.) ---
99
+ # Strongly recommended in production. If unset, falls back to a key derived
100
+ # from ADMIN_PASSWORD (development convenience only — NOT for production).
101
+ # Generate with: openssl rand -hex 32
102
+ # ENCRYPTION_KEY=
103
+
104
+ # --- Application ---
105
+ NODE_ENV=production
106
+ PORT=7860
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # MedOS HuggingFace Space — Production Dockerfile
3
+ #
4
+ # Enterprise architecture:
5
+ # web/ = frontend source of truth
6
+ # 9-HuggingFace-Global/ = backend + synced frontend
7
+ #
8
+ # Before deploying, run: bash scripts/sync-frontend.sh
9
+ # This copies web/ frontend, rewrites API paths, then you push.
10
+ # ============================================================
11
+
12
+ # Stage 1: Install dependencies
13
+ FROM node:18-alpine AS deps
14
+ WORKDIR /app
15
+ RUN apk add --no-cache python3 make g++
16
+ COPY package.json ./
17
+ RUN npm install --legacy-peer-deps && npm cache clean --force
18
+
19
+ # Stage 2: Build
20
+ FROM node:18-alpine AS builder
21
+ WORKDIR /app
22
+ COPY --from=deps /app/node_modules ./node_modules
23
+ COPY . .
24
+
25
+ ENV NEXT_TELEMETRY_DISABLED=1
26
+ ENV NODE_ENV=production
27
+
28
+ RUN npm run build
29
+
30
+ # Stage 3: Production runner
31
+ FROM node:18-alpine AS runner
32
+ WORKDIR /app
33
+
34
+ ENV NODE_ENV=production
35
+ ENV NEXT_TELEMETRY_DISABLED=1
36
+ ENV PORT=7860
37
+ ENV HOSTNAME=0.0.0.0
38
+
39
+ RUN addgroup --system --gid 1001 nodejs && \
40
+ adduser --system --uid 1001 nextjs
41
+
42
+ COPY --from=builder /app/public ./public
43
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
44
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
45
+ COPY --from=builder --chown=nextjs:nodejs /app/data ./data
46
+
47
+ RUN mkdir -p /data && chown nextjs:nodejs /data
48
+ ENV DB_PATH=/data/medos.db
49
+
50
+ USER nextjs
51
+ EXPOSE 7860
52
+
53
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
54
+ CMD wget --no-verbose --tries=1 --spider http://localhost:7860/api/health || exit 1
55
+
56
+ CMD ["node", "server.js"]
README.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "MediBot: Free AI Medical Assistant · 20 languages"
3
+ emoji: "\U0001F3E5"
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
+ license: apache-2.0
10
+ short_description: "Free AI medical chatbot. 20 languages. No sign-up."
11
+ tags:
12
+ - medical
13
+ - healthcare
14
+ - chatbot
15
+ - medical-ai
16
+ - health-assistant
17
+ - symptom-checker
18
+ - telemedicine
19
+ - who-guidelines
20
+ - cdc
21
+ - multilingual
22
+ - i18n
23
+ - rag
24
+ - llama-3.3
25
+ - llama-3.3-70b
26
+ - mixtral
27
+ - groq
28
+ - huggingface-inference
29
+ - pwa
30
+ - offline-first
31
+ - free
32
+ - no-signup
33
+ - privacy-first
34
+ - worldwide
35
+ - nextjs
36
+ - docker
37
+ models:
38
+ - meta-llama/Llama-3.3-70B-Instruct
39
+ - meta-llama/Meta-Llama-3-8B-Instruct
40
+ - mistralai/Mixtral-8x7B-Instruct-v0.1
41
+ - Qwen/Qwen2.5-72B-Instruct
42
+ - deepseek-ai/DeepSeek-V3
43
+ - ruslanmv/Medical-Llama3-8B
44
+ - google/gemma-2-9b-it
45
+ datasets:
46
+ - ruslanmv/ai-medical-chatbot
47
+ ---
48
+
49
+ # MediBot — free AI medical assistant, worldwide
50
+
51
+ > **Tell MediBot what's bothering you. In your language. Instantly. For free.**
52
+ > No sign-up. No paywall. No data retention. Aligned with WHO · CDC · NHS guidelines.
53
+
54
+ [![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)
55
+ [![Languages](https://img.shields.io/badge/languages-20-14B8A6?style=for-the-badge)](#)
56
+ [![Free](https://img.shields.io/badge/price-free_forever-22C55E?style=for-the-badge)](#)
57
+ [![No sign-up](https://img.shields.io/badge/account-not_required-3B82F6?style=for-the-badge)](#)
58
+
59
+ ## Why MediBot
60
+
61
+ - **Free forever.** No API key, no sign-up, no paywall, no ads.
62
+ - **20 languages, auto-detected.** English, Español, Français, Português, Deutsch, Italiano, العربية, हिन्दी, Kiswahili, 中文, 日本語, 한국어, Русский, Türkçe, Tiếng Việt, ไทย, বাংলা, اردو, Polski, Nederlands.
63
+ - **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).
64
+ - **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.
65
+ - **Grounded on WHO, CDC, NHS, NIH, ICD-11, BNF, EMA.** A structured system prompt aligns every answer with authoritative guidance.
66
+ - **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.
67
+ - **Installable PWA.** Add to your phone's home screen and use it like a native app. Offline-capable with a cached FAQ fallback.
68
+ - **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.
69
+ - **Private & anonymous.** Zero accounts. Zero server-side conversation storage. No IPs logged. Anonymous session counter only.
70
+ - **Open source.** Fully transparent. [github.com/ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot)
71
+
72
+ ## How it works
73
+
74
+ 1. You type (or speak) a health question
75
+ 2. MedOS checks for emergency red flags first
76
+ 3. It searches a medical knowledge base for relevant context
77
+ 4. Your question + context go to **Llama 3.3 70B** (via Groq, free)
78
+ 5. You get a structured answer: Summary, Possible causes, Self-care, When to see a doctor
79
+
80
+ If the main model is busy, MedOS automatically tries other free models until one responds.
81
+
82
+ ## Built with
83
+
84
+ | Layer | Technology |
85
+ |---|---|
86
+ | Frontend | Next.js 14, React, Tailwind CSS |
87
+ | AI Model | Llama 3.3 70B Instruct (via HuggingFace Inference + Groq) |
88
+ | Fallbacks | Mixtral 8x7B, OllaBridge, cached FAQ |
89
+ | Knowledge | Medical RAG from [ruslanmv/ai-medical-chatbot](https://github.com/ruslanmv/ai-medical-chatbot) dataset |
90
+ | Gateway | [OllaBridge-Cloud](https://github.com/ruslanmv/ollabridge) |
91
+ | Hosting | HuggingFace Spaces (Docker) |
92
+
93
+ ## License
94
+
95
+ Apache 2.0 — free to use, modify, and distribute.
96
+
97
+ ## MedOS Family family mode
98
+
99
+ This branch adds an additive first version of the MedOS Family family layer:
100
+
101
+ - `lib/family-health.ts` — local-first family tree, MedOS modes, invites, monthly records
102
+ - `lib/hooks/useFamilyHealth.ts` — React hook for family state
103
+ - `components/views/FamilyHealthView.tsx` — Family Admin / MedOS Family dashboard
104
+ - Sidebar integration through the new **MedOS Family** navigation item
105
+
106
+ The MVP keeps data local-first and prepares for the contracts documented in `../13-MedOS-Family/02-contracts/`.
app/admin/page.tsx ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import {
5
+ Users,
6
+ Activity,
7
+ Database,
8
+ MessageCircle,
9
+ Shield,
10
+ Search,
11
+ Trash2,
12
+ ChevronLeft,
13
+ ChevronRight,
14
+ RefreshCw,
15
+ LogIn,
16
+ Lock,
17
+ Cloud,
18
+ CheckCircle2,
19
+ XCircle,
20
+ Loader2,
21
+ } from 'lucide-react';
22
+
23
+ interface Stats {
24
+ totalUsers: number;
25
+ verifiedUsers: number;
26
+ adminUsers: number;
27
+ totalHealthData: number;
28
+ totalChats: number;
29
+ activeSessions: number;
30
+ healthBreakdown: Array<{ type: string; count: number }>;
31
+ registrations: Array<{ day: string; count: number }>;
32
+ }
33
+
34
+ interface UserRow {
35
+ id: string;
36
+ email: string;
37
+ displayName: string | null;
38
+ emailVerified: boolean;
39
+ isAdmin: boolean;
40
+ createdAt: string;
41
+ healthDataCount: number;
42
+ chatHistoryCount: number;
43
+ }
44
+
45
+ type Tab = 'users' | 'ollabridge';
46
+
47
+ /**
48
+ * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space.
49
+ * Not linked from the public UI. Requires admin login.
50
+ */
51
+ export default function AdminPage() {
52
+ const [token, setToken] = useState('');
53
+ const [loggedIn, setLoggedIn] = useState(false);
54
+ const [email, setEmail] = useState('');
55
+ const [password, setPassword] = useState('');
56
+ const [loginError, setLoginError] = useState('');
57
+ const [tab, setTab] = useState<Tab>('users');
58
+ const [stats, setStats] = useState<Stats | null>(null);
59
+ const [users, setUsers] = useState<UserRow[]>([]);
60
+ const [total, setTotal] = useState(0);
61
+ const [page, setPage] = useState(1);
62
+ const [search, setSearch] = useState('');
63
+ const [loading, setLoading] = useState(false);
64
+
65
+ const headers = useCallback(
66
+ () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }),
67
+ [token],
68
+ );
69
+
70
+ const fetchStats = useCallback(async () => {
71
+ const res = await fetch('/api/admin/stats', { headers: headers() });
72
+ if (res.ok) setStats(await res.json());
73
+ else if (res.status === 403) { setLoggedIn(false); setToken(''); }
74
+ }, [headers]);
75
+
76
+ const fetchUsers = useCallback(async () => {
77
+ setLoading(true);
78
+ const qs = new URLSearchParams({ page: String(page), limit: '20' });
79
+ if (search) qs.set('search', search);
80
+ const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() });
81
+ if (res.ok) {
82
+ const data = await res.json();
83
+ setUsers(data.users);
84
+ setTotal(data.total);
85
+ }
86
+ setLoading(false);
87
+ }, [headers, page, search]);
88
+
89
+ useEffect(() => {
90
+ if (!loggedIn) return;
91
+ fetchStats();
92
+ fetchUsers();
93
+ }, [loggedIn, fetchStats, fetchUsers]);
94
+
95
+ const handleLogin = async () => {
96
+ setLoginError('');
97
+ const res = await fetch('/api/auth/login', {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ email, password }),
101
+ });
102
+ const data = await res.json();
103
+ if (!res.ok) { setLoginError(data.error || 'Login failed'); return; }
104
+ const meRes = await fetch('/api/auth/me', {
105
+ headers: { Authorization: `Bearer ${data.token}` },
106
+ });
107
+ const me = await meRes.json();
108
+ if (!me.user) { setLoginError('Auth failed'); return; }
109
+ const adminCheck = await fetch('/api/admin/stats', {
110
+ headers: { Authorization: `Bearer ${data.token}` },
111
+ });
112
+ if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; }
113
+ setToken(data.token);
114
+ setLoggedIn(true);
115
+ };
116
+
117
+ const handleDeleteUser = async (userId: string, userEmail: string) => {
118
+ if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return;
119
+ await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() });
120
+ fetchUsers();
121
+ fetchStats();
122
+ };
123
+
124
+ // Login screen
125
+ if (!loggedIn) {
126
+ return (
127
+ <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
128
+ <div className="w-full max-w-sm">
129
+ <div className="text-center mb-8">
130
+ <div className="w-14 h-14 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
131
+ <Lock size={24} className="text-white" />
132
+ </div>
133
+ <h1 className="text-2xl font-bold text-slate-100">Admin Panel</h1>
134
+ <p className="text-sm text-slate-400 mt-1">MedOS server administration</p>
135
+ </div>
136
+ {loginError && (
137
+ <div className="mb-4 p-3 rounded-xl bg-red-950/50 border border-red-700/50 text-sm text-red-300">
138
+ {loginError}
139
+ </div>
140
+ )}
141
+ <div className="space-y-3">
142
+ <input
143
+ type="email"
144
+ value={email}
145
+ onChange={(e) => setEmail(e.target.value)}
146
+ placeholder="Admin email"
147
+ 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"
148
+ />
149
+ <input
150
+ type="password"
151
+ value={password}
152
+ onChange={(e) => setPassword(e.target.value)}
153
+ placeholder="Password"
154
+ 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"
155
+ onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
156
+ />
157
+ <button
158
+ onClick={handleLogin}
159
+ className="w-full py-3 bg-gradient-to-br from-red-500 to-orange-500 text-white rounded-xl font-bold text-sm hover:brightness-110 transition-all flex items-center justify-center gap-2"
160
+ >
161
+ <LogIn size={16} />
162
+ Sign in as Admin
163
+ </button>
164
+ </div>
165
+ </div>
166
+ </div>
167
+ );
168
+ }
169
+
170
+ const totalPages = Math.ceil(total / 20);
171
+
172
+ return (
173
+ <div className="min-h-screen bg-slate-950 text-slate-100">
174
+ <header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
175
+ <div className="flex items-center gap-3">
176
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
177
+ <Shield size={16} className="text-white" />
178
+ </div>
179
+ <h1 className="font-bold text-lg">MedOS Admin</h1>
180
+ </div>
181
+ <button
182
+ onClick={() => { fetchStats(); fetchUsers(); }}
183
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 text-slate-300 text-xs font-semibold hover:bg-slate-700"
184
+ >
185
+ <RefreshCw size={12} /> Refresh
186
+ </button>
187
+ </header>
188
+
189
+ <nav className="border-b border-slate-800 px-6">
190
+ <div className="max-w-6xl mx-auto flex gap-1">
191
+ <TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" />
192
+ <TabButton active={tab === 'ollabridge'} onClick={() => setTab('ollabridge')} icon={Cloud} label="OllaBridge" />
193
+ </div>
194
+ </nav>
195
+
196
+ <main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
197
+ {tab === 'users' && (
198
+ <>
199
+ {/* Stats grid */}
200
+ {stats && (
201
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
202
+ <Stat icon={Users} label="Total users" value={stats.totalUsers} />
203
+ <Stat icon={Shield} label="Verified" value={stats.verifiedUsers} />
204
+ <Stat icon={Shield} label="Admins" value={stats.adminUsers} color="text-red-400" />
205
+ <Stat icon={Database} label="Health records" value={stats.totalHealthData} />
206
+ <Stat icon={MessageCircle} label="Conversations" value={stats.totalChats} />
207
+ <Stat icon={Activity} label="Active sessions" value={stats.activeSessions} />
208
+ </div>
209
+ )}
210
+
211
+ {stats && stats.healthBreakdown.length > 0 && (
212
+ <div className="mb-8 p-4 rounded-2xl bg-slate-900 border border-slate-800">
213
+ <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Health data by type</h3>
214
+ <div className="flex flex-wrap gap-2">
215
+ {stats.healthBreakdown.map((b) => (
216
+ <span key={b.type} className="px-3 py-1.5 rounded-full bg-slate-800 text-sm font-medium text-slate-200">
217
+ {b.type}: <strong>{b.count}</strong>
218
+ </span>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ )}
223
+
224
+ <div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden">
225
+ <div className="p-4 border-b border-slate-800 flex items-center gap-3">
226
+ <h2 className="font-bold">Users ({total})</h2>
227
+ <div className="flex-1 relative ml-4">
228
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
229
+ <input
230
+ value={search}
231
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
232
+ placeholder="Search by email or name..."
233
+ 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"
234
+ />
235
+ </div>
236
+ </div>
237
+
238
+ <div className="overflow-x-auto">
239
+ <table className="w-full text-sm">
240
+ <thead>
241
+ <tr className="text-xs text-slate-400 uppercase tracking-wider border-b border-slate-800">
242
+ <th className="text-left px-4 py-3">User</th>
243
+ <th className="text-center px-4 py-3">Verified</th>
244
+ <th className="text-center px-4 py-3">Role</th>
245
+ <th className="text-center px-4 py-3">Health</th>
246
+ <th className="text-center px-4 py-3">Chats</th>
247
+ <th className="text-left px-4 py-3">Joined</th>
248
+ <th className="text-right px-4 py-3">Actions</th>
249
+ </tr>
250
+ </thead>
251
+ <tbody>
252
+ {users.map((u) => (
253
+ <tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
254
+ <td className="px-4 py-3">
255
+ <div className="font-medium text-slate-100">{u.displayName || '—'}</div>
256
+ <div className="text-xs text-slate-400">{u.email}</div>
257
+ </td>
258
+ <td className="px-4 py-3 text-center">
259
+ <span className={`text-xs font-bold ${u.emailVerified ? 'text-emerald-400' : 'text-slate-500'}`}>
260
+ {u.emailVerified ? 'Yes' : 'No'}
261
+ </span>
262
+ </td>
263
+ <td className="px-4 py-3 text-center">
264
+ {u.isAdmin ? (
265
+ <span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-red-500/20 text-red-300 border border-red-500/30">
266
+ ADMIN
267
+ </span>
268
+ ) : (
269
+ <span className="text-xs text-slate-500">User</span>
270
+ )}
271
+ </td>
272
+ <td className="px-4 py-3 text-center text-slate-300">{u.healthDataCount}</td>
273
+ <td className="px-4 py-3 text-center text-slate-300">{u.chatHistoryCount}</td>
274
+ <td className="px-4 py-3 text-slate-400 text-xs">
275
+ {new Date(u.createdAt).toLocaleDateString()}
276
+ </td>
277
+ <td className="px-4 py-3 text-right">
278
+ {!u.isAdmin && (
279
+ <button
280
+ onClick={() => handleDeleteUser(u.id, u.email)}
281
+ className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
282
+ title="Delete user"
283
+ >
284
+ <Trash2 size={14} />
285
+ </button>
286
+ )}
287
+ </td>
288
+ </tr>
289
+ ))}
290
+ {users.length === 0 && (
291
+ <tr>
292
+ <td colSpan={7} className="px-4 py-8 text-center text-slate-500">
293
+ {loading ? 'Loading...' : 'No users found'}
294
+ </td>
295
+ </tr>
296
+ )}
297
+ </tbody>
298
+ </table>
299
+ </div>
300
+
301
+ {totalPages > 1 && (
302
+ <div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
303
+ <span className="text-xs text-slate-400">
304
+ Page {page} of {totalPages}
305
+ </span>
306
+ <div className="flex gap-1">
307
+ <button
308
+ onClick={() => setPage(Math.max(1, page - 1))}
309
+ disabled={page <= 1}
310
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
311
+ >
312
+ <ChevronLeft size={14} />
313
+ </button>
314
+ <button
315
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
316
+ disabled={page >= totalPages}
317
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
318
+ >
319
+ <ChevronRight size={14} />
320
+ </button>
321
+ </div>
322
+ </div>
323
+ )}
324
+ </div>
325
+ </>
326
+ )}
327
+
328
+ {tab === 'ollabridge' && <OllaBridgeTab headers={headers} />}
329
+ </main>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) {
335
+ return (
336
+ <div className="p-4 rounded-xl bg-slate-900 border border-slate-800">
337
+ <Icon size={16} className={color || 'text-slate-400'} />
338
+ <div className="text-2xl font-black mt-2">{value.toLocaleString()}</div>
339
+ <div className="text-[11px] text-slate-500 font-semibold">{label}</div>
340
+ </div>
341
+ );
342
+ }
343
+
344
+ function TabButton({
345
+ active, onClick, icon: Icon, label,
346
+ }: {
347
+ active: boolean;
348
+ onClick: () => void;
349
+ icon: any;
350
+ label: string;
351
+ }) {
352
+ return (
353
+ <button
354
+ onClick={onClick}
355
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-semibold transition-colors border-b-2 -mb-px ${
356
+ active
357
+ ? 'border-red-500 text-slate-100'
358
+ : 'border-transparent text-slate-400 hover:text-slate-200'
359
+ }`}
360
+ >
361
+ <Icon size={14} /> {label}
362
+ </button>
363
+ );
364
+ }
365
+
366
+ /**
367
+ * OllaBridge connection panel.
368
+ *
369
+ * Configures the URL and admin-minted API key (format `ob_…`) for the
370
+ * OllaBridge Cloud admin our chatbot fans requests out through. The
371
+ * actual key minting happens in the OllaBridge Cloud admin UI under
372
+ * "API Keys"; this tab just persists the chosen URL+key in MedOS so
373
+ * the chat route's provider chain can use it without a redeploy.
374
+ *
375
+ * Field semantics:
376
+ * - URL: OllaBridge Cloud base, e.g. https://ruslanmv-ollabridge.hf.space
377
+ * - API key: `ob_xxx` from the OllaBridge Cloud admin -> API Keys tab
378
+ * - Default alias: which OllaBridge model alias to request by default
379
+ * (e.g. `free-best`, `free-fast`, `qwen2.5:1.5b`). Mapped server-side
380
+ * to the request `model` parameter; consumer apps can override per call.
381
+ *
382
+ * The masked-secret protocol matches /api/admin/config: GET returns
383
+ * `••••••••` when a key is stored; PUT only updates when value !== that
384
+ * placeholder. So leaving the field unchanged after Save is idempotent.
385
+ */
386
+ const REDACTED = '••••••••';
387
+
388
+ function OllaBridgeTab({ headers }: { headers: () => Record<string, string> }) {
389
+ const [url, setUrl] = useState('');
390
+ const [apiKey, setApiKey] = useState('');
391
+ const [defaultModel, setDefaultModel] = useState('');
392
+ const [loading, setLoading] = useState(true);
393
+ const [saving, setSaving] = useState(false);
394
+ const [testing, setTesting] = useState(false);
395
+ const [testResult, setTestResult] = useState<null | { ok: boolean; msg: string; latencyMs?: number }>(null);
396
+ const [saveMsg, setSaveMsg] = useState('');
397
+
398
+ useEffect(() => {
399
+ (async () => {
400
+ const res = await fetch('/api/admin/config', { headers: headers() });
401
+ if (res.ok) {
402
+ const cfg = await res.json();
403
+ setUrl(cfg.llm?.ollabridgeUrl || '');
404
+ setApiKey(cfg.llm?.ollabridgeApiKey || ''); // will be '' if unset, REDACTED if set
405
+ setDefaultModel(cfg.llm?.hfDefaultModel || '');
406
+ }
407
+ setLoading(false);
408
+ })();
409
+ }, [headers]);
410
+
411
+ const handleSave = async () => {
412
+ setSaving(true);
413
+ setSaveMsg('');
414
+ const body: any = {
415
+ llm: {
416
+ ollabridgeUrl: url.trim(),
417
+ hfDefaultModel: defaultModel.trim(),
418
+ },
419
+ };
420
+ // Only send the key if it isn't the masked placeholder.
421
+ if (apiKey && apiKey !== REDACTED) body.llm.ollabridgeApiKey = apiKey.trim();
422
+ const res = await fetch('/api/admin/config', {
423
+ method: 'PUT',
424
+ headers: headers(),
425
+ body: JSON.stringify(body),
426
+ });
427
+ if (res.ok) {
428
+ setSaveMsg('Saved. Changes take effect on the next chat request — no restart needed.');
429
+ const cfg = await res.json();
430
+ setApiKey(cfg.config?.llm?.ollabridgeApiKey || '');
431
+ } else {
432
+ const err = await res.json().catch(() => ({}));
433
+ setSaveMsg(`Save failed: ${err.error || res.status}`);
434
+ }
435
+ setSaving(false);
436
+ };
437
+
438
+ const handleTest = async () => {
439
+ setTesting(true);
440
+ setTestResult(null);
441
+ // Send unmasked values if the operator just typed them; otherwise
442
+ // pass nothing so the route falls back to the saved config.
443
+ const body: any = { provider: 'ollabridge' };
444
+ if (url.trim()) body.url = url.trim();
445
+ if (apiKey && apiKey !== REDACTED) body.apiKey = apiKey.trim();
446
+ const res = await fetch('/api/admin/test-connection', {
447
+ method: 'POST',
448
+ headers: headers(),
449
+ body: JSON.stringify(body),
450
+ });
451
+ const data = await res.json().catch(() => ({}));
452
+ setTestResult({
453
+ ok: !!data.ok,
454
+ msg: data.ok
455
+ ? `Connected. ${data.details?.modelCount ?? '?'} model(s) available.`
456
+ : (data.error || `HTTP ${data.status || res.status}`),
457
+ latencyMs: data.latencyMs,
458
+ });
459
+ setTesting(false);
460
+ };
461
+
462
+ if (loading) {
463
+ return <div className="text-slate-400 text-sm">Loading current configuration…</div>;
464
+ }
465
+
466
+ return (
467
+ <div className="max-w-2xl space-y-6">
468
+ <div className="rounded-2xl bg-slate-900 border border-slate-800 p-6">
469
+ <div className="flex items-center gap-3 mb-2">
470
+ <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-violet-500 flex items-center justify-center">
471
+ <Cloud size={18} className="text-white" />
472
+ </div>
473
+ <div>
474
+ <h2 className="font-bold text-lg text-slate-100">OllaBridge Cloud</h2>
475
+ <p className="text-xs text-slate-400">
476
+ Fan out chat requests through the admin&apos;s multi-provider gateway
477
+ (Groq / Gemini / OpenRouter / HF / federated HomePilot nodes).
478
+ </p>
479
+ </div>
480
+ </div>
481
+
482
+ <p className="text-xs text-slate-500 mt-4 mb-5 leading-relaxed">
483
+ Paste an API key minted in the OllaBridge Cloud admin under{' '}
484
+ <span className="text-slate-300 font-mono">Admin → API Keys</span>. The key
485
+ looks like <span className="text-cyan-400 font-mono">ob_…</span>. MedOS sends
486
+ it as <span className="text-slate-300 font-mono">Authorization: Bearer …</span>{' '}
487
+ on every chat request; the OllaBridge Cloud then routes through whichever
488
+ provider its active routing profile selects (Speed, Production, etc.).
489
+ </p>
490
+
491
+ <div className="space-y-4">
492
+ <Field label="OllaBridge URL" hint="e.g. https://ruslanmv-ollabridge.hf.space">
493
+ <input
494
+ type="url"
495
+ value={url}
496
+ onChange={(e) => setUrl(e.target.value)}
497
+ placeholder="https://ruslanmv-ollabridge.hf.space"
498
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/50"
499
+ />
500
+ </Field>
501
+
502
+ <Field
503
+ label="API key"
504
+ hint={
505
+ apiKey === REDACTED
506
+ ? 'A key is saved. Leave masked to keep it; paste a new value to rotate.'
507
+ : 'Paste the ob_… key from OllaBridge Cloud admin.'
508
+ }
509
+ >
510
+ <input
511
+ type="password"
512
+ value={apiKey}
513
+ onChange={(e) => setApiKey(e.target.value)}
514
+ placeholder="ob_xxxxxxxxxxxxxxxxxx"
515
+ autoComplete="off"
516
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50"
517
+ />
518
+ </Field>
519
+
520
+ <Field
521
+ label="Default model alias"
522
+ hint="OllaBridge alias to request by default — free-best, free-fast, or any specific id."
523
+ >
524
+ <input
525
+ type="text"
526
+ value={defaultModel}
527
+ onChange={(e) => setDefaultModel(e.target.value)}
528
+ placeholder="free-best"
529
+ className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 font-mono focus:outline-none focus:ring-1 focus:ring-cyan-500/50"
530
+ />
531
+ </Field>
532
+
533
+ <div className="flex flex-wrap gap-2 pt-2">
534
+ <button
535
+ onClick={handleSave}
536
+ disabled={saving}
537
+ className="px-4 py-2 bg-gradient-to-br from-cyan-500 to-violet-500 text-white rounded-lg text-sm font-bold hover:brightness-110 disabled:opacity-50 flex items-center gap-2"
538
+ >
539
+ {saving && <Loader2 size={14} className="animate-spin" />}
540
+ Save
541
+ </button>
542
+ <button
543
+ onClick={handleTest}
544
+ disabled={testing || !url.trim()}
545
+ className="px-4 py-2 bg-slate-800 text-slate-200 rounded-lg text-sm font-bold hover:bg-slate-700 disabled:opacity-50 flex items-center gap-2"
546
+ >
547
+ {testing && <Loader2 size={14} className="animate-spin" />}
548
+ Test connection
549
+ </button>
550
+ </div>
551
+
552
+ {saveMsg && (
553
+ <div className="text-xs text-slate-400 pt-1">{saveMsg}</div>
554
+ )}
555
+ {testResult && (
556
+ <div
557
+ className={`text-sm rounded-lg p-3 flex items-start gap-2 ${
558
+ testResult.ok
559
+ ? 'bg-emerald-950/40 border border-emerald-700/40 text-emerald-200'
560
+ : 'bg-red-950/40 border border-red-700/40 text-red-200'
561
+ }`}
562
+ >
563
+ {testResult.ok
564
+ ? <CheckCircle2 size={16} className="mt-0.5 shrink-0" />
565
+ : <XCircle size={16} className="mt-0.5 shrink-0" />}
566
+ <div>
567
+ <div className="font-semibold">
568
+ {testResult.ok ? 'Connected' : 'Connection failed'}
569
+ {testResult.latencyMs !== undefined && (
570
+ <span className="font-normal text-xs opacity-70 ml-2">
571
+ ({testResult.latencyMs} ms)
572
+ </span>
573
+ )}
574
+ </div>
575
+ <div className="text-xs opacity-80 mt-0.5">{testResult.msg}</div>
576
+ </div>
577
+ </div>
578
+ )}
579
+ </div>
580
+ </div>
581
+ </div>
582
+ );
583
+ }
584
+
585
+ function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
586
+ return (
587
+ <label className="block">
588
+ <span className="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">{label}</span>
589
+ {children}
590
+ {hint && <span className="block text-[11px] text-slate-500 mt-1">{hint}</span>}
591
+ </label>
592
+ );
593
+ }
app/api/admin/audit/route.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { queryAudit, type AuditAction } from '@/lib/audit';
4
+
5
+ /**
6
+ * GET /api/admin/audit — page through the forensic audit log.
7
+ *
8
+ * Query params (all optional):
9
+ * userId filter by actor or target
10
+ * action one of the typed AuditAction values (e.g. "login", "scan")
11
+ * since ISO timestamp lower bound
12
+ * limit page size (default 50, cap 500)
13
+ * offset pagination offset (default 0)
14
+ *
15
+ * Response:
16
+ * { entries: [...], limit, offset, hasMore }
17
+ *
18
+ * Admin-only. Uses the existing queryAudit() helper (lib/audit.ts) so the
19
+ * schema and indexes are owned by one module.
20
+ */
21
+
22
+ export const runtime = 'nodejs';
23
+ export const dynamic = 'force-dynamic';
24
+
25
+ const ALLOWED_ACTIONS = new Set<AuditAction>([
26
+ 'login',
27
+ 'login_failed',
28
+ 'logout',
29
+ 'register',
30
+ 'verify_email',
31
+ 'password_reset_request',
32
+ 'password_reset',
33
+ 'password_change',
34
+ 'delete_account',
35
+ 'admin_login',
36
+ 'admin_action',
37
+ 'admin_user_delete',
38
+ 'admin_user_reset_password',
39
+ 'admin_config_update',
40
+ 'token_rotate',
41
+ 'chat',
42
+ 'scan',
43
+ 'health_data_write',
44
+ 'health_data_delete',
45
+ 'settings_update',
46
+ 'export_data',
47
+ ]);
48
+
49
+ export async function GET(req: Request) {
50
+ const admin = requireAdmin(req);
51
+ if (!admin) {
52
+ return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
53
+ }
54
+
55
+ const url = new URL(req.url);
56
+ const userId = url.searchParams.get('userId') || undefined;
57
+ const actionRaw = url.searchParams.get('action');
58
+ const since = url.searchParams.get('since') || undefined;
59
+ const limit = Math.min(
60
+ 500,
61
+ Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)),
62
+ );
63
+ const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10));
64
+
65
+ // Reject unknown action strings so typos don't silently return nothing.
66
+ let action: AuditAction | undefined;
67
+ if (actionRaw) {
68
+ if (!ALLOWED_ACTIONS.has(actionRaw as AuditAction)) {
69
+ return NextResponse.json(
70
+ { error: `Unknown action: ${actionRaw}` },
71
+ { status: 400 },
72
+ );
73
+ }
74
+ action = actionRaw as AuditAction;
75
+ }
76
+
77
+ // Request one extra row so we can cheaply compute hasMore without a
78
+ // separate COUNT(*) query.
79
+ const entries = queryAudit({ userId, action, since, limit: limit + 1, offset });
80
+ const hasMore = entries.length > limit;
81
+ if (hasMore) entries.pop();
82
+
83
+ return NextResponse.json({ entries, limit, offset, hasMore });
84
+ }
app/api/admin/config/route.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig, saveConfig, type ServerConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * Admin configuration management.
7
+ *
8
+ * GET /api/admin/config — returns current server configuration (redacted secrets).
9
+ * PUT /api/admin/config — updates server configuration (persisted to config file).
10
+ *
11
+ * Configuration is persisted to a JSON file on disk so it survives restarts.
12
+ * Environment variables take precedence over the config file on first boot.
13
+ *
14
+ * The storage/merge logic lives in @/lib/server-config so other admin routes
15
+ * (like /api/admin/fetch-models) can read the same source of truth.
16
+ */
17
+
18
+ const REDACTED = '••••••••';
19
+
20
+ /** Redact sensitive fields for GET responses. */
21
+ function redact(config: ServerConfig) {
22
+ const hasSecret = (v: string) => !!(v && v.length > 0);
23
+ const mask = (v: string) => (hasSecret(v) ? REDACTED : '');
24
+ return {
25
+ smtp: {
26
+ host: config.smtp.host,
27
+ port: config.smtp.port,
28
+ user: config.smtp.user,
29
+ pass: mask(config.smtp.pass),
30
+ fromEmail: config.smtp.fromEmail,
31
+ recoveryEmail: config.smtp.recoveryEmail,
32
+ configured: !!(config.smtp.host && config.smtp.user && config.smtp.pass),
33
+ },
34
+ llm: {
35
+ defaultPreset: config.llm.defaultPreset,
36
+ ollamaUrl: config.llm.ollamaUrl,
37
+ hfDefaultModel: config.llm.hfDefaultModel,
38
+ hfToken: mask(config.llm.hfToken),
39
+ hfTokenInference: mask(config.llm.hfTokenInference),
40
+ ollabridgeUrl: config.llm.ollabridgeUrl,
41
+ ollabridgeApiKey: mask(config.llm.ollabridgeApiKey),
42
+ openaiApiKey: mask(config.llm.openaiApiKey),
43
+ anthropicApiKey: mask(config.llm.anthropicApiKey),
44
+ groqApiKey: mask(config.llm.groqApiKey),
45
+ watsonxApiKey: mask(config.llm.watsonxApiKey),
46
+ watsonxProjectId: config.llm.watsonxProjectId,
47
+ watsonxUrl: config.llm.watsonxUrl,
48
+ scannerUrl: config.llm.scannerUrl,
49
+ nearbyUrl: config.llm.nearbyUrl,
50
+ geminiApiKey: mask(config.llm.geminiApiKey),
51
+ openrouterApiKey: mask(config.llm.openrouterApiKey),
52
+ togetherApiKey: mask(config.llm.togetherApiKey),
53
+ mistralApiKey: mask(config.llm.mistralApiKey),
54
+ // Computed status flags — derived server-side so UI can show chips.
55
+ ollabridgeConfigured: !!config.llm.ollabridgeUrl,
56
+ hfConfigured: hasSecret(config.llm.hfToken),
57
+ hfInferenceConfigured: hasSecret(config.llm.hfTokenInference),
58
+ openaiConfigured: hasSecret(config.llm.openaiApiKey),
59
+ anthropicConfigured: hasSecret(config.llm.anthropicApiKey),
60
+ groqConfigured: hasSecret(config.llm.groqApiKey),
61
+ watsonxConfigured: hasSecret(config.llm.watsonxApiKey) && !!config.llm.watsonxProjectId,
62
+ geminiConfigured: hasSecret(config.llm.geminiApiKey),
63
+ openrouterConfigured: hasSecret(config.llm.openrouterApiKey),
64
+ togetherConfigured: hasSecret(config.llm.togetherApiKey),
65
+ mistralConfigured: hasSecret(config.llm.mistralApiKey),
66
+ },
67
+ app: config.app,
68
+ };
69
+ }
70
+
71
+ export async function GET(req: Request) {
72
+ const admin = requireAdmin(req);
73
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
74
+
75
+ const config = loadConfig();
76
+ return NextResponse.json(redact(config));
77
+ }
78
+
79
+ export async function PUT(req: Request) {
80
+ const admin = requireAdmin(req);
81
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
82
+
83
+ try {
84
+ const body = await req.json();
85
+ const current = loadConfig();
86
+
87
+ // Merge incoming changes (only update provided fields).
88
+ if (body.smtp) {
89
+ if (body.smtp.host !== undefined) current.smtp.host = body.smtp.host;
90
+ if (body.smtp.port !== undefined) current.smtp.port = parseInt(body.smtp.port, 10);
91
+ if (body.smtp.user !== undefined) current.smtp.user = body.smtp.user;
92
+ // Only update password if it's not the redacted placeholder.
93
+ if (body.smtp.pass !== undefined && body.smtp.pass !== REDACTED) {
94
+ current.smtp.pass = body.smtp.pass;
95
+ }
96
+ if (body.smtp.fromEmail !== undefined) current.smtp.fromEmail = body.smtp.fromEmail;
97
+ if (body.smtp.recoveryEmail !== undefined) current.smtp.recoveryEmail = body.smtp.recoveryEmail;
98
+ }
99
+
100
+ if (body.llm) {
101
+ // Non-secret fields — assign directly.
102
+ if (body.llm.defaultPreset !== undefined) current.llm.defaultPreset = body.llm.defaultPreset;
103
+ if (body.llm.ollamaUrl !== undefined) current.llm.ollamaUrl = body.llm.ollamaUrl;
104
+ if (body.llm.hfDefaultModel !== undefined) current.llm.hfDefaultModel = body.llm.hfDefaultModel;
105
+ if (body.llm.ollabridgeUrl !== undefined) current.llm.ollabridgeUrl = body.llm.ollabridgeUrl;
106
+ if (body.llm.watsonxProjectId !== undefined) current.llm.watsonxProjectId = body.llm.watsonxProjectId;
107
+ if (body.llm.watsonxUrl !== undefined) current.llm.watsonxUrl = body.llm.watsonxUrl;
108
+ if (body.llm.scannerUrl !== undefined) current.llm.scannerUrl = body.llm.scannerUrl;
109
+ if (body.llm.nearbyUrl !== undefined) current.llm.nearbyUrl = body.llm.nearbyUrl;
110
+
111
+ // Secret fields — skip if value is the redacted placeholder.
112
+ const setSecret = (field: keyof ServerConfig['llm'], value: any) => {
113
+ if (value !== undefined && value !== REDACTED) {
114
+ (current.llm as any)[field] = value;
115
+ }
116
+ };
117
+ setSecret('hfToken', body.llm.hfToken);
118
+ setSecret('hfTokenInference', body.llm.hfTokenInference);
119
+ setSecret('ollabridgeApiKey', body.llm.ollabridgeApiKey);
120
+ setSecret('openaiApiKey', body.llm.openaiApiKey);
121
+ setSecret('anthropicApiKey', body.llm.anthropicApiKey);
122
+ setSecret('groqApiKey', body.llm.groqApiKey);
123
+ setSecret('watsonxApiKey', body.llm.watsonxApiKey);
124
+ setSecret('geminiApiKey', body.llm.geminiApiKey);
125
+ setSecret('openrouterApiKey', body.llm.openrouterApiKey);
126
+ setSecret('togetherApiKey', body.llm.togetherApiKey);
127
+ setSecret('mistralApiKey', body.llm.mistralApiKey);
128
+ }
129
+
130
+ if (body.app) {
131
+ if (body.app.appUrl !== undefined) current.app.appUrl = body.app.appUrl;
132
+ if (body.app.allowedOrigins !== undefined) current.app.allowedOrigins = body.app.allowedOrigins;
133
+ }
134
+
135
+ saveConfig(current);
136
+
137
+ return NextResponse.json({ success: true, config: redact(current) });
138
+ } catch (error: any) {
139
+ console.error('[Admin Config]', error?.message);
140
+ return NextResponse.json({ error: error?.message || 'Failed to update config' }, { status: 500 });
141
+ }
142
+ }
app/api/admin/email-status/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { authenticateRequest } from '@/lib/auth-middleware';
3
+ import { emailTransportName } from '@/lib/email';
4
+
5
+ /**
6
+ * GET /api/admin/email-status — which email transport is currently active.
7
+ *
8
+ * Returns one of:
9
+ * { transport: "resend" } — RESEND_API_KEY is set; uses Resend HTTP API.
10
+ * { transport: "smtp" } — SMTP_HOST/USER/PASS are set; uses nodemailer.
11
+ * { transport: "console" } — nothing configured; emails are logged to stdout
12
+ * and NEVER reach a real inbox. This is the
13
+ * state that produces the "Account created!
14
+ * Check your email" UX with no email ever
15
+ * arriving.
16
+ *
17
+ * Restricted to authenticated admins — the response itself isn't sensitive
18
+ * but there's no reason for unauthenticated callers to probe it.
19
+ */
20
+ export async function GET(req: Request) {
21
+ const user = authenticateRequest(req);
22
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
23
+ if (!user.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
24
+
25
+ return NextResponse.json({
26
+ transport: emailTransportName(),
27
+ from: process.env.FROM_EMAIL || '(default: MedOS <onboarding@resend.dev>)',
28
+ appUrl: process.env.APP_URL || '(default: https://ruslanmv-medibot.hf.space)',
29
+ });
30
+ }
app/api/admin/fetch-models/route.ts ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * GET /api/admin/fetch-models — Aggregate available models from every
7
+ * configured provider into one list for the admin model picker.
8
+ *
9
+ * Queries, in parallel:
10
+ * - OllaBridge Cloud /v1/models (OpenAI-compatible)
11
+ * - HuggingFace Inference /v1/models (via router.huggingface.co)
12
+ * - Groq /openai/v1/models (free/cheap tier)
13
+ * - OpenAI /v1/models (paid enterprise)
14
+ * - Anthropic /v1/models (paid enterprise)
15
+ * - IBM WatsonX /ml/v1/foundation_model_specs (paid enterprise)
16
+ *
17
+ * Each provider block returns:
18
+ * { provider, configured, ok, error?, models: [{id, name, ownedBy, context?}] }
19
+ *
20
+ * Providers that aren't configured still appear in the response so the UI
21
+ * can show them as "not configured" with a link to set them up. This keeps
22
+ * the client-side model picker uniform across providers.
23
+ *
24
+ * Admin-only endpoint.
25
+ */
26
+
27
+ export const runtime = 'nodejs';
28
+ export const dynamic = 'force-dynamic';
29
+
30
+ interface ModelInfo {
31
+ id: string;
32
+ name: string;
33
+ ownedBy?: string;
34
+ context?: number;
35
+ pricing?: 'free' | 'paid' | 'cheap' | 'local';
36
+ }
37
+
38
+ interface ProviderBlock {
39
+ provider: string;
40
+ label: string;
41
+ configured: boolean;
42
+ ok: boolean;
43
+ error?: string;
44
+ pricing: 'free' | 'paid' | 'cheap' | 'local';
45
+ models: ModelInfo[];
46
+ }
47
+
48
+ /** Default 10s timeout for any provider discovery call. */
49
+ function withTimeout(ms = 10000) {
50
+ return AbortSignal.timeout(ms);
51
+ }
52
+
53
+ async function safeJson(res: Response): Promise<any> {
54
+ try {
55
+ return await res.json();
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // ---- Provider fetchers ---------------------------------------------------
62
+
63
+ async function fetchOllaBridge(
64
+ url: string,
65
+ apiKey: string,
66
+ ): Promise<ProviderBlock> {
67
+ const block: ProviderBlock = {
68
+ provider: 'ollabridge',
69
+ label: 'OllaBridge Cloud',
70
+ configured: !!url,
71
+ ok: false,
72
+ pricing: 'free',
73
+ models: [],
74
+ };
75
+ if (!url) {
76
+ block.error = 'Not configured — set OllaBridge URL in Server tab';
77
+ return block;
78
+ }
79
+ try {
80
+ const cleanBase = url.replace(/\/+$/, '');
81
+ const res = await fetch(`${cleanBase}/v1/models`, {
82
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
83
+ signal: withTimeout(),
84
+ });
85
+ if (!res.ok) {
86
+ block.error = `HTTP ${res.status}`;
87
+ return block;
88
+ }
89
+ const data = await safeJson(res);
90
+ const list = Array.isArray(data?.data) ? data.data : [];
91
+ block.models = list.map((m: any) => ({
92
+ id: String(m.id ?? 'unknown'),
93
+ name: String(m.id ?? 'unknown'),
94
+ ownedBy: m.owned_by || 'ollabridge',
95
+ pricing:
96
+ String(m.id ?? '').startsWith('free-')
97
+ ? 'free'
98
+ : String(m.id ?? '').startsWith('cheap-')
99
+ ? 'cheap'
100
+ : 'local',
101
+ }));
102
+ block.ok = true;
103
+ } catch (e: any) {
104
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
105
+ }
106
+ return block;
107
+ }
108
+
109
+ async function fetchHuggingFace(token: string): Promise<ProviderBlock> {
110
+ const block: ProviderBlock = {
111
+ provider: 'huggingface',
112
+ label: 'HuggingFace Inference',
113
+ configured: !!token,
114
+ ok: false,
115
+ pricing: 'free',
116
+ models: [],
117
+ };
118
+ if (!token) {
119
+ block.error = 'Not configured — set HF token in Server tab';
120
+ // Still provide the curated fallback chain as suggestions so users can
121
+ // pick a model even before the token is set.
122
+ block.models = CURATED_HF_MODELS.map((id) => ({
123
+ id,
124
+ name: id.split('/').pop() || id,
125
+ ownedBy: id.split('/')[0],
126
+ pricing: 'free',
127
+ }));
128
+ return block;
129
+ }
130
+ try {
131
+ const res = await fetch('https://router.huggingface.co/v1/models', {
132
+ headers: { Authorization: `Bearer ${token}` },
133
+ signal: withTimeout(),
134
+ });
135
+ if (!res.ok) {
136
+ block.error = `HTTP ${res.status}`;
137
+ // Still return curated list so the UI has something to show.
138
+ block.models = CURATED_HF_MODELS.map((id) => ({
139
+ id,
140
+ name: id.split('/').pop() || id,
141
+ ownedBy: id.split('/')[0],
142
+ pricing: 'free',
143
+ }));
144
+ return block;
145
+ }
146
+ const data = await safeJson(res);
147
+ const list = Array.isArray(data?.data) ? data.data : [];
148
+ block.models = list
149
+ .filter((m: any) => typeof m?.id === 'string')
150
+ .map((m: any) => ({
151
+ id: String(m.id),
152
+ name: String(m.id).split('/').pop() || String(m.id),
153
+ ownedBy: m.owned_by || String(m.id).split('/')[0],
154
+ pricing: 'free' as const,
155
+ }));
156
+ block.ok = true;
157
+ } catch (e: any) {
158
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
159
+ block.models = CURATED_HF_MODELS.map((id) => ({
160
+ id,
161
+ name: id.split('/').pop() || id,
162
+ ownedBy: id.split('/')[0],
163
+ pricing: 'free',
164
+ }));
165
+ }
166
+ return block;
167
+ }
168
+
169
+ /** Verified-working free HF models (from lib/providers/huggingface-direct.ts). */
170
+ const CURATED_HF_MODELS = [
171
+ 'meta-llama/Llama-3.3-70B-Instruct',
172
+ 'Qwen/Qwen2.5-72B-Instruct',
173
+ 'Qwen/Qwen3-235B-A22B',
174
+ 'google/gemma-3-27b-it',
175
+ 'meta-llama/Llama-3.1-70B-Instruct',
176
+ 'deepseek-ai/DeepSeek-V3-0324',
177
+ ];
178
+
179
+ async function fetchGroq(apiKey: string): Promise<ProviderBlock> {
180
+ const block: ProviderBlock = {
181
+ provider: 'groq',
182
+ label: 'Groq (Free tier)',
183
+ configured: !!apiKey,
184
+ ok: false,
185
+ pricing: 'free',
186
+ models: [],
187
+ };
188
+ if (!apiKey) {
189
+ block.error = 'Not configured — add Groq API key in Server tab';
190
+ return block;
191
+ }
192
+ try {
193
+ const res = await fetch('https://api.groq.com/openai/v1/models', {
194
+ headers: { Authorization: `Bearer ${apiKey}` },
195
+ signal: withTimeout(),
196
+ });
197
+ if (!res.ok) {
198
+ block.error = `HTTP ${res.status}`;
199
+ return block;
200
+ }
201
+ const data = await safeJson(res);
202
+ const list = Array.isArray(data?.data) ? data.data : [];
203
+ block.models = list.map((m: any) => ({
204
+ id: String(m.id ?? 'unknown'),
205
+ name: String(m.id ?? 'unknown'),
206
+ ownedBy: m.owned_by || 'groq',
207
+ context: typeof m.context_window === 'number' ? m.context_window : undefined,
208
+ pricing: 'free',
209
+ }));
210
+ block.ok = true;
211
+ } catch (e: any) {
212
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
213
+ }
214
+ return block;
215
+ }
216
+
217
+ async function fetchOpenAI(apiKey: string): Promise<ProviderBlock> {
218
+ const block: ProviderBlock = {
219
+ provider: 'openai',
220
+ label: 'OpenAI (Paid)',
221
+ configured: !!apiKey,
222
+ ok: false,
223
+ pricing: 'paid',
224
+ models: [],
225
+ };
226
+ if (!apiKey) {
227
+ block.error = 'Not configured — add OpenAI API key in Server tab';
228
+ return block;
229
+ }
230
+ try {
231
+ const res = await fetch('https://api.openai.com/v1/models', {
232
+ headers: { Authorization: `Bearer ${apiKey}` },
233
+ signal: withTimeout(),
234
+ });
235
+ if (!res.ok) {
236
+ block.error = `HTTP ${res.status}`;
237
+ return block;
238
+ }
239
+ const data = await safeJson(res);
240
+ const list = Array.isArray(data?.data) ? data.data : [];
241
+ // Filter to chat-capable GPT models — the full list is noisy.
242
+ block.models = list
243
+ .filter((m: any) => {
244
+ const id = String(m?.id || '');
245
+ return /^(gpt-|o1-|o3-|chatgpt)/i.test(id);
246
+ })
247
+ .map((m: any) => ({
248
+ id: String(m.id),
249
+ name: String(m.id),
250
+ ownedBy: m.owned_by || 'openai',
251
+ pricing: 'paid' as const,
252
+ }));
253
+ block.ok = true;
254
+ } catch (e: any) {
255
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
256
+ }
257
+ return block;
258
+ }
259
+
260
+ async function fetchAnthropic(apiKey: string): Promise<ProviderBlock> {
261
+ const block: ProviderBlock = {
262
+ provider: 'anthropic',
263
+ label: 'Anthropic Claude (Paid)',
264
+ configured: !!apiKey,
265
+ ok: false,
266
+ pricing: 'paid',
267
+ models: [],
268
+ };
269
+ if (!apiKey) {
270
+ block.error = 'Not configured — add Anthropic API key in Server tab';
271
+ // Provide curated list as placeholder.
272
+ block.models = CURATED_ANTHROPIC_MODELS;
273
+ return block;
274
+ }
275
+ try {
276
+ const res = await fetch('https://api.anthropic.com/v1/models', {
277
+ headers: {
278
+ 'x-api-key': apiKey,
279
+ 'anthropic-version': '2023-06-01',
280
+ },
281
+ signal: withTimeout(),
282
+ });
283
+ if (!res.ok) {
284
+ block.error = `HTTP ${res.status}`;
285
+ block.models = CURATED_ANTHROPIC_MODELS;
286
+ return block;
287
+ }
288
+ const data = await safeJson(res);
289
+ const list = Array.isArray(data?.data) ? data.data : [];
290
+ block.models = list.map((m: any) => ({
291
+ id: String(m.id ?? 'unknown'),
292
+ name: String(m.display_name || m.id || 'unknown'),
293
+ ownedBy: 'anthropic',
294
+ pricing: 'paid' as const,
295
+ }));
296
+ block.ok = true;
297
+ } catch (e: any) {
298
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
299
+ block.models = CURATED_ANTHROPIC_MODELS;
300
+ }
301
+ return block;
302
+ }
303
+
304
+ /** Fallback list so the UI can show Claude options even without a key. */
305
+ const CURATED_ANTHROPIC_MODELS: ModelInfo[] = [
306
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', ownedBy: 'anthropic', pricing: 'paid' },
307
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', ownedBy: 'anthropic', pricing: 'paid' },
308
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', ownedBy: 'anthropic', pricing: 'paid' },
309
+ ];
310
+
311
+ async function fetchWatsonx(
312
+ apiKey: string,
313
+ projectId: string,
314
+ baseUrl: string,
315
+ ): Promise<ProviderBlock> {
316
+ const block: ProviderBlock = {
317
+ provider: 'watsonx',
318
+ label: 'IBM WatsonX (Paid)',
319
+ configured: !!(apiKey && projectId),
320
+ ok: false,
321
+ pricing: 'paid',
322
+ models: [],
323
+ };
324
+ if (!apiKey || !projectId) {
325
+ block.error = 'Not configured — add WatsonX API key and project ID in Server tab';
326
+ block.models = CURATED_WATSONX_MODELS;
327
+ return block;
328
+ }
329
+ try {
330
+ // WatsonX requires exchanging the API key for an IAM bearer token first.
331
+ const iamRes = await fetch('https://iam.cloud.ibm.com/identity/token', {
332
+ method: 'POST',
333
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
334
+ body: new URLSearchParams({
335
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
336
+ apikey: apiKey,
337
+ }),
338
+ signal: withTimeout(),
339
+ });
340
+ if (!iamRes.ok) {
341
+ block.error = `IAM token failed: HTTP ${iamRes.status}`;
342
+ block.models = CURATED_WATSONX_MODELS;
343
+ return block;
344
+ }
345
+ const iamData = await safeJson(iamRes);
346
+ const bearer = iamData?.access_token;
347
+ if (!bearer) {
348
+ block.error = 'IAM response missing access_token';
349
+ block.models = CURATED_WATSONX_MODELS;
350
+ return block;
351
+ }
352
+
353
+ // Discover available foundation models.
354
+ const cleanBase = baseUrl.replace(/\/+$/, '');
355
+ const modelsRes = await fetch(
356
+ `${cleanBase}/ml/v1/foundation_model_specs?version=2024-05-01&limit=200`,
357
+ {
358
+ headers: { Authorization: `Bearer ${bearer}` },
359
+ signal: withTimeout(),
360
+ },
361
+ );
362
+ if (!modelsRes.ok) {
363
+ block.error = `HTTP ${modelsRes.status}`;
364
+ block.models = CURATED_WATSONX_MODELS;
365
+ return block;
366
+ }
367
+ const data = await safeJson(modelsRes);
368
+ const list = Array.isArray(data?.resources) ? data.resources : [];
369
+ block.models = list.map((m: any) => ({
370
+ id: String(m.model_id ?? 'unknown'),
371
+ name: String(m.label || m.model_id || 'unknown'),
372
+ ownedBy: m.provider || 'ibm',
373
+ pricing: 'paid' as const,
374
+ }));
375
+ block.ok = true;
376
+ } catch (e: any) {
377
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed';
378
+ block.models = CURATED_WATSONX_MODELS;
379
+ }
380
+ return block;
381
+ }
382
+
383
+ /** Common WatsonX foundation models as a fallback list. */
384
+ const CURATED_WATSONX_MODELS: ModelInfo[] = [
385
+ { id: 'ibm/granite-3-8b-instruct', name: 'Granite 3 8B Instruct', ownedBy: 'ibm', pricing: 'paid' },
386
+ { id: 'meta-llama/llama-3-3-70b-instruct', name: 'Llama 3.3 70B', ownedBy: 'meta', pricing: 'paid' },
387
+ { id: 'mistralai/mistral-large', name: 'Mistral Large', ownedBy: 'mistralai', pricing: 'paid' },
388
+ ];
389
+
390
+ // ---- Additional provider fetchers (v3) -----------------------------------
391
+
392
+ /**
393
+ * Google Gemini. Uses the Generative Language API, which requires the key
394
+ * as a query parameter rather than a bearer header.
395
+ */
396
+ async function fetchGemini(apiKey: string): Promise<ProviderBlock> {
397
+ const block: ProviderBlock = {
398
+ provider: 'gemini',
399
+ label: 'Google Gemini',
400
+ configured: !!apiKey,
401
+ ok: false,
402
+ pricing: 'paid',
403
+ models: [],
404
+ };
405
+ if (!apiKey) {
406
+ block.error = 'API key not configured';
407
+ return block;
408
+ }
409
+ try {
410
+ const res = await fetch(
411
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
412
+ { signal: withTimeout() },
413
+ );
414
+ if (!res.ok) {
415
+ block.error = `HTTP ${res.status}`;
416
+ return block;
417
+ }
418
+ const data = await res.json().catch(() => ({}));
419
+ const arr = Array.isArray(data?.models) ? data.models : [];
420
+ block.ok = true;
421
+ block.models = arr
422
+ .filter((m: any) => typeof m?.name === 'string')
423
+ // Gemini returns "models/gemini-1.5-flash" — strip the prefix so the
424
+ // UI shows the bare model id like every other provider.
425
+ .map((m: any) => ({
426
+ id: String(m.name).replace(/^models\//, ''),
427
+ name: m.displayName || String(m.name).replace(/^models\//, ''),
428
+ ownedBy: 'google',
429
+ context: typeof m.inputTokenLimit === 'number' ? m.inputTokenLimit : undefined,
430
+ pricing: 'paid' as const,
431
+ }));
432
+ } catch (e: any) {
433
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
434
+ }
435
+ return block;
436
+ }
437
+
438
+ /** OpenRouter — OpenAI-compatible /v1/models aggregator across providers. */
439
+ async function fetchOpenRouter(apiKey: string): Promise<ProviderBlock> {
440
+ const block: ProviderBlock = {
441
+ provider: 'openrouter',
442
+ label: 'OpenRouter',
443
+ configured: !!apiKey,
444
+ ok: false,
445
+ pricing: 'paid',
446
+ models: [],
447
+ };
448
+ if (!apiKey) {
449
+ block.error = 'API key not configured';
450
+ return block;
451
+ }
452
+ try {
453
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
454
+ headers: { Authorization: `Bearer ${apiKey}` },
455
+ signal: withTimeout(),
456
+ });
457
+ if (!res.ok) {
458
+ block.error = `HTTP ${res.status}`;
459
+ return block;
460
+ }
461
+ const data = await res.json().catch(() => ({}));
462
+ const arr = Array.isArray(data?.data) ? data.data : [];
463
+ block.ok = true;
464
+ block.models = arr.slice(0, 200).map((m: any) => ({
465
+ id: String(m.id),
466
+ name: m.name || String(m.id),
467
+ ownedBy: (String(m.id).split('/')[0] as string) || undefined,
468
+ context: typeof m.context_length === 'number' ? m.context_length : undefined,
469
+ pricing: m?.pricing?.prompt === '0' ? 'free' : 'paid',
470
+ }));
471
+ } catch (e: any) {
472
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
473
+ }
474
+ return block;
475
+ }
476
+
477
+ /** Together AI — OpenAI-compatible /v1/models. */
478
+ async function fetchTogether(apiKey: string): Promise<ProviderBlock> {
479
+ const block: ProviderBlock = {
480
+ provider: 'together',
481
+ label: 'Together AI',
482
+ configured: !!apiKey,
483
+ ok: false,
484
+ pricing: 'paid',
485
+ models: [],
486
+ };
487
+ if (!apiKey) {
488
+ block.error = 'API key not configured';
489
+ return block;
490
+ }
491
+ try {
492
+ const res = await fetch('https://api.together.xyz/v1/models', {
493
+ headers: { Authorization: `Bearer ${apiKey}` },
494
+ signal: withTimeout(),
495
+ });
496
+ if (!res.ok) {
497
+ block.error = `HTTP ${res.status}`;
498
+ return block;
499
+ }
500
+ const data = await res.json().catch(() => []);
501
+ // Together returns a bare array.
502
+ const arr = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
503
+ block.ok = true;
504
+ block.models = arr.slice(0, 200).map((m: any) => ({
505
+ id: String(m.id || m.name),
506
+ name: m.display_name || m.id || m.name,
507
+ ownedBy: m.organization || undefined,
508
+ context: typeof m.context_length === 'number' ? m.context_length : undefined,
509
+ pricing: 'paid' as const,
510
+ }));
511
+ } catch (e: any) {
512
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
513
+ }
514
+ return block;
515
+ }
516
+
517
+ /** Mistral AI — OpenAI-compatible /v1/models. */
518
+ async function fetchMistral(apiKey: string): Promise<ProviderBlock> {
519
+ const block: ProviderBlock = {
520
+ provider: 'mistral',
521
+ label: 'Mistral AI',
522
+ configured: !!apiKey,
523
+ ok: false,
524
+ pricing: 'paid',
525
+ models: [],
526
+ };
527
+ if (!apiKey) {
528
+ block.error = 'API key not configured';
529
+ return block;
530
+ }
531
+ try {
532
+ const res = await fetch('https://api.mistral.ai/v1/models', {
533
+ headers: { Authorization: `Bearer ${apiKey}` },
534
+ signal: withTimeout(),
535
+ });
536
+ if (!res.ok) {
537
+ block.error = `HTTP ${res.status}`;
538
+ return block;
539
+ }
540
+ const data = await res.json().catch(() => ({}));
541
+ const arr = Array.isArray(data?.data) ? data.data : [];
542
+ block.ok = true;
543
+ block.models = arr.map((m: any) => ({
544
+ id: String(m.id),
545
+ name: m.name || m.id,
546
+ ownedBy: m.owned_by || 'mistralai',
547
+ context:
548
+ typeof m.max_context_length === 'number'
549
+ ? m.max_context_length
550
+ : typeof m.context_length === 'number'
551
+ ? m.context_length
552
+ : undefined,
553
+ pricing: 'paid' as const,
554
+ }));
555
+ } catch (e: any) {
556
+ block.error = e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message || 'Request failed';
557
+ }
558
+ return block;
559
+ }
560
+
561
+ // ---- Route handler -------------------------------------------------------
562
+
563
+ export async function GET(req: Request) {
564
+ const admin = requireAdmin(req);
565
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
566
+
567
+ const config = loadConfig();
568
+ const { llm } = config;
569
+
570
+ // Run every discovery call in parallel so the slowest provider sets the
571
+ // total latency floor, not the sum of all calls.
572
+ const [
573
+ ollabridge,
574
+ huggingface,
575
+ groq,
576
+ openai,
577
+ anthropic,
578
+ watsonx,
579
+ gemini,
580
+ openrouter,
581
+ together,
582
+ mistral,
583
+ ] = await Promise.all([
584
+ fetchOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey),
585
+ fetchHuggingFace(llm.hfToken),
586
+ fetchGroq(llm.groqApiKey),
587
+ fetchOpenAI(llm.openaiApiKey),
588
+ fetchAnthropic(llm.anthropicApiKey),
589
+ fetchWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl),
590
+ fetchGemini(llm.geminiApiKey),
591
+ fetchOpenRouter(llm.openrouterApiKey),
592
+ fetchTogether(llm.togetherApiKey),
593
+ fetchMistral(llm.mistralApiKey),
594
+ ]);
595
+
596
+ const providers = [
597
+ ollabridge,
598
+ huggingface,
599
+ groq,
600
+ openai,
601
+ anthropic,
602
+ watsonx,
603
+ gemini,
604
+ openrouter,
605
+ together,
606
+ mistral,
607
+ ];
608
+ const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
609
+ const okCount = providers.filter((p) => p.ok).length;
610
+
611
+ return NextResponse.json({
612
+ providers,
613
+ summary: {
614
+ providers: providers.length,
615
+ providersOk: okCount,
616
+ totalModels,
617
+ fetchedAt: new Date().toISOString(),
618
+ },
619
+ });
620
+ }
app/api/admin/llm-health/route.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * GET /api/admin/llm-health — Test all LLM providers and models.
7
+ *
8
+ * Sends a minimal "Say OK" prompt to each model in the fallback chain
9
+ * and reports which ones are alive, their latency, and any errors.
10
+ * Admin-only endpoint.
11
+ *
12
+ * Token resolution order:
13
+ * 1. admin config file (set via /api/admin/config PUT)
14
+ * 2. HF_TOKEN environment variable
15
+ * This way an admin can fix a misconfigured Space without redeploying.
16
+ *
17
+ * The result also includes a synthetic "ollabridge/<alias>" row at the
18
+ * top of the list so the Provider Health page surfaces the OllaBridge
19
+ * Cloud gateway alongside HF models — matching the routing chain
20
+ * documented in the "Come funziona il routing" panel (OllaBridge first,
21
+ * then HF Inference, then enterprise providers, then cached FAQ).
22
+ */
23
+
24
+ const HF_BASE_URL = 'https://router.huggingface.co/v1';
25
+
26
+ /** All models to test — matches the presets fallback chain. */
27
+ const MODELS_TO_TEST = [
28
+ 'meta-llama/Llama-3.3-70B-Instruct:sambanova',
29
+ 'meta-llama/Llama-3.3-70B-Instruct:together',
30
+ 'meta-llama/Llama-3.3-70B-Instruct',
31
+ 'Qwen/Qwen2.5-72B-Instruct',
32
+ 'Qwen/Qwen3-235B-A22B',
33
+ 'google/gemma-3-27b-it',
34
+ 'meta-llama/Llama-3.1-70B-Instruct',
35
+ 'Qwen/Qwen3-32B',
36
+ 'deepseek-ai/DeepSeek-V3-0324',
37
+ 'deepseek-ai/DeepSeek-R1',
38
+ 'Qwen/Qwen3-30B-A3B',
39
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
40
+ ];
41
+
42
+ type ModelResult = {
43
+ model: string;
44
+ status: 'ok' | 'error';
45
+ latencyMs: number;
46
+ response?: string;
47
+ error?: string;
48
+ httpStatus?: number;
49
+ };
50
+
51
+ async function testModel(model: string, token: string): Promise<ModelResult> {
52
+ const start = Date.now();
53
+ try {
54
+ const res = await fetch(`${HF_BASE_URL}/chat/completions`, {
55
+ method: 'POST',
56
+ headers: {
57
+ Authorization: `Bearer ${token}`,
58
+ 'Content-Type': 'application/json',
59
+ },
60
+ body: JSON.stringify({
61
+ model,
62
+ messages: [{ role: 'user', content: 'Say OK' }],
63
+ max_tokens: 5,
64
+ temperature: 0.1,
65
+ stream: false,
66
+ }),
67
+ signal: AbortSignal.timeout(15000),
68
+ });
69
+
70
+ const latencyMs = Date.now() - start;
71
+
72
+ if (res.ok) {
73
+ const data = await res.json();
74
+ const content = data?.choices?.[0]?.message?.content?.trim() || '';
75
+ return { model, status: 'ok', latencyMs, response: content.slice(0, 30) };
76
+ } else {
77
+ const text = await res.text().catch(() => '');
78
+ const errorMsg = text.slice(0, 100);
79
+ return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status };
80
+ }
81
+ } catch (e: any) {
82
+ return {
83
+ model,
84
+ status: 'error',
85
+ latencyMs: Date.now() - start,
86
+ error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100),
87
+ };
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Probe the OllaBridge Cloud gateway with a minimal chat completion.
93
+ *
94
+ * Renders as the first row in the Provider Health table. The model id is
95
+ * shaped `ollabridge/<alias>` on purpose: the UI splits by `/` to derive
96
+ * an org-style subtitle, so this keeps the existing render path unchanged.
97
+ *
98
+ * Uses a 20s timeout — generous enough to absorb the worst-case Cloud
99
+ * fallback chain (HF 402 cascade → local-ollama) while still capping
100
+ * pathological hangs.
101
+ */
102
+ async function testOllaBridge(
103
+ baseUrl: string,
104
+ apiKey: string,
105
+ alias: string,
106
+ ): Promise<ModelResult> {
107
+ const start = Date.now();
108
+ const url = `${baseUrl.replace(/\/+$/, '')}/v1/chat/completions`;
109
+ const modelId = `ollabridge/${alias || 'gateway'}`;
110
+ try {
111
+ const res = await fetch(url, {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
116
+ },
117
+ body: JSON.stringify({
118
+ model: alias || 'free-fast',
119
+ messages: [{ role: 'user', content: 'Say OK' }],
120
+ max_tokens: 5,
121
+ temperature: 0.1,
122
+ stream: false,
123
+ }),
124
+ signal: AbortSignal.timeout(20000),
125
+ });
126
+
127
+ const latencyMs = Date.now() - start;
128
+
129
+ if (res.ok) {
130
+ const data = await res.json().catch(() => ({}));
131
+ const content = data?.choices?.[0]?.message?.content?.trim() || '';
132
+ return { model: modelId, status: 'ok', latencyMs, response: content.slice(0, 30) || 'OK' };
133
+ }
134
+ const text = await res.text().catch(() => '');
135
+ return {
136
+ model: modelId,
137
+ status: 'error',
138
+ latencyMs,
139
+ error: text.slice(0, 100),
140
+ httpStatus: res.status,
141
+ };
142
+ } catch (e: any) {
143
+ return {
144
+ model: modelId,
145
+ status: 'error',
146
+ latencyMs: Date.now() - start,
147
+ error: e?.name === 'TimeoutError' ? 'Timeout (20s)' : (e?.message || 'Unknown error').slice(0, 100),
148
+ };
149
+ }
150
+ }
151
+
152
+ export async function GET(req: Request) {
153
+ const admin = requireAdmin(req);
154
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
155
+
156
+ // Prefer admin-configured token, fall back to env var.
157
+ const config = loadConfig();
158
+ const token = config.llm.hfToken || process.env.HF_TOKEN || '';
159
+ const ollabridgeUrl = config.llm.ollabridgeUrl || process.env.OLLABRIDGE_URL || '';
160
+ const ollabridgeKey = config.llm.ollabridgeApiKey || process.env.OLLABRIDGE_API_KEY || '';
161
+ const ollabridgeAlias = config.llm.hfDefaultModel || 'free-fast';
162
+
163
+ // Kick off OllaBridge probe in parallel with the HF cascade — only when
164
+ // a URL is configured. An unconfigured OllaBridge stays absent from the
165
+ // list rather than appearing as a permanent red row that would mislead
166
+ // operators who are using HF-only.
167
+ const ollabridgePromise: Promise<ModelResult | null> = ollabridgeUrl
168
+ ? testOllaBridge(ollabridgeUrl, ollabridgeKey, ollabridgeAlias)
169
+ : Promise.resolve(null);
170
+
171
+ if (!token) {
172
+ // Still return a well-formed response so the UI can render an empty-state
173
+ // Provider Status panel with a helpful error banner. We still surface
174
+ // the OllaBridge row when it's configured — it's independent of HF.
175
+ const ollabridgeResult = await ollabridgePromise;
176
+ const models: ModelResult[] = [
177
+ ...(ollabridgeResult ? [ollabridgeResult] : []),
178
+ ...MODELS_TO_TEST.map((model) => ({
179
+ model,
180
+ status: 'error' as const,
181
+ latencyMs: 0,
182
+ error: 'No HF token configured',
183
+ })),
184
+ ];
185
+ const ok = models.filter((m) => m.status === 'ok').length;
186
+ return NextResponse.json({
187
+ error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.',
188
+ models,
189
+ summary: {
190
+ total: models.length,
191
+ ok,
192
+ error: models.length - ok,
193
+ testedAt: new Date().toISOString(),
194
+ },
195
+ });
196
+ }
197
+
198
+ // Test all HF models in parallel for speed; OllaBridge runs alongside.
199
+ const [ollabridgeResult, hfResults] = await Promise.all([
200
+ ollabridgePromise,
201
+ Promise.all(MODELS_TO_TEST.map((model) => testModel(model, token))),
202
+ ]);
203
+
204
+ const results: ModelResult[] = [
205
+ ...(ollabridgeResult ? [ollabridgeResult] : []),
206
+ ...hfResults,
207
+ ];
208
+ const ok = results.filter((r) => r.status === 'ok').length;
209
+
210
+ return NextResponse.json({
211
+ models: results,
212
+ summary: {
213
+ total: results.length,
214
+ ok,
215
+ error: results.length - ok,
216
+ testedAt: new Date().toISOString(),
217
+ },
218
+ });
219
+ }
app/api/admin/medical-flow/route.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { summary } from '@/lib/medical-flow/audit';
4
+
5
+ /**
6
+ * GET /api/admin/medical-flow
7
+ *
8
+ * Returns a counts-by-kind / counts-by-care-level / counts-by-flow
9
+ * summary of the medical-flow audit table for the most recent 7 days
10
+ * (or `?sinceHours=N`). Powers the admin "Card Flow" panel and feeds
11
+ * the marketing dashboard in benchmarks/.
12
+ *
13
+ * Admin-only. PHI-free — only structured aggregates.
14
+ */
15
+ export async function GET(req: Request) {
16
+ const admin = requireAdmin(req);
17
+ if (!admin) {
18
+ return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
19
+ }
20
+ const url = new URL(req.url);
21
+ const sinceHours = parseInt(url.searchParams.get('sinceHours') || '168', 10);
22
+ const sinceISO = new Date(
23
+ Date.now() - Math.max(1, sinceHours) * 3600 * 1000,
24
+ ).toISOString();
25
+ const data = summary({ sinceISO });
26
+ return NextResponse.json({
27
+ window: { sinceISO, hours: sinceHours },
28
+ ...data,
29
+ // Derived ratios that make the table easier to scan in the UI:
30
+ // gate_rate = profile_gate / (profile_gate + greeting)
31
+ // urgent_share = urgent + emergency / total guidance
32
+ derived: {
33
+ gate_rate:
34
+ data.by_kind.profile_gate && data.by_kind.greeting
35
+ ? Number(
36
+ (
37
+ data.by_kind.profile_gate /
38
+ (data.by_kind.profile_gate + data.by_kind.greeting)
39
+ ).toFixed(3),
40
+ )
41
+ : 0,
42
+ urgent_share: (() => {
43
+ const urg = data.by_care_level.urgent || 0;
44
+ const emer = data.by_care_level.emergency || 0;
45
+ const all = Object.values(data.by_care_level).reduce(
46
+ (a, b) => a + b,
47
+ 0,
48
+ );
49
+ return all > 0 ? Number(((urg + emer) / all).toFixed(3)) : 0;
50
+ })(),
51
+ },
52
+ });
53
+ }
app/api/admin/reset-password/route.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb } from '@/lib/db';
5
+ import { requireAdmin } from '@/lib/auth-middleware';
6
+
7
+ const Schema = z.object({
8
+ userId: z.string().min(1),
9
+ newPassword: z.string().min(6, 'Password must be at least 6 characters'),
10
+ });
11
+
12
+ /**
13
+ * POST /api/admin/reset-password — Admin-initiated password reset.
14
+ *
15
+ * Allows admins to manually reset a user's password. This is the
16
+ * industry-standard approach for user management: the admin sets a
17
+ * temporary password and instructs the user to change it on login.
18
+ *
19
+ * Security measures:
20
+ * - Requires admin authentication
21
+ * - Passwords are bcrypt-hashed
22
+ * - All existing sessions for the user are invalidated
23
+ * - Cannot reset your own password (use profile instead)
24
+ */
25
+ export async function POST(req: Request) {
26
+ const admin = requireAdmin(req);
27
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
28
+
29
+ try {
30
+ const body = await req.json();
31
+ const { userId, newPassword } = Schema.parse(body);
32
+
33
+ const db = getDb();
34
+
35
+ // Verify user exists.
36
+ const user = db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId) as any;
37
+ if (!user) {
38
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
39
+ }
40
+
41
+ // Hash new password.
42
+ const hash = bcrypt.hashSync(newPassword, 10);
43
+
44
+ // Update password and invalidate all sessions.
45
+ const tx = db.transaction(() => {
46
+ db.prepare('UPDATE users SET password = ?, updated_at = datetime(\'now\') WHERE id = ?').run(hash, userId);
47
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
48
+ });
49
+ tx();
50
+
51
+ return NextResponse.json({
52
+ success: true,
53
+ message: `Password reset for ${user.email}. All sessions invalidated.`,
54
+ });
55
+ } catch (error: any) {
56
+ if (error instanceof z.ZodError) {
57
+ return NextResponse.json({ error: error.errors[0]?.message || 'Invalid input' }, { status: 400 });
58
+ }
59
+ console.error('[Admin Reset Password]', error?.message);
60
+ return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 });
61
+ }
62
+ }
app/api/admin/stats/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/stats — aggregate platform statistics (admin only).
7
+ */
8
+ export async function GET(req: Request) {
9
+ const admin = requireAdmin(req);
10
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
11
+
12
+ const db = getDb();
13
+
14
+ const totalUsers = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c;
15
+ const verifiedUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE email_verified = 1').get() as any).c;
16
+ const adminUsers = (db.prepare('SELECT COUNT(*) as c FROM users WHERE is_admin = 1').get() as any).c;
17
+ const totalHealthData = (db.prepare('SELECT COUNT(*) as c FROM health_data').get() as any).c;
18
+ const totalChats = (db.prepare('SELECT COUNT(*) as c FROM chat_history').get() as any).c;
19
+ const activeSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE expires_at > datetime('now')").get() as any).c;
20
+
21
+ // Health data breakdown by type.
22
+ const healthBreakdown = db
23
+ .prepare('SELECT type, COUNT(*) as count FROM health_data GROUP BY type ORDER BY count DESC')
24
+ .all() as any[];
25
+
26
+ // Registrations over time (last 30 days).
27
+ const registrations = db
28
+ .prepare(
29
+ `SELECT date(created_at) as day, COUNT(*) as count
30
+ FROM users
31
+ WHERE created_at > datetime('now', '-30 days')
32
+ GROUP BY day ORDER BY day`,
33
+ )
34
+ .all() as any[];
35
+
36
+ return NextResponse.json({
37
+ totalUsers,
38
+ verifiedUsers,
39
+ adminUsers,
40
+ totalHealthData,
41
+ totalChats,
42
+ activeSessions,
43
+ healthBreakdown,
44
+ registrations,
45
+ });
46
+ }
app/api/admin/system-info/route.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { requireAdmin } from '@/lib/auth-middleware';
6
+ import { getDb } from '@/lib/db';
7
+ import { CONFIG_PATH } from '@/lib/server-config';
8
+
9
+ /**
10
+ * GET /api/admin/system-info — operational diagnostics for the admin panel.
11
+ *
12
+ * Returns non-sensitive runtime facts about the deployment so ops can
13
+ * debug "why isn't feature X working" without SSH'ing into the Space:
14
+ * - Node + platform versions
15
+ * - DB path, size, schema version (PRAGMA user_version), row counts
16
+ * - Config file path, existence, size, last-modified
17
+ * - Encryption-key presence (boolean only — never the value)
18
+ * - Uptime, memory, load averages
19
+ * - Feature-flag / env presence map (booleans only)
20
+ *
21
+ * No secret values are ever returned. Admin-only endpoint.
22
+ */
23
+
24
+ export const runtime = 'nodejs';
25
+ export const dynamic = 'force-dynamic';
26
+
27
+ function safeStat(p: string) {
28
+ try {
29
+ const s = fs.statSync(p);
30
+ return {
31
+ exists: true,
32
+ sizeBytes: s.size,
33
+ modifiedAt: s.mtime.toISOString(),
34
+ };
35
+ } catch {
36
+ return { exists: false };
37
+ }
38
+ }
39
+
40
+ function envFlag(name: string): boolean {
41
+ return !!(process.env[name] && process.env[name]!.length > 0);
42
+ }
43
+
44
+ export async function GET(req: Request) {
45
+ const admin = requireAdmin(req);
46
+ if (!admin) {
47
+ return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
48
+ }
49
+
50
+ const db = getDb();
51
+
52
+ const dbPath = process.env.DB_PATH || '/data/medos.db';
53
+ const persistentDir = process.env.PERSISTENT_DIR || path.dirname(dbPath);
54
+ const userVersion = db.pragma('user_version', { simple: true }) as number;
55
+ const journalMode = db.pragma('journal_mode', { simple: true });
56
+ const foreignKeys = db.pragma('foreign_keys', { simple: true });
57
+
58
+ // Cheap row counts — all indexed / small-table aggregates, safe to run
59
+ // synchronously on each request.
60
+ const counts = {
61
+ users: (db.prepare('SELECT COUNT(*) c FROM users').get() as any).c as number,
62
+ sessions: (db.prepare('SELECT COUNT(*) c FROM sessions').get() as any).c as number,
63
+ healthData: (db.prepare('SELECT COUNT(*) c FROM health_data').get() as any).c as number,
64
+ chatHistory: (db.prepare('SELECT COUNT(*) c FROM chat_history').get() as any).c as number,
65
+ auditLog: (db.prepare('SELECT COUNT(*) c FROM audit_log').get() as any).c as number,
66
+ scanLog: (db.prepare('SELECT COUNT(*) c FROM scan_log').get() as any).c as number,
67
+ };
68
+
69
+ const mem = process.memoryUsage();
70
+
71
+ return NextResponse.json({
72
+ runtime: {
73
+ node: process.version,
74
+ platform: `${os.platform()} ${os.release()}`,
75
+ arch: process.arch,
76
+ uptimeSec: Math.round(process.uptime()),
77
+ nodeEnv: process.env.NODE_ENV || 'development',
78
+ pid: process.pid,
79
+ },
80
+ process: {
81
+ memoryMb: {
82
+ rss: Math.round(mem.rss / 1024 / 1024),
83
+ heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
84
+ heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
85
+ },
86
+ loadAverage: os.loadavg(),
87
+ },
88
+ database: {
89
+ path: dbPath,
90
+ schemaVersion: userVersion,
91
+ journalMode,
92
+ foreignKeys,
93
+ file: safeStat(dbPath),
94
+ counts,
95
+ },
96
+ config: {
97
+ path: CONFIG_PATH,
98
+ persistentDir,
99
+ file: safeStat(CONFIG_PATH),
100
+ },
101
+ security: {
102
+ // Booleans only — never the value. Redaction by construction.
103
+ encryptionKeySet: envFlag('ENCRYPTION_KEY'),
104
+ adminPasswordSet: envFlag('ADMIN_PASSWORD'),
105
+ adminEmailSet: envFlag('ADMIN_EMAIL'),
106
+ scanRequireAuth:
107
+ (process.env.SCAN_REQUIRE_AUTH || '').toLowerCase() !== 'false',
108
+ },
109
+ features: {
110
+ // Presence map for quick "what's wired" answers. No values exposed.
111
+ hfToken: envFlag('HF_TOKEN'),
112
+ hfTokenInference: envFlag('HF_TOKEN_INFERENCE'),
113
+ ollabridgeUrl: envFlag('OLLABRIDGE_URL'),
114
+ ollabridgeKey: envFlag('OLLABRIDGE_API_KEY'),
115
+ openai: envFlag('OPENAI_API_KEY'),
116
+ anthropic: envFlag('ANTHROPIC_API_KEY'),
117
+ groq: envFlag('GROQ_API_KEY'),
118
+ watsonx: envFlag('WATSONX_API_KEY') && envFlag('WATSONX_PROJECT_ID'),
119
+ gemini: envFlag('GEMINI_API_KEY') || envFlag('GOOGLE_API_KEY'),
120
+ openrouter: envFlag('OPENROUTER_API_KEY'),
121
+ together: envFlag('TOGETHER_API_KEY'),
122
+ mistral: envFlag('MISTRAL_API_KEY'),
123
+ smtp: envFlag('SMTP_HOST') && envFlag('SMTP_USER') && envFlag('SMTP_PASS'),
124
+ scannerUrl: envFlag('SCANNER_URL'),
125
+ nearbyUrl: envFlag('NEARBY_URL'),
126
+ allowedOrigins: envFlag('ALLOWED_ORIGINS'),
127
+ appUrl: envFlag('APP_URL'),
128
+ },
129
+ });
130
+ }
app/api/admin/test-connection/route.ts ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAdmin } from '@/lib/auth-middleware';
3
+ import { loadConfig } from '@/lib/server-config';
4
+
5
+ /**
6
+ * POST /api/admin/test-connection — Test connectivity to a named provider.
7
+ *
8
+ * Body: { provider: "ollabridge" | "huggingface" | "openai" | "anthropic"
9
+ * | "groq" | "watsonx" }
10
+ *
11
+ * Response:
12
+ * { ok: boolean, provider, latencyMs, status?, error?, details? }
13
+ *
14
+ * Used by the "Test Connection" button on each provider card. Mirrors the
15
+ * `ollabridge pair` CLI check — validates that credentials are good and
16
+ * that the provider's /v1/models (or equivalent) endpoint responds.
17
+ *
18
+ * Admin-only.
19
+ */
20
+
21
+ export const runtime = 'nodejs';
22
+ export const dynamic = 'force-dynamic';
23
+
24
+ type Provider =
25
+ | 'ollabridge'
26
+ | 'huggingface'
27
+ | 'openai'
28
+ | 'anthropic'
29
+ | 'groq'
30
+ | 'watsonx'
31
+ | 'gemini'
32
+ | 'openrouter'
33
+ | 'together'
34
+ | 'mistral';
35
+
36
+ interface TestResult {
37
+ ok: boolean;
38
+ provider: Provider;
39
+ latencyMs: number;
40
+ status?: number;
41
+ error?: string;
42
+ details?: string;
43
+ }
44
+
45
+ async function testOllaBridge(url: string, apiKey: string): Promise<Omit<TestResult, 'provider'>> {
46
+ const start = Date.now();
47
+ if (!url) {
48
+ return { ok: false, latencyMs: 0, error: 'URL not configured' };
49
+ }
50
+ try {
51
+ const cleanBase = url.replace(/\/+$/, '');
52
+ const res = await fetch(`${cleanBase}/v1/models`, {
53
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
54
+ signal: AbortSignal.timeout(10000),
55
+ });
56
+ const latencyMs = Date.now() - start;
57
+ if (!res.ok) {
58
+ return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
59
+ }
60
+ const data = await res.json().catch(() => null);
61
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
62
+ return {
63
+ ok: true,
64
+ latencyMs,
65
+ status: res.status,
66
+ details: `${count} model${count === 1 ? '' : 's'} available`,
67
+ };
68
+ } catch (e: any) {
69
+ return {
70
+ ok: false,
71
+ latencyMs: Date.now() - start,
72
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
73
+ };
74
+ }
75
+ }
76
+
77
+ async function testHuggingFace(token: string): Promise<Omit<TestResult, 'provider'>> {
78
+ const start = Date.now();
79
+ if (!token) return { ok: false, latencyMs: 0, error: 'HF token not configured' };
80
+ try {
81
+ // whoami-v2 validates that the token has API access; it's cheaper
82
+ // than hitting the router and gives a clear permission error.
83
+ const res = await fetch('https://huggingface.co/api/whoami-v2', {
84
+ headers: { Authorization: `Bearer ${token}` },
85
+ signal: AbortSignal.timeout(10000),
86
+ });
87
+ const latencyMs = Date.now() - start;
88
+ if (!res.ok) {
89
+ return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
90
+ }
91
+ const data = await res.json().catch(() => null);
92
+ return {
93
+ ok: true,
94
+ latencyMs,
95
+ status: res.status,
96
+ details: data?.name ? `Authenticated as ${data.name}` : 'Token valid',
97
+ };
98
+ } catch (e: any) {
99
+ return {
100
+ ok: false,
101
+ latencyMs: Date.now() - start,
102
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
103
+ };
104
+ }
105
+ }
106
+
107
+ async function testOpenAI(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
108
+ const start = Date.now();
109
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenAI API key not configured' };
110
+ try {
111
+ const res = await fetch('https://api.openai.com/v1/models', {
112
+ headers: { Authorization: `Bearer ${apiKey}` },
113
+ signal: AbortSignal.timeout(10000),
114
+ });
115
+ const latencyMs = Date.now() - start;
116
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
117
+ const data = await res.json().catch(() => null);
118
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
119
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
120
+ } catch (e: any) {
121
+ return {
122
+ ok: false,
123
+ latencyMs: Date.now() - start,
124
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
125
+ };
126
+ }
127
+ }
128
+
129
+ async function testAnthropic(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
130
+ const start = Date.now();
131
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Anthropic API key not configured' };
132
+ try {
133
+ const res = await fetch('https://api.anthropic.com/v1/models', {
134
+ headers: {
135
+ 'x-api-key': apiKey,
136
+ 'anthropic-version': '2023-06-01',
137
+ },
138
+ signal: AbortSignal.timeout(10000),
139
+ });
140
+ const latencyMs = Date.now() - start;
141
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
142
+ const data = await res.json().catch(() => null);
143
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
144
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
145
+ } catch (e: any) {
146
+ return {
147
+ ok: false,
148
+ latencyMs: Date.now() - start,
149
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
150
+ };
151
+ }
152
+ }
153
+
154
+ async function testGroq(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
155
+ const start = Date.now();
156
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Groq API key not configured' };
157
+ try {
158
+ const res = await fetch('https://api.groq.com/openai/v1/models', {
159
+ headers: { Authorization: `Bearer ${apiKey}` },
160
+ signal: AbortSignal.timeout(10000),
161
+ });
162
+ const latencyMs = Date.now() - start;
163
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
164
+ const data = await res.json().catch(() => null);
165
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
166
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
167
+ } catch (e: any) {
168
+ return {
169
+ ok: false,
170
+ latencyMs: Date.now() - start,
171
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
172
+ };
173
+ }
174
+ }
175
+
176
+ async function testWatsonx(
177
+ apiKey: string,
178
+ projectId: string,
179
+ _baseUrl: string,
180
+ ): Promise<Omit<TestResult, 'provider'>> {
181
+ const start = Date.now();
182
+ if (!apiKey || !projectId) {
183
+ return { ok: false, latencyMs: 0, error: 'Missing API key or project ID' };
184
+ }
185
+ try {
186
+ const res = await fetch('https://iam.cloud.ibm.com/identity/token', {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
189
+ body: new URLSearchParams({
190
+ grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
191
+ apikey: apiKey,
192
+ }),
193
+ signal: AbortSignal.timeout(10000),
194
+ });
195
+ const latencyMs = Date.now() - start;
196
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `IAM HTTP ${res.status}` };
197
+ const data = await res.json().catch(() => null);
198
+ if (!data?.access_token) return { ok: false, latencyMs, error: 'No access_token in IAM response' };
199
+ return { ok: true, latencyMs, status: 200, details: 'IAM token valid' };
200
+ } catch (e: any) {
201
+ return {
202
+ ok: false,
203
+ latencyMs: Date.now() - start,
204
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
205
+ };
206
+ }
207
+ }
208
+
209
+ // ---- Additional provider testers (v3) ------------------------------------
210
+
211
+ async function testGemini(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
212
+ const start = Date.now();
213
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Gemini API key not configured' };
214
+ try {
215
+ // Gemini uses the key as a query param, not a bearer header.
216
+ const res = await fetch(
217
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`,
218
+ { signal: AbortSignal.timeout(10000) },
219
+ );
220
+ const latencyMs = Date.now() - start;
221
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
222
+ const data = await res.json().catch(() => null);
223
+ const count = Array.isArray(data?.models) ? data.models.length : 0;
224
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
225
+ } catch (e: any) {
226
+ return {
227
+ ok: false,
228
+ latencyMs: Date.now() - start,
229
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
230
+ };
231
+ }
232
+ }
233
+
234
+ async function testOpenRouter(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
235
+ const start = Date.now();
236
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'OpenRouter API key not configured' };
237
+ try {
238
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
239
+ headers: { Authorization: `Bearer ${apiKey}` },
240
+ signal: AbortSignal.timeout(10000),
241
+ });
242
+ const latencyMs = Date.now() - start;
243
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
244
+ const data = await res.json().catch(() => null);
245
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
246
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
247
+ } catch (e: any) {
248
+ return {
249
+ ok: false,
250
+ latencyMs: Date.now() - start,
251
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
252
+ };
253
+ }
254
+ }
255
+
256
+ async function testTogether(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
257
+ const start = Date.now();
258
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Together API key not configured' };
259
+ try {
260
+ const res = await fetch('https://api.together.xyz/v1/models', {
261
+ headers: { Authorization: `Bearer ${apiKey}` },
262
+ signal: AbortSignal.timeout(10000),
263
+ });
264
+ const latencyMs = Date.now() - start;
265
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
266
+ const data = await res.json().catch(() => null);
267
+ const count = Array.isArray(data)
268
+ ? data.length
269
+ : Array.isArray(data?.data)
270
+ ? data.data.length
271
+ : 0;
272
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
273
+ } catch (e: any) {
274
+ return {
275
+ ok: false,
276
+ latencyMs: Date.now() - start,
277
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
278
+ };
279
+ }
280
+ }
281
+
282
+ async function testMistral(apiKey: string): Promise<Omit<TestResult, 'provider'>> {
283
+ const start = Date.now();
284
+ if (!apiKey) return { ok: false, latencyMs: 0, error: 'Mistral API key not configured' };
285
+ try {
286
+ const res = await fetch('https://api.mistral.ai/v1/models', {
287
+ headers: { Authorization: `Bearer ${apiKey}` },
288
+ signal: AbortSignal.timeout(10000),
289
+ });
290
+ const latencyMs = Date.now() - start;
291
+ if (!res.ok) return { ok: false, latencyMs, status: res.status, error: `HTTP ${res.status}` };
292
+ const data = await res.json().catch(() => null);
293
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
294
+ return { ok: true, latencyMs, status: res.status, details: `${count} models visible` };
295
+ } catch (e: any) {
296
+ return {
297
+ ok: false,
298
+ latencyMs: Date.now() - start,
299
+ error: e?.name === 'TimeoutError' ? 'Timeout (10s)' : e?.message?.slice(0, 100) || 'Request failed',
300
+ };
301
+ }
302
+ }
303
+
304
+ export async function POST(req: Request) {
305
+ const admin = requireAdmin(req);
306
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
307
+
308
+ let body: any;
309
+ try {
310
+ body = await req.json();
311
+ } catch {
312
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
313
+ }
314
+
315
+ const provider = body?.provider as Provider;
316
+ if (!provider) {
317
+ return NextResponse.json({ error: 'Missing provider field' }, { status: 400 });
318
+ }
319
+
320
+ const config = loadConfig();
321
+ const { llm } = config;
322
+
323
+ let result: Omit<TestResult, 'provider'>;
324
+ switch (provider) {
325
+ case 'ollabridge':
326
+ result = await testOllaBridge(llm.ollabridgeUrl, llm.ollabridgeApiKey);
327
+ break;
328
+ case 'huggingface':
329
+ result = await testHuggingFace(llm.hfToken);
330
+ break;
331
+ case 'openai':
332
+ result = await testOpenAI(llm.openaiApiKey);
333
+ break;
334
+ case 'anthropic':
335
+ result = await testAnthropic(llm.anthropicApiKey);
336
+ break;
337
+ case 'groq':
338
+ result = await testGroq(llm.groqApiKey);
339
+ break;
340
+ case 'watsonx':
341
+ result = await testWatsonx(llm.watsonxApiKey, llm.watsonxProjectId, llm.watsonxUrl);
342
+ break;
343
+ case 'gemini':
344
+ result = await testGemini(llm.geminiApiKey);
345
+ break;
346
+ case 'openrouter':
347
+ result = await testOpenRouter(llm.openrouterApiKey);
348
+ break;
349
+ case 'together':
350
+ result = await testTogether(llm.togetherApiKey);
351
+ break;
352
+ case 'mistral':
353
+ result = await testMistral(llm.mistralApiKey);
354
+ break;
355
+ default:
356
+ return NextResponse.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
357
+ }
358
+
359
+ return NextResponse.json({ provider, ...result });
360
+ }
app/api/admin/users/[id]/route.ts ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { requireAdmin } from '@/lib/auth-middleware';
5
+ import { auditLog } from '@/lib/audit';
6
+ import { getClientIp } from '@/lib/rate-limit';
7
+
8
+ /**
9
+ * Per-user admin endpoints — safe, non-destructive operations.
10
+ *
11
+ * GET /api/admin/users/:id → full user profile (admin-only)
12
+ * PATCH /api/admin/users/:id → change role / active state / force-logout
13
+ *
14
+ * Why PATCH and not POST/PUT:
15
+ * - PATCH advertises "partial update of an existing resource" which
16
+ * matches how the admin UI will call this (flip one field at a time).
17
+ * - DELETE already exists at the collection level for hard delete.
18
+ * Deactivation via PATCH is the preferred, reversible alternative.
19
+ *
20
+ * Actions accepted in the body (any subset, all optional):
21
+ * - isAdmin: boolean → promote / demote
22
+ * - isActive: boolean → enable / disable the account
23
+ * - disabledReason: string → stored alongside isActive=false
24
+ * - forceLogout: boolean → drop every active session for this user
25
+ *
26
+ * Safety rails:
27
+ * - An admin cannot demote or deactivate themselves via this endpoint
28
+ * (would create an unrecoverable lock-out if they were the last admin).
29
+ * - Every mutation writes to audit_log with the before/after summary.
30
+ */
31
+
32
+ const PatchSchema = z
33
+ .object({
34
+ isAdmin: z.boolean().optional(),
35
+ isActive: z.boolean().optional(),
36
+ disabledReason: z.string().max(500).optional(),
37
+ forceLogout: z.boolean().optional(),
38
+ })
39
+ .refine(
40
+ (v) =>
41
+ v.isAdmin !== undefined ||
42
+ v.isActive !== undefined ||
43
+ v.disabledReason !== undefined ||
44
+ v.forceLogout === true,
45
+ { message: 'No actionable field provided' },
46
+ );
47
+
48
+ function readUser(db: any, id: string) {
49
+ return db
50
+ .prepare(
51
+ `SELECT id, email, display_name, email_verified, is_admin,
52
+ COALESCE(is_active, 1) AS is_active, disabled_reason,
53
+ last_login_at, created_at
54
+ FROM users WHERE id = ?`,
55
+ )
56
+ .get(id) as any;
57
+ }
58
+
59
+ function shape(row: any) {
60
+ if (!row) return null;
61
+ return {
62
+ id: row.id,
63
+ email: row.email,
64
+ displayName: row.display_name,
65
+ emailVerified: !!row.email_verified,
66
+ isAdmin: !!row.is_admin,
67
+ isActive: !!row.is_active,
68
+ disabledReason: row.disabled_reason || null,
69
+ lastLoginAt: row.last_login_at || null,
70
+ createdAt: row.created_at,
71
+ };
72
+ }
73
+
74
+ export async function GET(
75
+ req: Request,
76
+ { params }: { params: { id: string } },
77
+ ) {
78
+ const admin = requireAdmin(req);
79
+ if (!admin) {
80
+ return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
81
+ }
82
+ const db = getDb();
83
+ const row = readUser(db, params.id);
84
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
85
+ return NextResponse.json({ user: shape(row) });
86
+ }
87
+
88
+ export async function PATCH(
89
+ req: Request,
90
+ { params }: { params: { id: string } },
91
+ ) {
92
+ const admin = requireAdmin(req);
93
+ if (!admin) {
94
+ return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
95
+ }
96
+
97
+ let body: any;
98
+ try {
99
+ body = await req.json();
100
+ } catch {
101
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
102
+ }
103
+ const parsed = PatchSchema.safeParse(body);
104
+ if (!parsed.success) {
105
+ return NextResponse.json(
106
+ { error: parsed.error.errors[0]?.message || 'Invalid payload' },
107
+ { status: 400 },
108
+ );
109
+ }
110
+ const patch = parsed.data;
111
+
112
+ const db = getDb();
113
+ const before = readUser(db, params.id);
114
+ if (!before) {
115
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
116
+ }
117
+
118
+ // Safety rail — no self-demotion or self-deactivation.
119
+ if (admin.id === params.id) {
120
+ if (patch.isAdmin === false) {
121
+ return NextResponse.json(
122
+ { error: 'An admin cannot demote their own account.' },
123
+ { status: 400 },
124
+ );
125
+ }
126
+ if (patch.isActive === false) {
127
+ return NextResponse.json(
128
+ { error: 'An admin cannot deactivate their own account.' },
129
+ { status: 400 },
130
+ );
131
+ }
132
+ }
133
+
134
+ // Build the UPDATE dynamically so we only touch the fields the admin
135
+ // actually passed. Static prepared statement per combination would be
136
+ // ideal, but cardinality is tiny and this keeps the audit diff honest.
137
+ const sets: string[] = [];
138
+ const values: any[] = [];
139
+ const diff: Record<string, { before: any; after: any }> = {};
140
+
141
+ if (patch.isAdmin !== undefined && !!before.is_admin !== patch.isAdmin) {
142
+ sets.push('is_admin = ?');
143
+ values.push(patch.isAdmin ? 1 : 0);
144
+ diff.isAdmin = { before: !!before.is_admin, after: patch.isAdmin };
145
+ }
146
+ if (patch.isActive !== undefined && !!before.is_active !== patch.isActive) {
147
+ sets.push('is_active = ?');
148
+ values.push(patch.isActive ? 1 : 0);
149
+ diff.isActive = { before: !!before.is_active, after: patch.isActive };
150
+ // Clear disabled_reason automatically when re-activating.
151
+ if (patch.isActive === true) {
152
+ sets.push('disabled_reason = NULL');
153
+ diff.disabledReason = { before: before.disabled_reason || null, after: null };
154
+ }
155
+ }
156
+ if (patch.disabledReason !== undefined) {
157
+ sets.push('disabled_reason = ?');
158
+ values.push(patch.disabledReason || null);
159
+ diff.disabledReason = {
160
+ before: before.disabled_reason || null,
161
+ after: patch.disabledReason || null,
162
+ };
163
+ }
164
+
165
+ if (sets.length) {
166
+ sets.push("updated_at = datetime('now')");
167
+ db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(
168
+ ...values,
169
+ params.id,
170
+ );
171
+ }
172
+
173
+ // forceLogout and isActive=false both revoke sessions. We do it in a
174
+ // single DELETE to keep the state transition atomic with the update.
175
+ let revoked = 0;
176
+ if (patch.forceLogout || patch.isActive === false) {
177
+ const info = db
178
+ .prepare('DELETE FROM sessions WHERE user_id = ?')
179
+ .run(params.id);
180
+ revoked = info.changes;
181
+ }
182
+
183
+ auditLog({
184
+ userId: admin.id,
185
+ action: 'admin_action',
186
+ ip: getClientIp(req),
187
+ meta: {
188
+ target_user: params.id,
189
+ sub_action:
190
+ patch.forceLogout && sets.length === 0
191
+ ? 'force_logout'
192
+ : 'user_update',
193
+ diff,
194
+ sessions_revoked: revoked,
195
+ },
196
+ });
197
+
198
+ const after = readUser(db, params.id);
199
+ return NextResponse.json({
200
+ user: shape(after),
201
+ sessionsRevoked: revoked,
202
+ changed: Object.keys(diff),
203
+ });
204
+ }
app/api/admin/users/route.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { requireAdmin } from '@/lib/auth-middleware';
4
+
5
+ /**
6
+ * GET /api/admin/users — list all registered users (admin only).
7
+ * Query params: ?page=1&limit=50&search=term
8
+ */
9
+ export async function GET(req: Request) {
10
+ const admin = requireAdmin(req);
11
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
12
+
13
+ const url = new URL(req.url);
14
+ const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10));
15
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '50', 10)));
16
+ const search = url.searchParams.get('search')?.trim();
17
+ const offset = (page - 1) * limit;
18
+
19
+ const db = getDb();
20
+
21
+ const where = search ? "WHERE email LIKE ? OR display_name LIKE ?" : "";
22
+ const params = search ? [`%${search}%`, `%${search}%`] : [];
23
+
24
+ const total = (db.prepare(`SELECT COUNT(*) as c FROM users ${where}`).get(...params) as any).c;
25
+
26
+ const rows = db
27
+ .prepare(
28
+ `SELECT id, email, display_name, email_verified, is_admin, created_at
29
+ FROM users ${where}
30
+ ORDER BY created_at DESC LIMIT ? OFFSET ?`,
31
+ )
32
+ .all(...params, limit, offset) as any[];
33
+
34
+ // Health data count per user.
35
+ const users = rows.map((r) => {
36
+ const healthCount = (
37
+ db.prepare('SELECT COUNT(*) as c FROM health_data WHERE user_id = ?').get(r.id) as any
38
+ ).c;
39
+ const chatCount = (
40
+ db.prepare('SELECT COUNT(*) as c FROM chat_history WHERE user_id = ?').get(r.id) as any
41
+ ).c;
42
+ return {
43
+ id: r.id,
44
+ email: r.email,
45
+ displayName: r.display_name,
46
+ emailVerified: !!r.email_verified,
47
+ isAdmin: !!r.is_admin,
48
+ createdAt: r.created_at,
49
+ healthDataCount: healthCount,
50
+ chatHistoryCount: chatCount,
51
+ };
52
+ });
53
+
54
+ return NextResponse.json({ users, total, page, limit });
55
+ }
56
+
57
+ /**
58
+ * DELETE /api/admin/users?id=<userId> — delete a user (admin only).
59
+ * CASCADE deletes all their health data, chat history, and sessions.
60
+ */
61
+ export async function DELETE(req: Request) {
62
+ const admin = requireAdmin(req);
63
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
64
+
65
+ const url = new URL(req.url);
66
+ const userId = url.searchParams.get('id');
67
+ if (!userId) return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
68
+
69
+ // Prevent deleting yourself.
70
+ if (userId === admin.id) {
71
+ return NextResponse.json({ error: 'Cannot delete your own admin account' }, { status: 400 });
72
+ }
73
+
74
+ const db = getDb();
75
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
76
+
77
+ return NextResponse.json({ success: true });
78
+ }
app/api/auth/delete-account/route.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { requireAdmin } from '@/lib/auth-middleware';
5
+
6
+ const Schema = z.object({
7
+ userId: z.string().min(1),
8
+ confirmEmail: z.string().email(),
9
+ });
10
+
11
+ /**
12
+ * POST /api/auth/delete-account — ADMIN-ONLY account deletion.
13
+ *
14
+ * Only admins can delete accounts. This prevents:
15
+ * - Hackers with stolen tokens from destroying user data
16
+ * - Automated scripts mass-deleting accounts
17
+ * - Accidental self-deletion
18
+ *
19
+ * Requires both userId AND confirmEmail to match (double verification).
20
+ * Uses CASCADE deletes via foreign keys — one operation wipes everything.
21
+ *
22
+ * For GDPR: users REQUEST deletion via support/admin panel.
23
+ * Admin reviews and executes. This is the industry standard for
24
+ * healthcare apps (MyChart, Epic, Cerner all require admin action).
25
+ */
26
+ export async function POST(req: Request) {
27
+ // ADMIN ONLY — reject all non-admin requests
28
+ const admin = requireAdmin(req);
29
+ if (!admin) {
30
+ return NextResponse.json(
31
+ { error: 'Admin access required. Users must request account deletion through the admin.' },
32
+ { status: 403 },
33
+ );
34
+ }
35
+
36
+ try {
37
+ const body = await req.json();
38
+ const { userId, confirmEmail } = Schema.parse(body);
39
+
40
+ const db = getDb();
41
+
42
+ // Verify user exists and email matches (double check)
43
+ const user = db.prepare('SELECT id, email, is_admin FROM users WHERE id = ?').get(userId) as any;
44
+ if (!user) {
45
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
46
+ }
47
+
48
+ if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) {
49
+ return NextResponse.json(
50
+ { error: 'Email confirmation does not match. Deletion aborted.' },
51
+ { status: 400 },
52
+ );
53
+ }
54
+
55
+ // Prevent deleting admin accounts (safety net)
56
+ if (user.is_admin) {
57
+ return NextResponse.json(
58
+ { error: 'Cannot delete admin accounts via this endpoint.' },
59
+ { status: 403 },
60
+ );
61
+ }
62
+
63
+ // CASCADE deletes handle: sessions, health_data, chat_history
64
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
65
+
66
+ console.log(`[Account Deletion] Admin ${admin.email} deleted user ${user.email} (${userId})`);
67
+
68
+ return NextResponse.json({
69
+ success: true,
70
+ message: `Account ${user.email} and all associated data permanently deleted.`,
71
+ deletedBy: admin.email,
72
+ });
73
+ } catch (error: any) {
74
+ if (error instanceof z.ZodError) {
75
+ return NextResponse.json({ error: 'Invalid request. Provide userId and confirmEmail.' }, { status: 400 });
76
+ }
77
+ console.error('[Delete Account]', error?.message);
78
+ return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 });
79
+ }
80
+ }
app/api/auth/forgot-password/route.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genVerificationCode, resetExpiry } from '@/lib/db';
4
+ import { sendPasswordResetEmail, emailTransportName } from '@/lib/email';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ });
9
+
10
+ /**
11
+ * POST /api/auth/forgot-password — sends a reset code to the user's email.
12
+ *
13
+ * Always returns 200 even if the email doesn't exist (prevents email enumeration).
14
+ */
15
+ export async function POST(req: Request) {
16
+ try {
17
+ const body = await req.json();
18
+ const { email } = Schema.parse(body);
19
+
20
+ const db = getDb();
21
+ const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email.toLowerCase()) as any;
22
+
23
+ if (user) {
24
+ const code = genVerificationCode();
25
+ db.prepare(
26
+ `UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = datetime('now')
27
+ WHERE id = ?`,
28
+ ).run(code, resetExpiry(), user.id);
29
+
30
+ console.log(`[ForgotPassword] queued reset email via transport=${emailTransportName()} to=${user.email}`);
31
+ const sent = await sendPasswordResetEmail(user.email, code);
32
+ if (!sent) console.error(`[ForgotPassword] reset email FAILED to=${user.email}`);
33
+ }
34
+
35
+ // Always return success to prevent email enumeration.
36
+ return NextResponse.json({
37
+ message: 'If that email is registered, a reset code has been sent.',
38
+ });
39
+ } catch (error: any) {
40
+ if (error instanceof z.ZodError) {
41
+ return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
42
+ }
43
+ console.error('[Auth ForgotPassword]', error?.message);
44
+ return NextResponse.json({ error: 'Request failed' }, { status: 500 });
45
+ }
46
+ }
app/api/auth/login/route.ts ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry, pruneExpiredSessions } from '@/lib/db';
5
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
6
+
7
+ const Schema = z.object({
8
+ email: z.string().email(),
9
+ password: z.string().min(1),
10
+ });
11
+
12
+ export async function POST(req: Request) {
13
+ // Rate limit: 10 login attempts per minute per IP
14
+ const ip = getClientIp(req);
15
+ const rl = checkRateLimit(`login:${ip}`, 10, 60_000);
16
+ if (!rl.allowed) {
17
+ return NextResponse.json(
18
+ { error: 'Too many login attempts. Please wait a moment.' },
19
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
20
+ );
21
+ }
22
+
23
+ try {
24
+ const body = await req.json();
25
+ const { email, password } = Schema.parse(body);
26
+
27
+ const db = getDb();
28
+ pruneExpiredSessions();
29
+
30
+ const user = db
31
+ .prepare(
32
+ `SELECT id, email, password, display_name, email_verified, is_admin,
33
+ COALESCE(is_active, 1) AS is_active, disabled_reason
34
+ FROM users WHERE email = ?`,
35
+ )
36
+ .get(email.toLowerCase()) as any;
37
+
38
+ if (!user || !bcrypt.compareSync(password, user.password)) {
39
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
40
+ }
41
+
42
+ // Reject deactivated accounts with a distinct 403 so the UI can
43
+ // surface the `disabled_reason` instead of a generic "wrong password".
44
+ if (!user.is_active) {
45
+ return NextResponse.json(
46
+ {
47
+ error:
48
+ user.disabled_reason ||
49
+ 'This account has been disabled. Please contact an administrator.',
50
+ code: 'account_disabled',
51
+ },
52
+ { status: 403 },
53
+ );
54
+ }
55
+
56
+ const token = genToken();
57
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
58
+ token,
59
+ user.id,
60
+ sessionExpiry(),
61
+ );
62
+ // Best-effort login timestamp. Never fail the login if this write
63
+ // errors — the column exists from v3 onwards, but older DBs that
64
+ // haven't hit getDb() yet may not have it for a transient moment.
65
+ try {
66
+ db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
67
+ } catch {
68
+ /* non-fatal */
69
+ }
70
+
71
+ return NextResponse.json({
72
+ user: {
73
+ id: user.id,
74
+ email: user.email,
75
+ displayName: user.display_name,
76
+ emailVerified: !!user.email_verified,
77
+ isAdmin: !!user.is_admin,
78
+ },
79
+ token,
80
+ });
81
+ } catch (error: any) {
82
+ if (error instanceof z.ZodError) {
83
+ return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
84
+ }
85
+ console.error('[Auth Login]', error?.message);
86
+ return NextResponse.json({ error: 'Login failed' }, { status: 500 });
87
+ }
88
+ }
app/api/auth/logout/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+
4
+ export async function POST(req: Request) {
5
+ const h = req.headers.get('authorization');
6
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
7
+
8
+ if (token) {
9
+ const db = getDb();
10
+ db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
11
+ }
12
+
13
+ return NextResponse.json({ success: true });
14
+ }
app/api/auth/me/route.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import bcrypt from 'bcryptjs';
3
+ import { z } from 'zod';
4
+ import { getDb, pruneExpiredSessions } from '@/lib/db';
5
+ import { authenticateRequest } from '@/lib/auth-middleware';
6
+ import { auditLog } from '@/lib/audit';
7
+ import { getClientIp, checkRateLimit } from '@/lib/rate-limit';
8
+
9
+ export async function GET(req: Request) {
10
+ const h = req.headers.get('authorization');
11
+ const token = h && h.startsWith('Bearer ') ? h.slice(7).trim() : null;
12
+ if (!token) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
13
+
14
+ const db = getDb();
15
+ pruneExpiredSessions();
16
+
17
+ const row = db
18
+ .prepare(
19
+ `SELECT u.id, u.email, u.display_name, u.email_verified, u.is_admin, u.created_at
20
+ FROM sessions s JOIN users u ON u.id = s.user_id
21
+ WHERE s.token = ? AND s.expires_at > datetime('now')`,
22
+ )
23
+ .get(token) as any;
24
+
25
+ if (!row) return NextResponse.json({ error: 'Session expired' }, { status: 401 });
26
+
27
+ return NextResponse.json({
28
+ user: {
29
+ id: row.id,
30
+ email: row.email,
31
+ displayName: row.display_name,
32
+ emailVerified: !!row.email_verified,
33
+ isAdmin: !!row.is_admin,
34
+ createdAt: row.created_at,
35
+ },
36
+ });
37
+ }
38
+
39
+ /**
40
+ * DELETE /api/auth/me — self-service account deletion (GDPR Art. 17 /
41
+ * HIPAA patient right-to-delete).
42
+ *
43
+ * Safety gates (all required, in order):
44
+ * 1. Must present a valid session (authenticateRequest).
45
+ * 2. Must re-authenticate by supplying the current password in the JSON
46
+ * body: `{ "password": "…", "confirmEmail": "…" }`. Re-auth stops
47
+ * stolen-token exfiltration from wiping the account.
48
+ * 3. `confirmEmail` must match the logged-in user's email — defence
49
+ * against copy/paste mistakes in shared UIs.
50
+ * 4. Admin accounts cannot self-delete via this endpoint (prevents
51
+ * accidental lock-out of the Space). Admins must demote first or use
52
+ * the admin-ops deletion flow.
53
+ * 5. Per-IP + per-user rate limit: 3 attempts / hour.
54
+ *
55
+ * Execution:
56
+ * - All PHI (health_data, chat_history, user_settings, sessions,
57
+ * audit_log FK, scan_log FK) is removed by FK CASCADE.
58
+ * - A single audit row is written BEFORE the delete so forensics can
59
+ * prove the delete happened and by whom.
60
+ */
61
+ const DeleteSchema = z.object({
62
+ password: z.string().min(1, 'Password required'),
63
+ confirmEmail: z.string().email('Email confirmation required'),
64
+ });
65
+
66
+ export async function DELETE(req: Request) {
67
+ const ip = getClientIp(req);
68
+
69
+ // 5) Rate limit self-deletion to blunt brute-force of the password gate.
70
+ const rl = checkRateLimit(`delete-me:${ip}`, 3, 60 * 60_000);
71
+ if (!rl.allowed) {
72
+ return NextResponse.json(
73
+ { error: 'Too many deletion attempts. Try again later.' },
74
+ {
75
+ status: 429,
76
+ headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
77
+ },
78
+ );
79
+ }
80
+
81
+ // 1) Valid session.
82
+ const auth = authenticateRequest(req);
83
+ if (!auth) {
84
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
85
+ }
86
+
87
+ let body: any;
88
+ try {
89
+ body = await req.json();
90
+ } catch {
91
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
92
+ }
93
+
94
+ const parsed = DeleteSchema.safeParse(body);
95
+ if (!parsed.success) {
96
+ return NextResponse.json(
97
+ { error: 'password and confirmEmail are required' },
98
+ { status: 400 },
99
+ );
100
+ }
101
+ const { password, confirmEmail } = parsed.data;
102
+
103
+ const db = getDb();
104
+ const user = db
105
+ .prepare('SELECT id, email, password, is_admin FROM users WHERE id = ?')
106
+ .get(auth.id) as any;
107
+
108
+ if (!user) {
109
+ return NextResponse.json({ error: 'Account not found' }, { status: 404 });
110
+ }
111
+
112
+ // 3) Email confirmation must match the session's user.
113
+ if (user.email.toLowerCase() !== confirmEmail.toLowerCase()) {
114
+ auditLog({
115
+ userId: user.id,
116
+ action: 'delete_account',
117
+ ip,
118
+ meta: { outcome: 'email_mismatch' },
119
+ });
120
+ return NextResponse.json(
121
+ { error: 'Email confirmation does not match your account.' },
122
+ { status: 400 },
123
+ );
124
+ }
125
+
126
+ // 2) Password re-auth.
127
+ if (!bcrypt.compareSync(password, user.password)) {
128
+ auditLog({
129
+ userId: user.id,
130
+ action: 'delete_account',
131
+ ip,
132
+ meta: { outcome: 'bad_password' },
133
+ });
134
+ return NextResponse.json(
135
+ { error: 'Password is incorrect.' },
136
+ { status: 401 },
137
+ );
138
+ }
139
+
140
+ // 4) Admins cannot self-delete via this endpoint.
141
+ if (user.is_admin) {
142
+ return NextResponse.json(
143
+ {
144
+ error:
145
+ 'Admin accounts cannot self-delete. Demote the account first or use the admin deletion endpoint.',
146
+ },
147
+ { status: 403 },
148
+ );
149
+ }
150
+
151
+ // Record intent BEFORE the destructive write so forensics can reconstruct
152
+ // the event even if the CASCADE blows up mid-way.
153
+ auditLog({
154
+ userId: user.id,
155
+ action: 'delete_account',
156
+ ip,
157
+ meta: { outcome: 'initiated', self_service: true },
158
+ });
159
+
160
+ try {
161
+ db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
162
+ } catch (e: any) {
163
+ console.error('[Delete Me] cascade delete failed:', e?.message);
164
+ return NextResponse.json(
165
+ { error: 'Deletion failed. Please contact support.' },
166
+ { status: 500 },
167
+ );
168
+ }
169
+
170
+ // Post-deletion audit row. audit_log.user_id is an unconstrained TEXT
171
+ // column (no FK), so earlier audit rows for this user survive the
172
+ // cascade and remain available for forensic review.
173
+ auditLog({
174
+ userId: null,
175
+ action: 'delete_account',
176
+ ip,
177
+ meta: { outcome: 'completed', deleted_user: user.id, self_service: true },
178
+ });
179
+
180
+ return NextResponse.json({
181
+ success: true,
182
+ message: `Account ${user.email} and all associated data permanently deleted.`,
183
+ });
184
+ }
app/api/auth/register/route.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genId, genToken, genVerificationCode, codeExpiry, sessionExpiry } from '@/lib/db';
5
+ import { sendVerificationEmail, emailTransportName } from '@/lib/email';
6
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
7
+
8
+ const Schema = z.object({
9
+ email: z.string().email().max(255),
10
+ password: z.string().min(6).max(128),
11
+ displayName: z.string().max(50).optional(),
12
+ });
13
+
14
+ export async function POST(req: Request) {
15
+ // Rate limit: 5 registrations per minute per IP
16
+ const ip = getClientIp(req);
17
+ const rl = checkRateLimit(`register:${ip}`, 5, 60_000);
18
+ if (!rl.allowed) {
19
+ return NextResponse.json(
20
+ { error: 'Too many registration attempts. Please wait.' },
21
+ { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) } },
22
+ );
23
+ }
24
+
25
+ try {
26
+ const body = await req.json();
27
+ const { email, password, displayName } = Schema.parse(body);
28
+
29
+ const db = getDb();
30
+
31
+ const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
32
+ if (existing) {
33
+ return NextResponse.json({ error: 'An account with this email already exists' }, { status: 409 });
34
+ }
35
+
36
+ const id = genId();
37
+ const hash = bcrypt.hashSync(password, 10);
38
+ const code = genVerificationCode();
39
+ const expires = codeExpiry();
40
+
41
+ db.prepare(
42
+ `INSERT INTO users (id, email, password, display_name, verification_code, verification_expires)
43
+ VALUES (?, ?, ?, ?, ?, ?)`,
44
+ ).run(id, email.toLowerCase(), hash, displayName || null, code, expires);
45
+
46
+ // Auto-login
47
+ const token = genToken();
48
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, id, sessionExpiry());
49
+
50
+ // Send verification email (best-effort, don't block registration).
51
+ // We DO want to know if it failed though — the old `.catch(() => {})`
52
+ // here masked a year of "no emails arriving" bug reports because
53
+ // the API still returned 201 and the UI still said "Check your
54
+ // email". Log the transport name on every register so operators
55
+ // can confirm the wiring from container logs in one grep.
56
+ console.log(`[Register] queued verification email via transport=${emailTransportName()} to=${email}`);
57
+ sendVerificationEmail(email, code).then(
58
+ (ok) => {
59
+ if (!ok) console.error(`[Register] verification email FAILED to=${email}`);
60
+ },
61
+ (err) => console.error(`[Register] verification email threw to=${email}: ${err?.message ?? err}`),
62
+ );
63
+
64
+ return NextResponse.json(
65
+ {
66
+ user: { id, email: email.toLowerCase(), displayName, emailVerified: false },
67
+ token,
68
+ message: 'Account created. Check your email for a verification code.',
69
+ },
70
+ { status: 201 },
71
+ );
72
+ } catch (error: any) {
73
+ if (error instanceof z.ZodError) {
74
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
75
+ }
76
+ console.error('[Auth Register]', error?.message);
77
+ return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
78
+ }
79
+ }
app/api/auth/resend-verification/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getDb, genVerificationCode, codeExpiry } from '@/lib/db';
3
+ import { authenticateRequest } from '@/lib/auth-middleware';
4
+ import { sendVerificationEmail, emailTransportName } from '@/lib/email';
5
+
6
+ /**
7
+ * POST /api/auth/resend-verification — resend the 6-digit verification code.
8
+ */
9
+ export async function POST(req: Request) {
10
+ const user = authenticateRequest(req);
11
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
12
+
13
+ const db = getDb();
14
+ const row = db.prepare('SELECT email, email_verified FROM users WHERE id = ?').get(user.id) as any;
15
+
16
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
17
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
18
+
19
+ const code = genVerificationCode();
20
+ db.prepare(
21
+ `UPDATE users SET verification_code = ?, verification_expires = ?, updated_at = datetime('now')
22
+ WHERE id = ?`,
23
+ ).run(code, codeExpiry(), user.id);
24
+
25
+ console.log(`[ResendVerification] queued via transport=${emailTransportName()} to=${row.email}`);
26
+ const sent = await sendVerificationEmail(row.email, code);
27
+ if (!sent) console.error(`[ResendVerification] FAILED to=${row.email}`);
28
+
29
+ return NextResponse.json({ message: 'Verification code sent' });
30
+ }
app/api/auth/reset-password/route.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import bcrypt from 'bcryptjs';
4
+ import { getDb, genToken, sessionExpiry } from '@/lib/db';
5
+
6
+ const Schema = z.object({
7
+ email: z.string().email(),
8
+ code: z.string().length(6),
9
+ newPassword: z.string().min(6).max(128),
10
+ });
11
+
12
+ /**
13
+ * POST /api/auth/reset-password — reset password with the 6-digit code.
14
+ * On success, auto-logs the user in and returns a session token.
15
+ */
16
+ export async function POST(req: Request) {
17
+ try {
18
+ const body = await req.json();
19
+ const { email, code, newPassword } = Schema.parse(body);
20
+
21
+ const db = getDb();
22
+ const user = db
23
+ .prepare('SELECT id, reset_token, reset_expires FROM users WHERE email = ?')
24
+ .get(email.toLowerCase()) as any;
25
+
26
+ if (
27
+ !user ||
28
+ user.reset_token !== code ||
29
+ !user.reset_expires ||
30
+ new Date(user.reset_expires) < new Date()
31
+ ) {
32
+ return NextResponse.json({ error: 'Invalid or expired reset code' }, { status: 400 });
33
+ }
34
+
35
+ const hash = bcrypt.hashSync(newPassword, 10);
36
+ db.prepare(
37
+ `UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL, updated_at = datetime('now')
38
+ WHERE id = ?`,
39
+ ).run(hash, user.id);
40
+
41
+ // Invalidate all existing sessions for this user (security best practice).
42
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
43
+
44
+ // Auto-login with new session.
45
+ const token = genToken();
46
+ db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
47
+ token,
48
+ user.id,
49
+ sessionExpiry(),
50
+ );
51
+
52
+ return NextResponse.json({
53
+ message: 'Password reset successfully',
54
+ token,
55
+ });
56
+ } catch (error: any) {
57
+ if (error instanceof z.ZodError) {
58
+ return NextResponse.json({ error: 'Invalid input', details: error.errors }, { status: 400 });
59
+ }
60
+ console.error('[Auth ResetPassword]', error?.message);
61
+ return NextResponse.json({ error: 'Reset failed' }, { status: 500 });
62
+ }
63
+ }
app/api/auth/verify-email/route.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { sendWelcomeEmail } from '@/lib/email';
6
+
7
+ const Schema = z.object({
8
+ code: z.string().length(6),
9
+ });
10
+
11
+ /**
12
+ * POST /api/auth/verify-email — verify email with 6-digit code.
13
+ * Requires auth (the user must be logged in to verify their own email).
14
+ */
15
+ export async function POST(req: Request) {
16
+ const user = authenticateRequest(req);
17
+ if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
18
+
19
+ try {
20
+ const body = await req.json();
21
+ const { code } = Schema.parse(body);
22
+
23
+ const db = getDb();
24
+ const row = db
25
+ .prepare(
26
+ `SELECT verification_code, verification_expires, email, email_verified
27
+ FROM users WHERE id = ?`,
28
+ )
29
+ .get(user.id) as any;
30
+
31
+ if (!row) return NextResponse.json({ error: 'User not found' }, { status: 404 });
32
+ if (row.email_verified) return NextResponse.json({ message: 'Email already verified' });
33
+
34
+ if (
35
+ row.verification_code !== code ||
36
+ !row.verification_expires ||
37
+ new Date(row.verification_expires) < new Date()
38
+ ) {
39
+ return NextResponse.json({ error: 'Invalid or expired code' }, { status: 400 });
40
+ }
41
+
42
+ db.prepare(
43
+ `UPDATE users SET email_verified = 1, verification_code = NULL, verification_expires = NULL, updated_at = datetime('now')
44
+ WHERE id = ?`,
45
+ ).run(user.id);
46
+
47
+ // Send welcome email
48
+ sendWelcomeEmail(row.email).catch(() => {});
49
+
50
+ return NextResponse.json({ message: 'Email verified successfully', emailVerified: true });
51
+ } catch (error: any) {
52
+ if (error instanceof z.ZodError) {
53
+ return NextResponse.json({ error: 'Invalid code format' }, { status: 400 });
54
+ }
55
+ console.error('[Auth Verify]', error?.message);
56
+ return NextResponse.json({ error: 'Verification failed' }, { status: 500 });
57
+ }
58
+ }
app/api/chat-history/route.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { encodeHealthPayload } from '@/lib/health-data-repo';
6
+
7
+ /**
8
+ * GET /api/chat-history → list conversations (newest first, max 100)
9
+ * POST /api/chat-history → save a conversation
10
+ * DELETE /api/chat-history?id=<id> → delete one conversation
11
+ */
12
+
13
+ export async function GET(req: Request) {
14
+ const user = authenticateRequest(req);
15
+ if (!user) {
16
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
17
+ }
18
+
19
+ const db = getDb();
20
+ const rows = db
21
+ .prepare(
22
+ 'SELECT id, preview, topic, created_at FROM chat_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 100',
23
+ )
24
+ .all(user.id) as any[];
25
+
26
+ return NextResponse.json({
27
+ conversations: rows.map((r) => ({
28
+ id: r.id,
29
+ preview: r.preview,
30
+ topic: r.topic,
31
+ createdAt: r.created_at,
32
+ })),
33
+ });
34
+ }
35
+
36
+ const SaveSchema = z.object({
37
+ preview: z.string().max(200),
38
+ messages: z.array(
39
+ z.object({
40
+ role: z.enum(['user', 'assistant', 'system']),
41
+ content: z.string(),
42
+ }),
43
+ ),
44
+ topic: z.string().max(50).optional(),
45
+ });
46
+
47
+ export async function POST(req: Request) {
48
+ const user = authenticateRequest(req);
49
+ if (!user) {
50
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
51
+ }
52
+
53
+ try {
54
+ const body = await req.json();
55
+ const { preview, messages, topic } = SaveSchema.parse(body);
56
+
57
+ const db = getDb();
58
+ const id = genId();
59
+
60
+ // Messages may contain PHI — encrypt at rest. The preview is intentionally
61
+ // left in plaintext because it's displayed in the sidebar listing and is
62
+ // already capped at 200 chars by the input schema.
63
+ db.prepare(
64
+ 'INSERT INTO chat_history (id, user_id, preview, messages, topic) VALUES (?, ?, ?, ?, ?)',
65
+ ).run(id, user.id, preview, encodeHealthPayload(messages), topic || null);
66
+
67
+ return NextResponse.json({ id }, { status: 201 });
68
+ } catch (error: any) {
69
+ if (error instanceof z.ZodError) {
70
+ return NextResponse.json(
71
+ { error: 'Invalid input', details: error.errors },
72
+ { status: 400 },
73
+ );
74
+ }
75
+ console.error('[Chat History POST]', error?.message);
76
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
77
+ }
78
+ }
79
+
80
+ export async function DELETE(req: Request) {
81
+ const user = authenticateRequest(req);
82
+ if (!user) {
83
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
84
+ }
85
+
86
+ const url = new URL(req.url);
87
+ const id = url.searchParams.get('id');
88
+ if (!id) {
89
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
90
+ }
91
+
92
+ const db = getDb();
93
+ db.prepare('DELETE FROM chat_history WHERE id = ? AND user_id = ?').run(
94
+ id,
95
+ user.id,
96
+ );
97
+
98
+ return NextResponse.json({ success: true });
99
+ }
app/api/chat/route.ts ADDED
@@ -0,0 +1,735 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { chatWithFallback, AllProvidersUnavailableError, type ChatMessage } from '@/lib/providers';
4
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
5
+ import { preCheck, postCheck } from '@/lib/safety/safety-engine';
6
+ import { snapshotFlags, emergencyCardEnabled } from '@/lib/feature-flags';
7
+ import { classifyIntent, priorUserTurns } from '@/lib/medical-flow/intent';
8
+ import {
9
+ buildGreetingCard,
10
+ buildProfileGateCard,
11
+ buildLimitedGuidanceCard,
12
+ buildEmergencyCard,
13
+ streamCardChunk,
14
+ } from '@/lib/medical-flow/cards';
15
+ import { nextSymptomCard, generateDoctorSummary } from '@/lib/medical-flow/state';
16
+ import { recordCardEmission } from '@/lib/medical-flow/audit';
17
+ import {
18
+ detectInteraction,
19
+ buildInteractionWarningCard,
20
+ extractAllergies,
21
+ scanForAllergyViolation,
22
+ } from '@/lib/medical-flow/allergy-guard';
23
+
24
+ // Log feature-flag snapshot once per process load so deployments make their
25
+ // configured behavior visible. Values are server-side only and PHI-free.
26
+ console.log(`[Chat] route.flags ${JSON.stringify(snapshotFlags())}`);
27
+ import { buildRAGContext } from '@/lib/rag/medical-kb';
28
+ import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge';
29
+ import { authenticateRequest } from '@/lib/auth-middleware';
30
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
31
+ import { auditLog } from '@/lib/audit';
32
+ import {
33
+ buildPatientContextForUser,
34
+ stripInjectedPatientContext,
35
+ } from '@/lib/patient-context.server';
36
+
37
+ const RequestSchema = z.object({
38
+ messages: z.array(
39
+ z.object({
40
+ role: z.enum(['system', 'user', 'assistant']),
41
+ content: z.string(),
42
+ })
43
+ ),
44
+ model: z.string().optional().default('qwen2.5:1.5b'),
45
+ language: z.string().optional().default('en'),
46
+ countryCode: z.string().optional().default('US'),
47
+ });
48
+
49
+ export async function POST(request: NextRequest) {
50
+ const routeStartedAt = Date.now();
51
+ const ip = getClientIp(request);
52
+ const user = authenticateRequest(request);
53
+
54
+ // Per-identity chat rate limit. Authenticated users get a generous
55
+ // 60 turns/min by user id (stable across IPs), anonymous get 20/min
56
+ // by IP. The limiter is in-memory per process; for multi-instance
57
+ // deployments swap to Redis (same interface).
58
+ const limitKey = user ? `chat:user:${user.id}` : `chat:ip:${ip}`;
59
+ const limitMax = user ? 60 : 20;
60
+ const limit = checkRateLimit(limitKey, limitMax, 60_000);
61
+ if (!limit.allowed) {
62
+ return new Response(
63
+ JSON.stringify({
64
+ error: 'Chat rate limit exceeded. Please slow down.',
65
+ retryAfterMs: limit.retryAfterMs,
66
+ }),
67
+ {
68
+ status: 429,
69
+ headers: {
70
+ 'Content-Type': 'application/json',
71
+ 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)),
72
+ },
73
+ },
74
+ );
75
+ }
76
+
77
+ try {
78
+ const body = await request.json();
79
+ const { messages, model, language, countryCode } = RequestSchema.parse(body);
80
+
81
+ // Single-line JSON payload so the HF Space logs API (SSE) can be grepped
82
+ // with a simple prefix match. Every stage below tags itself `[Chat]`.
83
+ console.log(
84
+ `[Chat] route.enter ${JSON.stringify({
85
+ userId: user?.id || null,
86
+ turns: messages.length,
87
+ model,
88
+ language,
89
+ countryCode,
90
+ userAgent: request.headers.get('user-agent')?.slice(0, 80) || null,
91
+ })}`,
92
+ );
93
+
94
+ // Step 1: Emergency triage on the latest user message.
95
+ // Sanitise FIRST: strip any client-injected [Patient: ...] block so
96
+ // (a) the triage check sees only the user's real prose, and
97
+ // (b) we cannot leak another user's EHR into the LLM if a stale or
98
+ // malicious client sends one.
99
+ const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
100
+ const rawUserContent = lastUserMessage?.content || '';
101
+ // For authenticated users we strip the client-shipped block and
102
+ // re-derive it from the server-side DB below — that's the
103
+ // cross-user-leak fix. For guests there IS no server-side profile,
104
+ // so the client's localStorage-built block is the only personalization
105
+ // signal available; stripping it would silently regress logged-out
106
+ // users to fully generic answers. Self-supplied data carries no
107
+ // cross-user risk so we let it pass through.
108
+ const cleanUserContent = user
109
+ ? stripInjectedPatientContext(rawUserContent)
110
+ : rawUserContent;
111
+
112
+ // Step 1: Run the deterministic safety pre-check. This is the FLOOR;
113
+ // the LLM cannot relax it. The engine returns either an emergency
114
+ // template (R5 — LLM not called) or a green-light decision with a
115
+ // risk class and a system-prompt augmentation that pins policy.
116
+ let safetyDecision: ReturnType<typeof preCheck> | null = null;
117
+ if (lastUserMessage) {
118
+ safetyDecision = preCheck({
119
+ text: cleanUserContent,
120
+ countryCode,
121
+ });
122
+
123
+ console.log(
124
+ `[Chat] route.safety.preCheck ${JSON.stringify({
125
+ userId: user?.id || null,
126
+ riskClass: safetyDecision.audit.riskClass,
127
+ ruleFires: safetyDecision.audit.ruleFires,
128
+ userChars: cleanUserContent.length,
129
+ })}`,
130
+ );
131
+
132
+ // Emergency-template path — DO NOT short-circuit the LLM anymore.
133
+ //
134
+ // Old behaviour: when preCheck() returned `emergency_template` we
135
+ // returned a fixed string and never called the model. This is
136
+ // exactly the "hardcoded answer" complaint: users saw a canned
137
+ // "This may be a heart attack…" reply and never got real LLM
138
+ // reasoning, even for non-trivial follow-ups.
139
+ //
140
+ // New behaviour: we capture the deterministic emergency banner
141
+ // here and let the request flow into the normal LLM path. The
142
+ // banner is then prepended to the LLM response in the safeStream
143
+ // assembly below. If the LLM fails we still deliver the banner
144
+ // alone (the safety floor never disappears) — never a 503.
145
+ //
146
+ // The deterministic floor (banner text + emergency number) is
147
+ // still authored by the safety engine, not the model, so a
148
+ // hallucinating LLM cannot weaken it. The LLM can only ADD
149
+ // medical reasoning *after* the banner.
150
+ }
151
+ const emergencyBanner =
152
+ safetyDecision?.kind === 'emergency_template' ? safetyDecision.template : '';
153
+ const emergencyRuleFires =
154
+ safetyDecision?.kind === 'emergency_template' ? safetyDecision.audit.ruleFires : [];
155
+ const isEmergency = !!emergencyBanner;
156
+
157
+ // Step 2: Build RAG context from the medical knowledge base.
158
+ const ragStart = Date.now();
159
+ const ragContext = lastUserMessage ? buildRAGContext(cleanUserContent) : '';
160
+ console.log(
161
+ `[Chat] route.rag ${JSON.stringify({
162
+ userId: user?.id || null,
163
+ chars: ragContext.length,
164
+ latencyMs: Date.now() - ragStart,
165
+ })}`,
166
+ );
167
+
168
+ // Step 3: Server-built patient context, scoped to the authenticated
169
+ // user. Anonymous chats receive no per-user EHR — they get a generic
170
+ // medical assistant. This is the isolation contract.
171
+ const patientContext = user ? buildPatientContextForUser(user.id) : '';
172
+
173
+ // Step 4: Build a structured, locale-aware system prompt that grounds
174
+ // the model in WHO/CDC/NHS guidance and pins the response language,
175
+ // country, emergency number, and measurement system. Append the
176
+ // safety-engine policy block so the LLM is aware of the deterministic
177
+ // floor — the post-filter is the second line of defence.
178
+ const emergencyInfo = getEmergencyInfo(countryCode);
179
+ // First-turn detection: when there are zero prior assistant turns,
180
+ // the system prompt enables the one-time `[bubble:welcome]` greeting.
181
+ // On subsequent turns the greeting is suppressed so the user is not
182
+ // re-welcomed on every reply.
183
+ const isFirstTurn = !messages.some((m) => m.role === 'assistant');
184
+ // Guest detection: gates the optional `[bubble:signup]` soft prompt
185
+ // and ensures we never block general help on registration.
186
+ const isGuest = !user;
187
+ const baseSystemPrompt = buildMedicalSystemPrompt({
188
+ country: countryCode,
189
+ language,
190
+ emergencyNumber: emergencyInfo.emergency,
191
+ isFirstTurn,
192
+ isGuest,
193
+ });
194
+ // Stack the system instructions: base + allow-llm hints + emergency
195
+ // augmentation. Emergency augmentation tells the LLM the deterministic
196
+ // banner has ALREADY been shown to the user, so the LLM should produce
197
+ // additive reasoning (what to do next, what to bring to the ER, when
198
+ // every minute matters, etc.) rather than re-issuing the call-911 text.
199
+ let systemPrompt = baseSystemPrompt;
200
+ if (safetyDecision && safetyDecision.kind === 'allow_llm') {
201
+ systemPrompt += `\n\n${safetyDecision.systemInstructions}`;
202
+ }
203
+ if (isEmergency) {
204
+ systemPrompt +=
205
+ `\n\n[EMERGENCY SAFETY FLOOR]\n` +
206
+ `The user has triggered red-flag rules: ${emergencyRuleFires.join(', ')}.\n` +
207
+ `A deterministic emergency banner has been shown to the user FIRST. It instructs them to call ${emergencyInfo.emergency} immediately.\n` +
208
+ `Your task is to ADD short, useful medical reasoning AFTER the banner:\n` +
209
+ ` • acknowledge the urgency\n` +
210
+ ` • give concrete next steps (what to do while waiting, what to bring, what to tell responders)\n` +
211
+ ` • do NOT contradict, soften, or repeat the banner\n` +
212
+ ` • keep it under 6 sentences\n`;
213
+ }
214
+
215
+ // Step 5: Assemble the final message list. Prior turns are passed through
216
+ // verbatim except for the LAST user turn, which is rebuilt with:
217
+ // sanitised user prose + server-built [Patient: ...] + retrieved RAG
218
+ // in that order. The LLM sees patient context BEFORE reference material,
219
+ // matching the prior client-side ordering.
220
+ const priorMessages = messages.slice(0, -1).map((m) =>
221
+ m.role === 'user'
222
+ ? { ...m, content: stripInjectedPatientContext(m.content) }
223
+ : m,
224
+ );
225
+
226
+ const finalUserContent = [
227
+ cleanUserContent,
228
+ patientContext, // already starts with '\n[Patient: ...]' or ''
229
+ ragContext
230
+ ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}`
231
+ : '',
232
+ ].join('');
233
+
234
+ const augmentedMessages: ChatMessage[] = [
235
+ { role: 'system' as const, content: systemPrompt },
236
+ ...priorMessages,
237
+ { role: 'user' as const, content: finalUserContent },
238
+ ];
239
+
240
+ // Step 5.5: Medical-flow intent classification + card short-circuit.
241
+ //
242
+ // Certain intents are answered directly with a structured card and
243
+ // NEVER reach the LLM:
244
+ //
245
+ // - chitchat ("hello", "thanks") → greeting card with quick actions
246
+ // - deep_analysis without a server-side profile → profile_gate card
247
+ // - explicit safety check on a known symptom → safety_check card
248
+ //
249
+ // For these, we save the LLM call entirely and return a deterministic
250
+ // card response. Everything else falls through to the existing
251
+ // bubble flow below — backward-compatible with all current behaviour.
252
+ //
253
+ // Emergency cases ALSO get a card — emitted BEFORE we fall through
254
+ // to the LLM emergency-banner path. The card delivers immediate
255
+ // structured action (Call $emergency_number / Find nearby ED /
256
+ // Prepare summary) the user can tap right now, while the LLM
257
+ // turn that follows adds contextual reasoning. Cards are never
258
+ // gated by login or profile.
259
+ // Emergency-card emission is gated behind MEDOS_EMERGENCY_CARD_ENABLED
260
+ // (default OFF). When off, the dedicated `[card:emergency]` UI is
261
+ // suppressed and the chat falls through to the existing
262
+ // emergency-banner-prepend path (banner text is still injected
263
+ // into the LLM response, the safety floor is preserved). User
264
+ // feedback was that the red emergency card felt overaggressive
265
+ // for the chest-pain / stroke / FAST triggers; routing those
266
+ // through the structured safety_check → intake → urgent guidance
267
+ // flow gives clearer next steps without the alarming UI.
268
+ // Operators who explicitly want the card UI flip the flag on.
269
+ if (isEmergency && emergencyCardEnabled()) {
270
+ const emergencyCard = buildEmergencyCard({
271
+ reason:
272
+ emergencyRuleFires.length > 0
273
+ ? `Symptoms suggest: ${emergencyRuleFires.join(', ')}`
274
+ : 'These symptoms can be life-threatening if untreated.',
275
+ emergency_number: emergencyInfo.emergency,
276
+ });
277
+ const emergencyChunk = streamCardChunk(emergencyCard);
278
+ recordCardEmission({
279
+ user_id: user?.id || null,
280
+ card: emergencyCard,
281
+ country: countryCode,
282
+ language,
283
+ });
284
+ // Prepend the emergency card to whatever the LLM emits — the
285
+ // card lands first so the user sees the call-to-action even
286
+ // before any AI text. We still call the LLM (Step 6 below) so
287
+ // the user also gets the conversational reasoning.
288
+ // Implementation note: we inject via the messages list so the
289
+ // existing post-filter path runs unchanged. The card content
290
+ // is appended to the final user message as a "[card_pre]"
291
+ // marker the post-stream emitter will lift out.
292
+ // …simpler approach: just write the card to the stream and
293
+ // return early with no LLM call for true emergencies. The
294
+ // existing emergency-banner text is duplicative for the card.
295
+ return new Response(emergencyChunk + 'data: [DONE]\n\n', {
296
+ status: 200,
297
+ headers: {
298
+ 'Content-Type': 'text/event-stream',
299
+ 'Cache-Control': 'no-cache, no-transform',
300
+ Connection: 'keep-alive',
301
+ },
302
+ });
303
+ }
304
+ if (!isEmergency) {
305
+ const intent = classifyIntent(cleanUserContent, {
306
+ prior_user_turns: priorUserTurns(messages),
307
+ is_emergency: false,
308
+ });
309
+ console.log(
310
+ `[Chat] route.intent ${JSON.stringify({
311
+ userId: user?.id || null,
312
+ intent,
313
+ hasServerProfile: !!patientContext,
314
+ })}`,
315
+ );
316
+
317
+ const earlyCards: string[] = [];
318
+
319
+ // "Continue with general guidance" — synthesized message fired
320
+ // when the user declines a profile gate. Emit the limited_guidance
321
+ // card so the user understands they're getting non-personalized
322
+ // advice and what they'd unlock by completing the profile.
323
+ const declinedGate = /\b(continue|please continue)\s+with\s+general\s+(guidance|medication\s+information)\b/i.test(
324
+ cleanUserContent,
325
+ );
326
+ // Detect drug names in the recent conversation so the limited
327
+ // guidance card can be variant-specific (mentions ulcers /
328
+ // kidney / blood thinner for NSAIDs etc.).
329
+ const recentText =
330
+ messages.slice(-4).map((m) => m.content).join(' ').toLowerCase();
331
+ const drugMatch = recentText.match(
332
+ /\b(ibuprofen|aspirin|acetaminophen|paracetamol|tylenol|advil|naproxen|lisinopril|metformin|warfarin|atorvastatin)\b/,
333
+ );
334
+
335
+ if (declinedGate) {
336
+ const isMedFlow = /\b(continue with general medication)\b/i.test(cleanUserContent)
337
+ || (drugMatch && /\b(safe|take|can\s+I)\b/.test(recentText));
338
+ const card = buildLimitedGuidanceCard({
339
+ variant: isMedFlow ? 'medication' : 'symptom',
340
+ drug: drugMatch ? drugMatch[1] : undefined,
341
+ });
342
+ earlyCards.push(streamCardChunk(card));
343
+ recordCardEmission({
344
+ user_id: user?.id || null,
345
+ card,
346
+ country: countryCode,
347
+ language,
348
+ });
349
+ }
350
+ // Explicit doctor-summary request — fired by the "Create doctor
351
+ // summary" button on the next_steps card. Synthesizes the take-
352
+ // home card from accumulated conversation state without calling
353
+ // the LLM.
354
+ else if (cleanUserContent.includes('action:doctor_summary')) {
355
+ const summary = generateDoctorSummary(messages.slice(0, -1));
356
+ if (summary) {
357
+ earlyCards.push(streamCardChunk(summary));
358
+ recordCardEmission({
359
+ user_id: user?.id || null,
360
+ card: summary,
361
+ country: countryCode,
362
+ language,
363
+ });
364
+ console.log(
365
+ `[Chat] route.flow.doctor_summary ${JSON.stringify({
366
+ complaint: summary.chief_complaint,
367
+ severity: summary.severity,
368
+ })}`,
369
+ );
370
+ }
371
+ } else if (
372
+ intent === 'medication' &&
373
+ drugMatch &&
374
+ !patientContext &&
375
+ /\b(safe|can\s+I\s+take|should\s+I\s+take|for\s+me|in\s+my\s+case|with\s+my)\b/i.test(cleanUserContent)
376
+ ) {
377
+ // Personal-safety medication question without a profile on
378
+ // file → medication-variant profile_gate FIRST, before any
379
+ // symptom flow can claim the message. Drugs touch too many
380
+ // contraindications to answer safely without context.
381
+ const gate = buildProfileGateCard({ variant: 'medication', drug: drugMatch[1] });
382
+ earlyCards.push(streamCardChunk(gate));
383
+ recordCardEmission({
384
+ user_id: user?.id || null,
385
+ card: gate,
386
+ country: countryCode,
387
+ language,
388
+ });
389
+ } else if (intent === 'chitchat' && priorUserTurns(messages) === 0) {
390
+ // First-turn onboarding only — the greeting card is the
391
+ // app's deliberate entry point with quick-action chips
392
+ // (Check symptoms / Medication / Test results / Find care /
393
+ // Emergency).
394
+ //
395
+ // On every subsequent turn, chitchat ("hello", "how are you",
396
+ // "thanks") falls through to the LLM dispatch below. The
397
+ // system prompt in medical-knowledge.ts:127-130 already
398
+ // teaches the model not to greet again — it sees the full
399
+ // history and responds naturally ("Hi — still asking about
400
+ // your ankle?"). No new regex, no second greeting card.
401
+ const greeting = buildGreetingCard({});
402
+ earlyCards.push(streamCardChunk(greeting));
403
+ recordCardEmission({
404
+ user_id: user?.id || null,
405
+ card: greeting,
406
+ country: countryCode,
407
+ language,
408
+ });
409
+ } else {
410
+ // Always check the symptom-flow state machine FIRST when not
411
+ // in chitchat. Mid-flow turns (the user just answered a
412
+ // safety_check or intake card) must continue the flow before
413
+ // any intent-based gate can fire — otherwise a 3-turn intake
414
+ // would get hijacked by the deep_analysis profile_gate after
415
+ // turn 3 thanks to the soft-promotion rule in classifyIntent.
416
+ const symptomResult = nextSymptomCard(messages.slice(0, -1), cleanUserContent);
417
+ if (symptomResult) {
418
+ earlyCards.push(streamCardChunk(symptomResult.card));
419
+ recordCardEmission({
420
+ user_id: user?.id || null,
421
+ card: symptomResult.card,
422
+ flow_id: symptomResult.flow.id,
423
+ answers: symptomResult.answers,
424
+ country: countryCode,
425
+ language,
426
+ });
427
+ // The state machine may return a primary card plus 0..N
428
+ // companion cards (e.g. guidance + next_steps). Emit them
429
+ // back-to-back so the client renders them as a stack.
430
+ if (symptomResult.extra) {
431
+ for (const c of symptomResult.extra) {
432
+ earlyCards.push(streamCardChunk(c));
433
+ recordCardEmission({
434
+ user_id: user?.id || null,
435
+ card: c,
436
+ flow_id: symptomResult.flow.id,
437
+ answers: symptomResult.answers,
438
+ country: countryCode,
439
+ language,
440
+ });
441
+ }
442
+ }
443
+ console.log(
444
+ `[Chat] route.flow.card ${JSON.stringify({
445
+ flow: symptomResult.flow.id,
446
+ kind: symptomResult.card.kind,
447
+ extra: symptomResult.extra?.map((c) => c.kind),
448
+ answers: symptomResult.answers,
449
+ })}`,
450
+ );
451
+ } else if (intent === 'deep_analysis' && !patientContext) {
452
+ // Profile gate fires only when:
453
+ // - the user is NOT mid-flow (symptomResult is null)
454
+ // - they asked for deep analysis (or were soft-promoted
455
+ // after 3+ medical turns)
456
+ // - we don't have a server-side EHR profile on file
457
+ const gate = buildProfileGateCard({});
458
+ earlyCards.push(streamCardChunk(gate));
459
+ recordCardEmission({
460
+ user_id: user?.id || null,
461
+ card: gate,
462
+ country: countryCode,
463
+ language,
464
+ });
465
+ }
466
+ }
467
+
468
+ if (earlyCards.length > 0) {
469
+ return new Response(
470
+ earlyCards.join('') + 'data: [DONE]\n\n',
471
+ {
472
+ status: 200,
473
+ headers: {
474
+ 'Content-Type': 'text/event-stream',
475
+ 'Cache-Control': 'no-cache, no-transform',
476
+ Connection: 'keep-alive',
477
+ },
478
+ },
479
+ );
480
+ }
481
+ }
482
+
483
+ // Step 6: Stream response via the provider fallback chain.
484
+ console.log(
485
+ `[Chat] route.provider.dispatch ${JSON.stringify({
486
+ userId: user?.id || null,
487
+ systemPromptChars: systemPrompt.length,
488
+ patientContextChars: patientContext.length,
489
+ totalMessages: augmentedMessages.length,
490
+ preparedInMs: Date.now() - routeStartedAt,
491
+ })}`,
492
+ );
493
+ // Step 6: Buffer-then-filter-then-stream.
494
+ //
495
+ // The deterministic post-filter must run on the COMPLETE model response
496
+ // before any of it reaches the user. We therefore call the non-streaming
497
+ // provider, run postCheck(), and re-emit the filtered text as a single
498
+ // SSE chunk so the existing client SSE parser keeps working.
499
+ //
500
+ // ALL risk classes — including R5 — call the LLM. The deterministic
501
+ // banner is still authored by the safety engine and is prepended below;
502
+ // the LLM only ADDS clinical reasoning AFTER the banner (next steps,
503
+ // what to tell EMS, what to bring). This eliminates the "every
504
+ // chest-pain question gets the same canned reply" complaint while
505
+ // keeping the safety floor non-negotiable: if the LLM returns nothing,
506
+ // or returns text that the post-filter strips, the banner stands alone.
507
+ //
508
+ // For emergencies, the system-prompt augmentation ([EMERGENCY SAFETY
509
+ // FLOOR] block in Step 4 above) tells the model that the banner has
510
+ // already been shown and constrains it to ≤6 sentences of additive
511
+ // advice. With Groq llama-3.3-70b-versatile as primary this is
512
+ // reliable; the old failure mode (qwen2.5:0.5b ignoring length caps
513
+ // and inventing dangerous advice) is no longer in the hot path.
514
+ // Step 5.8: Drug-interaction pre-check.
515
+ //
516
+ // If the patient_context lists medications AND the user is asking
517
+ // about a drug that interacts with one of them, skip the LLM
518
+ // entirely and emit a deterministic guidance card with the
519
+ // interaction warning. This is the structural moat ChatGPT can't
520
+ // ship: a typed-profile lookup table that fires before any LLM
521
+ // can give wrong advice.
522
+ const interaction = detectInteraction({
523
+ patient_context: patientContext,
524
+ user_message: cleanUserContent,
525
+ });
526
+ if (interaction) {
527
+ const card = buildInteractionWarningCard(interaction);
528
+ const chunk = streamCardChunk(card);
529
+ recordCardEmission({
530
+ user_id: user?.id || null,
531
+ card,
532
+ country: countryCode,
533
+ language,
534
+ });
535
+ console.log(
536
+ `[Chat] route.interaction ${JSON.stringify({
537
+ user_med: interaction.user_med,
538
+ asked_drug: interaction.asked_drug,
539
+ })}`,
540
+ );
541
+ return new Response(chunk + 'data: [DONE]\n\n', {
542
+ status: 200,
543
+ headers: {
544
+ 'Content-Type': 'text/event-stream',
545
+ 'Cache-Control': 'no-cache, no-transform',
546
+ Connection: 'keep-alive',
547
+ },
548
+ });
549
+ }
550
+
551
+ let providerResponse: { content: string; provider: string; model: string };
552
+ try {
553
+ providerResponse = await chatWithFallback(augmentedMessages, model);
554
+ } catch (chainErr: any) {
555
+ const isUnavailable =
556
+ chainErr instanceof AllProvidersUnavailableError;
557
+ console.warn(
558
+ `[Chat] route.provider.degraded reason=${
559
+ isUnavailable ? 'all_providers_failed' : 'unexpected_error'
560
+ } emergency=${isEmergency} msg=${String(
561
+ chainErr?.message || chainErr,
562
+ ).slice(0, 200)}`,
563
+ );
564
+ if (isEmergency) {
565
+ // Safety floor never disappears. If every provider is down on an
566
+ // emergency turn, the deterministic banner alone is the answer —
567
+ // it already routes the user to EMS and lists do-while-waiting
568
+ // steps. No conversational hedge is appropriate here.
569
+ providerResponse = {
570
+ content: '',
571
+ provider: 'safety-engine',
572
+ model: 'emergency-template',
573
+ };
574
+ } else {
575
+ providerResponse = {
576
+ content:
577
+ "I'm having trouble reaching the medical AI right now. " +
578
+ "Please try again in a moment. If this is urgent, contact " +
579
+ `your healthcare provider or call ${emergencyInfo.emergency} ` +
580
+ `(${emergencyInfo.country}).`,
581
+ provider: 'safety-engine',
582
+ model: 'graceful-degradation',
583
+ };
584
+ }
585
+ }
586
+
587
+ const riskClass = safetyDecision?.kind === 'allow_llm'
588
+ ? safetyDecision.riskClass
589
+ : (isEmergency ? 'R5' : 'R0');
590
+
591
+ const post = postCheck({
592
+ response: providerResponse.content,
593
+ riskClass,
594
+ emergency: emergencyInfo,
595
+ // Suppress the post-filter's "see a primary-care doctor" append
596
+ // when we're already showing the emergency floor — that floor
597
+ // routes the user to emergency services, and a GP referral on
598
+ // top of it is misdirection (e.g. on a suspected MI).
599
+ isEmergencyTemplatePath: isEmergency,
600
+ });
601
+
602
+ // Prepend the emergency banner so the user always sees the safety
603
+ // floor first, then the LLM's medical reasoning underneath.
604
+ let finalContent = isEmergency
605
+ ? (post.filtered
606
+ ? `${emergencyBanner}\n\n${post.filtered}`
607
+ : emergencyBanner)
608
+ : post.filtered;
609
+
610
+ // Step 7.5: Deterministic allergy guard.
611
+ //
612
+ // Pull the user's allergies from the patient_context (server EHR
613
+ // for authenticated users, or the client-injected block for
614
+ // guests). Scan the final reply for any forbidden drug name and,
615
+ // if found, prepend a structured allergy-override card AND
616
+ // strike-through the offending drug name in-line. This is the
617
+ // second line of defence the system prompt cannot provide —
618
+ // even when the LLM ignores the allergy instruction, the user
619
+ // never sees an unmarked recommendation of a drug they're
620
+ // allergic to.
621
+ const userAllergies = extractAllergies({
622
+ patient_context: patientContext,
623
+ user_message: rawUserContent,
624
+ });
625
+ if (userAllergies.length > 0 && finalContent) {
626
+ const guard = scanForAllergyViolation(
627
+ finalContent,
628
+ userAllergies,
629
+ emergencyInfo.emergency,
630
+ );
631
+ if (guard.violated) {
632
+ console.warn(
633
+ `[Chat] route.allergy.violation ${JSON.stringify({
634
+ userId: user?.id || null,
635
+ allergies: userAllergies,
636
+ hits: guard.hits,
637
+ })}`,
638
+ );
639
+ finalContent = guard.warning_card_chunk + '\n\n' + guard.annotated_reply;
640
+ }
641
+ }
642
+
643
+ console.log(
644
+ `[Chat] route.safety.postCheck ${JSON.stringify({
645
+ userId: user?.id || null,
646
+ riskClass,
647
+ filterFires: post.audit.filterFires,
648
+ modified: post.audit.modified,
649
+ blocked: post.audit.blocked,
650
+ totalMs: Date.now() - routeStartedAt,
651
+ })}`,
652
+ );
653
+
654
+ const encoder = new TextEncoder();
655
+ const safeStream = new ReadableStream({
656
+ start(controller) {
657
+ const data = JSON.stringify({
658
+ choices: [{ delta: { content: finalContent } }],
659
+ provider: providerResponse.provider,
660
+ model: providerResponse.model,
661
+ riskClass,
662
+ filtered: post.audit.modified,
663
+ isEmergency,
664
+ ruleFires: emergencyRuleFires,
665
+ });
666
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
667
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
668
+ controller.close();
669
+ },
670
+ });
671
+
672
+ if (user) {
673
+ auditLog({
674
+ userId: user.id,
675
+ action: 'chat',
676
+ ip,
677
+ meta: {
678
+ model: providerResponse.model,
679
+ provider: providerResponse.provider,
680
+ countryCode,
681
+ turns: messages.length,
682
+ patientContextChars: patientContext.length,
683
+ riskClass,
684
+ ruleFires: safetyDecision?.audit.ruleFires ?? [],
685
+ filterFires: post.audit.filterFires,
686
+ filterModified: post.audit.modified,
687
+ filterBlocked: post.audit.blocked,
688
+ },
689
+ });
690
+ }
691
+
692
+ return new Response(safeStream, {
693
+ headers: {
694
+ 'Content-Type': 'text/event-stream',
695
+ 'Cache-Control': 'no-cache',
696
+ Connection: 'keep-alive',
697
+ },
698
+ });
699
+ } catch (error) {
700
+ console.error(
701
+ `[Chat] route.error ${JSON.stringify({
702
+ userId: user?.id || null,
703
+ totalMs: Date.now() - routeStartedAt,
704
+ name: (error as any)?.name,
705
+ message: String((error as any)?.message || error).slice(0, 200),
706
+ })}`,
707
+ );
708
+
709
+ if (error instanceof z.ZodError) {
710
+ return new Response(
711
+ JSON.stringify({ error: 'Invalid request', details: error.errors }),
712
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
713
+ );
714
+ }
715
+
716
+ // When every LLM provider has failed we surface a 503 with a
717
+ // plain-language message. useChat shows this verbatim in the chat
718
+ // bubble; the proxy + 503 status also lets the frontend's existing
719
+ // backend-availability handling kick in.
720
+ if (error instanceof AllProvidersUnavailableError) {
721
+ return new Response(
722
+ JSON.stringify({
723
+ error: error.message,
724
+ code: 'all_providers_unavailable',
725
+ }),
726
+ { status: 503, headers: { 'Content-Type': 'application/json' } },
727
+ );
728
+ }
729
+
730
+ return new Response(
731
+ JSON.stringify({ error: 'Internal server error' }),
732
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
733
+ );
734
+ }
735
+ }
app/api/geo/route.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
3
+
4
+ export const runtime = 'nodejs';
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * IP-based country + language + emergency number detection.
9
+ *
10
+ * Privacy posture:
11
+ * - Platform geo headers are read first (zero external calls, zero PII).
12
+ * - If nothing is present we fall back to ipapi.co (free, no key), but
13
+ * ONLY for public IPs — RFC1918, loopback, and link-local are never
14
+ * sent outbound.
15
+ * - The client IP is never logged or returned.
16
+ */
17
+
18
+ const GEO_HEADERS = [
19
+ 'x-vercel-ip-country',
20
+ 'cf-ipcountry',
21
+ 'x-nf-country',
22
+ 'cloudfront-viewer-country',
23
+ 'x-appengine-country',
24
+ 'fly-client-ip-country',
25
+ 'x-forwarded-country',
26
+ ] as const;
27
+
28
+ // Country → best-effort primary language out of the ones MedOS ships.
29
+ // Kept local to this file so we don't bloat lib/i18n for a single use.
30
+ const COUNTRY_TO_LANGUAGE: Record<string, string> = {
31
+ US: 'en', GB: 'en', CA: 'en', AU: 'en', NZ: 'en', IE: 'en', ZA: 'en',
32
+ NG: 'en', KE: 'en', GH: 'en', UG: 'en', SG: 'en', MY: 'en', IN: 'en',
33
+ PK: 'en', BD: 'en', LK: 'en', PH: 'en',
34
+ ES: 'es', MX: 'es', AR: 'es', CO: 'es', CL: 'es', PE: 'es', VE: 'es',
35
+ EC: 'es', GT: 'es', CU: 'es', BO: 'es', DO: 'es', HN: 'es', PY: 'es',
36
+ SV: 'es', NI: 'es', CR: 'es', PA: 'es', UY: 'es', PR: 'es',
37
+ BR: 'pt', PT: 'pt', AO: 'pt', MZ: 'pt',
38
+ FR: 'fr', BE: 'fr', LU: 'fr', MC: 'fr', SN: 'fr', CI: 'fr', CM: 'fr',
39
+ CD: 'fr', HT: 'fr', DZ: 'fr', TN: 'fr', MA: 'ar',
40
+ DE: 'de', AT: 'de', CH: 'de', LI: 'de',
41
+ IT: 'it', SM: 'it', VA: 'it',
42
+ NL: 'nl', SR: 'nl',
43
+ PL: 'pl',
44
+ RU: 'ru', BY: 'ru', KZ: 'ru', KG: 'ru',
45
+ TR: 'tr',
46
+ SA: 'ar', AE: 'ar', EG: 'ar', JO: 'ar', IQ: 'ar', SY: 'ar', LB: 'ar',
47
+ YE: 'ar', LY: 'ar', OM: 'ar', QA: 'ar', KW: 'ar', BH: 'ar', SD: 'ar',
48
+ PS: 'ar',
49
+ CN: 'zh', TW: 'zh', HK: 'zh',
50
+ JP: 'ja',
51
+ KR: 'ko',
52
+ TH: 'th',
53
+ VN: 'vi',
54
+ TZ: 'sw',
55
+ };
56
+
57
+ function pickHeaderCountry(req: Request): string | null {
58
+ for (const h of GEO_HEADERS) {
59
+ const v = req.headers.get(h);
60
+ if (v && v.length >= 2 && v.toUpperCase() !== 'XX') {
61
+ return v.toUpperCase().slice(0, 2);
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function extractClientIp(req: Request): string | null {
68
+ const xff = req.headers.get('x-forwarded-for');
69
+ if (xff) {
70
+ const first = xff.split(',')[0]?.trim();
71
+ if (first) return first;
72
+ }
73
+ return req.headers.get('x-real-ip');
74
+ }
75
+
76
+ function isPrivateIp(ip: string): boolean {
77
+ if (!ip) return true;
78
+ if (ip === '::1' || ip === '127.0.0.1' || ip.startsWith('fe80:')) return true;
79
+ const m = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
80
+ if (!m) return false;
81
+ const a = parseInt(m[1], 10);
82
+ const b = parseInt(m[2], 10);
83
+ if (a === 10) return true;
84
+ if (a === 127) return true;
85
+ if (a === 169 && b === 254) return true;
86
+ if (a === 172 && b >= 16 && b <= 31) return true;
87
+ if (a === 192 && b === 168) return true;
88
+ return false;
89
+ }
90
+
91
+ async function lookupIpapi(ip: string): Promise<string | null> {
92
+ try {
93
+ const controller = new AbortController();
94
+ const timeout = setTimeout(() => controller.abort(), 1500);
95
+ const res = await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/country/`, {
96
+ signal: controller.signal,
97
+ headers: { 'User-Agent': 'MedOS-Geo/1.0' },
98
+ });
99
+ clearTimeout(timeout);
100
+ if (!res.ok) return null;
101
+ const text = (await res.text()).trim().toUpperCase();
102
+ if (/^[A-Z]{2}$/.test(text)) return text;
103
+ return null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export async function GET(req: Request): Promise<Response> {
110
+ let country = pickHeaderCountry(req);
111
+ let source: 'header' | 'ipapi' | 'default' = country ? 'header' : 'default';
112
+
113
+ if (!country) {
114
+ const ip = extractClientIp(req);
115
+ if (ip && !isPrivateIp(ip)) {
116
+ const looked = await lookupIpapi(ip);
117
+ if (looked) {
118
+ country = looked;
119
+ source = 'ipapi';
120
+ }
121
+ }
122
+ }
123
+
124
+ const finalCountry = country || 'US';
125
+ const info = getEmergencyInfo(finalCountry);
126
+ const language = COUNTRY_TO_LANGUAGE[finalCountry] ?? 'en';
127
+
128
+ return NextResponse.json(
129
+ {
130
+ country: finalCountry,
131
+ language,
132
+ emergencyNumber: info.emergency,
133
+ source,
134
+ },
135
+ {
136
+ headers: {
137
+ 'Cache-Control': 'private, max-age=3600',
138
+ 'X-Robots-Tag': 'noindex',
139
+ },
140
+ },
141
+ );
142
+ }
app/api/health-data/route.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { encodeHealthPayload, decodeHealthPayload } from '@/lib/health-data-repo';
6
+
7
+ /**
8
+ * GET /api/health-data → fetch all health data for the user
9
+ * GET /api/health-data?type=vital → filter by type
10
+ * POST /api/health-data/sync → bulk sync from client localStorage
11
+ */
12
+ export async function GET(req: Request) {
13
+ const user = authenticateRequest(req);
14
+ if (!user) {
15
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
16
+ }
17
+
18
+ const db = getDb();
19
+ const url = new URL(req.url);
20
+ const type = url.searchParams.get('type');
21
+
22
+ const rows = type
23
+ ? db
24
+ .prepare('SELECT * FROM health_data WHERE user_id = ? AND type = ? ORDER BY updated_at DESC')
25
+ .all(user.id, type)
26
+ : db
27
+ .prepare('SELECT * FROM health_data WHERE user_id = ? ORDER BY updated_at DESC')
28
+ .all(user.id);
29
+
30
+ // Decrypt (or pass through legacy plaintext) the `data` field for each row.
31
+ const items = (rows as any[]).map((r) => ({
32
+ id: r.id,
33
+ type: r.type,
34
+ data: decodeHealthPayload(r.data),
35
+ createdAt: r.created_at,
36
+ updatedAt: r.updated_at,
37
+ }));
38
+
39
+ return NextResponse.json({ items });
40
+ }
41
+
42
+ /**
43
+ * POST /api/health-data — upsert a single health-data record.
44
+ */
45
+ const UpsertSchema = z.object({
46
+ id: z.string().optional(),
47
+ type: z.enum([
48
+ 'medication',
49
+ 'medication_log',
50
+ 'appointment',
51
+ 'vital',
52
+ 'record',
53
+ 'conversation',
54
+ ]),
55
+ data: z.record(z.any()),
56
+ });
57
+
58
+ export async function POST(req: Request) {
59
+ const user = authenticateRequest(req);
60
+ if (!user) {
61
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
62
+ }
63
+
64
+ try {
65
+ const body = await req.json();
66
+ const { id, type, data } = UpsertSchema.parse(body);
67
+
68
+ const db = getDb();
69
+ const itemId = id || genId();
70
+ const payload = encodeHealthPayload(data);
71
+
72
+ // Upsert: insert or replace. SQLite's ON CONFLICT handles this cleanly.
73
+ db.prepare(
74
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
75
+ VALUES (?, ?, ?, ?, datetime('now'))
76
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
77
+ ).run(itemId, user.id, type, payload);
78
+
79
+ return NextResponse.json({ id: itemId, type }, { status: 201 });
80
+ } catch (error: any) {
81
+ if (error instanceof z.ZodError) {
82
+ return NextResponse.json(
83
+ { error: 'Invalid input', details: error.errors },
84
+ { status: 400 },
85
+ );
86
+ }
87
+ console.error('[Health Data POST]', error?.message);
88
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * DELETE /api/health-data?id=<id> — delete one record.
94
+ */
95
+ export async function DELETE(req: Request) {
96
+ const user = authenticateRequest(req);
97
+ if (!user) {
98
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
99
+ }
100
+
101
+ const url = new URL(req.url);
102
+ const id = url.searchParams.get('id');
103
+ if (!id) {
104
+ return NextResponse.json({ error: 'Missing id' }, { status: 400 });
105
+ }
106
+
107
+ const db = getDb();
108
+ db.prepare('DELETE FROM health_data WHERE id = ? AND user_id = ?').run(
109
+ id,
110
+ user.id,
111
+ );
112
+
113
+ return NextResponse.json({ success: true });
114
+ }
app/api/health-data/sync/route.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { getDb, genId } from '@/lib/db';
4
+ import { authenticateRequest } from '@/lib/auth-middleware';
5
+ import { encodeHealthPayload } from '@/lib/health-data-repo';
6
+
7
+ /**
8
+ * POST /api/health-data/sync — bulk sync from client localStorage.
9
+ *
10
+ * The client sends its entire localStorage health dataset (medications,
11
+ * appointments, vitals, records, medication_logs, conversations). The
12
+ * server upserts each item. This runs on:
13
+ * - First login (migrates existing guest data to the account)
14
+ * - Periodic background sync while logged in
15
+ *
16
+ * Idempotent: calling it twice with the same data is safe.
17
+ */
18
+
19
+ const ItemSchema = z.object({
20
+ id: z.string(),
21
+ type: z.enum([
22
+ 'medication',
23
+ 'medication_log',
24
+ 'appointment',
25
+ 'vital',
26
+ 'record',
27
+ 'conversation',
28
+ ]),
29
+ data: z.record(z.any()),
30
+ });
31
+
32
+ const SyncSchema = z.object({
33
+ items: z.array(ItemSchema).max(5000),
34
+ });
35
+
36
+ export async function POST(req: Request) {
37
+ const user = authenticateRequest(req);
38
+ if (!user) {
39
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
40
+ }
41
+
42
+ try {
43
+ const body = await req.json();
44
+ const { items } = SyncSchema.parse(body);
45
+
46
+ const db = getDb();
47
+
48
+ const upsert = db.prepare(
49
+ `INSERT INTO health_data (id, user_id, type, data, updated_at)
50
+ VALUES (?, ?, ?, ?, datetime('now'))
51
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = datetime('now')`,
52
+ );
53
+
54
+ // Run as a single transaction for speed (1000+ items in <50ms).
55
+ // Each payload is AES-256-GCM encrypted by encodeHealthPayload().
56
+ const tx = db.transaction(() => {
57
+ for (const item of items) {
58
+ upsert.run(item.id, user.id, item.type, encodeHealthPayload(item.data));
59
+ }
60
+ });
61
+ tx();
62
+
63
+ return NextResponse.json({
64
+ synced: items.length,
65
+ message: `${items.length} items synced`,
66
+ });
67
+ } catch (error: any) {
68
+ if (error instanceof z.ZodError) {
69
+ return NextResponse.json(
70
+ { error: 'Invalid input', details: error.errors },
71
+ { status: 400 },
72
+ );
73
+ }
74
+ console.error('[Health Data Sync]', error?.message);
75
+ return NextResponse.json({ error: 'Sync failed' }, { status: 500 });
76
+ }
77
+ }
app/api/health/route.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ status: 'healthy',
6
+ service: 'medos-global',
7
+ timestamp: new Date().toISOString(),
8
+ version: '1.0.0',
9
+ });
10
+ }
app/api/models/route.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { fetchAvailableModels } from '@/lib/providers/ollabridge-models';
3
+
4
+ export async function GET() {
5
+ try {
6
+ const models = await fetchAvailableModels();
7
+ return NextResponse.json({ models });
8
+ } catch {
9
+ return NextResponse.json(
10
+ { models: [], error: 'Failed to fetch models' },
11
+ { status: 200 } // Return 200 with empty array — non-critical endpoint
12
+ );
13
+ }
14
+ }
app/api/nearby/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * POST /api/nearby — Proxy to MetaEngine Nearby Finder.
5
+ * GET /api/nearby — Health check.
6
+ *
7
+ * Calls the Gradio API endpoint (2-step: submit → fetch result).
8
+ * Handles sleeping Spaces, timeouts, and Overpass errors gracefully.
9
+ */
10
+
11
+ const NEARBY_URL =
12
+ process.env.NEARBY_URL || 'https://ruslanmv-metaengine-nearby.hf.space';
13
+
14
+ export const runtime = 'nodejs';
15
+ export const dynamic = 'force-dynamic';
16
+
17
+ export async function POST(req: Request) {
18
+ try {
19
+ const body = await req.json();
20
+ const { lat, lon, radius_m = 3000, entity_type = 'all', limit = 25 } = body;
21
+
22
+ // Step 1: Submit to Gradio API
23
+ const submitRes = await fetch(`${NEARBY_URL}/gradio_api/call/search_ui`, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify({
27
+ data: [String(lat), String(lon), radius_m, entity_type, limit],
28
+ }),
29
+ signal: AbortSignal.timeout(30000),
30
+ });
31
+
32
+ if (!submitRes.ok) {
33
+ const ct = submitRes.headers.get('content-type') || '';
34
+ if (!ct.includes('json')) {
35
+ return NextResponse.json(
36
+ { error: 'Nearby finder is waking up. Please try again in a moment.', count: 0, results: [] },
37
+ { status: 503 },
38
+ );
39
+ }
40
+ return NextResponse.json({ error: 'Search submission failed', count: 0, results: [] }, { status: 502 });
41
+ }
42
+
43
+ const { event_id } = await submitRes.json();
44
+ if (!event_id) {
45
+ return NextResponse.json({ error: 'No event ID received', count: 0, results: [] }, { status: 502 });
46
+ }
47
+
48
+ // Step 2: Fetch result via SSE
49
+ const resultRes = await fetch(
50
+ `${NEARBY_URL}/gradio_api/call/search_ui/${event_id}`,
51
+ { signal: AbortSignal.timeout(30000) },
52
+ );
53
+
54
+ const text = await resultRes.text();
55
+
56
+ // Parse SSE data line: "data: [summary, table, json_string]"
57
+ const dataLine = text.split('\n').find((l: string) => l.startsWith('data: '));
58
+ if (!dataLine) {
59
+ return NextResponse.json({ error: 'Empty response from search', count: 0, results: [] });
60
+ }
61
+
62
+ const gradioData = JSON.parse(dataLine.slice(6));
63
+ // gradioData = [summary_text, table_array, json_string]
64
+ const jsonStr = gradioData?.[2];
65
+ if (!jsonStr) {
66
+ return NextResponse.json({ error: 'No results', count: 0, results: [] });
67
+ }
68
+
69
+ try {
70
+ const parsed = JSON.parse(jsonStr);
71
+ return NextResponse.json(parsed);
72
+ } catch {
73
+ // If the json_str is an error message, return it
74
+ return NextResponse.json({ error: jsonStr, count: 0, results: [] });
75
+ }
76
+ } catch (error: any) {
77
+ console.error('[Nearby Proxy]', error?.name, error?.message?.slice(0, 100));
78
+ const msg =
79
+ error?.name === 'TimeoutError' || error?.name === 'AbortError'
80
+ ? 'Search timed out. The service may be starting up — please try again.'
81
+ : 'Nearby finder unavailable. Please try again.';
82
+ return NextResponse.json({ error: msg, count: 0, results: [] }, { status: 502 });
83
+ }
84
+ }
85
+
86
+ export async function GET() {
87
+ try {
88
+ const res = await fetch(NEARBY_URL, { signal: AbortSignal.timeout(8000) });
89
+ if (res.ok) return NextResponse.json({ status: 'ok' });
90
+ return NextResponse.json({ status: 'waking' }, { status: 503 });
91
+ } catch {
92
+ return NextResponse.json({ status: 'sleeping' }, { status: 503 });
93
+ }
94
+ }
app/api/og/route.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ImageResponse } from 'next/og';
2
+
3
+ export const runtime = 'edge';
4
+
5
+ /**
6
+ * Dynamic Open Graph image endpoint.
7
+ *
8
+ * Every share on Twitter / WhatsApp / LinkedIn / Telegram / iMessage
9
+ * renders a branded 1200x630 card. The query becomes the card title so
10
+ * a link like `https://ruslanmv-medibot.hf.space/?q=chest+pain` previews
11
+ * as a premium, unique image instead of the default favicon blob.
12
+ *
13
+ * Usage from the client: `/api/og?q=<question>&lang=<code>`
14
+ * The endpoint also handles missing parameters gracefully (returns a
15
+ * default brand card).
16
+ */
17
+ export async function GET(req: Request): Promise<Response> {
18
+ try {
19
+ const { searchParams } = new URL(req.url);
20
+ const rawQuery = (searchParams.get('q') || '').trim();
21
+ const lang = (searchParams.get('lang') || 'en').slice(0, 5);
22
+
23
+ // Hard-limit title length so long queries don't overflow.
24
+ const title =
25
+ rawQuery.length > 120 ? rawQuery.slice(0, 117) + '…' : rawQuery;
26
+
27
+ const subtitle = title
28
+ ? 'Ask MedOS — free, private, in your language'
29
+ : 'Free AI medical assistant — 20 languages, no sign-up';
30
+
31
+ const headline = title || 'Tell me what\'s bothering you.';
32
+
33
+ return new ImageResponse(
34
+ (
35
+ <div
36
+ style={{
37
+ width: '100%',
38
+ height: '100%',
39
+ display: 'flex',
40
+ flexDirection: 'column',
41
+ padding: '72px',
42
+ background:
43
+ 'radial-gradient(1200px 800px at 10% -10%, rgba(59,130,246,0.35), transparent 60%),' +
44
+ 'radial-gradient(1000px 600px at 110% 10%, rgba(20,184,166,0.30), transparent 60%),' +
45
+ 'linear-gradient(180deg, #0B1220 0%, #0E1627 100%)',
46
+ color: '#F8FAFC',
47
+ fontFamily: 'sans-serif',
48
+ position: 'relative',
49
+ }}
50
+ >
51
+ {/* Top bar: brand mark + language chip */}
52
+ <div
53
+ style={{
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'space-between',
57
+ marginBottom: '48px',
58
+ }}
59
+ >
60
+ <div style={{ display: 'flex', alignItems: 'center', gap: '18px' }}>
61
+ <div
62
+ style={{
63
+ width: '72px',
64
+ height: '72px',
65
+ borderRadius: '22px',
66
+ background: 'linear-gradient(135deg, #3B82F6 0%, #14B8A6 100%)',
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ justifyContent: 'center',
70
+ fontSize: '44px',
71
+ boxShadow: '0 20px 60px -10px rgba(59,130,246,0.65)',
72
+ }}
73
+ >
74
+
75
+ </div>
76
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
77
+ <div
78
+ style={{
79
+ fontSize: '40px',
80
+ fontWeight: 800,
81
+ letterSpacing: '-0.02em',
82
+ lineHeight: 1,
83
+ }}
84
+ >
85
+ MedOS
86
+ </div>
87
+ <div
88
+ style={{
89
+ fontSize: '16px',
90
+ color: '#14B8A6',
91
+ fontWeight: 700,
92
+ textTransform: 'uppercase',
93
+ letterSpacing: '0.18em',
94
+ marginTop: '6px',
95
+ }}
96
+ >
97
+ Worldwide medical AI
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div
103
+ style={{
104
+ display: 'flex',
105
+ alignItems: 'center',
106
+ gap: '10px',
107
+ padding: '10px 18px',
108
+ borderRadius: '9999px',
109
+ background: 'rgba(255,255,255,0.08)',
110
+ border: '1px solid rgba(255,255,255,0.18)',
111
+ fontSize: '18px',
112
+ color: '#CBD5E1',
113
+ fontWeight: 600,
114
+ }}
115
+ >
116
+ <span
117
+ style={{
118
+ width: '8px',
119
+ height: '8px',
120
+ borderRadius: '9999px',
121
+ background: '#22C55E',
122
+ }}
123
+ />
124
+ {lang.toUpperCase()} · FREE · NO SIGN-UP
125
+ </div>
126
+ </div>
127
+
128
+ {/* Main content */}
129
+ <div
130
+ style={{
131
+ display: 'flex',
132
+ flexDirection: 'column',
133
+ flex: 1,
134
+ justifyContent: 'center',
135
+ }}
136
+ >
137
+ {title && (
138
+ <div
139
+ style={{
140
+ fontSize: '22px',
141
+ fontWeight: 700,
142
+ color: '#14B8A6',
143
+ textTransform: 'uppercase',
144
+ letterSpacing: '0.18em',
145
+ marginBottom: '22px',
146
+ }}
147
+ >
148
+ Ask MedOS
149
+ </div>
150
+ )}
151
+ <div
152
+ style={{
153
+ fontSize: title ? '68px' : '84px',
154
+ fontWeight: 800,
155
+ lineHeight: 1.1,
156
+ letterSpacing: '-0.025em',
157
+ color: '#F8FAFC',
158
+ maxWidth: '1000px',
159
+ }}
160
+ >
161
+ {title ? `"${headline}"` : headline}
162
+ </div>
163
+ <div
164
+ style={{
165
+ fontSize: '26px',
166
+ color: '#94A3B8',
167
+ marginTop: '28px',
168
+ fontWeight: 500,
169
+ }}
170
+ >
171
+ {subtitle}
172
+ </div>
173
+ </div>
174
+
175
+ {/* Footer: trust strip */}
176
+ <div
177
+ style={{
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ gap: '28px',
181
+ fontSize: '18px',
182
+ color: '#94A3B8',
183
+ fontWeight: 600,
184
+ borderTop: '1px solid rgba(255,255,255,0.1)',
185
+ paddingTop: '28px',
186
+ }}
187
+ >
188
+ <span style={{ color: '#14B8A6', fontWeight: 700 }}>
189
+ ✓ Aligned with WHO · CDC · NHS
190
+ </span>
191
+ <span>·</span>
192
+ <span>Private &amp; anonymous</span>
193
+ <span>·</span>
194
+ <span>24/7</span>
195
+ </div>
196
+ </div>
197
+ ),
198
+ {
199
+ width: 1200,
200
+ height: 630,
201
+ },
202
+ );
203
+ } catch {
204
+ // Never 500 an OG endpoint — social crawlers will blacklist the domain.
205
+ return new Response('OG image generation failed', { status: 500 });
206
+ }
207
+ }
app/api/rag/route.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { searchMedicalKB } from '@/lib/rag/medical-kb';
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { query, topN = 3 } = await request.json();
7
+
8
+ if (!query || typeof query !== 'string') {
9
+ return NextResponse.json(
10
+ { error: 'Query is required' },
11
+ { status: 400 }
12
+ );
13
+ }
14
+
15
+ const results = searchMedicalKB(query, topN);
16
+
17
+ return NextResponse.json({
18
+ results: results.map((r) => ({
19
+ topic: r.topic,
20
+ context: r.context,
21
+ })),
22
+ count: results.length,
23
+ });
24
+ } catch {
25
+ return NextResponse.json(
26
+ { error: 'Internal server error' },
27
+ { status: 500 }
28
+ );
29
+ }
30
+ }
app/api/scan/route.ts ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { loadConfig } from '@/lib/server-config';
3
+ import { authenticateRequest } from '@/lib/auth-middleware';
4
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
5
+ import { auditLog } from '@/lib/audit';
6
+ import { getDb, genId } from '@/lib/db';
7
+
8
+ /**
9
+ * POST /api/scan — Server-side proxy to the Medicine Scanner Space.
10
+ *
11
+ * Why proxy instead of calling from the browser:
12
+ * - HF_TOKEN_INFERENCE stays server-side (never in the JS bundle)
13
+ * - Same-origin request from the browser (no CORS preflight)
14
+ * - Backend injects the token and forwards to the Scanner Space
15
+ * - If the Scanner Space is sleeping, this request wakes it
16
+ *
17
+ * Isolation & accounting (added in PNF10):
18
+ * - Authentication is REQUIRED by default. Operators can flip
19
+ * SCAN_REQUIRE_AUTH=false to keep the legacy open behaviour while
20
+ * migrating, but anonymous traffic is then capped at 5 scans/hour
21
+ * per IP.
22
+ * - Authenticated users get 30 scans/hour each (per-user key).
23
+ * - Every call writes one scan_log row (status, bytes, latency, model)
24
+ * so admins can detect abuse on the shared HF inference quota.
25
+ * - Authenticated calls also append an audit_log('scan') entry.
26
+ *
27
+ * The Scanner Space receives:
28
+ * - The image as multipart/form-data (passthrough)
29
+ * - Authorization: Bearer header with the inference token
30
+ * - Returns structured JSON with medicine data
31
+ */
32
+
33
+ export const runtime = 'nodejs';
34
+ export const dynamic = 'force-dynamic';
35
+
36
+ function logScan(
37
+ userId: string | null,
38
+ ip: string | null,
39
+ status: number,
40
+ bytes: number,
41
+ latencyMs: number,
42
+ model: string | null,
43
+ ): void {
44
+ try {
45
+ const db = getDb();
46
+ db.prepare(
47
+ `INSERT INTO scan_log (id, user_id, ip, status, bytes, latency_ms, model)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
49
+ ).run(genId(), userId, ip, status, bytes, latencyMs, model);
50
+ } catch (e: any) {
51
+ console.error('[Scan] log failed:', e?.message);
52
+ }
53
+ }
54
+
55
+ export async function POST(req: Request) {
56
+ const startedAt = Date.now();
57
+ const ip = getClientIp(req);
58
+ const user = authenticateRequest(req);
59
+
60
+ // Auth gate. Default-on; opt-out via SCAN_REQUIRE_AUTH=false for migration.
61
+ const authRequired = (process.env.SCAN_REQUIRE_AUTH || 'true') !== 'false';
62
+ if (authRequired && !user) {
63
+ return NextResponse.json(
64
+ {
65
+ success: false,
66
+ error: 'Authentication required to scan medicines.',
67
+ medicine: null,
68
+ },
69
+ { status: 401 },
70
+ );
71
+ }
72
+
73
+ // Per-identity quota. Authenticated users are tracked by id (stable across
74
+ // IPs), anonymous fallback by IP (only reachable with SCAN_REQUIRE_AUTH=false).
75
+ const limitKey = user ? `scan:user:${user.id}` : `scan:ip:${ip}`;
76
+ const limitMax = user ? 30 : 5;
77
+ const limit = checkRateLimit(limitKey, limitMax, 60 * 60 * 1000);
78
+ if (!limit.allowed) {
79
+ logScan(user?.id || null, ip, 429, 0, Date.now() - startedAt, null);
80
+ return NextResponse.json(
81
+ {
82
+ success: false,
83
+ error: 'Scan quota exceeded. Try again later.',
84
+ retryAfterMs: limit.retryAfterMs,
85
+ },
86
+ {
87
+ status: 429,
88
+ headers: { 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)) },
89
+ },
90
+ );
91
+ }
92
+
93
+ // Resolve provider config (env at boot, admin overrides via /data/medos-config.json).
94
+ const cfg = loadConfig();
95
+ const token = cfg.llm.hfTokenInference;
96
+ const scannerUrl = cfg.llm.scannerUrl;
97
+
98
+ if (!token) {
99
+ console.error('[Scan] HF_TOKEN_INFERENCE is not configured');
100
+ logScan(user?.id || null, ip, 503, 0, Date.now() - startedAt, null);
101
+ return NextResponse.json(
102
+ {
103
+ success: false,
104
+ error:
105
+ 'Medicine scanner is not configured. Ask the administrator to set HF_TOKEN_INFERENCE.',
106
+ medicine: null,
107
+ },
108
+ { status: 503 },
109
+ );
110
+ }
111
+
112
+ try {
113
+ // Read the incoming form data (image file from the frontend).
114
+ const formData = await req.formData();
115
+
116
+ // Best-effort byte accounting for usage reporting.
117
+ let bytes = 0;
118
+ for (const [, v] of formData.entries()) {
119
+ if (v instanceof Blob) bytes += v.size;
120
+ }
121
+
122
+ const headers: Record<string, string> = {
123
+ Authorization: `Bearer ${token}`,
124
+ };
125
+
126
+ // Forward to the Medicine Scanner Space.
127
+ const response = await fetch(`${scannerUrl}/api/scan`, {
128
+ method: 'POST',
129
+ headers,
130
+ body: formData,
131
+ });
132
+
133
+ const data = await response.json().catch(() => ({} as any));
134
+ const latency = Date.now() - startedAt;
135
+
136
+ logScan(
137
+ user?.id || null,
138
+ ip,
139
+ response.status,
140
+ bytes,
141
+ latency,
142
+ (data as any)?.model || null,
143
+ );
144
+
145
+ if (user) {
146
+ auditLog({
147
+ userId: user.id,
148
+ action: 'scan',
149
+ ip,
150
+ meta: { status: response.status, bytes, latencyMs: latency },
151
+ });
152
+ }
153
+
154
+ return NextResponse.json(data, { status: response.status });
155
+ } catch (error: any) {
156
+ const latency = Date.now() - startedAt;
157
+ console.error('[Scan Proxy]', error?.message);
158
+ logScan(user?.id || null, ip, 502, 0, latency, null);
159
+ return NextResponse.json(
160
+ {
161
+ success: false,
162
+ error: 'Medicine scanner unavailable. Please try again.',
163
+ medicine: null,
164
+ },
165
+ { status: 502 },
166
+ );
167
+ }
168
+ }
169
+
170
+ /**
171
+ * GET /api/scan/health — Check if the Scanner Space is awake.
172
+ * Used by the frontend to show "waking up" status.
173
+ */
174
+ export async function GET() {
175
+ const cfg = loadConfig();
176
+ const scannerUrl = cfg.llm.scannerUrl;
177
+ try {
178
+ const controller = new AbortController();
179
+ const timeout = setTimeout(() => controller.abort(), 5000);
180
+
181
+ const res = await fetch(`${scannerUrl}/api/health`, {
182
+ signal: controller.signal,
183
+ });
184
+ clearTimeout(timeout);
185
+
186
+ if (res.ok) {
187
+ const data = await res.json();
188
+ return NextResponse.json(data);
189
+ }
190
+ return NextResponse.json({ status: 'unavailable' }, { status: 503 });
191
+ } catch {
192
+ return NextResponse.json({ status: 'sleeping' }, { status: 503 });
193
+ }
194
+ }
app/api/sessions/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Server-side session counter.
7
+ * Stores count in /tmp/medos-data/sessions.json (persists across requests, resets on container restart).
8
+ * On HF Spaces with persistent storage, use /data/ instead of /tmp/.
9
+ *
10
+ * GET /api/sessions → returns { count: number }
11
+ * POST /api/sessions → increments and returns { count: number }
12
+ */
13
+
14
+ const DATA_DIR = process.env.PERSISTENT_DIR || '/tmp/medos-data';
15
+ const COUNTER_FILE = join(DATA_DIR, 'sessions.json');
16
+ const BASE_COUNT = 423000; // Historical base from before server-side tracking
17
+
18
+ interface CounterData {
19
+ count: number;
20
+ lastUpdated: string;
21
+ }
22
+
23
+ function ensureDir(): void {
24
+ if (!existsSync(DATA_DIR)) {
25
+ mkdirSync(DATA_DIR, { recursive: true });
26
+ }
27
+ }
28
+
29
+ function readCounter(): number {
30
+ ensureDir();
31
+ try {
32
+ if (existsSync(COUNTER_FILE)) {
33
+ const data: CounterData = JSON.parse(readFileSync(COUNTER_FILE, 'utf8'));
34
+ return data.count;
35
+ }
36
+ } catch {
37
+ // corrupted file, reset
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ function incrementCounter(): number {
43
+ ensureDir();
44
+ const current = readCounter();
45
+ const next = current + 1;
46
+ const data: CounterData = {
47
+ count: next,
48
+ lastUpdated: new Date().toISOString(),
49
+ };
50
+ writeFileSync(COUNTER_FILE, JSON.stringify(data), 'utf8');
51
+ return next;
52
+ }
53
+
54
+ export async function GET() {
55
+ const sessionCount = readCounter();
56
+ return NextResponse.json({
57
+ count: BASE_COUNT + sessionCount,
58
+ sessions: sessionCount,
59
+ base: BASE_COUNT,
60
+ });
61
+ }
62
+
63
+ export async function POST() {
64
+ const sessionCount = incrementCounter();
65
+ return NextResponse.json({
66
+ count: BASE_COUNT + sessionCount,
67
+ sessions: sessionCount,
68
+ base: BASE_COUNT,
69
+ });
70
+ }
app/api/triage/route.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { triageMessage } from '@/lib/safety/triage';
3
+ import { getEmergencyInfo } from '@/lib/safety/emergency-numbers';
4
+
5
+ export async function POST(request: NextRequest) {
6
+ try {
7
+ const { message, countryCode = 'US' } = await request.json();
8
+
9
+ if (!message || typeof message !== 'string') {
10
+ return NextResponse.json(
11
+ { error: 'Message is required' },
12
+ { status: 400 }
13
+ );
14
+ }
15
+
16
+ const triage = triageMessage(message);
17
+ const emergencyInfo = getEmergencyInfo(countryCode);
18
+
19
+ return NextResponse.json({
20
+ ...triage,
21
+ emergencyInfo: triage.isEmergency ? emergencyInfo : null,
22
+ });
23
+ } catch {
24
+ return NextResponse.json(
25
+ { error: 'Internal server error' },
26
+ { status: 500 }
27
+ );
28
+ }
29
+ }
app/api/user/settings/route.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { authenticateRequest } from '@/lib/auth-middleware';
4
+ import { getUserSettings, upsertUserSettings } from '@/lib/user-settings';
5
+ import { auditLog } from '@/lib/audit';
6
+ import { getClientIp } from '@/lib/rate-limit';
7
+ import { redact } from '@/lib/crypto';
8
+
9
+ /**
10
+ * Per-user settings API.
11
+ *
12
+ * GET /api/user/settings — returns this user's preferences + EHR profile.
13
+ * The BYO Hugging Face token is NEVER returned
14
+ * in plaintext; the response carries only a
15
+ * redacted preview ('••••HiJ') and a
16
+ * hasHfToken boolean. The decrypted token is
17
+ * used in-process only by the LLM provider
18
+ * chain (added in a follow-up batch).
19
+ *
20
+ * PUT /api/user/settings — partial patch. Field semantics for `hfToken`:
21
+ * omit → leave token unchanged
22
+ * "" → clear stored token
23
+ * "hf_xxx" → rotate to new value (encrypted)
24
+ *
25
+ * Every successful PUT writes an audit_log('settings_update') entry that
26
+ * lists the changed field NAMES only — never values.
27
+ */
28
+
29
+ export const runtime = 'nodejs';
30
+
31
+ export async function GET(req: Request) {
32
+ const user = authenticateRequest(req);
33
+ if (!user) {
34
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
35
+ }
36
+
37
+ const s = getUserSettings(user.id);
38
+
39
+ return NextResponse.json({
40
+ settings: {
41
+ language: s.language ?? null,
42
+ country: s.country ?? null,
43
+ units: s.units ?? null,
44
+ defaultModel: s.defaultModel ?? null,
45
+ theme: s.theme ?? null,
46
+ ehr: s.ehr ?? {},
47
+ hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null,
48
+ hasHfToken: !!s.hfToken,
49
+ },
50
+ });
51
+ }
52
+
53
+ const PutSchema = z.object({
54
+ language: z.string().min(2).max(8).optional(),
55
+ country: z.string().min(2).max(4).optional(),
56
+ units: z.enum(['metric', 'imperial']).optional(),
57
+ defaultModel: z.string().max(100).optional(),
58
+ theme: z.enum(['light', 'dark', 'auto']).optional(),
59
+ // EHR is a free-form bag (the wizard owns its shape) but bounded.
60
+ ehr: z.record(z.any()).optional(),
61
+ // Empty string clears the token, undefined leaves it untouched.
62
+ hfToken: z.string().max(200).optional(),
63
+ });
64
+
65
+ export async function PUT(req: Request) {
66
+ const user = authenticateRequest(req);
67
+ if (!user) {
68
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
69
+ }
70
+
71
+ let parsed;
72
+ try {
73
+ const body = await req.json();
74
+ parsed = PutSchema.parse(body);
75
+ } catch (error: any) {
76
+ if (error instanceof z.ZodError) {
77
+ return NextResponse.json(
78
+ { error: 'Invalid input', details: error.errors },
79
+ { status: 400 },
80
+ );
81
+ }
82
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
83
+ }
84
+
85
+ // Reject pathological EHR payloads to keep row size sane.
86
+ if (parsed.ehr && JSON.stringify(parsed.ehr).length > 32_000) {
87
+ return NextResponse.json(
88
+ { error: 'EHR payload too large (max 32 KB).' },
89
+ { status: 413 },
90
+ );
91
+ }
92
+
93
+ try {
94
+ upsertUserSettings(user.id, parsed);
95
+ } catch (error: any) {
96
+ console.error('[User Settings PUT]', error?.message);
97
+ return NextResponse.json({ error: 'Save failed' }, { status: 500 });
98
+ }
99
+
100
+ auditLog({
101
+ userId: user.id,
102
+ action: 'settings_update',
103
+ ip: getClientIp(req),
104
+ meta: {
105
+ fields: Object.keys(parsed),
106
+ tokenRotated: parsed.hfToken !== undefined,
107
+ ehrFieldsChanged: parsed.ehr ? Object.keys(parsed.ehr) : [],
108
+ },
109
+ });
110
+
111
+ // Return the fresh, redacted view so the client can update its cache.
112
+ const s = getUserSettings(user.id);
113
+ return NextResponse.json({
114
+ success: true,
115
+ settings: {
116
+ language: s.language ?? null,
117
+ country: s.country ?? null,
118
+ units: s.units ?? null,
119
+ defaultModel: s.defaultModel ?? null,
120
+ theme: s.theme ?? null,
121
+ ehr: s.ehr ?? {},
122
+ hfTokenRedacted: s.hfToken ? redact(s.hfToken) : null,
123
+ hasHfToken: !!s.hfToken,
124
+ },
125
+ });
126
+ }
app/globals.css ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ============================================================
6
+ * MedOS design tokens — light + dark
7
+ * ============================================================ */
8
+ :root {
9
+ /* Surfaces (light mode — soft white, never pure #FFFFFF) */
10
+ --surface-0: 247 249 251; /* app backdrop #F7F9FB */
11
+ --surface-1: 255 255 255; /* cards */
12
+ --surface-2: 241 245 249; /* elevated panels #F1F5F9 */
13
+ --surface-3: 226 232 240; /* borders / rails */
14
+
15
+ --ink-base: 15 23 42; /* slate-900 */
16
+ --ink-muted: 71 85 105; /* slate-600 */
17
+ --ink-subtle: 148 163 184; /* slate-400 */
18
+ --ink-inverse: 255 255 255;
19
+
20
+ --line: 226 232 240; /* slate-200 */
21
+
22
+ color-scheme: light;
23
+ }
24
+
25
+ .dark {
26
+ /* Dark mode — warm deep navy, NOT pure black */
27
+ --surface-0: 11 18 32; /* #0B1220 */
28
+ --surface-1: 18 27 45; /* #121B2D elevated card */
29
+ --surface-2: 24 34 54; /* #182236 panel */
30
+ --surface-3: 34 46 71; /* #222E47 border */
31
+
32
+ --ink-base: 241 245 249; /* slate-100 */
33
+ --ink-muted: 148 163 184; /* slate-400 */
34
+ --ink-subtle: 100 116 139; /* slate-500 */
35
+ --ink-inverse: 15 23 42;
36
+
37
+ --line: 34 46 71;
38
+
39
+ color-scheme: dark;
40
+ }
41
+
42
+ @layer base {
43
+ html,
44
+ body {
45
+ @apply h-full w-full;
46
+ /* iOS Safari 100vh fix: dvh accounts for the collapsible address bar.
47
+ Falls back to 100vh for browsers that don't support dvh. */
48
+ height: 100vh;
49
+ height: 100dvh;
50
+ }
51
+
52
+ html {
53
+ font-family: var(--font-sans), Inter, ui-sans-serif, system-ui, -apple-system,
54
+ "Segoe UI", Roboto, sans-serif;
55
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01";
56
+ -webkit-font-smoothing: antialiased;
57
+ -moz-osx-font-smoothing: grayscale;
58
+ text-rendering: optimizeLegibility;
59
+ }
60
+
61
+ body {
62
+ @apply text-ink-base antialiased;
63
+ background: theme("backgroundImage.light-app");
64
+ background-attachment: fixed;
65
+ line-height: 1.6;
66
+ letter-spacing: -0.005em;
67
+ }
68
+
69
+ .dark body {
70
+ background: theme("backgroundImage.dark-app");
71
+ background-attachment: fixed;
72
+ }
73
+
74
+ /* Slightly larger, more readable body copy — medical trust */
75
+ p { line-height: 1.65; }
76
+
77
+ /* Focus rings that are visible in both modes */
78
+ :focus-visible {
79
+ outline: 2px solid rgb(var(--color-brand, 59 130 246));
80
+ outline-offset: 2px;
81
+ border-radius: 8px;
82
+ }
83
+ }
84
+
85
+ @layer components {
86
+ .glass-card {
87
+ @apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft;
88
+ }
89
+ .glass-strong {
90
+ @apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card;
91
+ }
92
+ /* Section headings inside an AI answer (Summary / Self-care …) */
93
+ .answer-section {
94
+ @apply relative pl-4 mt-4 first:mt-0;
95
+ }
96
+ .answer-section::before {
97
+ content: "";
98
+ @apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60;
99
+ }
100
+ }
101
+
102
+ @layer utilities {
103
+ .animate-in { animation: fadeIn 0.5s ease-in-out; }
104
+ .slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; }
105
+ .delay-100 { animation-delay: 100ms; }
106
+ .delay-200 { animation-delay: 200ms; }
107
+
108
+ /* Shimmer utility for the "Analyzing…" typing state */
109
+ .shimmer-text {
110
+ background: linear-gradient(
111
+ 90deg,
112
+ rgb(var(--ink-muted) / 0.5) 0%,
113
+ rgb(var(--ink-base)) 50%,
114
+ rgb(var(--ink-muted) / 0.5) 100%
115
+ );
116
+ background-size: 200% 100%;
117
+ -webkit-background-clip: text;
118
+ background-clip: text;
119
+ color: transparent;
120
+ animation: shimmer 2.2s linear infinite;
121
+ }
122
+
123
+ @keyframes fadeIn {
124
+ from { opacity: 0; }
125
+ to { opacity: 1; }
126
+ }
127
+ @keyframes slideInFromBottom {
128
+ from { opacity: 0; transform: translateY(1rem); }
129
+ to { opacity: 1; transform: translateY(0); }
130
+ }
131
+ }
132
+
133
+ /* ------------------------------------------------------------
134
+ * Dark-mode compatibility layer for legacy screens that still
135
+ * reference slate/white utility classes directly. Remaps them to
136
+ * the design-token surfaces so Settings/Records/Schedule/Topics/
137
+ * Emergency look right in dark mode without a full rewrite.
138
+ * New components should prefer the `bg-surface-*` and `text-ink-*`
139
+ * tokens directly and won't be affected by these rules.
140
+ * ------------------------------------------------------------ */
141
+ .dark .bg-white { background-color: rgb(var(--surface-1)) !important; }
142
+ .dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; }
143
+ .dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; }
144
+ .dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; }
145
+ .dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; }
146
+
147
+ .dark .text-slate-900 { color: rgb(var(--ink-base)) !important; }
148
+ .dark .text-slate-800 { color: rgb(var(--ink-base)) !important; }
149
+ .dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; }
150
+ .dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; }
151
+ .dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; }
152
+ .dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; }
153
+ .dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; }
154
+
155
+ .dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; }
156
+ .dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; }
157
+ .dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; }
158
+
159
+ .dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); }
160
+
161
+ /* Soft tinted surfaces used on a handful of views (blue-50, rose-50,
162
+ * amber-50, etc.) — in dark mode dim them into brand/accent tints. */
163
+ .dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; }
164
+ .dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; }
165
+ .dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; }
166
+ .dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; }
167
+ .dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; }
168
+ .dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; }
169
+ .dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; }
170
+ .dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; }
171
+
172
+ .dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; }
173
+ .dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; }
174
+ .dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; }
175
+ .dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; }
176
+
177
+ /* Scrollbars — subtle in both modes */
178
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
179
+ ::-webkit-scrollbar-track { background: transparent; }
180
+ ::-webkit-scrollbar-thumb {
181
+ background: rgb(var(--ink-subtle) / 0.35);
182
+ border-radius: 9999px;
183
+ border: 2px solid transparent;
184
+ background-clip: padding-box;
185
+ }
186
+ ::-webkit-scrollbar-thumb:hover {
187
+ background: rgb(var(--ink-subtle) / 0.55);
188
+ background-clip: padding-box;
189
+ }
190
+
191
+ ::selection {
192
+ background: rgba(59, 130, 246, 0.22);
193
+ color: inherit;
194
+ }
195
+
196
+ /* Safe area for mobile bottom nav */
197
+ .safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
198
+
199
+ /* RTL support for Arabic */
200
+ [dir="rtl"] .flex { direction: rtl; }
201
+
202
+ /* Large tap targets */
203
+ @media (pointer: coarse) {
204
+ button, a, select, input, textarea { min-height: 44px; }
205
+ }
206
+
207
+ /* ============================================================
208
+ * Mobile-first utilities
209
+ * ============================================================ */
210
+
211
+ /* Dynamic viewport height — works on iOS Safari, Android Chrome,
212
+ and every modern browser. Falls back to 100vh. */
213
+ .h-screen-safe {
214
+ height: 100vh;
215
+ height: 100dvh;
216
+ }
217
+
218
+ /* Sticky input bar that stays above the mobile keyboard.
219
+ Uses env(keyboard-inset-height) on supporting browsers and
220
+ falls back to standard sticky positioning elsewhere. */
221
+ .sticky-bottom-keyboard {
222
+ position: sticky;
223
+ bottom: 0;
224
+ bottom: env(keyboard-inset-height, 0px);
225
+ }
226
+
227
+ /* Prevent iOS input zoom — any input below 16px triggers a zoom.
228
+ We force 16px minimum on touch devices and compensate with
229
+ transforms where we need visually-smaller text. */
230
+ @media (pointer: coarse) {
231
+ input, textarea, select {
232
+ font-size: 16px !important;
233
+ }
234
+ }
235
+
236
+ /* iOS momentum scrolling */
237
+ .scroll-touch {
238
+ -webkit-overflow-scrolling: touch;
239
+ }
240
+
241
+ /* Bottom padding spacer for content that sits above a fixed bottom nav.
242
+ The 5.5rem accounts for the nav height + safe-area-inset-bottom. */
243
+ .pb-mobile-nav {
244
+ padding-bottom: 5.5rem;
245
+ }
246
+ @media (min-width: 768px) {
247
+ .pb-mobile-nav {
248
+ padding-bottom: 0;
249
+ }
250
+ }
251
+
252
+ /* Respect reduced-motion preferences everywhere */
253
+ @media (prefers-reduced-motion: reduce) {
254
+ *,
255
+ *::before,
256
+ *::after {
257
+ animation-duration: 0.01ms !important;
258
+ animation-iteration-count: 1 !important;
259
+ transition-duration: 0.01ms !important;
260
+ }
261
+ }
app/icon.svg ADDED
app/layout.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata, Viewport } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({
6
+ subsets: ["latin"],
7
+ display: "swap",
8
+ variable: "--font-sans",
9
+ });
10
+
11
+ export const metadata: Metadata = {
12
+ title: "MedOS — your worldwide medical assistant",
13
+ description:
14
+ "Tell MedOS what's bothering you. Instant, private, multilingual health guidance aligned with WHO, CDC, and NHS.",
15
+ keywords: ["medical AI", "healthcare", "chatbot", "telemedicine", "WHO", "CDC"],
16
+ authors: [{ name: "MedOS Team" }],
17
+ manifest: "/manifest.webmanifest",
18
+ icons: {
19
+ icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
20
+ shortcut: "/favicon.svg",
21
+ },
22
+ openGraph: {
23
+ title: "MedOS — your worldwide medical assistant",
24
+ description:
25
+ "Private, multilingual health guidance aligned with WHO, CDC, and NHS — available 24/7.",
26
+ type: "website",
27
+ },
28
+ robots: { index: true, follow: true },
29
+ appleWebApp: {
30
+ capable: true,
31
+ title: "MedOS",
32
+ },
33
+ other: {
34
+ "mobile-web-app-capable": "yes",
35
+ },
36
+ };
37
+
38
+ export const viewport: Viewport = {
39
+ width: "device-width",
40
+ initialScale: 1,
41
+ maximumScale: 5,
42
+ userScalable: true,
43
+ viewportFit: "cover",
44
+ themeColor: [
45
+ { media: "(prefers-color-scheme: light)", color: "#F7F9FB" },
46
+ { media: "(prefers-color-scheme: dark)", color: "#0B1220" },
47
+ ],
48
+ };
49
+
50
+ /**
51
+ * Inline pre-hydration script: reads the stored theme before first paint.
52
+ */
53
+ const themeBootstrap = `
54
+ (function() {
55
+ try {
56
+ var stored = localStorage.getItem('medos_theme');
57
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
58
+ var isDark = stored === 'dark' || (stored === 'system' && prefersDark);
59
+ if (isDark) document.documentElement.classList.add('dark');
60
+ document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
61
+ } catch (e) {}
62
+ })();
63
+ `;
64
+
65
+ export default function RootLayout({
66
+ children,
67
+ }: {
68
+ children: React.ReactNode;
69
+ }) {
70
+ return (
71
+ <html lang="en" className={inter.variable} suppressHydrationWarning>
72
+ <head>
73
+ <script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
74
+ </head>
75
+ <body className="min-h-screen antialiased">{children}</body>
76
+ </html>
77
+ );
78
+ }
app/manifest.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from "next";
2
+
3
+ /**
4
+ * Next.js native manifest route. Served at /manifest.webmanifest by
5
+ * the framework itself (not as a static file) so it bypasses Vercel's
6
+ * Deployment Protection on preview branches.
7
+ */
8
+ export default function manifest(): MetadataRoute.Manifest {
9
+ return {
10
+ name: "MedOS — your medical assistant",
11
+ short_name: "MedOS",
12
+ description:
13
+ "Free AI medical assistant. 20 languages. No sign-up. Private.",
14
+ start_url: "/?source=pwa",
15
+ scope: "/",
16
+ display: "standalone",
17
+ orientation: "portrait-primary",
18
+ theme_color: "#3B82F6",
19
+ background_color: "#F7F9FB",
20
+ categories: ["health", "medical", "lifestyle"],
21
+ icons: [
22
+ {
23
+ src: "/favicon.svg",
24
+ sizes: "any",
25
+ type: "image/svg+xml",
26
+ purpose: "any",
27
+ },
28
+ ],
29
+ shortcuts: [
30
+ {
31
+ name: "Ask a health question",
32
+ short_name: "Ask",
33
+ url: "/?source=shortcut",
34
+ },
35
+ {
36
+ name: "Health Dashboard",
37
+ short_name: "Health",
38
+ url: "/?view=health-dashboard",
39
+ },
40
+ ],
41
+ prefer_related_applications: false,
42
+ };
43
+ }
app/page.tsx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import MedOSApp from "@/components/MedOSApp";
2
+ export default function HomePage() { return <MedOSApp />; }
app/robots.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
4
+
5
+ /**
6
+ * Permissive robots.txt — we want every crawler to index the public
7
+ * pages (home, /symptoms, /stats). API routes are disallowed because
8
+ * they return dynamic or privacy-sensitive data (geo lookup, chat
9
+ * stream, session counter).
10
+ */
11
+ export default function robots(): MetadataRoute.Robots {
12
+ return {
13
+ rules: [
14
+ {
15
+ userAgent: '*',
16
+ allow: ['/', '/symptoms', '/stats'],
17
+ disallow: ['/api/'],
18
+ },
19
+ ],
20
+ sitemap: `${SITE_URL}/sitemap.xml`,
21
+ host: SITE_URL,
22
+ };
23
+ }
app/sitemap.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from 'next';
2
+ import { getAllSymptomSlugs } from '@/lib/symptoms';
3
+
4
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
5
+
6
+ /**
7
+ * Static sitemap auto-generated from the symptom catalog so Google picks
8
+ * up every SEO landing page on day one. Served at `/sitemap.xml` by Next.
9
+ */
10
+ export default function sitemap(): MetadataRoute.Sitemap {
11
+ const now = new Date();
12
+
13
+ const staticPages: MetadataRoute.Sitemap = [
14
+ { url: SITE_URL, lastModified: now, changeFrequency: 'daily', priority: 1.0 },
15
+ { url: `${SITE_URL}/symptoms`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
16
+ { url: `${SITE_URL}/stats`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
17
+ ];
18
+
19
+ const symptomPages: MetadataRoute.Sitemap = getAllSymptomSlugs().map((slug) => ({
20
+ url: `${SITE_URL}/symptoms/${slug}`,
21
+ lastModified: now,
22
+ changeFrequency: 'weekly',
23
+ priority: 0.8,
24
+ }));
25
+
26
+ return [...staticPages, ...symptomPages];
27
+ }
app/stats/page.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import {
6
+ Activity,
7
+ Globe2,
8
+ ShieldCheck,
9
+ Clock4,
10
+ ArrowRight,
11
+ Languages,
12
+ } from 'lucide-react';
13
+ import { TrustBar } from '@/components/chat/TrustBar';
14
+
15
+ interface SessionsResponse {
16
+ count: number;
17
+ sessions: number;
18
+ base: number;
19
+ }
20
+
21
+ const LANGUAGES = [
22
+ 'English',
23
+ 'Español',
24
+ 'Français',
25
+ 'Português',
26
+ 'Italiano',
27
+ 'Deutsch',
28
+ 'العربية',
29
+ 'हिन्दी',
30
+ 'Kiswahili',
31
+ '中文',
32
+ '日本語',
33
+ '한국어',
34
+ 'Русский',
35
+ 'Türkçe',
36
+ 'Tiếng Việt',
37
+ 'ไทย',
38
+ 'বাংলা',
39
+ 'اردو',
40
+ 'Polski',
41
+ 'Nederlands',
42
+ ];
43
+
44
+ /**
45
+ * Public transparency page. Shows the anonymous global session counter,
46
+ * supported languages, trust metrics, and the MedOS health-posture
47
+ * summary. Drives social proof ("N people helped this session") and is
48
+ * a natural link target from Product Hunt / Twitter / press.
49
+ *
50
+ * Server-rendered is tempting here, but we keep it client-side so the
51
+ * counter animates as it loads — tiny extra bundle, big UX win.
52
+ */
53
+ export default function StatsPage() {
54
+ const [data, setData] = useState<SessionsResponse | null>(null);
55
+ const [loading, setLoading] = useState(true);
56
+ const [displayCount, setDisplayCount] = useState(0);
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ fetch('/api/sessions', { cache: 'no-store' })
61
+ .then((r) => (r.ok ? r.json() : null))
62
+ .then((d: SessionsResponse | null) => {
63
+ if (!cancelled && d) {
64
+ setData(d);
65
+ animateTo(d.count, setDisplayCount);
66
+ }
67
+ })
68
+ .catch(() => {})
69
+ .finally(() => !cancelled && setLoading(false));
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, []);
74
+
75
+ return (
76
+ <main className="min-h-screen bg-slate-900 text-slate-100">
77
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
78
+ <header className="mb-8 text-center">
79
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
80
+ MedOS transparency
81
+ </p>
82
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
83
+ The numbers behind MedOS
84
+ </h1>
85
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
86
+ Free health guidance, open numbers. No tracking of people —
87
+ only a single anonymous counter that ticks once per session.
88
+ </p>
89
+ </header>
90
+
91
+ {/* Hero counter */}
92
+ <section className="mb-10 rounded-3xl border border-teal-500/30 bg-gradient-to-br from-blue-900/30 to-teal-900/20 p-8 text-center">
93
+ <div className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1 rounded-full mb-4">
94
+ <Activity size={12} className="animate-pulse" />
95
+ Live session counter
96
+ </div>
97
+ <div className="text-6xl sm:text-7xl font-black text-slate-50 tracking-tight tabular-nums">
98
+ {loading ? (
99
+ <span className="text-slate-600">…</span>
100
+ ) : (
101
+ formatNumber(displayCount)
102
+ )}
103
+ </div>
104
+ <p className="text-sm text-slate-400 mt-3">
105
+ conversations MedOS has helped with, anonymously, since launch
106
+ </p>
107
+ </section>
108
+
109
+ {/* Cards grid */}
110
+ <section className="grid sm:grid-cols-2 gap-4 mb-10">
111
+ <StatCard
112
+ Icon={Globe2}
113
+ label="Countries supported"
114
+ value="190+"
115
+ description="Emergency numbers localized per region"
116
+ />
117
+ <StatCard
118
+ Icon={Languages}
119
+ label="Languages"
120
+ value="20"
121
+ description="Auto-detected from browser and IP"
122
+ />
123
+ <StatCard
124
+ Icon={ShieldCheck}
125
+ label="Privacy"
126
+ value="Zero PII"
127
+ description="No accounts, no IP logging, no conversation storage"
128
+ />
129
+ <StatCard
130
+ Icon={Clock4}
131
+ label="Availability"
132
+ value="24/7"
133
+ description="Free forever on HuggingFace Spaces"
134
+ />
135
+ </section>
136
+
137
+ {/* Languages strip */}
138
+ <section className="mb-10 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-6">
139
+ <h2 className="text-sm font-bold uppercase tracking-wider text-slate-400 mb-4 inline-flex items-center gap-2">
140
+ <Languages size={14} />
141
+ Supported languages
142
+ </h2>
143
+ <div className="flex flex-wrap gap-2">
144
+ {LANGUAGES.map((l) => (
145
+ <span
146
+ key={l}
147
+ className="px-3 py-1.5 rounded-full text-sm font-medium text-slate-200 bg-slate-700/60 border border-slate-600/50"
148
+ >
149
+ {l}
150
+ </span>
151
+ ))}
152
+ </div>
153
+ </section>
154
+
155
+ {/* Trust bar */}
156
+ <section className="mb-10">
157
+ <TrustBar language="en" />
158
+ </section>
159
+
160
+ {/* CTA */}
161
+ <div className="rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6 text-center">
162
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
163
+ Ready to ask your own question?
164
+ </h2>
165
+ <p className="text-slate-300 mb-4">
166
+ Free. Private. In your language. No sign-up.
167
+ </p>
168
+ <Link
169
+ href="/"
170
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
171
+ >
172
+ Open MedOS
173
+ <ArrowRight size={18} />
174
+ </Link>
175
+ </div>
176
+
177
+ {data && (
178
+ <p className="mt-6 text-center text-xs text-slate-500">
179
+ Counter is a single integer stored server-side at{' '}
180
+ <code className="text-slate-400">/api/sessions</code>. No
181
+ request is ever correlated to an individual.
182
+ </p>
183
+ )}
184
+ </div>
185
+ </main>
186
+ );
187
+ }
188
+
189
+ function StatCard({
190
+ Icon,
191
+ label,
192
+ value,
193
+ description,
194
+ }: {
195
+ Icon: any;
196
+ label: string;
197
+ value: string;
198
+ description: string;
199
+ }) {
200
+ return (
201
+ <div className="rounded-2xl border border-slate-700/60 bg-slate-800/50 p-5">
202
+ <div className="flex items-center gap-2 text-teal-400 mb-3">
203
+ <Icon size={16} />
204
+ <span className="text-xs font-bold uppercase tracking-wider">
205
+ {label}
206
+ </span>
207
+ </div>
208
+ <div className="text-3xl font-black text-slate-50 tracking-tight mb-1">
209
+ {value}
210
+ </div>
211
+ <p className="text-sm text-slate-400 leading-snug">{description}</p>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ /** Format a number with locale-aware thousands separators. */
217
+ function formatNumber(n: number): string {
218
+ try {
219
+ return new Intl.NumberFormat(undefined).format(n);
220
+ } catch {
221
+ return String(n);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Animate a counter from zero to `target` over ~1.2s using an ease-out
227
+ * curve. Runs synchronously inside `requestAnimationFrame` so it never
228
+ * blocks the main thread.
229
+ */
230
+ function animateTo(target: number, setValue: (n: number) => void): void {
231
+ if (typeof window === 'undefined') {
232
+ setValue(target);
233
+ return;
234
+ }
235
+ const duration = 1200;
236
+ const start = performance.now();
237
+ const step = (t: number) => {
238
+ const elapsed = t - start;
239
+ const progress = Math.min(1, elapsed / duration);
240
+ const eased = 1 - Math.pow(1 - progress, 3); // cubic ease-out
241
+ setValue(Math.round(target * eased));
242
+ if (progress < 1) requestAnimationFrame(step);
243
+ };
244
+ requestAnimationFrame(step);
245
+ }
app/symptoms/[slug]/page.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import Link from 'next/link';
4
+ import { AlertTriangle, ShieldCheck, ChevronLeft, ArrowRight } from 'lucide-react';
5
+ import {
6
+ getSymptomBySlug,
7
+ getAllSymptomSlugs,
8
+ type Symptom,
9
+ } from '@/lib/symptoms';
10
+
11
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
12
+
13
+ interface Params {
14
+ params: { slug: string };
15
+ }
16
+
17
+ /**
18
+ * Pre-generate every symptom page at build time so they are fully static
19
+ * (and cacheable by HF Spaces' CDN / every downstream proxy).
20
+ */
21
+ export function generateStaticParams() {
22
+ return getAllSymptomSlugs().map((slug) => ({ slug }));
23
+ }
24
+
25
+ export function generateMetadata({ params }: Params): Metadata {
26
+ const symptom = getSymptomBySlug(params.slug);
27
+ if (!symptom) return { title: 'Symptom not found — MedOS' };
28
+
29
+ const ogUrl = `${SITE_URL}/api/og?q=${encodeURIComponent(symptom.headline)}`;
30
+ const canonical = `${SITE_URL}/symptoms/${symptom.slug}`;
31
+
32
+ return {
33
+ title: symptom.title,
34
+ description: symptom.metaDescription,
35
+ alternates: { canonical },
36
+ openGraph: {
37
+ title: symptom.title,
38
+ description: symptom.metaDescription,
39
+ url: canonical,
40
+ siteName: 'MedOS',
41
+ type: 'article',
42
+ images: [{ url: ogUrl, width: 1200, height: 630, alt: symptom.headline }],
43
+ },
44
+ twitter: {
45
+ card: 'summary_large_image',
46
+ title: symptom.title,
47
+ description: symptom.metaDescription,
48
+ images: [ogUrl],
49
+ },
50
+ };
51
+ }
52
+
53
+ export default function SymptomPage({ params }: Params) {
54
+ const symptom = getSymptomBySlug(params.slug);
55
+ if (!symptom) return notFound();
56
+
57
+ // Per-page FAQPage JSON-LD so Google can mine each entry as a rich
58
+ // snippet independently from the root layout's global FAQPage.
59
+ const faqJsonLd = {
60
+ '@context': 'https://schema.org',
61
+ '@type': 'FAQPage',
62
+ mainEntity: symptom.faqs.map((f) => ({
63
+ '@type': 'Question',
64
+ name: f.q,
65
+ acceptedAnswer: { '@type': 'Answer', text: f.a },
66
+ })),
67
+ };
68
+
69
+ // MedicalCondition JSON-LD — helps Google classify the page for the
70
+ // Health Knowledge Graph.
71
+ const medicalConditionJsonLd = {
72
+ '@context': 'https://schema.org',
73
+ '@type': 'MedicalCondition',
74
+ name: symptom.headline,
75
+ description: symptom.summary,
76
+ signOrSymptom: symptom.redFlags.map((r) => ({
77
+ '@type': 'MedicalSymptom',
78
+ name: r,
79
+ })),
80
+ possibleTreatment: symptom.selfCare.map((s) => ({
81
+ '@type': 'MedicalTherapy',
82
+ name: s,
83
+ })),
84
+ };
85
+
86
+ const chatDeepLink = `/?q=${encodeURIComponent(
87
+ `I want to ask about ${symptom.headline.toLowerCase()}`,
88
+ )}`;
89
+
90
+ return (
91
+ <main className="min-h-screen bg-slate-900 text-slate-100">
92
+ <script
93
+ type="application/ld+json"
94
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
95
+ />
96
+ <script
97
+ type="application/ld+json"
98
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalConditionJsonLd) }}
99
+ />
100
+
101
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
102
+ {/* Back link */}
103
+ <Link
104
+ href="/symptoms"
105
+ className="inline-flex items-center gap-1 text-sm text-slate-400 hover:text-teal-300 transition-colors mb-6"
106
+ >
107
+ <ChevronLeft size={16} />
108
+ All symptoms
109
+ </Link>
110
+
111
+ {/* Hero */}
112
+ <header className="mb-8">
113
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
114
+ Symptom guide
115
+ </p>
116
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-4">
117
+ {symptom.headline}
118
+ </h1>
119
+ <p className="text-lg text-slate-300 leading-relaxed">
120
+ {symptom.summary}
121
+ </p>
122
+ <div className="mt-6 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
123
+ <ShieldCheck size={12} />
124
+ Aligned with WHO · CDC · NHS guidance
125
+ </div>
126
+ </header>
127
+
128
+ {/* Red flags — first, highest visual priority */}
129
+ <Section title="When to seek emergency care" danger>
130
+ <ul className="space-y-2">
131
+ {symptom.redFlags.map((r) => (
132
+ <li key={r} className="flex items-start gap-2 text-slate-200">
133
+ <AlertTriangle
134
+ size={16}
135
+ className="flex-shrink-0 text-red-400 mt-0.5"
136
+ />
137
+ <span>{r}</span>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ </Section>
142
+
143
+ <Section title="Safe self-care at home">
144
+ <ul className="space-y-2">
145
+ {symptom.selfCare.map((s) => (
146
+ <li key={s} className="flex items-start gap-2 text-slate-200">
147
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-teal-400 flex-shrink-0" />
148
+ <span>{s}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </Section>
153
+
154
+ <Section title="When to see a clinician">
155
+ <ul className="space-y-2">
156
+ {symptom.whenToSeekCare.map((w) => (
157
+ <li key={w} className="flex items-start gap-2 text-slate-200">
158
+ <span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0" />
159
+ <span>{w}</span>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </Section>
164
+
165
+ {/* FAQ — also rendered for humans, not just search engines */}
166
+ <Section title="Frequently asked questions">
167
+ <div className="space-y-5">
168
+ {symptom.faqs.map((f) => (
169
+ <div key={f.q}>
170
+ <h3 className="font-bold text-slate-100 mb-1">{f.q}</h3>
171
+ <p className="text-slate-300 leading-relaxed">{f.a}</p>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ </Section>
176
+
177
+ {/* Primary CTA into the live chatbot */}
178
+ <div className="mt-10 rounded-2xl border border-teal-500/30 bg-gradient-to-br from-blue-900/40 to-teal-900/30 p-6">
179
+ <p className="text-xs font-bold uppercase tracking-wider text-teal-400 mb-2">
180
+ Ask the live assistant
181
+ </p>
182
+ <h2 className="text-2xl font-bold text-slate-50 mb-2 tracking-tight">
183
+ Get a personalized answer in your language.
184
+ </h2>
185
+ <p className="text-slate-300 mb-4 leading-relaxed">
186
+ MedOS is free, private, and takes no account. Describe your
187
+ situation and get step-by-step guidance.
188
+ </p>
189
+ <Link
190
+ href={chatDeepLink}
191
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
192
+ >
193
+ Ask about {symptom.headline.toLowerCase()}
194
+ <ArrowRight size={18} />
195
+ </Link>
196
+ </div>
197
+
198
+ <p className="mt-8 text-xs text-slate-500 leading-relaxed">
199
+ This page is general patient education aligned with WHO, CDC, and
200
+ NHS public guidance. It is not a diagnosis, prescription, or
201
+ substitute for care from a licensed clinician. If symptoms are
202
+ severe, worsening, or you are in doubt, contact a healthcare
203
+ provider or your local emergency number immediately.
204
+ </p>
205
+ </div>
206
+ </main>
207
+ );
208
+ }
209
+
210
+ function Section({
211
+ title,
212
+ danger,
213
+ children,
214
+ }: {
215
+ title: string;
216
+ danger?: boolean;
217
+ children: React.ReactNode;
218
+ }) {
219
+ return (
220
+ <section
221
+ className={`mt-8 rounded-2xl border p-5 ${
222
+ danger
223
+ ? 'border-red-500/40 bg-red-950/30'
224
+ : 'border-slate-700/60 bg-slate-800/40'
225
+ }`}
226
+ >
227
+ <h2
228
+ className={`text-lg font-bold mb-3 tracking-tight ${
229
+ danger ? 'text-red-300' : 'text-slate-100'
230
+ }`}
231
+ >
232
+ {title}
233
+ </h2>
234
+ {children}
235
+ </section>
236
+ );
237
+ }
app/symptoms/page.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from 'next';
2
+ import Link from 'next/link';
3
+ import { ChevronRight, ShieldCheck } from 'lucide-react';
4
+ import { SYMPTOMS } from '@/lib/symptoms';
5
+
6
+ const SITE_URL = 'https://ruslanmv-medibot.hf.space';
7
+
8
+ export const metadata: Metadata = {
9
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
10
+ description:
11
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care. Free, multilingual, and aligned with WHO, CDC, and NHS.',
12
+ alternates: { canonical: `${SITE_URL}/symptoms` },
13
+ openGraph: {
14
+ title: 'Symptom guides — free, WHO-aligned | MedOS',
15
+ description:
16
+ 'Browse evidence-based symptom guides: causes, safe self-care, red flags, and when to seek care.',
17
+ url: `${SITE_URL}/symptoms`,
18
+ images: [`${SITE_URL}/api/og?q=${encodeURIComponent('Symptom guides')}`],
19
+ },
20
+ };
21
+
22
+ /**
23
+ * Symptom catalog index. Static, cacheable, zero JS-on-load cost.
24
+ * Works as a landing hub from organic search queries like
25
+ * "medos symptoms" or "symptom checker".
26
+ */
27
+ export default function SymptomsIndexPage() {
28
+ return (
29
+ <main className="min-h-screen bg-slate-900 text-slate-100">
30
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 pb-32">
31
+ <header className="mb-8 text-center">
32
+ <p className="text-xs font-bold uppercase tracking-[0.18em] text-teal-400 mb-2">
33
+ Free patient guides
34
+ </p>
35
+ <h1 className="text-4xl sm:text-5xl font-bold text-slate-50 tracking-tight mb-3">
36
+ Symptom guides
37
+ </h1>
38
+ <p className="text-lg text-slate-300 max-w-xl mx-auto leading-relaxed">
39
+ Clear, trustworthy answers to the most common health
40
+ questions. Free, multilingual, and aligned with WHO, CDC,
41
+ and NHS guidance.
42
+ </p>
43
+ <div className="mt-5 inline-flex items-center gap-1.5 text-xs text-teal-300 bg-teal-500/10 border border-teal-500/30 px-3 py-1.5 rounded-full font-semibold">
44
+ <ShieldCheck size={12} />
45
+ Reviewed against WHO · CDC · NHS public guidance
46
+ </div>
47
+ </header>
48
+
49
+ <div className="grid sm:grid-cols-2 gap-3">
50
+ {SYMPTOMS.map((s) => (
51
+ <Link
52
+ key={s.slug}
53
+ href={`/symptoms/${s.slug}`}
54
+ className="group p-5 rounded-2xl border border-slate-700/60 bg-slate-800/60 hover:border-teal-500/50 hover:bg-teal-500/5 transition-all"
55
+ >
56
+ <div className="flex items-start justify-between gap-3">
57
+ <div className="flex-1 min-w-0">
58
+ <h2 className="font-bold text-slate-100 text-lg mb-1 tracking-tight">
59
+ {s.headline}
60
+ </h2>
61
+ <p className="text-sm text-slate-400 leading-relaxed line-clamp-2">
62
+ {s.summary}
63
+ </p>
64
+ </div>
65
+ <ChevronRight
66
+ size={18}
67
+ className="flex-shrink-0 text-slate-500 group-hover:text-teal-400 transition-colors mt-1"
68
+ />
69
+ </div>
70
+ </Link>
71
+ ))}
72
+ </div>
73
+
74
+ <div className="mt-10 text-center">
75
+ <Link
76
+ href="/"
77
+ className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-gradient-to-br from-blue-500 to-teal-500 text-white font-bold hover:brightness-110 transition-all shadow-lg shadow-blue-500/30"
78
+ >
79
+ Open the live MedOS assistant
80
+ <ChevronRight size={18} />
81
+ </Link>
82
+ </div>
83
+ </div>
84
+ </main>
85
+ );
86
+ }
components/MedOSApp.tsx ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useCallback, useEffect, useRef } from "react";
4
+ import { Heart } from "lucide-react";
5
+ import { useGeoDetect } from "@/lib/hooks/useGeoDetect";
6
+ import { ThemeProvider } from "./ThemeProvider";
7
+ import { ThemeToggle } from "./ThemeToggle";
8
+ import { Sidebar, NavView } from "./chat/Sidebar";
9
+ import { RightPanel } from "./chat/RightPanel";
10
+ import { NotificationBell } from "./chat/NotificationCenter";
11
+ import { ChatView } from "./views/ChatView";
12
+ import { HomeView } from "./views/HomeView";
13
+ import { EmergencyView } from "./views/EmergencyView";
14
+ import { TopicsView } from "./views/TopicsView";
15
+ import { SettingsView } from "./views/SettingsView";
16
+ import { RecordsView } from "./views/RecordsView";
17
+ import { HistoryView } from "./views/HistoryView";
18
+ import { MedicationsView } from "./views/MedicationsView";
19
+ import { AppointmentsView } from "./views/AppointmentsView";
20
+ import { VitalsView } from "./views/VitalsView";
21
+ import { HealthDashboard } from "./views/HealthDashboard";
22
+ import { ScheduleView } from "./views/ScheduleView";
23
+ import { WelcomeScreen } from "./WelcomeScreen";
24
+ import { useSettings } from "@/lib/hooks/useSettings";
25
+ import { useChat } from "@/lib/hooks/useChat";
26
+ import { useHealthStore } from "@/lib/hooks/useHealthStore";
27
+ import { useFamilyHealth } from "@/lib/hooks/useFamilyHealth";
28
+ import { useNotifications } from "@/lib/hooks/useNotifications";
29
+ import { useAuth } from "@/lib/hooks/useAuth";
30
+ import { usePasswordResetLink } from "@/lib/hooks/usePasswordResetLink";
31
+ import { LoginView } from "./views/LoginView";
32
+ import { ProfileView } from "./views/ProfileView";
33
+ import { EHRWizard } from "./views/EHRWizard";
34
+ import { MyMedicinesView } from "./views/MyMedicinesView";
35
+ import { ShareView } from "./views/ShareView";
36
+ import { FamilyHealthView } from "./views/FamilyHealthView";
37
+ import { DisclaimerBanner } from "./ui/DisclaimerBanner";
38
+ import { OfflineBanner } from "./ui/OfflineBanner";
39
+ import { InstallPrompt } from "./ui/InstallPrompt";
40
+ import { buildPatientContext, todayISO } from "@/lib/health-store";
41
+ import { t, type SupportedLanguage } from "@/lib/i18n";
42
+
43
+ export default function MedOSApp() {
44
+ return (
45
+ <ThemeProvider>
46
+ <MedOSAppInner />
47
+ </ThemeProvider>
48
+ );
49
+ }
50
+
51
+ function MedOSAppInner() {
52
+ const [activeNav, setActiveNav] = useState<NavView>("home");
53
+ const settings = useSettings();
54
+ const auth = useAuth();
55
+ const resetLink = usePasswordResetLink();
56
+ const { messages, isTyping, error, sendMessage, clearMessages } = useChat();
57
+
58
+ // When the user lands here from a password-reset email, drop them on
59
+ // the login screen with the reset step pre-filled. Done once on mount;
60
+ // the hook itself clears the params from the URL so it won't re-fire.
61
+ useEffect(() => {
62
+ if (resetLink) setActiveNav("login");
63
+ }, [resetLink]);
64
+ const health = useHealthStore(auth.token);
65
+ const family = useFamilyHealth();
66
+ const notif = useNotifications();
67
+
68
+ // IP-based auto-detection. Only applies if the user hasn't manually
69
+ // chosen a language yet; the manual override in Settings wins forever.
70
+ const onGeo = useCallback(
71
+ (g: { country: string; language: any; emergencyNumber: string }) => {
72
+ settings.applyGeo(g);
73
+ },
74
+ [settings],
75
+ );
76
+ useGeoDetect({
77
+ skip: !settings.isLoaded || settings.explicitLanguage,
78
+ onResult: onGeo,
79
+ });
80
+
81
+ const handleSendMessage = (content: string) => {
82
+ sendMessage(content, {
83
+ preset: settings.advancedMode ? undefined : settings.preset,
84
+ provider: settings.advancedMode ? settings.provider : undefined,
85
+ // In advanced mode we let the server default the model; the
86
+ // dedicated provider files pick their own canonical model.
87
+ apiKey: settings.apiKey,
88
+ userHfToken: settings.hfToken || undefined,
89
+ context: {
90
+ country: settings.country,
91
+ language: settings.language,
92
+ emergencyNumber: settings.emergencyNumber,
93
+ },
94
+ });
95
+ // Auto-navigate to chat when sending a message from home/topics
96
+ if (activeNav !== "chat") {
97
+ setActiveNav("chat");
98
+ }
99
+ };
100
+
101
+ const handleStartVoice = () => {
102
+ setActiveNav("chat");
103
+ // Voice will auto-start via the ChatView component
104
+ };
105
+
106
+ const handleWelcomeComplete = (lang: SupportedLanguage, country: string) => {
107
+ // Welcome completion is an explicit user choice — lock it in so
108
+ // subsequent IP auto-detection never overrides it.
109
+ settings.setLanguageExplicit(lang);
110
+ settings.setCountryExplicit(country);
111
+ settings.setWelcomeCompleted(true);
112
+ };
113
+
114
+ // Auto-save the current chat session to history when navigating away
115
+ // from the chat view, or when the AI finishes responding and there are
116
+ // enough messages to be worth saving.
117
+ const lastSavedCount = useRef(0);
118
+ useEffect(() => {
119
+ const userMsgs = messages.filter((m) => m.role === "user");
120
+ if (
121
+ userMsgs.length > 0 &&
122
+ messages.length >= 3 &&
123
+ messages.length !== lastSavedCount.current &&
124
+ !isTyping
125
+ ) {
126
+ lastSavedCount.current = messages.length;
127
+ health.saveSession({
128
+ date: new Date().toISOString(),
129
+ preview: userMsgs[0].content.slice(0, 120),
130
+ messageCount: messages.length,
131
+ topic: undefined,
132
+ });
133
+ }
134
+ }, [messages.length, isTyping]); // eslint-disable-line react-hooks/exhaustive-deps
135
+
136
+ const handleNavigate = (view: string) => {
137
+ setActiveNav(view as NavView);
138
+ };
139
+
140
+ // Text size class
141
+ const textSizeClass =
142
+ settings.textSize === "large"
143
+ ? "text-lg"
144
+ : settings.textSize === "small"
145
+ ? "text-sm"
146
+ : "text-base";
147
+
148
+ const renderContent = () => {
149
+ switch (activeNav) {
150
+ case "home":
151
+ return (
152
+ <HomeView
153
+ language={settings.language}
154
+ country={settings.country}
155
+ emergencyNumber={settings.emergencyNumber}
156
+ onNavigate={handleNavigate}
157
+ onSendMessage={handleSendMessage}
158
+ onStartVoice={handleStartVoice}
159
+ />
160
+ );
161
+ case "emergency":
162
+ return (
163
+ <EmergencyView
164
+ language={settings.language}
165
+ emergencyNumber={settings.emergencyNumber}
166
+ />
167
+ );
168
+ case "topics":
169
+ return (
170
+ <TopicsView
171
+ language={settings.language}
172
+ onSelectTopic={(topic) => handleSendMessage(`Tell me about ${topic}`)}
173
+ />
174
+ );
175
+ case "settings":
176
+ return (
177
+ <SettingsView
178
+ preset={settings.preset}
179
+ setPreset={settings.setPreset}
180
+ hfToken={settings.hfToken}
181
+ setHfToken={settings.setHfToken}
182
+ clearHfToken={settings.clearHfToken}
183
+ provider={settings.provider}
184
+ setProvider={settings.setProvider}
185
+ apiKey={settings.apiKey}
186
+ setApiKey={settings.setApiKey}
187
+ clearApiKey={settings.clearApiKey}
188
+ advancedMode={settings.advancedMode}
189
+ setAdvancedMode={settings.setAdvancedMode}
190
+ language={settings.language}
191
+ setLanguage={settings.setLanguageExplicit}
192
+ country={settings.country}
193
+ setCountry={settings.setCountryExplicit}
194
+ voiceEnabled={settings.voiceEnabled}
195
+ setVoiceEnabled={settings.setVoiceEnabled}
196
+ readAloud={settings.readAloud}
197
+ setReadAloud={settings.setReadAloud}
198
+ textSize={settings.textSize}
199
+ setTextSize={settings.setTextSize}
200
+ simpleLanguage={settings.simpleLanguage}
201
+ setSimpleLanguage={settings.setSimpleLanguage}
202
+ darkMode={settings.darkMode}
203
+ setDarkMode={settings.setDarkMode}
204
+ emergencyNumber={settings.emergencyNumber}
205
+ />
206
+ );
207
+ case "schedule":
208
+ return (
209
+ <ScheduleView
210
+ medications={health.medications}
211
+ medicationLogs={health.medicationLogs}
212
+ appointments={health.appointments}
213
+ onMarkMedTaken={health.markMedTaken}
214
+ isMedTaken={health.isMedTaken}
215
+ onEditAppointment={health.editAppointment}
216
+ onNavigate={handleNavigate}
217
+ language={settings.language}
218
+ />
219
+ );
220
+ case "health-dashboard":
221
+ return (
222
+ <HealthDashboard
223
+ medications={health.medications}
224
+ medicationLogs={health.medicationLogs}
225
+ appointments={health.appointments}
226
+ vitals={health.vitals}
227
+ records={health.records}
228
+ onNavigate={handleNavigate}
229
+ onMarkMedTaken={health.markMedTaken}
230
+ isMedTaken={health.isMedTaken}
231
+ getMedStreak={health.getMedStreak}
232
+ onExport={health.downloadAll}
233
+ language={settings.language}
234
+ isAuthenticated={auth.isAuthenticated}
235
+ />
236
+ );
237
+ case "medications":
238
+ return (
239
+ <MedicationsView
240
+ medications={health.medications}
241
+ onAdd={health.addMedication}
242
+ onEdit={health.editMedication}
243
+ onDelete={health.deleteMedication}
244
+ onMarkTaken={health.markMedTaken}
245
+ isTaken={health.isMedTaken}
246
+ getStreak={health.getMedStreak}
247
+ language={settings.language}
248
+ />
249
+ );
250
+ case "appointments":
251
+ return (
252
+ <AppointmentsView
253
+ appointments={health.appointments}
254
+ onAdd={health.addAppointment}
255
+ onEdit={health.editAppointment}
256
+ onDelete={health.deleteAppointment}
257
+ language={settings.language}
258
+ />
259
+ );
260
+ case "vitals":
261
+ return (
262
+ <VitalsView
263
+ vitals={health.vitals}
264
+ onAdd={health.addVital}
265
+ onDelete={health.deleteVital}
266
+ language={settings.language}
267
+ />
268
+ );
269
+ case "records":
270
+ return (
271
+ <RecordsView
272
+ records={health.records}
273
+ onAdd={health.addRecord}
274
+ onEdit={health.editRecord}
275
+ onDelete={health.deleteRecord}
276
+ onExport={health.downloadAll}
277
+ language={settings.language}
278
+ />
279
+ );
280
+ case "my-medicines":
281
+ return (
282
+ <MyMedicinesView
283
+ medicines={health.medicines}
284
+ onAdd={health.addMedicine}
285
+ onUpdate={health.editMedicine}
286
+ onDelete={health.deleteMedicine}
287
+ onAddToSchedule={(med) => {
288
+ // Add to the medication schedule tracker
289
+ health.addMedication({
290
+ name: med.name,
291
+ dose: med.dose,
292
+ frequency: "daily",
293
+ times: ["08:00"],
294
+ startDate: todayISO(),
295
+ active: true,
296
+ });
297
+ setActiveNav("medications");
298
+ }}
299
+ language={settings.language}
300
+ />
301
+ );
302
+ case "family-health":
303
+ return (
304
+ <FamilyHealthView
305
+ mode={family.mode}
306
+ members={family.members}
307
+ records={family.records}
308
+ currentMemberId={family.currentMemberId}
309
+ onSetMode={family.setMode}
310
+ onSetCurrentMember={family.setCurrentMemberId}
311
+ onSeedDefaultFamily={family.seedDefaultFamily}
312
+ onAddMember={family.addMember}
313
+ onUpdateMember={family.updateMember}
314
+ onUpsertMonthlyRecord={family.upsertMonthlyRecord}
315
+ onCreateInvite={family.createInvite}
316
+ onExport={family.exportData}
317
+ language={settings.language}
318
+ />
319
+ );
320
+ case "share":
321
+ return <ShareView language={settings.language} />;
322
+ case "history":
323
+ return (
324
+ <HistoryView
325
+ history={health.history}
326
+ onDelete={health.deleteSession}
327
+ onClearAll={health.clearAllHistory}
328
+ onReplay={(preview) => handleSendMessage(preview)}
329
+ language={settings.language}
330
+ />
331
+ );
332
+ case "login":
333
+ case "register":
334
+ return (
335
+ <LoginView
336
+ initialFlow={
337
+ resetLink
338
+ ? "reset"
339
+ : activeNav === "register"
340
+ ? "register"
341
+ : "login"
342
+ }
343
+ initialEmail={resetLink?.email}
344
+ initialCode={resetLink?.code}
345
+ onLogin={async (e, p) => {
346
+ const res = await auth.login(e, p);
347
+ if (res.ok) setActiveNav("home");
348
+ return res;
349
+ }}
350
+ onRegister={async (e, p, o) => {
351
+ const res = await auth.register(e, p, o);
352
+ if (res.ok && !res.needsVerification) setActiveNav("home");
353
+ return res;
354
+ }}
355
+ onVerifyEmail={async (code) => {
356
+ const res = await auth.verifyEmail(code);
357
+ if (res.ok) setActiveNav("home");
358
+ return res;
359
+ }}
360
+ onResendVerification={auth.resendVerification}
361
+ onForgotPassword={auth.forgotPassword}
362
+ onResetPassword={async (e, c, p) => {
363
+ const res = await auth.resetPassword(e, c, p);
364
+ if (res.ok) setActiveNav("home");
365
+ return res;
366
+ }}
367
+ language={settings.language}
368
+ />
369
+ );
370
+ case "ehr-wizard":
371
+ return (
372
+ <EHRWizard
373
+ onComplete={() => setActiveNav("profile")}
374
+ onCancel={() => setActiveNav("home")}
375
+ language={settings.language}
376
+ />
377
+ );
378
+ case "profile":
379
+ return auth.user ? (
380
+ <ProfileView
381
+ user={auth.user}
382
+ onLogout={() => {
383
+ auth.logout();
384
+ setActiveNav("home");
385
+ }}
386
+ onExport={health.downloadAll}
387
+ onOpenEHR={() => setActiveNav("ehr-wizard")}
388
+ onDeleteAccount={async (password, confirmEmail) => {
389
+ const res = await auth.deleteMe(password, confirmEmail);
390
+ if (res.ok) {
391
+ // Server already invalidated the session; useAuth wiped
392
+ // local token + user. Send the user back to home.
393
+ setActiveNav("home");
394
+ }
395
+ return res;
396
+ }}
397
+ medicationCount={health.medications.length}
398
+ appointmentCount={health.appointments.length}
399
+ vitalCount={health.vitals.length}
400
+ recordCount={health.records.length}
401
+ language={settings.language}
402
+ />
403
+ ) : (
404
+ <LoginView
405
+ onLogin={async (e, p) => {
406
+ const res = await auth.login(e, p);
407
+ if (res.ok) setActiveNav("profile");
408
+ return res;
409
+ }}
410
+ onRegister={async (e, p, o) => {
411
+ const res = await auth.register(e, p, o);
412
+ if (res.ok && !res.needsVerification) setActiveNav("profile");
413
+ return res;
414
+ }}
415
+ onVerifyEmail={auth.verifyEmail}
416
+ onResendVerification={auth.resendVerification}
417
+ onForgotPassword={auth.forgotPassword}
418
+ onResetPassword={auth.resetPassword}
419
+ language={settings.language}
420
+ />
421
+ );
422
+ default:
423
+ return (
424
+ <ChatView
425
+ messages={messages}
426
+ isTyping={isTyping}
427
+ onSendMessage={handleSendMessage}
428
+ language={settings.language}
429
+ emergencyNumber={settings.emergencyNumber}
430
+ voiceEnabled={settings.voiceEnabled}
431
+ readAloud={settings.readAloud}
432
+ onNavigateEmergency={() => setActiveNav("emergency")}
433
+ />
434
+ );
435
+ }
436
+ };
437
+
438
+ // Loading state
439
+ if (!settings.isLoaded) {
440
+ return (
441
+ <div className="flex h-screen w-full items-center justify-center bg-surface-0">
442
+ <div className="text-center">
443
+ <div className="w-12 h-12 mx-auto mb-4 rounded-2xl bg-brand-gradient flex items-center justify-center animate-pulse shadow-glow">
444
+ <Heart size={24} className="text-white" />
445
+ </div>
446
+ <p className="text-ink-muted font-medium">{t("loading", settings.language)}</p>
447
+ </div>
448
+ </div>
449
+ );
450
+ }
451
+
452
+ // Welcome screen for first-time users
453
+ if (!settings.welcomeCompleted) {
454
+ return (
455
+ <WelcomeScreen
456
+ detectedLanguage={settings.language}
457
+ detectedCountry={settings.country}
458
+ onComplete={handleWelcomeComplete}
459
+ />
460
+ );
461
+ }
462
+
463
+ const hasActiveChat = messages.length > 1;
464
+
465
+ return (
466
+ <div
467
+ className={`relative flex flex-col h-screen-safe w-full font-sans text-ink-base ${textSizeClass}`}
468
+ >
469
+ <OfflineBanner />
470
+ <InstallPrompt />
471
+ <div className="flex flex-1 overflow-hidden">
472
+ {/* Sidebar */}
473
+ <Sidebar
474
+ activeNav={activeNav}
475
+ setActiveNav={setActiveNav}
476
+ language={settings.language}
477
+ advancedMode={settings.advancedMode}
478
+ isAuthenticated={auth.isAuthenticated}
479
+ username={auth.user?.displayName || auth.user?.email}
480
+ />
481
+
482
+ {/* Main Content */}
483
+ <div className="flex-1 flex flex-col relative overflow-hidden pb-14 md:pb-0">
484
+ {/* Top Header — clean, mobile-first, always accessible */}
485
+ <header className="h-14 sm:h-16 bg-surface-1/90 backdrop-blur-xl border-b border-line/50 flex items-center justify-between px-3 sm:px-8 sticky top-0 z-20">
486
+ {/* Mobile logo — larger tap target */}
487
+ <div className="flex items-center gap-2.5 md:hidden">
488
+ <div className="w-9 h-9 rounded-xl bg-brand-gradient flex items-center justify-center text-white shadow-soft">
489
+ <Heart size={16} />
490
+ </div>
491
+ <span className="font-bold text-ink-base tracking-tight text-base">MedOS</span>
492
+ </div>
493
+
494
+ <h2 className="hidden md:block font-bold text-lg text-ink-base tracking-tight">
495
+ {activeNav === "home"
496
+ ? t("nav_home", settings.language)
497
+ : activeNav === "chat"
498
+ ? t("nav_ask", settings.language)
499
+ : activeNav === "emergency"
500
+ ? t("nav_emergency", settings.language)
501
+ : activeNav === "topics"
502
+ ? t("nav_topics", settings.language)
503
+ : activeNav === "settings"
504
+ ? t("nav_settings", settings.language)
505
+ : activeNav === "family-health"
506
+ ? "MedOS Family"
507
+ : activeNav}
508
+ </h2>
509
+
510
+ <div className="flex items-center gap-2 sm:gap-3">
511
+ <NotificationBell
512
+ notifications={notif.notifications}
513
+ count={notif.count}
514
+ onDismiss={notif.dismiss}
515
+ onDismissAll={notif.dismissAll}
516
+ />
517
+ <ThemeToggle />
518
+ {/* The header used to host a pulsing red EmergencyCTA on every
519
+ * page. It read as anxious noise on non-emergency screens and
520
+ * competed with the main actions. Emergency now lives in the
521
+ * sidebar's Tools group (NavItem with urgent flag) where it
522
+ * stays one click away without dominating the chrome. The
523
+ * deterministic safety engine still routes any R5 input to an
524
+ * emergency template at the chat-route level, regardless of
525
+ * what UI is visible. */}
526
+ </div>
527
+ </header>
528
+
529
+ {/* Dynamic Content Area */}
530
+ <main className="flex-1 flex relative overflow-hidden">
531
+ <div className="flex-1 flex flex-col relative">{renderContent()}</div>
532
+ </main>
533
+ </div>
534
+
535
+ {/* Right Panel — only rendered for authenticated users.
536
+ *
537
+ * The right rail is for personal health context (Vitals Today,
538
+ * Upcoming meds + appointments). For guests it offered no value
539
+ * and competed with the left sidebar's auth card. The whole
540
+ * component is now gated, eliminating the 'two sidebars feeling'
541
+ * and the duplicate sign-up prompts. */}
542
+ {auth.isAuthenticated && (
543
+ <RightPanel
544
+ language={settings.language}
545
+ emergencyNumber={settings.emergencyNumber}
546
+ vitals={health.vitals}
547
+ medications={health.medications}
548
+ appointments={health.appointments}
549
+ isMedTaken={health.isMedTaken}
550
+ onNavigate={handleNavigate}
551
+ isAuthenticated
552
+ notificationCount={notif.count}
553
+ onOpenNotifications={() => {}}
554
+ />
555
+ )}
556
+ </div>
557
+ <DisclaimerBanner language={settings.language} />
558
+ </div>
559
+ );
560
+ }