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

Deploy MediBot from cc48a830

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 +93 -0
  2. .gitignore +6 -0
  3. Dockerfile +56 -0
  4. Makefile +195 -0
  5. README.md +106 -0
  6. app/admin/page.tsx +327 -0
  7. app/api/admin/audit/route.ts +84 -0
  8. app/api/admin/config/route.ts +142 -0
  9. app/api/admin/email-status/route.ts +30 -0
  10. app/api/admin/fetch-models/route.ts +620 -0
  11. app/api/admin/llm-health/route.ts +127 -0
  12. app/api/admin/reset-password/route.ts +62 -0
  13. app/api/admin/stats/route.ts +46 -0
  14. app/api/admin/system-info/route.ts +130 -0
  15. app/api/admin/test-connection/route.ts +360 -0
  16. app/api/admin/users/[id]/route.ts +204 -0
  17. app/api/admin/users/route.ts +78 -0
  18. app/api/auth/delete-account/route.ts +80 -0
  19. app/api/auth/forgot-password/route.ts +46 -0
  20. app/api/auth/login/route.ts +88 -0
  21. app/api/auth/logout/route.ts +14 -0
  22. app/api/auth/me/route.ts +184 -0
  23. app/api/auth/register/route.ts +79 -0
  24. app/api/auth/resend-verification/route.ts +30 -0
  25. app/api/auth/reset-password/route.ts +63 -0
  26. app/api/auth/verify-email/route.ts +58 -0
  27. app/api/chat-history/route.ts +99 -0
  28. app/api/chat/route.ts +320 -0
  29. app/api/geo/route.ts +142 -0
  30. app/api/health-data/route.ts +114 -0
  31. app/api/health-data/sync/route.ts +77 -0
  32. app/api/health/route.ts +10 -0
  33. app/api/models/route.ts +14 -0
  34. app/api/nearby/route.ts +94 -0
  35. app/api/og/route.tsx +207 -0
  36. app/api/rag/route.ts +30 -0
  37. app/api/scan/route.ts +194 -0
  38. app/api/sessions/route.ts +70 -0
  39. app/api/triage/route.ts +29 -0
  40. app/api/user/settings/route.ts +126 -0
  41. app/globals.css +378 -0
  42. app/icon.svg +10 -0
  43. app/layout.tsx +78 -0
  44. app/manifest.ts +43 -0
  45. app/page.tsx +2 -0
  46. app/robots.ts +23 -0
  47. app/sitemap.ts +27 -0
  48. app/stats/page.tsx +245 -0
  49. app/symptoms/[slug]/page.tsx +237 -0
  50. app/symptoms/page.tsx +86 -0
.env.example ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ OLLABRIDGE_URL=https://ruslanmv-ollabridge.hf.space
13
+ OLLABRIDGE_API_KEY=sk-ollabridge-your-key-here # admin-rotatable
14
+ HF_TOKEN=hf_your-token-here # admin-rotatable
15
+ DEFAULT_MODEL=free-best
16
+
17
+ # --- Database (SQLite, persistent on HF Spaces /data/) ---
18
+ # SQLite is the fallback driver. To run against Postgres (production), set
19
+ # DATABASE_URL below — SQLite at DB_PATH is then ignored.
20
+ DB_PATH=/data/medos.db
21
+
22
+ # --- Database (Postgres, production primary) ---
23
+ # When set and starting with postgres:// or postgresql://, the runtime
24
+ # uses Postgres instead of SQLite. Neon: use the pooler endpoint.
25
+ # Example:
26
+ # postgresql://USER:PASS@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require
27
+ #
28
+ # Unset → SQLite at DB_PATH (development).
29
+ # Unset AND NODE_ENV=production → the server refuses to start.
30
+ #
31
+ # No other DB knobs are needed; SSL, pool size, and statement timeout
32
+ # all have sensible defaults baked in.
33
+ DATABASE_URL=
34
+
35
+ # --- Admin seed ---
36
+ # First-run admin user. seedAdmin() creates this account on first boot.
37
+ # Do NOT leave ADMIN_PASSWORD at the legacy "admin123456" default in
38
+ # production.
39
+ ADMIN_EMAIL=admin@medos.health
40
+ ADMIN_PASSWORD=
41
+
42
+ # --- Email (verification + password reset) ---
43
+ # Pick ONE transport. They're tried in this order at runtime:
44
+ # 1. RESEND_API_KEY → Resend HTTP API (recommended on serverless)
45
+ # 2. SMTP_HOST + SMTP_USER + SMTP_PASS → nodemailer SMTP
46
+ # 3. (nothing) → emails are logged to stdout only — they never
47
+ # reach a real inbox. Useful for local dev, NEVER
48
+ # leave this state in production (this is the
49
+ # exact state that causes "Account created! Check
50
+ # your email" with no email ever arriving).
51
+ #
52
+ # Resend setup: https://resend.com → API Keys → create → paste below.
53
+ # Until you verify a sending domain, FROM_EMAIL must use onboarding@resend.dev
54
+ # (Resend rejects other senders for unverified domains).
55
+ RESEND_API_KEY=
56
+ FROM_EMAIL=MedOS <onboarding@resend.dev>
57
+
58
+ # SMTP fallback (only used if RESEND_API_KEY is unset).
59
+ # SMTP_HOST=smtp.sendgrid.net
60
+ # SMTP_PORT=587
61
+ # SMTP_USER=apikey
62
+ # SMTP_PASS=
63
+
64
+ # Verify the active transport at runtime by hitting (as an admin):
65
+ # GET /api/admin/email-status
66
+
67
+ # Used in the password-reset email's "Reset password" link. Set to the
68
+ # canonical user-facing URL of your deployment (Vercel domain or HF Space URL).
69
+ APP_URL=https://ruslanmv-medibot.hf.space
70
+
71
+ # --- CORS (comma-separated Vercel frontend origins) ---
72
+ ALLOWED_ORIGINS=https://your-vercel-app.vercel.app,http://localhost:3000
73
+
74
+ # --- Medicine Scanner proxy ---
75
+ # Token with "Make calls to Inference Providers" permission.
76
+ # Used SERVER-SIDE ONLY by /api/scan — never exposed to the browser, never
77
+ # returned in any HTTP response body. Per-user quota and audit are enforced
78
+ # server-side; see docs/USER_ISOLATION.md.
79
+ # Create at:
80
+ # https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained
81
+ HF_TOKEN_INFERENCE=hf_your-inference-token-here # admin-rotatable
82
+ SCANNER_URL=https://ruslanmv-medicine-scanner.hf.space # admin-rotatable
83
+ NEARBY_URL=https://ruslanmv-metaengine-nearby.hf.space # admin-rotatable
84
+
85
+ # --- At-rest encryption key (for BYO user tokens, audit fields, etc.) ---
86
+ # Strongly recommended in production. If unset, falls back to a key derived
87
+ # from ADMIN_PASSWORD (development convenience only — NOT for production).
88
+ # Generate with: openssl rand -hex 32
89
+ # ENCRYPTION_KEY=
90
+
91
+ # --- Application ---
92
+ NODE_ENV=production
93
+ PORT=7860
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ .env
5
+ .env.local
6
+ *.tsbuildinfo
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"]
Makefile ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # MedOS Global - Makefile
3
+ # Free AI Medical Assistant for Hugging Face Spaces
4
+ # =============================================================================
5
+
6
+ SHELL := /bin/bash
7
+ .DEFAULT_GOAL := help
8
+
9
+ # Directories
10
+ APP_DIR := 9-HuggingFace-Global
11
+ NODE_MODULES := $(APP_DIR)/node_modules
12
+
13
+ # Colors
14
+ GREEN := \033[0;32m
15
+ YELLOW := \033[0;33m
16
+ RED := \033[0;31m
17
+ NC := \033[0m
18
+
19
+ # =============================================================================
20
+ # Help
21
+ # =============================================================================
22
+
23
+ .PHONY: help
24
+ help: ## Show this help message
25
+ @echo ""
26
+ @echo " MedOS Global - Development Commands"
27
+ @echo " ===================================="
28
+ @echo ""
29
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
30
+ awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
31
+ @echo ""
32
+
33
+ # =============================================================================
34
+ # Installation
35
+ # =============================================================================
36
+
37
+ .PHONY: install
38
+ install: ## Install all dependencies
39
+ @echo "$(GREEN)Installing dependencies...$(NC)"
40
+ cd $(APP_DIR) && npm install
41
+ @echo "$(GREEN)Dependencies installed successfully.$(NC)"
42
+
43
+ .PHONY: install-ci
44
+ install-ci: ## Install dependencies for CI (clean, reproducible)
45
+ @echo "$(GREEN)Installing dependencies (CI mode)...$(NC)"
46
+ cd $(APP_DIR) && npm ci
47
+ @echo "$(GREEN)CI dependencies installed.$(NC)"
48
+
49
+ .PHONY: clean
50
+ clean: ## Remove node_modules and build artifacts
51
+ @echo "$(YELLOW)Cleaning build artifacts...$(NC)"
52
+ rm -rf $(APP_DIR)/node_modules
53
+ rm -rf $(APP_DIR)/.next
54
+ rm -rf $(APP_DIR)/out
55
+ @echo "$(GREEN)Clean complete.$(NC)"
56
+
57
+ # =============================================================================
58
+ # Quality Checks
59
+ # =============================================================================
60
+
61
+ .PHONY: lint
62
+ lint: ## Run ESLint
63
+ @echo "$(GREEN)Running linter...$(NC)"
64
+ cd $(APP_DIR) && npx next lint
65
+
66
+ .PHONY: type-check
67
+ type-check: ## Run TypeScript type checking
68
+ @echo "$(GREEN)Running type checker...$(NC)"
69
+ cd $(APP_DIR) && npx tsc --noEmit
70
+
71
+ .PHONY: format-check
72
+ format-check: ## Check code formatting
73
+ @echo "$(GREEN)Checking code formatting...$(NC)"
74
+ cd $(APP_DIR) && npx prettier --check "**/*.{ts,tsx,js,json,css}" 2>/dev/null || echo "Prettier not configured - skipping"
75
+
76
+ # =============================================================================
77
+ # Testing
78
+ # =============================================================================
79
+
80
+ .PHONY: test
81
+ test: test-unit test-structure test-providers test-i18n test-safety ## Run all tests
82
+ @echo "$(GREEN)All tests passed!$(NC)"
83
+
84
+ .PHONY: test-unit
85
+ test-unit: ## Run unit tests
86
+ @echo "$(GREEN)Running unit tests...$(NC)"
87
+ cd $(APP_DIR) && node --experimental-vm-modules ../tests/9-hf-global/run-tests.js
88
+
89
+ .PHONY: test-structure
90
+ test-structure: ## Verify folder structure and required files exist
91
+ @echo "$(GREEN)Verifying folder structure...$(NC)"
92
+ @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
93
+ @test -f $(APP_DIR)/package.json || (echo "$(RED)FAIL: package.json missing$(NC)" && exit 1)
94
+ @test -f $(APP_DIR)/next.config.js || (echo "$(RED)FAIL: next.config.js missing$(NC)" && exit 1)
95
+ @test -f $(APP_DIR)/tsconfig.json || (echo "$(RED)FAIL: tsconfig.json missing$(NC)" && exit 1)
96
+ @test -f $(APP_DIR)/tailwind.config.ts || (echo "$(RED)FAIL: tailwind.config.ts missing$(NC)" && exit 1)
97
+ @test -f $(APP_DIR)/README.md || (echo "$(RED)FAIL: README.md missing$(NC)" && exit 1)
98
+ @test -f $(APP_DIR)/app/layout.tsx || (echo "$(RED)FAIL: app/layout.tsx missing$(NC)" && exit 1)
99
+ @test -f $(APP_DIR)/app/page.tsx || (echo "$(RED)FAIL: app/page.tsx missing$(NC)" && exit 1)
100
+ @test -f $(APP_DIR)/app/globals.css || (echo "$(RED)FAIL: app/globals.css missing$(NC)" && exit 1)
101
+ @test -f $(APP_DIR)/app/api/chat/route.ts || (echo "$(RED)FAIL: chat API route missing$(NC)" && exit 1)
102
+ @test -f $(APP_DIR)/app/api/triage/route.ts || (echo "$(RED)FAIL: triage API route missing$(NC)" && exit 1)
103
+ @test -f $(APP_DIR)/app/api/health/route.ts || (echo "$(RED)FAIL: health API route missing$(NC)" && exit 1)
104
+ @test -f $(APP_DIR)/public/manifest.json || (echo "$(RED)FAIL: manifest.json missing$(NC)" && exit 1)
105
+ @test -f $(APP_DIR)/public/sw.js || (echo "$(RED)FAIL: sw.js missing$(NC)" && exit 1)
106
+ @test -f $(APP_DIR)/Dockerfile || (echo "$(RED)FAIL: Dockerfile missing$(NC)" && exit 1)
107
+ @echo "$(GREEN) Structure check passed (15/15 files verified).$(NC)"
108
+
109
+ .PHONY: test-providers
110
+ test-providers: ## Test provider modules exist and export correctly
111
+ @echo "$(GREEN)Verifying provider modules...$(NC)"
112
+ @test -f $(APP_DIR)/lib/providers/index.ts || (echo "$(RED)FAIL: providers/index.ts missing$(NC)" && exit 1)
113
+ @test -f $(APP_DIR)/lib/providers/ollabridge.ts || (echo "$(RED)FAIL: ollabridge.ts missing$(NC)" && exit 1)
114
+ @test -f $(APP_DIR)/lib/providers/huggingface-direct.ts || (echo "$(RED)FAIL: huggingface-direct.ts missing$(NC)" && exit 1)
115
+ @test -f $(APP_DIR)/lib/providers/cached-faq.ts || (echo "$(RED)FAIL: cached-faq.ts missing$(NC)" && exit 1)
116
+ @test -f $(APP_DIR)/lib/providers/ollabridge-models.ts || (echo "$(RED)FAIL: ollabridge-models.ts missing$(NC)" && exit 1)
117
+ @echo "$(GREEN) Provider modules verified (5/5).$(NC)"
118
+
119
+ .PHONY: test-i18n
120
+ test-i18n: ## Verify i18n translations exist (synced or in web/ source)
121
+ @echo "$(GREEN)Verifying i18n...$(NC)"
122
+ @if test -f $(APP_DIR)/lib/i18n.ts; then \
123
+ echo "$(GREEN) Found synced lib/i18n.ts$(NC)"; \
124
+ elif test -f web/lib/i18n.ts; then \
125
+ echo "$(GREEN) Found web/lib/i18n.ts (source of truth)$(NC)"; \
126
+ else \
127
+ echo "$(RED)FAIL: i18n.ts not found in lib/ or web/lib/$(NC)" && exit 1; \
128
+ fi
129
+
130
+ .PHONY: test-safety
131
+ test-safety: ## Verify safety modules exist
132
+ @echo "$(GREEN)Verifying safety modules...$(NC)"
133
+ @test -f $(APP_DIR)/lib/safety/triage.ts || (echo "$(RED)FAIL: triage.ts missing$(NC)" && exit 1)
134
+ @test -f $(APP_DIR)/lib/safety/emergency-numbers.ts || (echo "$(RED)FAIL: emergency-numbers.ts missing$(NC)" && exit 1)
135
+ @test -f $(APP_DIR)/lib/safety/disclaimer.ts || (echo "$(RED)FAIL: disclaimer.ts missing$(NC)" && exit 1)
136
+ @echo "$(GREEN) Safety modules verified (3/3).$(NC)"
137
+
138
+ # =============================================================================
139
+ # Build
140
+ # =============================================================================
141
+
142
+ .PHONY: build
143
+ build: ## Build the Next.js application
144
+ @echo "$(GREEN)Building application...$(NC)"
145
+ cd $(APP_DIR) && npm run build
146
+ @echo "$(GREEN)Build complete.$(NC)"
147
+
148
+ .PHONY: docker-build
149
+ docker-build: ## Build Docker image
150
+ @echo "$(GREEN)Building Docker image...$(NC)"
151
+ cd $(APP_DIR) && docker build -t medos-global .
152
+ @echo "$(GREEN)Docker image built successfully.$(NC)"
153
+
154
+ .PHONY: docker-run
155
+ docker-run: ## Run Docker container locally
156
+ @echo "$(GREEN)Starting MedOS on http://localhost:7860...$(NC)"
157
+ cd $(APP_DIR) && docker run -p 7860:7860 \
158
+ -e OLLABRIDGE_URL=$${OLLABRIDGE_URL:-https://ruslanmv-ollabridge-cloud.hf.space} \
159
+ -e HF_TOKEN=$${HF_TOKEN:-} \
160
+ medos-global
161
+
162
+ # =============================================================================
163
+ # Development
164
+ # =============================================================================
165
+
166
+ .PHONY: dev
167
+ dev: ## Start development server on port 7860
168
+ @echo "$(GREEN)Starting dev server on http://localhost:7860...$(NC)"
169
+ cd $(APP_DIR) && npm run dev
170
+
171
+ .PHONY: start
172
+ start: ## Start production server on port 7860
173
+ cd $(APP_DIR) && npm run start
174
+
175
+ # =============================================================================
176
+ # Deployment
177
+ # =============================================================================
178
+
179
+ .PHONY: deploy-hf
180
+ deploy-hf: ## Deploy to Hugging Face Spaces (requires HF_TOKEN)
181
+ @test -n "$(HF_TOKEN)" || (echo "$(RED)ERROR: HF_TOKEN is required. Set it with: make deploy-hf HF_TOKEN=hf_...$(NC)" && exit 1)
182
+ @echo "$(GREEN)Deploying to Hugging Face Spaces...$(NC)"
183
+ @bash scripts/deploy-hf.sh $(HF_TOKEN)
184
+
185
+ # =============================================================================
186
+ # CI Pipeline (combines all checks)
187
+ # =============================================================================
188
+
189
+ .PHONY: ci
190
+ ci: install-ci test build ## Full CI pipeline: install, test, build
191
+ @echo "$(GREEN)CI pipeline passed successfully!$(NC)"
192
+
193
+ .PHONY: check
194
+ check: test-structure test-providers test-i18n test-safety ## Quick check (no install needed)
195
+ @echo "$(GREEN)All structural checks passed!$(NC)"
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,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ } from 'lucide-react';
18
+
19
+ interface Stats {
20
+ totalUsers: number;
21
+ verifiedUsers: number;
22
+ adminUsers: number;
23
+ totalHealthData: number;
24
+ totalChats: number;
25
+ activeSessions: number;
26
+ healthBreakdown: Array<{ type: string; count: number }>;
27
+ registrations: Array<{ day: string; count: number }>;
28
+ }
29
+
30
+ interface UserRow {
31
+ id: string;
32
+ email: string;
33
+ displayName: string | null;
34
+ emailVerified: boolean;
35
+ isAdmin: boolean;
36
+ createdAt: string;
37
+ healthDataCount: number;
38
+ chatHistoryCount: number;
39
+ }
40
+
41
+ /**
42
+ * Admin dashboard — accessible ONLY at /admin on the HuggingFace Space.
43
+ * Not linked from the public UI. Requires admin login.
44
+ */
45
+ export default function AdminPage() {
46
+ const [token, setToken] = useState('');
47
+ const [loggedIn, setLoggedIn] = useState(false);
48
+ const [email, setEmail] = useState('');
49
+ const [password, setPassword] = useState('');
50
+ const [loginError, setLoginError] = useState('');
51
+ const [stats, setStats] = useState<Stats | null>(null);
52
+ const [users, setUsers] = useState<UserRow[]>([]);
53
+ const [total, setTotal] = useState(0);
54
+ const [page, setPage] = useState(1);
55
+ const [search, setSearch] = useState('');
56
+ const [loading, setLoading] = useState(false);
57
+
58
+ const headers = useCallback(
59
+ () => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }),
60
+ [token],
61
+ );
62
+
63
+ const fetchStats = useCallback(async () => {
64
+ const res = await fetch('/api/admin/stats', { headers: headers() });
65
+ if (res.ok) setStats(await res.json());
66
+ else if (res.status === 403) { setLoggedIn(false); setToken(''); }
67
+ }, [headers]);
68
+
69
+ const fetchUsers = useCallback(async () => {
70
+ setLoading(true);
71
+ const qs = new URLSearchParams({ page: String(page), limit: '20' });
72
+ if (search) qs.set('search', search);
73
+ const res = await fetch(`/api/admin/users?${qs}`, { headers: headers() });
74
+ if (res.ok) {
75
+ const data = await res.json();
76
+ setUsers(data.users);
77
+ setTotal(data.total);
78
+ }
79
+ setLoading(false);
80
+ }, [headers, page, search]);
81
+
82
+ useEffect(() => {
83
+ if (!loggedIn) return;
84
+ fetchStats();
85
+ fetchUsers();
86
+ }, [loggedIn, fetchStats, fetchUsers]);
87
+
88
+ const handleLogin = async () => {
89
+ setLoginError('');
90
+ const res = await fetch('/api/auth/login', {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ email, password }),
94
+ });
95
+ const data = await res.json();
96
+ if (!res.ok) { setLoginError(data.error || 'Login failed'); return; }
97
+ // Verify this user is actually an admin.
98
+ const meRes = await fetch('/api/auth/me', {
99
+ headers: { Authorization: `Bearer ${data.token}` },
100
+ });
101
+ const me = await meRes.json();
102
+ if (!me.user) { setLoginError('Auth failed'); return; }
103
+ // Check admin flag by trying the admin API.
104
+ const adminCheck = await fetch('/api/admin/stats', {
105
+ headers: { Authorization: `Bearer ${data.token}` },
106
+ });
107
+ if (adminCheck.status === 403) { setLoginError('Not an admin account'); return; }
108
+ setToken(data.token);
109
+ setLoggedIn(true);
110
+ };
111
+
112
+ const handleDeleteUser = async (userId: string, userEmail: string) => {
113
+ if (!confirm(`Delete user ${userEmail} and ALL their data?`)) return;
114
+ await fetch(`/api/admin/users?id=${userId}`, { method: 'DELETE', headers: headers() });
115
+ fetchUsers();
116
+ fetchStats();
117
+ };
118
+
119
+ // Login screen
120
+ if (!loggedIn) {
121
+ return (
122
+ <div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
123
+ <div className="w-full max-w-sm">
124
+ <div className="text-center mb-8">
125
+ <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">
126
+ <Lock size={24} className="text-white" />
127
+ </div>
128
+ <h1 className="text-2xl font-bold text-slate-100">Admin Panel</h1>
129
+ <p className="text-sm text-slate-400 mt-1">MedOS server administration</p>
130
+ </div>
131
+ {loginError && (
132
+ <div className="mb-4 p-3 rounded-xl bg-red-950/50 border border-red-700/50 text-sm text-red-300">
133
+ {loginError}
134
+ </div>
135
+ )}
136
+ <div className="space-y-3">
137
+ <input
138
+ type="email"
139
+ value={email}
140
+ onChange={(e) => setEmail(e.target.value)}
141
+ placeholder="Admin email"
142
+ 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"
143
+ />
144
+ <input
145
+ type="password"
146
+ value={password}
147
+ onChange={(e) => setPassword(e.target.value)}
148
+ placeholder="Password"
149
+ 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"
150
+ onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
151
+ />
152
+ <button
153
+ onClick={handleLogin}
154
+ 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"
155
+ >
156
+ <LogIn size={16} />
157
+ Sign in as Admin
158
+ </button>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ const totalPages = Math.ceil(total / 20);
166
+
167
+ return (
168
+ <div className="min-h-screen bg-slate-950 text-slate-100">
169
+ <header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
170
+ <div className="flex items-center gap-3">
171
+ <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
172
+ <Shield size={16} className="text-white" />
173
+ </div>
174
+ <h1 className="font-bold text-lg">MedOS Admin</h1>
175
+ </div>
176
+ <button
177
+ onClick={() => { fetchStats(); fetchUsers(); }}
178
+ 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"
179
+ >
180
+ <RefreshCw size={12} /> Refresh
181
+ </button>
182
+ </header>
183
+
184
+ <main className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
185
+ {/* Stats grid */}
186
+ {stats && (
187
+ <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
188
+ <Stat icon={Users} label="Total users" value={stats.totalUsers} />
189
+ <Stat icon={Shield} label="Verified" value={stats.verifiedUsers} />
190
+ <Stat icon={Shield} label="Admins" value={stats.adminUsers} color="text-red-400" />
191
+ <Stat icon={Database} label="Health records" value={stats.totalHealthData} />
192
+ <Stat icon={MessageCircle} label="Conversations" value={stats.totalChats} />
193
+ <Stat icon={Activity} label="Active sessions" value={stats.activeSessions} />
194
+ </div>
195
+ )}
196
+
197
+ {/* Health data breakdown */}
198
+ {stats && stats.healthBreakdown.length > 0 && (
199
+ <div className="mb-8 p-4 rounded-2xl bg-slate-900 border border-slate-800">
200
+ <h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Health data by type</h3>
201
+ <div className="flex flex-wrap gap-2">
202
+ {stats.healthBreakdown.map((b) => (
203
+ <span key={b.type} className="px-3 py-1.5 rounded-full bg-slate-800 text-sm font-medium text-slate-200">
204
+ {b.type}: <strong>{b.count}</strong>
205
+ </span>
206
+ ))}
207
+ </div>
208
+ </div>
209
+ )}
210
+
211
+ {/* User management */}
212
+ <div className="rounded-2xl bg-slate-900 border border-slate-800 overflow-hidden">
213
+ <div className="p-4 border-b border-slate-800 flex items-center gap-3">
214
+ <h2 className="font-bold">Users ({total})</h2>
215
+ <div className="flex-1 relative ml-4">
216
+ <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
217
+ <input
218
+ value={search}
219
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
220
+ placeholder="Search by email or name..."
221
+ 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"
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ <div className="overflow-x-auto">
227
+ <table className="w-full text-sm">
228
+ <thead>
229
+ <tr className="text-xs text-slate-400 uppercase tracking-wider border-b border-slate-800">
230
+ <th className="text-left px-4 py-3">User</th>
231
+ <th className="text-center px-4 py-3">Verified</th>
232
+ <th className="text-center px-4 py-3">Role</th>
233
+ <th className="text-center px-4 py-3">Health</th>
234
+ <th className="text-center px-4 py-3">Chats</th>
235
+ <th className="text-left px-4 py-3">Joined</th>
236
+ <th className="text-right px-4 py-3">Actions</th>
237
+ </tr>
238
+ </thead>
239
+ <tbody>
240
+ {users.map((u) => (
241
+ <tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
242
+ <td className="px-4 py-3">
243
+ <div className="font-medium text-slate-100">{u.displayName || '—'}</div>
244
+ <div className="text-xs text-slate-400">{u.email}</div>
245
+ </td>
246
+ <td className="px-4 py-3 text-center">
247
+ <span className={`text-xs font-bold ${u.emailVerified ? 'text-emerald-400' : 'text-slate-500'}`}>
248
+ {u.emailVerified ? 'Yes' : 'No'}
249
+ </span>
250
+ </td>
251
+ <td className="px-4 py-3 text-center">
252
+ {u.isAdmin ? (
253
+ <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">
254
+ ADMIN
255
+ </span>
256
+ ) : (
257
+ <span className="text-xs text-slate-500">User</span>
258
+ )}
259
+ </td>
260
+ <td className="px-4 py-3 text-center text-slate-300">{u.healthDataCount}</td>
261
+ <td className="px-4 py-3 text-center text-slate-300">{u.chatHistoryCount}</td>
262
+ <td className="px-4 py-3 text-slate-400 text-xs">
263
+ {new Date(u.createdAt).toLocaleDateString()}
264
+ </td>
265
+ <td className="px-4 py-3 text-right">
266
+ {!u.isAdmin && (
267
+ <button
268
+ onClick={() => handleDeleteUser(u.id, u.email)}
269
+ className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
270
+ title="Delete user"
271
+ >
272
+ <Trash2 size={14} />
273
+ </button>
274
+ )}
275
+ </td>
276
+ </tr>
277
+ ))}
278
+ {users.length === 0 && (
279
+ <tr>
280
+ <td colSpan={7} className="px-4 py-8 text-center text-slate-500">
281
+ {loading ? 'Loading...' : 'No users found'}
282
+ </td>
283
+ </tr>
284
+ )}
285
+ </tbody>
286
+ </table>
287
+ </div>
288
+
289
+ {/* Pagination */}
290
+ {totalPages > 1 && (
291
+ <div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
292
+ <span className="text-xs text-slate-400">
293
+ Page {page} of {totalPages}
294
+ </span>
295
+ <div className="flex gap-1">
296
+ <button
297
+ onClick={() => setPage(Math.max(1, page - 1))}
298
+ disabled={page <= 1}
299
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
300
+ >
301
+ <ChevronLeft size={14} />
302
+ </button>
303
+ <button
304
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
305
+ disabled={page >= totalPages}
306
+ className="p-1.5 rounded-lg bg-slate-800 text-slate-300 disabled:opacity-30"
307
+ >
308
+ <ChevronRight size={14} />
309
+ </button>
310
+ </div>
311
+ </div>
312
+ )}
313
+ </div>
314
+ </main>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ function Stat({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color?: string }) {
320
+ return (
321
+ <div className="p-4 rounded-xl bg-slate-900 border border-slate-800">
322
+ <Icon size={16} className={color || 'text-slate-400'} />
323
+ <div className="text-2xl font-black mt-2">{value.toLocaleString()}</div>
324
+ <div className="text-[11px] text-slate-500 font-semibold">{label}</div>
325
+ </div>
326
+ );
327
+ }
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,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
18
+ const HF_BASE_URL = 'https://router.huggingface.co/v1';
19
+
20
+ /** All models to test — matches the presets fallback chain. */
21
+ const MODELS_TO_TEST = [
22
+ 'meta-llama/Llama-3.3-70B-Instruct:sambanova',
23
+ 'meta-llama/Llama-3.3-70B-Instruct:together',
24
+ 'meta-llama/Llama-3.3-70B-Instruct',
25
+ 'Qwen/Qwen2.5-72B-Instruct',
26
+ 'Qwen/Qwen3-235B-A22B',
27
+ 'google/gemma-3-27b-it',
28
+ 'meta-llama/Llama-3.1-70B-Instruct',
29
+ 'Qwen/Qwen3-32B',
30
+ 'deepseek-ai/DeepSeek-V3-0324',
31
+ 'deepseek-ai/DeepSeek-R1',
32
+ 'Qwen/Qwen3-30B-A3B',
33
+ 'Qwen/Qwen2.5-Coder-32B-Instruct',
34
+ ];
35
+
36
+ async function testModel(model: string, token: string): Promise<{
37
+ model: string;
38
+ status: 'ok' | 'error';
39
+ latencyMs: number;
40
+ response?: string;
41
+ error?: string;
42
+ httpStatus?: number;
43
+ }> {
44
+ const start = Date.now();
45
+ try {
46
+ const res = await fetch(`${HF_BASE_URL}/chat/completions`, {
47
+ method: 'POST',
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ 'Content-Type': 'application/json',
51
+ },
52
+ body: JSON.stringify({
53
+ model,
54
+ messages: [{ role: 'user', content: 'Say OK' }],
55
+ max_tokens: 5,
56
+ temperature: 0.1,
57
+ stream: false,
58
+ }),
59
+ signal: AbortSignal.timeout(15000),
60
+ });
61
+
62
+ const latencyMs = Date.now() - start;
63
+
64
+ if (res.ok) {
65
+ const data = await res.json();
66
+ const content = data?.choices?.[0]?.message?.content?.trim() || '';
67
+ return { model, status: 'ok', latencyMs, response: content.slice(0, 30) };
68
+ } else {
69
+ const text = await res.text().catch(() => '');
70
+ const errorMsg = text.slice(0, 100);
71
+ return { model, status: 'error', latencyMs, error: errorMsg, httpStatus: res.status };
72
+ }
73
+ } catch (e: any) {
74
+ return {
75
+ model,
76
+ status: 'error',
77
+ latencyMs: Date.now() - start,
78
+ error: e?.name === 'TimeoutError' ? 'Timeout (15s)' : (e?.message || 'Unknown error').slice(0, 100),
79
+ };
80
+ }
81
+ }
82
+
83
+ export async function GET(req: Request) {
84
+ const admin = requireAdmin(req);
85
+ if (!admin) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
86
+
87
+ // Prefer admin-configured token, fall back to env var.
88
+ const config = loadConfig();
89
+ const token = config.llm.hfToken || process.env.HF_TOKEN || '';
90
+
91
+ if (!token) {
92
+ // Still return a well-formed response so the UI can render an empty-state
93
+ // Provider Status panel with a helpful error banner.
94
+ return NextResponse.json({
95
+ error: 'HF_TOKEN not configured — set it in Admin → Server → HuggingFace.',
96
+ models: MODELS_TO_TEST.map((model) => ({
97
+ model,
98
+ status: 'error' as const,
99
+ latencyMs: 0,
100
+ error: 'No HF token configured',
101
+ })),
102
+ summary: {
103
+ total: MODELS_TO_TEST.length,
104
+ ok: 0,
105
+ error: MODELS_TO_TEST.length,
106
+ testedAt: new Date().toISOString(),
107
+ },
108
+ });
109
+ }
110
+
111
+ // Test all models in parallel for speed.
112
+ const results = await Promise.all(
113
+ MODELS_TO_TEST.map((model) => testModel(model, token))
114
+ );
115
+
116
+ const ok = results.filter((r) => r.status === 'ok').length;
117
+
118
+ return NextResponse.json({
119
+ models: results,
120
+ summary: {
121
+ total: results.length,
122
+ ok,
123
+ error: results.length - ok,
124
+ testedAt: new Date().toISOString(),
125
+ },
126
+ });
127
+ }
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,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest } from 'next/server';
2
+ import { z } from 'zod';
3
+ import { chatWithFallback, 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 } from '@/lib/feature-flags';
7
+
8
+ // Log feature-flag snapshot once per process load so deployments make their
9
+ // configured behavior visible. Values are server-side only and PHI-free.
10
+ console.log(`[Chat] route.flags ${JSON.stringify(snapshotFlags())}`);
11
+ import { buildRAGContext } from '@/lib/rag/medical-kb';
12
+ import { buildMedicalSystemPrompt } from '@/lib/medical-knowledge';
13
+ import { authenticateRequest } from '@/lib/auth-middleware';
14
+ import { checkRateLimit, getClientIp } from '@/lib/rate-limit';
15
+ import { auditLog } from '@/lib/audit';
16
+ import {
17
+ buildPatientContextForUser,
18
+ stripInjectedPatientContext,
19
+ } from '@/lib/patient-context.server';
20
+
21
+ const RequestSchema = z.object({
22
+ messages: z.array(
23
+ z.object({
24
+ role: z.enum(['system', 'user', 'assistant']),
25
+ content: z.string(),
26
+ })
27
+ ),
28
+ model: z.string().optional().default('qwen2.5:1.5b'),
29
+ language: z.string().optional().default('en'),
30
+ countryCode: z.string().optional().default('US'),
31
+ });
32
+
33
+ export async function POST(request: NextRequest) {
34
+ const routeStartedAt = Date.now();
35
+ const ip = getClientIp(request);
36
+ const user = authenticateRequest(request);
37
+
38
+ // Per-identity chat rate limit. Authenticated users get a generous
39
+ // 60 turns/min by user id (stable across IPs), anonymous get 20/min
40
+ // by IP. The limiter is in-memory per process; for multi-instance
41
+ // deployments swap to Redis (same interface).
42
+ const limitKey = user ? `chat:user:${user.id}` : `chat:ip:${ip}`;
43
+ const limitMax = user ? 60 : 20;
44
+ const limit = checkRateLimit(limitKey, limitMax, 60_000);
45
+ if (!limit.allowed) {
46
+ return new Response(
47
+ JSON.stringify({
48
+ error: 'Chat rate limit exceeded. Please slow down.',
49
+ retryAfterMs: limit.retryAfterMs,
50
+ }),
51
+ {
52
+ status: 429,
53
+ headers: {
54
+ 'Content-Type': 'application/json',
55
+ 'Retry-After': String(Math.ceil(limit.retryAfterMs / 1000)),
56
+ },
57
+ },
58
+ );
59
+ }
60
+
61
+ try {
62
+ const body = await request.json();
63
+ const { messages, model, language, countryCode } = RequestSchema.parse(body);
64
+
65
+ // Single-line JSON payload so the HF Space logs API (SSE) can be grepped
66
+ // with a simple prefix match. Every stage below tags itself `[Chat]`.
67
+ console.log(
68
+ `[Chat] route.enter ${JSON.stringify({
69
+ userId: user?.id || null,
70
+ turns: messages.length,
71
+ model,
72
+ language,
73
+ countryCode,
74
+ userAgent: request.headers.get('user-agent')?.slice(0, 80) || null,
75
+ })}`,
76
+ );
77
+
78
+ // Step 1: Emergency triage on the latest user message.
79
+ // Sanitise FIRST: strip any client-injected [Patient: ...] block so
80
+ // (a) the triage check sees only the user's real prose, and
81
+ // (b) we cannot leak another user's EHR into the LLM if a stale or
82
+ // malicious client sends one.
83
+ const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
84
+ const rawUserContent = lastUserMessage?.content || '';
85
+ const cleanUserContent = stripInjectedPatientContext(rawUserContent);
86
+
87
+ // Step 1: Run the deterministic safety pre-check. This is the FLOOR;
88
+ // the LLM cannot relax it. The engine returns either an emergency
89
+ // template (R5 — LLM not called) or a green-light decision with a
90
+ // risk class and a system-prompt augmentation that pins policy.
91
+ let safetyDecision: ReturnType<typeof preCheck> | null = null;
92
+ if (lastUserMessage) {
93
+ safetyDecision = preCheck({
94
+ text: cleanUserContent,
95
+ countryCode,
96
+ });
97
+
98
+ console.log(
99
+ `[Chat] route.safety.preCheck ${JSON.stringify({
100
+ userId: user?.id || null,
101
+ riskClass: safetyDecision.audit.riskClass,
102
+ ruleFires: safetyDecision.audit.ruleFires,
103
+ userChars: cleanUserContent.length,
104
+ })}`,
105
+ );
106
+
107
+ if (safetyDecision.kind === 'emergency_template') {
108
+ // Capture the narrowed values before entering the ReadableStream
109
+ // callback — discriminated-union narrowing on `safetyDecision`
110
+ // does not survive into the inner closure under strict TS.
111
+ const emergencyTemplate = safetyDecision.template;
112
+ const emergencyRuleFires = safetyDecision.audit.ruleFires;
113
+
114
+ const encoder = new TextEncoder();
115
+ const stream = new ReadableStream({
116
+ start(controller) {
117
+ const data = JSON.stringify({
118
+ choices: [{ delta: { content: emergencyTemplate } }],
119
+ provider: 'safety-engine',
120
+ model: 'emergency-template',
121
+ isEmergency: true,
122
+ riskClass: 'R5',
123
+ });
124
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
125
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
126
+ controller.close();
127
+ },
128
+ });
129
+
130
+ if (user) {
131
+ auditLog({
132
+ userId: user.id,
133
+ action: 'chat',
134
+ ip,
135
+ meta: {
136
+ riskClass: 'R5',
137
+ ruleFires: emergencyRuleFires,
138
+ countryCode,
139
+ model: 'emergency-template',
140
+ },
141
+ });
142
+ }
143
+
144
+ return new Response(stream, {
145
+ headers: {
146
+ 'Content-Type': 'text/event-stream',
147
+ 'Cache-Control': 'no-cache',
148
+ Connection: 'keep-alive',
149
+ },
150
+ });
151
+ }
152
+ }
153
+
154
+ // Step 2: Build RAG context from the medical knowledge base.
155
+ const ragStart = Date.now();
156
+ const ragContext = lastUserMessage ? buildRAGContext(cleanUserContent) : '';
157
+ console.log(
158
+ `[Chat] route.rag ${JSON.stringify({
159
+ userId: user?.id || null,
160
+ chars: ragContext.length,
161
+ latencyMs: Date.now() - ragStart,
162
+ })}`,
163
+ );
164
+
165
+ // Step 3: Server-built patient context, scoped to the authenticated
166
+ // user. Anonymous chats receive no per-user EHR — they get a generic
167
+ // medical assistant. This is the isolation contract.
168
+ const patientContext = user ? buildPatientContextForUser(user.id) : '';
169
+
170
+ // Step 4: Build a structured, locale-aware system prompt that grounds
171
+ // the model in WHO/CDC/NHS guidance and pins the response language,
172
+ // country, emergency number, and measurement system. Append the
173
+ // safety-engine policy block so the LLM is aware of the deterministic
174
+ // floor — the post-filter is the second line of defence.
175
+ const emergencyInfo = getEmergencyInfo(countryCode);
176
+ const baseSystemPrompt = buildMedicalSystemPrompt({
177
+ country: countryCode,
178
+ language,
179
+ emergencyNumber: emergencyInfo.emergency,
180
+ });
181
+ const systemPrompt =
182
+ safetyDecision && safetyDecision.kind === 'allow_llm'
183
+ ? `${baseSystemPrompt}\n\n${safetyDecision.systemInstructions}`
184
+ : baseSystemPrompt;
185
+
186
+ // Step 5: Assemble the final message list. Prior turns are passed through
187
+ // verbatim except for the LAST user turn, which is rebuilt with:
188
+ // sanitised user prose + server-built [Patient: ...] + retrieved RAG
189
+ // in that order. The LLM sees patient context BEFORE reference material,
190
+ // matching the prior client-side ordering.
191
+ const priorMessages = messages.slice(0, -1).map((m) =>
192
+ m.role === 'user'
193
+ ? { ...m, content: stripInjectedPatientContext(m.content) }
194
+ : m,
195
+ );
196
+
197
+ const finalUserContent = [
198
+ cleanUserContent,
199
+ patientContext, // already starts with '\n[Patient: ...]' or ''
200
+ ragContext
201
+ ? `\n\n[Reference material retrieved from the medical knowledge base — use if relevant]\n${ragContext}`
202
+ : '',
203
+ ].join('');
204
+
205
+ const augmentedMessages: ChatMessage[] = [
206
+ { role: 'system' as const, content: systemPrompt },
207
+ ...priorMessages,
208
+ { role: 'user' as const, content: finalUserContent },
209
+ ];
210
+
211
+ // Step 6: Stream response via the provider fallback chain.
212
+ console.log(
213
+ `[Chat] route.provider.dispatch ${JSON.stringify({
214
+ userId: user?.id || null,
215
+ systemPromptChars: systemPrompt.length,
216
+ patientContextChars: patientContext.length,
217
+ totalMessages: augmentedMessages.length,
218
+ preparedInMs: Date.now() - routeStartedAt,
219
+ })}`,
220
+ );
221
+ // Step 6: Buffer-then-filter-then-stream.
222
+ //
223
+ // The deterministic post-filter must run on the COMPLETE model response
224
+ // before any of it reaches the user. We therefore call the non-streaming
225
+ // provider, run postCheck(), and re-emit the filtered text as a single
226
+ // SSE chunk so the existing client SSE parser keeps working.
227
+ //
228
+ // This adds end-to-end latency relative to mid-stream display, but it is
229
+ // the only honest way to enforce the safety contract (SAFETY.md). UX
230
+ // optimisations (server-side chunking of the filtered output) can
231
+ // happen in a follow-up without changing the safety guarantee.
232
+ const providerResponse = await chatWithFallback(augmentedMessages, model);
233
+
234
+ const riskClass = safetyDecision?.kind === 'allow_llm'
235
+ ? safetyDecision.riskClass
236
+ : 'R0';
237
+
238
+ const post = postCheck({
239
+ response: providerResponse.content,
240
+ riskClass,
241
+ emergency: emergencyInfo,
242
+ });
243
+
244
+ console.log(
245
+ `[Chat] route.safety.postCheck ${JSON.stringify({
246
+ userId: user?.id || null,
247
+ riskClass,
248
+ filterFires: post.audit.filterFires,
249
+ modified: post.audit.modified,
250
+ blocked: post.audit.blocked,
251
+ totalMs: Date.now() - routeStartedAt,
252
+ })}`,
253
+ );
254
+
255
+ const encoder = new TextEncoder();
256
+ const safeStream = new ReadableStream({
257
+ start(controller) {
258
+ const data = JSON.stringify({
259
+ choices: [{ delta: { content: post.filtered } }],
260
+ provider: providerResponse.provider,
261
+ model: providerResponse.model,
262
+ riskClass,
263
+ filtered: post.audit.modified,
264
+ });
265
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
266
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
267
+ controller.close();
268
+ },
269
+ });
270
+
271
+ if (user) {
272
+ auditLog({
273
+ userId: user.id,
274
+ action: 'chat',
275
+ ip,
276
+ meta: {
277
+ model: providerResponse.model,
278
+ provider: providerResponse.provider,
279
+ countryCode,
280
+ turns: messages.length,
281
+ patientContextChars: patientContext.length,
282
+ riskClass,
283
+ ruleFires: safetyDecision?.audit.ruleFires ?? [],
284
+ filterFires: post.audit.filterFires,
285
+ filterModified: post.audit.modified,
286
+ filterBlocked: post.audit.blocked,
287
+ },
288
+ });
289
+ }
290
+
291
+ return new Response(safeStream, {
292
+ headers: {
293
+ 'Content-Type': 'text/event-stream',
294
+ 'Cache-Control': 'no-cache',
295
+ Connection: 'keep-alive',
296
+ },
297
+ });
298
+ } catch (error) {
299
+ console.error(
300
+ `[Chat] route.error ${JSON.stringify({
301
+ userId: user?.id || null,
302
+ totalMs: Date.now() - routeStartedAt,
303
+ name: (error as any)?.name,
304
+ message: String((error as any)?.message || error).slice(0, 200),
305
+ })}`,
306
+ );
307
+
308
+ if (error instanceof z.ZodError) {
309
+ return new Response(
310
+ JSON.stringify({ error: 'Invalid request', details: error.errors }),
311
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
312
+ );
313
+ }
314
+
315
+ return new Response(
316
+ JSON.stringify({ error: 'Internal server error' }),
317
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
318
+ );
319
+ }
320
+ }
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,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
54
+ /* CJK fallbacks (Chinese, Japanese, Korean) */
55
+ "Noto Sans CJK SC", "PingFang SC", "Hiragino Sans", "MS Gothic",
56
+ "Malgun Gothic", "Apple SD Gothic Neo",
57
+ /* Arabic */
58
+ "Noto Sans Arabic", "Geeza Pro", "Tahoma",
59
+ /* Devanagari (Hindi) */
60
+ "Noto Sans Devanagari",
61
+ /* Thai */
62
+ "Noto Sans Thai",
63
+ /* System fallbacks */
64
+ ui-sans-serif, system-ui, -apple-system,
65
+ "Segoe UI", Roboto, sans-serif;
66
+ font-feature-settings: "cv02", "cv03", "cv04", "cv11", "ss01";
67
+ -webkit-font-smoothing: antialiased;
68
+ -moz-osx-font-smoothing: grayscale;
69
+ text-rendering: optimizeLegibility;
70
+ }
71
+
72
+ body {
73
+ @apply text-ink-base antialiased;
74
+ background: theme("backgroundImage.light-app");
75
+ background-attachment: fixed;
76
+ line-height: 1.6;
77
+ letter-spacing: -0.005em;
78
+ }
79
+
80
+ .dark body {
81
+ background: theme("backgroundImage.dark-app");
82
+ background-attachment: fixed;
83
+ }
84
+
85
+ /* Slightly larger, more readable body copy — medical trust */
86
+ p { line-height: 1.65; }
87
+
88
+ /* Focus rings that are visible in both modes */
89
+ :focus-visible {
90
+ outline: 2px solid rgb(var(--color-brand, 59 130 246));
91
+ outline-offset: 2px;
92
+ border-radius: 8px;
93
+ }
94
+ }
95
+
96
+ @layer components {
97
+ .glass-card {
98
+ @apply bg-surface-1/80 backdrop-blur-xl border border-line/60 shadow-soft;
99
+ }
100
+ .glass-strong {
101
+ @apply bg-surface-1/95 backdrop-blur-2xl border border-line/70 shadow-card;
102
+ }
103
+ /* Section headings inside an AI answer (Summary / Self-care …) */
104
+ .answer-section {
105
+ @apply relative pl-4 mt-4 first:mt-0;
106
+ }
107
+ .answer-section::before {
108
+ content: "";
109
+ @apply absolute left-0 top-1 bottom-1 w-1 rounded-full bg-brand-500/60;
110
+ }
111
+ }
112
+
113
+ @layer utilities {
114
+ .animate-in { animation: fadeIn 0.5s ease-in-out; }
115
+ .slide-in-from-bottom-4 { animation: slideInFromBottom 0.5s ease-in-out; }
116
+ .delay-100 { animation-delay: 100ms; }
117
+ .delay-200 { animation-delay: 200ms; }
118
+
119
+ /* Shimmer utility for the "Analyzing…" typing state */
120
+ .shimmer-text {
121
+ background: linear-gradient(
122
+ 90deg,
123
+ rgb(var(--ink-muted) / 0.5) 0%,
124
+ rgb(var(--ink-base)) 50%,
125
+ rgb(var(--ink-muted) / 0.5) 100%
126
+ );
127
+ background-size: 200% 100%;
128
+ -webkit-background-clip: text;
129
+ background-clip: text;
130
+ color: transparent;
131
+ animation: shimmer 2.2s linear infinite;
132
+ }
133
+
134
+ @keyframes fadeIn {
135
+ from { opacity: 0; }
136
+ to { opacity: 1; }
137
+ }
138
+ @keyframes slideInFromBottom {
139
+ from { opacity: 0; transform: translateY(1rem); }
140
+ to { opacity: 1; transform: translateY(0); }
141
+ }
142
+
143
+ /* Medicine scanner viewfinder — pulsing green border */
144
+ @keyframes scannerPulse {
145
+ 0%, 100% { border-color: rgba(34, 197, 94, 0.5); box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
146
+ 50% { border-color: rgba(34, 197, 94, 1); box-shadow: 0 0 20px 4px rgba(34, 197, 94, 0.15); }
147
+ }
148
+ @keyframes scanLine {
149
+ 0% { top: 8%; }
150
+ 50% { top: 88%; }
151
+ 100% { top: 8%; }
152
+ }
153
+ @keyframes scanSuccess {
154
+ 0% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
155
+ 50% { transform: scale(1.02); border-color: rgba(34, 197, 94, 1); }
156
+ 100% { transform: scale(1); border-color: rgba(34, 197, 94, 0.6); }
157
+ }
158
+ @keyframes scanProgress {
159
+ 0% { width: 0%; }
160
+ 100% { width: 100%; }
161
+ }
162
+ .animate-scanner-pulse { animation: scannerPulse 2s ease-in-out infinite; }
163
+ .animate-scan-line { animation: scanLine 2.5s ease-in-out infinite; }
164
+ .animate-scan-success { animation: scanSuccess 0.6s ease-out; }
165
+ .animate-scan-progress { animation: scanProgress 3s ease-out forwards; }
166
+ }
167
+
168
+ /* ------------------------------------------------------------
169
+ * Dark-mode compatibility layer for legacy screens that still
170
+ * reference slate/white utility classes directly. Remaps them to
171
+ * the design-token surfaces so Settings/Records/Schedule/Topics/
172
+ * Emergency look right in dark mode without a full rewrite.
173
+ * New components should prefer the `bg-surface-*` and `text-ink-*`
174
+ * tokens directly and won't be affected by these rules.
175
+ * ------------------------------------------------------------ */
176
+ .dark .bg-white { background-color: rgb(var(--surface-1)) !important; }
177
+ .dark .bg-slate-50 { background-color: rgb(var(--surface-2)) !important; }
178
+ .dark .bg-slate-100 { background-color: rgb(var(--surface-2)) !important; }
179
+ .dark .bg-\[\#F8FAFC\] { background-color: rgb(var(--surface-0)) !important; }
180
+ .dark .bg-\[\#F7F9FB\] { background-color: rgb(var(--surface-0)) !important; }
181
+
182
+ .dark .text-slate-900 { color: rgb(var(--ink-base)) !important; }
183
+ .dark .text-slate-800 { color: rgb(var(--ink-base)) !important; }
184
+ .dark .text-slate-700 { color: rgb(var(--ink-base) / 0.92) !important; }
185
+ .dark .text-slate-600 { color: rgb(var(--ink-muted)) !important; }
186
+ .dark .text-slate-500 { color: rgb(var(--ink-muted)) !important; }
187
+ .dark .text-slate-400 { color: rgb(var(--ink-subtle)) !important; }
188
+ .dark .text-slate-300 { color: rgb(var(--ink-subtle) / 0.85) !important; }
189
+
190
+ .dark .border-slate-50 { border-color: rgb(var(--line) / 0.55) !important; }
191
+ .dark .border-slate-100 { border-color: rgb(var(--line) / 0.7) !important; }
192
+ .dark .border-slate-200 { border-color: rgb(var(--line) / 0.9) !important; }
193
+
194
+ .dark .placeholder-slate-400::placeholder { color: rgb(var(--ink-subtle)); }
195
+
196
+ /* Soft tinted surfaces used on a handful of views (blue-50, rose-50,
197
+ * amber-50, etc.) — in dark mode dim them into brand/accent tints. */
198
+ .dark .bg-blue-50 { background-color: rgba(59,130,246,0.10) !important; }
199
+ .dark .bg-blue-100 { background-color: rgba(59,130,246,0.18) !important; }
200
+ .dark .bg-indigo-50 { background-color: rgba(99,102,241,0.10) !important; }
201
+ .dark .bg-rose-50 { background-color: rgba(244,63,94,0.10) !important; }
202
+ .dark .bg-red-50 { background-color: rgba(239,68,68,0.10) !important; }
203
+ .dark .bg-amber-50 { background-color: rgba(245,158,11,0.10) !important; }
204
+ .dark .bg-emerald-50 { background-color: rgba(16,185,129,0.10) !important; }
205
+ .dark .bg-purple-50 { background-color: rgba(168,85,247,0.10) !important; }
206
+
207
+ .dark .border-blue-100 { border-color: rgba(59,130,246,0.28) !important; }
208
+ .dark .border-blue-200 { border-color: rgba(59,130,246,0.38) !important; }
209
+ .dark .border-red-200 { border-color: rgba(239,68,68,0.38) !important; }
210
+ .dark .border-amber-200 { border-color: rgba(245,158,11,0.38) !important; }
211
+
212
+ /* Scrollbars — subtle in both modes */
213
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
214
+ ::-webkit-scrollbar-track { background: transparent; }
215
+ ::-webkit-scrollbar-thumb {
216
+ background: rgb(var(--ink-subtle) / 0.35);
217
+ border-radius: 9999px;
218
+ border: 2px solid transparent;
219
+ background-clip: padding-box;
220
+ }
221
+ ::-webkit-scrollbar-thumb:hover {
222
+ background: rgb(var(--ink-subtle) / 0.55);
223
+ background-clip: padding-box;
224
+ }
225
+
226
+ ::selection {
227
+ background: rgba(59, 130, 246, 0.22);
228
+ color: inherit;
229
+ }
230
+
231
+ /* Safe area utilities for notched phones (iPhone X+, Android gesture nav) */
232
+ .safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
233
+ .pb-safe-area { padding-bottom: env(safe-area-inset-bottom, 0px); }
234
+ .safe-area-top { padding-top: env(safe-area-inset-top, 0px); }
235
+ .px-safe-area {
236
+ padding-left: env(safe-area-inset-left, 0px);
237
+ padding-right: env(safe-area-inset-right, 0px);
238
+ }
239
+
240
+ /* ============================================================
241
+ * RTL (Right-to-Left) support for Arabic (ar) and Urdu (ur)
242
+ * Set via document.documentElement.dir = "rtl" in MedOSApp.tsx
243
+ * ============================================================ */
244
+ [dir="rtl"] {
245
+ /* Base direction */
246
+ direction: rtl;
247
+ text-align: right;
248
+ }
249
+
250
+ /* Flex containers reverse in RTL */
251
+ [dir="rtl"] .flex { direction: rtl; }
252
+
253
+ /* Fix drawer slide direction (left → right for RTL) */
254
+ [dir="rtl"] aside { left: auto; right: 0; }
255
+ [dir="rtl"] .-translate-x-full { transform: translateX(100%); }
256
+ [dir="rtl"] .translate-x-0 { transform: translateX(0); }
257
+
258
+ /* Flip margins and paddings for common patterns */
259
+ [dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
260
+ [dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
261
+ [dir="rtl"] .text-left { text-align: right; }
262
+ [dir="rtl"] .text-right { text-align: left; }
263
+
264
+ /* Fix icon + text alignment in nav items */
265
+ [dir="rtl"] .gap-2 { gap: 0.5rem; }
266
+ [dir="rtl"] .gap-3 { gap: 0.75rem; }
267
+
268
+ /* Scrollbar on left side for RTL */
269
+ [dir="rtl"] ::-webkit-scrollbar { direction: rtl; }
270
+
271
+ /* Fix rounded corners (swap left/right) */
272
+ [dir="rtl"] .rounded-tl-sm { border-top-left-radius: 0; border-top-right-radius: 0.125rem; }
273
+ [dir="rtl"] .rounded-tr-sm { border-top-right-radius: 0; border-top-left-radius: 0.125rem; }
274
+
275
+ /* Large tap targets */
276
+ @media (pointer: coarse) {
277
+ button, a, select, input, textarea { min-height: 44px; }
278
+ }
279
+
280
+ /* ============================================================
281
+ * Mobile-first utilities
282
+ * ============================================================ */
283
+
284
+ /* Dynamic viewport height — works on iOS Safari, Android Chrome,
285
+ and every modern browser. Falls back to 100vh. */
286
+ .h-screen-safe {
287
+ height: 100vh;
288
+ height: 100dvh;
289
+ }
290
+
291
+ /* Sticky input bar that stays above the mobile keyboard.
292
+ Uses env(keyboard-inset-height) on supporting browsers and
293
+ falls back to standard sticky positioning elsewhere. */
294
+ .sticky-bottom-keyboard {
295
+ position: sticky;
296
+ bottom: 0;
297
+ bottom: env(keyboard-inset-height, 0px);
298
+ }
299
+
300
+ /* Prevent iOS input zoom — any input below 16px triggers a zoom.
301
+ We force 16px minimum on touch devices and compensate with
302
+ transforms where we need visually-smaller text. */
303
+ @media (pointer: coarse) {
304
+ input, textarea, select {
305
+ font-size: 16px !important;
306
+ }
307
+ }
308
+
309
+ /* CJK word-break — prevents overflow for Chinese/Japanese/Korean text */
310
+ :lang(zh), :lang(ja), :lang(ko) {
311
+ word-break: break-all;
312
+ overflow-wrap: break-word;
313
+ }
314
+
315
+ /* iOS momentum scrolling */
316
+ .scroll-touch {
317
+ -webkit-overflow-scrolling: touch;
318
+ }
319
+
320
+ /* Bottom padding spacer for content that sits above a fixed bottom nav.
321
+ The 5.5rem accounts for the nav height + safe-area-inset-bottom. */
322
+ .pb-mobile-nav {
323
+ padding-bottom: 5.5rem;
324
+ }
325
+ @media (min-width: 768px) {
326
+ .pb-mobile-nav {
327
+ padding-bottom: 0;
328
+ }
329
+ }
330
+
331
+ /* Respect reduced-motion preferences everywhere */
332
+ @media (prefers-reduced-motion: reduce) {
333
+ *,
334
+ *::before,
335
+ *::after {
336
+ animation-duration: 0.01ms !important;
337
+ animation-iteration-count: 1 !important;
338
+ transition-duration: 0.01ms !important;
339
+ }
340
+ }
341
+
342
+ /* ============================================================
343
+ * Print styles — professional PDF export via window.print()
344
+ * Hides navigation, maximizes content, clean typography
345
+ * ============================================================ */
346
+ @media print {
347
+ /* Hide navigation, headers, footers */
348
+ aside, nav, header, footer,
349
+ .safe-area-bottom, .pb-mobile-nav,
350
+ button, [role="navigation"],
351
+ .md\:hidden, .lg\:flex { display: none !important; }
352
+
353
+ /* Full width, no sidebar */
354
+ body { background: white !important; color: black !important; font-size: 12pt; }
355
+ .flex-1 { width: 100% !important; max-width: 100% !important; }
356
+ .overflow-hidden { overflow: visible !important; }
357
+ .overflow-y-auto { overflow: visible !important; }
358
+
359
+ /* Clean card borders for print */
360
+ .bg-surface-1, .bg-surface-0 { background: white !important; }
361
+ .border { border-color: #ddd !important; }
362
+ .shadow-soft, .shadow-card, .shadow-glow { box-shadow: none !important; }
363
+ .rounded-2xl { border-radius: 8px !important; }
364
+
365
+ /* Keep colors readable */
366
+ .text-ink-base { color: #111 !important; }
367
+ .text-ink-muted { color: #555 !important; }
368
+ .text-brand-500, .text-brand-600 { color: #2563eb !important; }
369
+ .text-danger-500 { color: #dc2626 !important; }
370
+ .text-success-500 { color: #16a34a !important; }
371
+
372
+ /* Page breaks */
373
+ .stat-card, .rounded-2xl { break-inside: avoid; }
374
+ h2, h3 { break-after: avoid; }
375
+
376
+ /* Print header */
377
+ @page { margin: 1.5cm; size: A4; }
378
+ }
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
+ }