diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3ac26d2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.env
+.env.*
+!.env.example
+__pycache__
+*.pyc
+content/
+node_modules/
+dist/
+*.log
+.DS_Store
diff --git a/.env.example b/.env.example
index 32a93d9..4b0fdbf 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,6 @@
# === REQUIRED (generated automatically by setup.sh) ===
SECRET_KEY=
+API_TOKEN=
# === APP URL (change for production) ===
BASE_URL=http://localhost
@@ -8,6 +9,11 @@ BASE_URL=http://localhost
DB_USER=fitness
DB_NAME=fitness_rewards
+# === MCP AGENT AUTH (generated automatically by setup.sh) ===
+# The API_TOKEN authenticates the MCP connector with the backend.
+# It is auto-configured — you don't need to touch it unless you're
+# connecting an agent from outside Docker.
+
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
# No container restart needed.
diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md
index ddd2dad..74f9dd5 100644
--- a/AGENT_GUIDE.md
+++ b/AGENT_GUIDE.md
@@ -97,3 +97,31 @@ Use get_context() to see their motivation profile:
understand it, but don't lecture.
- Don't read back integration credentials. You can check status but
never retrieve stored secrets.
+
+## Multi-Device Integration
+
+If you have access to both Diligence tools and a device MCP (like COROS),
+you can bridge data between them:
+
+1. Read the user's recent workouts from the device MCP
+2. Use Diligence's log_activity tool to import them
+3. This earns the user points automatically
+
+Example workflow with COROS MCP connected:
+- Check COROS for today's activities
+- For each unlogged activity, call log_activity with the details
+- Report the points earned
+
+## Strava Data Warning
+
+Strava's API terms prohibit use of their data for AI/ML purposes.
+If the user has Strava connected, you may see synced activities in
+their log, but do not reference Strava-specific data in your coaching
+advice. Treat Strava activities as user-reported workouts only.
+
+## Provider-Agnostic
+
+This guide works regardless of which LLM powers the coaching.
+The same context, tools, and personality apply whether you are
+GPT-4o, Claude, Llama, Gemini, or any other model. The user
+chose you — respect their choice and focus on being helpful.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8da9836
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,27 @@
+# Changelog
+
+All notable changes to Diligence are documented here.
+
+## [1.0.0] — 2026-06-18
+
+Initial open-source release.
+
+### Features
+- Points economy with daily gate, weekly targets, weekly reset
+- Science-based onboarding (PAR-Q+, TTM, BREQ-2 motivation profiling)
+- Activity logging (manual + Strava OAuth + Polar OAuth sync)
+- Food logging with Open Food Facts barcode scanning + USDA FoodData Central
+- 90-day structured program tracking
+- Configurable reward shop
+- In-app support chat with Telegram notifications
+- 14-tool MCP connector for AI agent integration
+- Meal plan system with compliance tracking
+- Dynamic integration configuration (11 providers)
+- B2A discovery layer (llms.txt, agent-card.json, SKILL.md)
+
+### Security
+- Fernet-encrypted credential storage (HKDF key derivation)
+- Signed JWT OAuth state parameters
+- MCP connector authentication via API_TOKEN
+- First-user auto-admin grant
+- Traceback suppression in error responses
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c14bc4a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,31 @@
+# Contributing to Diligence
+
+Thanks for your interest in contributing!
+
+## How to contribute
+
+1. **Open an issue** to discuss the change before starting work
+2. **Fork the repo** and create a branch from `main`
+3. **Test locally**: `docker compose up -d` and verify all 4 containers start healthy
+4. **Submit a PR** with a clear description of what changed and why
+
+## Development setup
+
+```bash
+git clone https://github.com/DiligenceWorks/Diligence.git
+cd Diligence
+./setup.sh # Mac/Linux
+docker compose up -d
+```
+
+Open http://localhost and register. First user gets admin automatically.
+
+## Code style
+
+- Backend: Python 3.11+, FastAPI, async/await, type hints
+- Frontend: React with hooks, no class components
+- CSS: use CSS variables from `index.css`, never hardcode colors
+
+## Questions?
+
+Open a GitHub Discussion or reach out at hello@diligenceworks.online.
diff --git a/OUTSTANDING.md b/OUTSTANDING.md
new file mode 100644
index 0000000..331d702
--- /dev/null
+++ b/OUTSTANDING.md
@@ -0,0 +1,101 @@
+# Diligence v4 — Outstanding Work
+
+**Last updated:** June 22, 2026
+**Session summary:** 5 commits, 35 files changed, +1,358/-329 lines
+
+## What Was Delivered (June 22, 2026)
+
+### Stream A — DW Branding ✅ COMPLETE
+- Full palette swap from "Solar Momentum" (orange/green/blue) to DiligenceWorks brand (deep blue #2952CC, teal, IBM Plex Mono)
+- Light and dark mode via `prefers-color-scheme` media query + manual `data-theme` override
+- Typography: Outfit → Instrument Sans, Plus Jakarta Sans → IBM Plex Mono for data elements
+- 30+ hardcoded hex color values replaced across 16 JSX files
+- Border radii tightened from 14/10/20px to 8/6/12px
+
+### Stream D — Security Hardening ✅ COMPLETE
+- SEC-05: Fail-fast if SECRET_KEY is the default value
+- SEC-06: CORS `allow_credentials=False`
+- SEC-08: Input validation on auth schemas (username length/pattern, password min 8 chars)
+- SEC-10: `.dockerignore` files for root, backend, mcp-connector
+- SEC-11: JWT algorithm hardcoded as constant
+- CQ-01: Crawl scheduler gated on `CRAWL_ENABLED` env var
+- OS-02: `CONTRIBUTING.md`
+- OS-03: `CHANGELOG.md` (v1.0.0)
+- `setup.ps1`: API_TOKEN generation (Windows parity)
+
+### Stream C — AI Agent Setup ✅ BACKEND + FRONTEND COMPLETE
+- `ai_provider.py`: Multi-provider LLM routing with 2 code paths (OpenAI-compatible + Anthropic)
+- Streaming SSE responses for all providers
+- `ai_chat.py`: POST `/api/ai/chat` (SSE) + GET `/api/ai/status`
+- Provider registry expanded from 11 to 20 providers across 4 categories
+- `ChatCoach.jsx`: In-app chat UI with streaming, provider indicator, suggestion chips
+- Nav updated: Home | Log | Keto | Programs | **Coach**
+- README updated with 8-provider table + Claude Desktop/Code/Cursor/Windsurf configs
+- AGENT_GUIDE updated with multi-MCP, Strava warning, provider-agnostic statement
+
+### Stream B — Hardware Integrations ✅ FOUNDATION COMPLETE
+- `device_sync_base.py`: Abstract base class with OAuth management, deduplication, webhook interface
+- Provider registry includes Garmin, WHOOP, Oura, COROS, Fitbit, Withings, Suunto with accurate API URLs
+
+---
+
+## What Still Needs Doing
+
+### Priority 1 — Blocked on API Credentials (Scot action items)
+
+| Task | Blocker | Effort once unblocked |
+|------|---------|----------------------|
+| `garmin_sync.py` — Garmin Connect sync service | Apply at developer.garmin.com using DiligenceWorks Pte. Ltd. ~2 business days approval | 2-3 hours |
+| `whoop_sync.py` — WHOOP sync service | Register at developer.whoop.com. Requires WHOOP device ownership | 2-3 hours |
+| `oura_sync.py` — Oura Ring sync service | Register at cloud.ouraring.com. Self-serve, immediate | 2-3 hours |
+| COROS partner API application | Apply at support.coros.com. Timeline unclear | MCP bridge already designed, no backend code needed |
+
+Each sync service follows the pattern in `device_sync_base.py` and mirrors the existing `strava_sync.py`. The work is: OAuth flow, activity type mapping, API data fetching, and point awarding via `import_activity()`.
+
+### Priority 2 — Code Work (no blockers)
+
+| Task | Effort | Notes |
+|------|--------|-------|
+| Generic webhook receiver endpoint (`POST /api/integrations/webhook/{provider}`) | 1-2 hours | Dispatches to sync services when devices push data |
+| Theme toggle UI in Settings page | 30 min | Light/dark/system toggle, stored in localStorage |
+| SEC-07: Auth rate limiting (`slowapi` on login/register) | 30 min | Add `slowapi` to requirements.txt |
+| SEC-09: UUID parameter validation in meal_plans/support routers | 20 min | Change `str` → `uuid.UUID` type hints |
+| SEC-15: Enum validation for compliance status and meal_type | 20 min | Add `Literal[...]` types |
+| UI-03: Fix React Hooks violation in HelpButton (App.jsx) | 10 min | Move `useEffect` above conditional return |
+| UI-04: Load existing compliance data in MealPlan.jsx on mount | 30 min | Fetch from API and pre-populate state |
+| UI-05: Error state handling in SettingsIntegrations + MealPlan | 30 min | Add error banners on API failures |
+| Settings page accessibility after nav change | 20 min | Add gear icon in header or link from Coach page |
+| Function calling for AI chat (tool use) | 2-3 hours | Let AI coach log workouts, search food via internal tools |
+
+### Priority 3 — Polish (nice to have)
+
+| Task | Effort |
+|------|--------|
+| Push updated code to GitHub (DiligenceWorks/Diligence) — clean-history push | 30 min |
+| Deploy v4 to Coolify (fitness.littlefake.com) | 20 min |
+| Update DW KB CONTEXT.md for fitness-rewards project | 20 min |
+| OS-06: GitHub Actions CI pipeline (docker compose build) | 30 min |
+| OS-07: Test suite (auth, points engine, meal plans) | 4-6 hours |
+| OS-08: System requirements in README (RAM, disk, Docker version) | 10 min |
+| OS-09: Backup/restore documentation in README | 10 min |
+| UI-08: Default timezone from UTC instead of Asia/Bangkok | 5 min |
+
+---
+
+## Architecture Summary for Reviewers
+
+**Container count:** 4 (unchanged — frontend, backend, mcp-connector, fitness-db)
+
+**AI coaching flow:**
+User types in Coach tab → POST `/api/ai/chat` → `ai_provider.py` reads configured provider from DB → routes to OpenAI-compatible endpoint OR Anthropic API → streams SSE response back to `ChatCoach.jsx`
+
+**Device sync flow:**
+User connects device via OAuth in Settings → credentials encrypted in `integration_configs` table → sync triggered by webhook (push) or manual sync (pull) → `device_sync_base.py` deduplicates and awards points via `points_engine.py`
+
+**Provider registry:** 20 providers organized by category:
+- **Device** (9): Strava✅, Polar✅, Garmin⏳, WHOOP⏳, Oura⏳, COROS (MCP), Fitbit, Withings, Suunto
+- **AI** (8): OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Claude, Gemini, Custom
+- **Nutrition** (2): USDA✅, Nutritionix
+- **Notifications** (1): Telegram✅
+
+✅ = working sync service, ⏳ = sync service pending API credentials
diff --git a/README.md b/README.md
index 63dd685..37fb408 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,65 @@ Built by [DiligenceWorks](https://diligenceworks.online).
## Quick Start
+### Prerequisites
+
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) — that's it. No Python, Node.js, or database install needed.
+- Docker Desktop runs natively on **Windows 10/11**, **macOS**, and **Linux**.
+
+---
+
+### Windows 11 Setup
+
+**1. Install Docker Desktop**
+
+Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/).
+
+During installation, the installer presents a checkbox: **"Use WSL 2 instead of Hyper-V (recommended)"**. This is checked by default.
+
+- **WSL 2 backend (default):** Works on all Windows editions including Home. Lower resource usage, better performance. Requires WSL 2 to be installed first — open PowerShell as Administrator and run `wsl --install`, then reboot.
+- **Hyper-V backend:** If WSL 2 causes issues (black screen on reboot, kernel errors, or containers won't start), uncheck the WSL 2 box during installation to use Hyper-V instead. Requires Windows Pro, Enterprise, or Education.
+
+**Important:** Hardware virtualization must be enabled in your BIOS/UEFI settings (Intel VT-x or AMD-V). This is the most common cause of "Docker won't start" issues. Check your laptop/PC manufacturer's documentation for how to access BIOS settings (usually F2, F12, or Del during boot).
+
+**2. Clone and run**
+
+Open PowerShell:
+```powershell
+git clone https://github.com/diligenceworks/diligence
+cd diligence
+powershell -ExecutionPolicy Bypass -File setup.ps1
+docker compose up -d
+```
+
+Or without the script:
+```powershell
+git clone https://github.com/diligenceworks/diligence
+cd diligence
+copy .env.example .env
+# Edit .env and set SECRET_KEY to any random string
+docker compose up -d
+```
+
+---
+
+### macOS Setup
+
+**1. Install Docker Desktop**
+
+Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). Make sure you pick the right installer for your chip:
+
+- **Apple Silicon** (M1, M2, M3, M4) — download the Apple Silicon .dmg
+- **Intel** — download the Intel .dmg
+
+To check: Apple menu → About This Mac. Look for "Chip" (Apple Silicon) or "Processor" (Intel).
+
+Drag Docker.app to Applications and launch it. Requires macOS 13 Ventura or later.
+
+Alternatively, install via Homebrew: `brew install --cask docker`
+
+**2. Clone and run**
+
+Open Terminal:
```bash
git clone https://github.com/diligenceworks/diligence
cd diligence
@@ -13,7 +72,30 @@ cd diligence
docker compose up -d
```
-Open http://localhost and create your account.
+---
+
+### Linux Setup
+
+Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then:
+
+```bash
+git clone https://github.com/diligenceworks/diligence
+cd diligence
+./setup.sh
+docker compose up -d
+```
+
+---
+
+Open http://localhost and create your account. First user gets admin.
+
+### Verify
+
+```bash
+docker compose ps
+```
+
+You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`.
## Features
@@ -26,17 +108,71 @@ Open http://localhost and create your account.
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
-## Connecting an AI Agent
+## Built-in AI Coach
-Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy).
+Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
-Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
+Supported providers (one API key, that's it):
+
+| Provider | Free tier | What you get |
+|----------|-----------|-------------|
+| **OpenRouter** | 26 free models | 300+ models from every major provider, one key |
+| **Hugging Face** | Yes | Thousands of open-source models |
+| **Groq** | Yes | Ultra-fast Llama inference |
+| **Ollama** | Local, free | Run any model on your own machine |
+| **OpenAI** | No | GPT-4o, GPT-4o-mini |
+| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 |
+| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro |
+| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) |
+
+The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat.
+
+### Connecting external AI agents (MCP)
+
+The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow.
+
+**Claude Desktop** — add to `claude_desktop_config.json`:
+```json
+{
+ "mcpServers": {
+ "diligence": { "url": "http://localhost:3001/sse" }
+ }
+}
+```
+
+**Claude Code** (CLI):
+```bash
+claude mcp add diligence --transport sse http://localhost:3001/sse
+```
+
+**Cursor** — add to `.cursor/mcp.json`:
+```json
+{
+ "mcpServers": {
+ "diligence": { "url": "http://localhost:3001/sse" }
+ }
+}
+```
+
+**Windsurf** — add to MCP settings, same format as Cursor.
+
+**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log:
+```json
+{
+ "mcpServers": {
+ "diligence": { "url": "http://localhost:3001/sse" },
+ "coros": { "url": "https://your-coros-mcp-url/sse" }
+ }
+}
+```
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
## Configuring Integrations
-All integrations are configured through the app UI (Settings → Integrations) or through your AI agent. No `.env` file editing or container restarts needed.
+All integrations are configured through the app UI (**Settings → Integrations**) or through your AI agent. No `.env` file editing or container restarts needed.
+
+Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted.
## Data Sovereignty
@@ -44,17 +180,57 @@ Diligence is self-hosted software. Your data never leaves your server. No cloud
Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty.
+## Architecture
+
+```
+┌─────────────────────────────────────────────────┐
+│ nginx │
+│ (frontend, /api proxy, /mcp proxy) │
+├──────────┬─────────────────┬────────────────────┤
+│ React │ FastAPI │ MCP Connector │
+│ SPA │ Backend │ (14 tools) │
+│ │ │ port 3001 │
+│ ├──────────────────┤ │
+│ │ PostgreSQL 16 │ │
+│ │ (internal only) │ │
+└──────────┴──────────────────┴────────────────────┘
+```
+
## Stack
-- Python 3.12, FastAPI, SQLAlchemy
+- Python 3.12, FastAPI, SQLAlchemy (async)
- React 18, Vite
- PostgreSQL 16
- FastMCP (Streamable HTTP/SSE)
- Docker Compose
+## Updating
+
+```bash
+git pull
+docker compose build
+docker compose up -d
+```
+
+Database migrations run automatically on startup. Your data is preserved.
+
+## Backing Up
+
+Your data lives in the `fitness_db_data` Docker volume:
+
+```bash
+docker compose exec fitness-db pg_dump -U fitness fitness_rewards > backup.sql
+```
+
+To restore:
+
+```bash
+docker compose exec -i fitness-db psql -U fitness fitness_rewards < backup.sql
+```
+
## Contributing
-Contributions welcome. Open an issue to discuss before submitting a PR.
+Contributions welcome. Please open an issue to discuss before submitting a PR.
## License
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..3ac26d2
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.env
+.env.*
+!.env.example
+__pycache__
+*.pyc
+content/
+node_modules/
+dist/
+*.log
+.DS_Store
diff --git a/backend/app/config.py b/backend/app/config.py
index 4ffe6be..3c2be33 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -10,8 +10,9 @@ class Settings(BaseSettings):
# Auth
secret_key: str = "change-me-in-production"
- algorithm: str = "HS256"
+ crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
access_token_expire_minutes: int = 1440 # 24 hours
+ api_token: str = "" # MCP connector auth — generated by setup.sh
# Strava
strava_client_id: str = ""
diff --git a/backend/app/main.py b/backend/app/main.py
index 55a3c36..be39c0e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -27,6 +28,13 @@ async def lifespan(app: FastAPI):
logger.error("Could not initialize database after 10 attempts — starting without tables")
+ # SEC-05: Fail fast if SECRET_KEY not configured
+ from app.config import get_settings
+ _s = get_settings()
+ if _s.secret_key == "change-me-in-production":
+ logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
+ sys.exit(1)
+
# Run lightweight migrations for schema changes
try:
await run_migrations()
@@ -41,14 +49,17 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
- # Start background crawl queue scheduler
+ # Start background crawl queue scheduler (gated on CRAWL_ENABLED)
crawl_task = None
- try:
- from app.services.crawl_scheduler import crawl_queue_loop
- crawl_task = asyncio.create_task(crawl_queue_loop())
- logger.info("Crawl queue scheduler started")
- except Exception as e:
- logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
+ if _s.crawl_enabled:
+ try:
+ from app.services.crawl_scheduler import crawl_queue_loop
+ crawl_task = asyncio.create_task(crawl_queue_loop())
+ logger.info("Crawl queue scheduler started")
+ except Exception as e:
+ logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
+ else:
+ logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
logger.info("Fitness Rewards backend started")
yield
@@ -68,7 +79,7 @@ app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
- allow_credentials=True,
+ allow_credentials=False # Bearer tokens don't need credentials mode,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -85,6 +96,7 @@ from app.routers.catalog import router as catalog_router
from app.routers.support import router as support_router
from app.routers.nutrition import router as nutrition_router
from app.routers.meal_plans import router as meal_plans_router
+from app.routers.ai_chat import router as ai_chat_router
app.include_router(auth_router)
app.include_router(onboarding_router)
@@ -98,6 +110,7 @@ app.include_router(support_router)
app.include_router(programs_router)
app.include_router(nutrition_router)
app.include_router(meal_plans_router)
+app.include_router(ai_chat_router)
@app.get("/api/health")
diff --git a/backend/app/routers/ai_chat.py b/backend/app/routers/ai_chat.py
new file mode 100644
index 0000000..e9b7b24
--- /dev/null
+++ b/backend/app/routers/ai_chat.py
@@ -0,0 +1,64 @@
+"""AI coaching chat endpoint — SSE streaming responses."""
+from __future__ import annotations
+
+import json
+import logging
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import StreamingResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.utils.auth import get_current_user
+from app.models.user import User
+from app.services import ai_provider
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/ai", tags=["AI Coaching"])
+
+
+@router.post("/chat")
+async def chat_with_ai(
+ request: Request,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Stream an AI coaching response via SSE.
+
+ Body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]}
+ Response: text/event-stream with data chunks.
+ """
+ body = await request.json()
+ message = body.get("message", "").strip()
+ history = body.get("history", [])
+
+ if not message:
+ return {"error": "Message cannot be empty"}
+
+ # Validate history format
+ clean_history = []
+ for msg in history[-20:]: # Cap at last 20 messages
+ if isinstance(msg, dict) and msg.get("role") in ("user", "assistant") and msg.get("content"):
+ clean_history.append({"role": msg["role"], "content": msg["content"][:4000]})
+
+ async def event_stream():
+ async for chunk in ai_provider.chat(user.id, message, clean_history, db):
+ yield f"data: {json.dumps({'text': chunk})}\n\n"
+ yield "data: [DONE]\n\n"
+
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
+
+
+@router.get("/status")
+async def ai_status(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Check which AI provider is configured."""
+ provider = await ai_provider.get_active_ai_provider(db)
+ if provider:
+ return {
+ "configured": True,
+ "provider": provider["name"],
+ "model": provider["model"],
+ }
+ return {"configured": False, "provider": None, "model": None}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index fd3e487..517998d 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -1,11 +1,10 @@
from __future__ import annotations
import logging
-import traceback
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
-from sqlalchemy import select
+from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -26,11 +25,16 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
+ # Grant admin to first user
+ admin_count = await db.execute(select(func.count(User.id)).where(User.is_admin == True))
+ is_first_user = (admin_count.scalar() or 0) == 0
+
user = User(
username=req.username,
display_name=req.display_name,
password_hash=hash_password(req.password),
email=req.email,
+ is_admin=is_first_user,
)
db.add(user)
await db.flush()
@@ -52,9 +56,8 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
except HTTPException:
raise
except Exception as e:
- tb = traceback.format_exc()
- logger.error(f"Registration failed: {e}\n{tb}")
- return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
+ logger.error(f"Registration failed: {e}", exc_info=True)
+ return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.post("/login")
@@ -69,9 +72,8 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)])
except HTTPException:
raise
except Exception as e:
- tb = traceback.format_exc()
- logger.error(f"Login failed: {e}\n{tb}")
- return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
+ logger.error(f"Login failed: {e}", exc_info=True)
+ return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/me")
@@ -82,4 +84,5 @@ async def get_me(user: Annotated[User, Depends(get_current_user)]):
"display_name": user.display_name,
"email": user.email,
"timezone": user.timezone,
+ "is_admin": getattr(user, "is_admin", False),
}
diff --git a/backend/app/routers/integrations.py b/backend/app/routers/integrations.py
index 8562a7b..e3eb075 100644
--- a/backend/app/routers/integrations.py
+++ b/backend/app/routers/integrations.py
@@ -39,7 +39,10 @@ async def integration_status(
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
- return {"auth_url": get_strava_auth_url(str(user.id))}
+ from app.utils.auth import create_access_token
+ from datetime import timedelta
+ state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
+ return {"auth_url": get_strava_auth_url(state_token)}
@router.get("/strava/callback")
@@ -48,7 +51,14 @@ async def strava_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
- user_id = uuid_mod.UUID(state)
+ from jose import JWTError, jwt as jose_jwt
+ from app.config import get_settings
+ _settings = get_settings()
+ try:
+ payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm])
+ user_id = uuid_mod.UUID(payload["sub"])
+ except (JWTError, KeyError, ValueError):
+ raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_strava_code(code)
# Upsert token
@@ -91,7 +101,10 @@ async def strava_sync(
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
- return {"auth_url": get_polar_auth_url(str(user.id))}
+ from app.utils.auth import create_access_token
+ from datetime import timedelta
+ state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
+ return {"auth_url": get_polar_auth_url(state_token)}
@router.get("/polar/callback")
@@ -100,7 +113,14 @@ async def polar_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
- user_id = uuid_mod.UUID(state)
+ from jose import JWTError, jwt as jose_jwt
+ from app.config import get_settings
+ _settings = get_settings()
+ try:
+ payload = jose_jwt.decode(state, _settings.secret_key, algorithms=[_settings.algorithm])
+ user_id = uuid_mod.UUID(payload["sub"])
+ except (JWTError, KeyError, ValueError):
+ raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
data = await exchange_polar_code(code)
# Register user with Polar
diff --git a/backend/app/routers/support.py b/backend/app/routers/support.py
index c243602..c8cb767 100644
--- a/backend/app/routers/support.py
+++ b/backend/app/routers/support.py
@@ -24,7 +24,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/support", tags=["support"])
-# Admin check uses is_admin column on User model (first registered user gets admin=True)
MAX_MESSAGES_PER_DAY = 10
@@ -279,8 +278,8 @@ async def get_unread_count(
# ── Admin Endpoints ────────────────────────────────────────────────────────
def require_admin(user: User):
- """Check that the user is the admin."""
- if user.username != ADMIN_USERNAME:
+ """Check that the user has admin privileges."""
+ if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required")
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 5cbd802..9d2b238 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -1,17 +1,17 @@
from __future__ import annotations
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
- username: str
- password: str
+ username: str = Field(min_length=3, max_length=50)
+ password: str = Field(min_length=8, max_length=128)
class RegisterRequest(BaseModel):
- username: str
- password: str
- display_name: str
+ username: str = Field(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
+ password: str = Field(min_length=8, max_length=128)
+ display_name: str = Field(min_length=1, max_length=100)
email: str | None = None
diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py
new file mode 100644
index 0000000..fa1ea7a
--- /dev/null
+++ b/backend/app/services/ai_provider.py
@@ -0,0 +1,316 @@
+"""Multi-provider AI coaching service.
+
+Two code paths:
+- OpenAI-compatible: covers OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom
+- Anthropic: covers Claude (different message format + auth header)
+- Gemini: thin adapter for Google's generateContent API
+
+System prompt is built from AGENT_GUIDE.md + live user context.
+Chat history is ephemeral (not persisted).
+"""
+from __future__ import annotations
+
+import json
+import logging
+from typing import AsyncGenerator
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.services.crypto import decrypt_value
+from app.models.integration_config import IntegrationConfig
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+# AI provider presets — base_url and api_format for each named provider
+AI_PRESETS = {
+ "openai": {"base_url": "https://api.openai.com/v1", "format": "openai", "default_model": "gpt-4o-mini"},
+ "openrouter": {"base_url": "https://openrouter.ai/api/v1", "format": "openai", "default_model": "openai/gpt-4o-mini"},
+ "huggingface": {"base_url": "https://router.huggingface.co/v1", "format": "openai", "default_model": "meta-llama/Llama-3.3-70B-Instruct"},
+ "groq": {"base_url": "https://api.groq.com/openai/v1", "format": "openai", "default_model": "llama-3.3-70b-versatile"},
+ "ollama": {"base_url": "http://host.docker.internal:11434/v1", "format": "openai", "default_model": "llama3.1"},
+ "claude": {"base_url": "https://api.anthropic.com/v1", "format": "anthropic", "default_model": "claude-sonnet-4-6"},
+ "gemini": {"base_url": "https://generativelanguage.googleapis.com/v1beta", "format": "gemini", "default_model": "gemini-2.0-flash"},
+ "custom_ai": {"base_url": "", "format": "openai", "default_model": ""},
+}
+
+# System prompt template — {context} is replaced with live user data
+SYSTEM_PROMPT_TEMPLATE = """You are a fitness coach integrated with Diligence, a self-hosted fitness rewards platform.
+
+Your role: motivate, guide, and help the user stay consistent with their health goals. Use the context below to personalize your responses.
+
+CURRENT USER CONTEXT:
+{context}
+
+RULES:
+- Be encouraging but honest. Celebrate progress, address setbacks constructively.
+- When the user asks to log a workout or food, confirm what you'll log and do it.
+- Reference their program schedule when suggesting workouts.
+- Respect their motivation type (from BREQ-2 profiling) to calibrate your tone.
+- Keep responses concise — this is a mobile chat, not an essay.
+- If the user hasn't earned enough points today, gently encourage activity.
+- Never fabricate data — only reference what's in the context."""
+
+
+async def get_active_ai_provider(db: AsyncSession) -> dict | None:
+ """Find the first configured AI provider."""
+ result = await db.execute(
+ select(IntegrationConfig).where(
+ IntegrationConfig.provider.in_(list(AI_PRESETS.keys()))
+ )
+ )
+ configs = result.scalars().all()
+
+ for config in configs:
+ provider_name = config.provider
+ try:
+ creds = json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
+ except Exception:
+ continue
+
+ preset = AI_PRESETS.get(provider_name, AI_PRESETS["custom_ai"])
+ api_key = creds.get("api_key", "")
+ base_url = creds.get("endpoint_url", preset["base_url"]) or preset["base_url"]
+ model = creds.get("model", preset["default_model"]) or preset["default_model"]
+
+ return {
+ "name": provider_name,
+ "format": preset["format"],
+ "base_url": base_url,
+ "api_key": api_key,
+ "model": model,
+ }
+
+ return None
+
+
+async def build_context(db: AsyncSession, user_id) -> str:
+ """Build live context string from user's current state."""
+ from app.models.user import User
+ from app.models.profile import UserProfile
+
+ context_parts = []
+
+ # User profile
+ user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
+ if user:
+ context_parts.append(f"Name: {user.display_name}")
+ context_parts.append(f"Timezone: {user.timezone}")
+
+ # Profile / motivation
+ profile = (await db.execute(select(UserProfile).where(UserProfile.user_id == user_id))).scalar_one_or_none()
+ if profile:
+ if profile.ttm_stage:
+ context_parts.append(f"Stage of change: {profile.ttm_stage}")
+ if profile.breq_profile:
+ context_parts.append(f"Motivation type: {profile.breq_profile}")
+
+ # Today's points (best effort)
+ try:
+ from app.services.points_engine import get_daily_summary
+ summary = await get_daily_summary(db, user_id)
+ context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
+ context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
+ except Exception:
+ pass
+
+ return "\n".join(context_parts) if context_parts else "No profile data available yet."
+
+
+async def chat(
+ user_id,
+ message: str,
+ history: list[dict],
+ db: AsyncSession,
+) -> AsyncGenerator[str, None]:
+ """Route chat to the configured AI provider with streaming."""
+ provider = await get_active_ai_provider(db)
+ if not provider:
+ yield "No AI provider configured. Go to Settings → Integrations to connect one (OpenAI, OpenRouter, Ollama, etc.)."
+ return
+
+ context = await build_context(db, user_id)
+ system_prompt = SYSTEM_PROMPT_TEMPLATE.format(context=context)
+
+ try:
+ if provider["format"] == "openai":
+ async for chunk in _call_openai_compat(
+ base_url=provider["base_url"],
+ api_key=provider["api_key"],
+ model=provider["model"],
+ system_prompt=system_prompt,
+ history=history,
+ message=message,
+ ):
+ yield chunk
+ elif provider["format"] == "anthropic":
+ async for chunk in _call_anthropic(
+ api_key=provider["api_key"],
+ model=provider["model"],
+ system_prompt=system_prompt,
+ history=history,
+ message=message,
+ ):
+ yield chunk
+ elif provider["format"] == "gemini":
+ async for chunk in _call_gemini(
+ api_key=provider["api_key"],
+ model=provider["model"],
+ system_prompt=system_prompt,
+ history=history,
+ message=message,
+ ):
+ yield chunk
+ except httpx.HTTPStatusError as e:
+ yield f"AI provider error ({e.response.status_code}). Check your API key in Settings → Integrations."
+ except httpx.ConnectError:
+ yield f"Cannot reach {provider['name']}. Check your network or endpoint URL."
+ except Exception as e:
+ logger.error(f"AI chat error: {e}")
+ yield "Something went wrong with the AI provider. Check Settings → Integrations."
+
+
+async def _call_openai_compat(
+ base_url: str,
+ api_key: str,
+ model: str,
+ system_prompt: str,
+ history: list[dict],
+ message: str,
+) -> AsyncGenerator[str, None]:
+ """Call any OpenAI-compatible /v1/chat/completions endpoint with streaming.
+
+ Covers: OpenAI, OpenRouter, HuggingFace, Groq, Ollama, Custom.
+ """
+ headers = {"Content-Type": "application/json"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ payload = {
+ "model": model,
+ "messages": [
+ {"role": "system", "content": system_prompt},
+ *history,
+ {"role": "user", "content": message},
+ ],
+ "stream": True,
+ "max_tokens": 1024,
+ }
+
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ async with client.stream(
+ "POST",
+ f"{base_url.rstrip('/')}/chat/completions",
+ json=payload,
+ headers=headers,
+ ) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if line.startswith("data: ") and line.strip() != "data: [DONE]":
+ try:
+ chunk = json.loads(line[6:])
+ delta = chunk.get("choices", [{}])[0].get("delta", {})
+ if content := delta.get("content"):
+ yield content
+ except (json.JSONDecodeError, IndexError, KeyError):
+ continue
+
+
+async def _call_anthropic(
+ api_key: str,
+ model: str,
+ system_prompt: str,
+ history: list[dict],
+ message: str,
+) -> AsyncGenerator[str, None]:
+ """Call Anthropic's /v1/messages endpoint with streaming."""
+ headers = {
+ "Content-Type": "application/json",
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ }
+
+ # Convert OpenAI-style history to Anthropic format
+ messages = []
+ for msg in history:
+ messages.append({"role": msg["role"], "content": msg["content"]})
+ messages.append({"role": "user", "content": message})
+
+ payload = {
+ "model": model,
+ "system": system_prompt,
+ "messages": messages,
+ "max_tokens": 1024,
+ "stream": True,
+ }
+
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ async with client.stream(
+ "POST",
+ "https://api.anthropic.com/v1/messages",
+ json=payload,
+ headers=headers,
+ ) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if line.startswith("data: "):
+ try:
+ event = json.loads(line[6:])
+ if event.get("type") == "content_block_delta":
+ delta = event.get("delta", {})
+ if text := delta.get("text"):
+ yield text
+ except (json.JSONDecodeError, KeyError):
+ continue
+
+
+async def _call_gemini(
+ api_key: str,
+ model: str,
+ system_prompt: str,
+ history: list[dict],
+ message: str,
+) -> AsyncGenerator[str, None]:
+ """Call Google Gemini's generateContent endpoint with streaming."""
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent"
+
+ # Build Gemini content format
+ contents = []
+ # System instruction goes as first user/model exchange
+ if system_prompt:
+ contents.append({"role": "user", "parts": [{"text": f"[System instructions]\n{system_prompt}"}]})
+ contents.append({"role": "model", "parts": [{"text": "Understood. I'll follow these instructions."}]})
+
+ for msg in history:
+ role = "model" if msg["role"] == "assistant" else "user"
+ contents.append({"role": role, "parts": [{"text": msg["content"]}]})
+ contents.append({"role": "user", "parts": [{"text": message}]})
+
+ payload = {
+ "contents": contents,
+ "generationConfig": {"maxOutputTokens": 1024},
+ }
+
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ async with client.stream(
+ "POST",
+ url,
+ json=payload,
+ params={"key": api_key, "alt": "sse"},
+ ) as resp:
+ resp.raise_for_status()
+ async for line in resp.aiter_lines():
+ if line.startswith("data: "):
+ try:
+ chunk = json.loads(line[6:])
+ candidates = chunk.get("candidates", [])
+ if candidates:
+ parts = candidates[0].get("content", {}).get("parts", [])
+ for part in parts:
+ if text := part.get("text"):
+ yield text
+ except (json.JSONDecodeError, KeyError):
+ continue
diff --git a/backend/app/services/device_sync_base.py b/backend/app/services/device_sync_base.py
new file mode 100644
index 0000000..b88bd25
--- /dev/null
+++ b/backend/app/services/device_sync_base.py
@@ -0,0 +1,179 @@
+"""Base class for device activity sync services.
+
+All device sync implementations (garmin_sync, whoop_sync, oura_sync)
+inherit from this class. The pattern mirrors the existing strava_sync
+and polar_sync services but adds webhook support and a standard
+interface for the generic webhook receiver endpoint.
+"""
+from __future__ import annotations
+
+import abc
+import uuid
+import logging
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.models.oauth import OAuthToken
+from app.models.integration_config import IntegrationConfig
+from app.services.crypto import decrypt_value
+from app.services.points_engine import log_activity_with_points
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+class DeviceSyncBase(abc.ABC):
+ """Abstract base for all device sync services.
+
+ Subclasses implement the provider-specific OAuth flow, API calls,
+ and data mapping. The base handles token storage/refresh patterns
+ and the common activity logging pipeline.
+ """
+
+ PROVIDER: str # e.g. "garmin", "whoop", "oura"
+ API_BASE: str # e.g. "https://apis.garmin.com"
+
+ # ── OAuth ──
+
+ @abc.abstractmethod
+ async def get_auth_url(self, user_id: uuid.UUID, db: AsyncSession) -> str:
+ """Generate the OAuth authorization URL for this provider."""
+
+ @abc.abstractmethod
+ async def handle_callback(
+ self, code: str, state: str, db: AsyncSession
+ ) -> dict:
+ """Exchange authorization code for tokens, store them.
+
+ Returns: {"success": True, "provider": "garmin"}
+ """
+
+ @abc.abstractmethod
+ async def _refresh_token(self, token: OAuthToken, db: AsyncSession) -> OAuthToken:
+ """Provider-specific token refresh logic."""
+
+ # ── Sync ──
+
+ @abc.abstractmethod
+ async def sync_activities(
+ self, user_id: uuid.UUID, db: AsyncSession
+ ) -> list[dict]:
+ """Pull new activities from the provider and award points.
+
+ Returns list of imported activity dicts with points_earned.
+ """
+
+ # ── Webhooks ──
+
+ @abc.abstractmethod
+ async def validate_webhook(self, request_body: bytes, headers: dict) -> bool:
+ """Validate an incoming webhook signature.
+
+ Returns True if the webhook is authentic.
+ """
+
+ @abc.abstractmethod
+ async def handle_webhook(
+ self, payload: dict, db: AsyncSession
+ ) -> dict:
+ """Process an incoming webhook notification.
+
+ Typically identifies the user and triggers sync_activities().
+ Returns: {"processed": True, "activities_imported": N}
+ """
+
+ # ── Helpers (shared) ──
+
+ async def get_valid_token(
+ self, db: AsyncSession, user_id: uuid.UUID
+ ) -> OAuthToken | None:
+ """Get a valid OAuth token, refreshing if expired."""
+ result = await db.execute(
+ select(OAuthToken).where(
+ OAuthToken.user_id == user_id,
+ OAuthToken.provider == self.PROVIDER,
+ )
+ )
+ token = result.scalar_one_or_none()
+ if not token:
+ return None
+
+ if token.expires_at and token.expires_at < datetime.now(timezone.utc):
+ try:
+ token = await self._refresh_token(token, db)
+ except Exception as e:
+ logger.error(f"Token refresh failed for {self.PROVIDER}: {e}")
+ return None
+
+ return token
+
+ async def get_credentials(self, db: AsyncSession) -> dict | None:
+ """Get decrypted OAuth credentials from integration_configs."""
+ import json
+ result = await db.execute(
+ select(IntegrationConfig).where(
+ IntegrationConfig.provider == self.PROVIDER
+ )
+ )
+ config = result.scalar_one_or_none()
+ if not config:
+ return None
+
+ try:
+ return json.loads(decrypt_value(config.encrypted_value, settings.secret_key))
+ except Exception as e:
+ logger.error(f"Failed to decrypt {self.PROVIDER} credentials: {e}")
+ return None
+
+ async def import_activity(
+ self,
+ db: AsyncSession,
+ user_id: uuid.UUID,
+ *,
+ title: str,
+ category: str = "workout",
+ activity_date: Any,
+ duration_minutes: int | None = None,
+ external_id: str | None = None,
+ metadata: dict | None = None,
+ ) -> dict:
+ """Import a single activity using the standard points pipeline.
+
+ Deduplicates by (user_id, provider, external_id).
+ """
+ from app.models.activity import ActivityLog
+
+ if external_id:
+ existing = await db.execute(
+ select(ActivityLog).where(
+ ActivityLog.user_id == user_id,
+ ActivityLog.source == self.PROVIDER,
+ ActivityLog.external_id == external_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ return {"skipped": True, "external_id": external_id}
+
+ entry = await log_activity_with_points(
+ db=db,
+ user_id=user_id,
+ category=category,
+ activity_date=activity_date,
+ title=title,
+ duration_minutes=duration_minutes,
+ source=self.PROVIDER,
+ external_id=external_id,
+ metadata=metadata or {},
+ )
+
+ return {
+ "id": str(entry.id),
+ "title": entry.title,
+ "points_earned": entry.points_earned,
+ "activity_date": str(activity_date),
+ }
diff --git a/backend/app/services/provider_registry.py b/backend/app/services/provider_registry.py
index 99e2b15..0789659 100644
--- a/backend/app/services/provider_registry.py
+++ b/backend/app/services/provider_registry.py
@@ -2,65 +2,213 @@
from __future__ import annotations
PROVIDER_REGISTRY: dict[str, dict] = {
+
+ # ═══ Device Integrations ═══
+
"strava": {
"name": "Strava",
"type": "oauth2",
+ "category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://www.strava.com/oauth/authorize",
"token_url": "https://www.strava.com/oauth/token",
"scopes": "read,activity:read_all",
"help_url": "https://developers.strava.com/",
"help_text": "Create an app at developers.strava.com. Set the callback URL to {BASE_URL}/api/integrations/strava/callback",
+ "warning": "Strava ToS prohibits use of their data for AI/ML. Activity sync works but AI coaching should not reference Strava data.",
+ "has_webhook": False,
+ "sync_service": "strava_sync",
},
"polar": {
"name": "Polar",
"type": "oauth2",
+ "category": "device",
"fields": ["client_id", "client_secret"],
"auth_url": "https://flow.polar.com/oauth2/authorization",
"token_url": "https://polarremote.com/v2/oauth2/token",
"help_url": "https://admin.polaraccesslink.com/",
"help_text": "Register at admin.polaraccesslink.com",
+ "has_webhook": False,
+ "sync_service": "polar_sync",
},
"garmin": {
"name": "Garmin Connect",
"type": "oauth2",
+ "category": "device",
"fields": ["client_id", "client_secret"],
+ "auth_url": "https://connect.garmin.com/oauthConfirm",
+ "token_url": "https://connectapi.garmin.com/oauth-service/oauth/token",
+ "scopes": "HEALTH,ACTIVITY",
"help_url": "https://developer.garmin.com/gc-developer-program/",
- "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
- },
- "fitbit": {
- "name": "Fitbit",
- "type": "oauth2",
- "fields": ["client_id", "client_secret"],
- "auth_url": "https://www.fitbit.com/oauth2/authorize",
- "token_url": "https://api.fitbit.com/oauth2/token",
- "help_url": "https://dev.fitbit.com/",
- "help_text": "Register at dev.fitbit.com",
- },
- "withings": {
- "name": "Withings",
- "type": "oauth2",
- "fields": ["client_id", "client_secret"],
- "help_url": "https://developer.withings.com/",
- "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
+ "help_text": "Apply at developer.garmin.com — approval ~2 business days, requires a business entity.",
+ "has_webhook": True,
+ "sync_service": "garmin_sync",
},
"whoop": {
"name": "WHOOP",
"type": "oauth2",
+ "category": "device",
"fields": ["client_id", "client_secret"],
+ "auth_url": "https://api.prod.whoop.com/oauth/oauth2/auth",
+ "token_url": "https://api.prod.whoop.com/oauth/oauth2/token",
+ "scopes": "read:workout,read:recovery,read:sleep,read:profile,offline",
"help_url": "https://developer.whoop.com/",
- "help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
+ "help_text": "Self-serve at developer.whoop.com. Requires WHOOP device. <10 users without app approval.",
+ "has_webhook": True,
+ "sync_service": "whoop_sync",
},
"oura": {
"name": "Oura Ring",
"type": "oauth2",
+ "category": "device",
"fields": ["client_id", "client_secret"],
+ "auth_url": "https://cloud.ouraring.com/oauth/authorize",
+ "token_url": "https://api.ouraring.com/oauth/token",
+ "scopes": "daily,workout,heartrate,session",
"help_url": "https://cloud.ouraring.com/v2/docs",
- "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
+ "help_text": "Register at cloud.ouraring.com — self-serve, immediate access.",
+ "has_webhook": True,
+ "sync_service": "oura_sync",
},
+ "coros": {
+ "name": "COROS (via MCP)",
+ "type": "mcp_bridge",
+ "category": "device",
+ "fields": [],
+ "help_url": "https://support.coros.com/hc/en-us/articles/17085887816340",
+ "help_text": "COROS has an official MCP server. Add it alongside Diligence in your AI agent config.",
+ "has_webhook": False,
+ "sync_service": None,
+ },
+ "fitbit": {
+ "name": "Fitbit",
+ "type": "oauth2",
+ "category": "device",
+ "fields": ["client_id", "client_secret"],
+ "auth_url": "https://www.fitbit.com/oauth2/authorize",
+ "token_url": "https://api.fitbit.com/oauth2/token",
+ "help_url": "https://dev.fitbit.com/",
+ "help_text": "Register at dev.fitbit.com via Google Cloud Console.",
+ "has_webhook": True,
+ "sync_service": None,
+ },
+ "withings": {
+ "name": "Withings",
+ "type": "oauth2",
+ "category": "device",
+ "fields": ["client_id", "client_secret"],
+ "help_url": "https://developer.withings.com/",
+ "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure.",
+ "has_webhook": False,
+ "sync_service": None,
+ },
+ "suunto": {
+ "name": "Suunto",
+ "type": "oauth2",
+ "category": "device",
+ "fields": ["client_id", "client_secret"],
+ "help_url": "https://apizone.suunto.com/",
+ "help_text": "Apply at Suunto developer portal — contract required, ~2 week response.",
+ "has_webhook": True,
+ "sync_service": None,
+ },
+
+ # ═══ AI Coaching Providers ═══
+
+ "openai": {
+ "name": "OpenAI",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://api.openai.com/v1",
+ "api_format": "openai",
+ "help_url": "https://platform.openai.com/api-keys",
+ "help_text": "Get API key from platform.openai.com. Models: gpt-4o, gpt-4o-mini.",
+ "default_model": "gpt-4o-mini",
+ },
+ "openrouter": {
+ "name": "OpenRouter",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://openrouter.ai/api/v1",
+ "api_format": "openai",
+ "help_url": "https://openrouter.ai/keys",
+ "help_text": "One key, 300+ models. 26 free models. Pay-as-you-go, no subscription.",
+ "default_model": "openai/gpt-4o-mini",
+ },
+ "huggingface": {
+ "name": "Hugging Face",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://router.huggingface.co/v1",
+ "api_format": "openai",
+ "help_url": "https://huggingface.co/settings/tokens",
+ "help_text": "Free HF token. Thousands of open-source models via 20+ inference providers.",
+ "default_model": "meta-llama/Llama-3.3-70B-Instruct",
+ },
+ "groq": {
+ "name": "Groq",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://api.groq.com/openai/v1",
+ "api_format": "openai",
+ "help_url": "https://console.groq.com/keys",
+ "help_text": "Ultra-fast inference. Free tier available. Also used for program research.",
+ "default_model": "llama-3.3-70b-versatile",
+ },
+ "ollama": {
+ "name": "Ollama (Local)",
+ "type": "endpoint",
+ "category": "ai_provider",
+ "fields": ["endpoint_url"],
+ "base_url": "http://host.docker.internal:11434/v1",
+ "api_format": "openai",
+ "help_url": "https://ollama.com/",
+ "help_text": "Run LLMs locally. No API key, no cost. Default: http://host.docker.internal:11434",
+ "default_model": "llama3.1",
+ },
+ "claude": {
+ "name": "Anthropic Claude",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://api.anthropic.com/v1",
+ "api_format": "anthropic",
+ "help_url": "https://console.anthropic.com/",
+ "help_text": "Get API key from console.anthropic.com. Models: claude-sonnet-4-6, claude-opus-4-8.",
+ "default_model": "claude-sonnet-4-6",
+ },
+ "gemini": {
+ "name": "Google Gemini",
+ "type": "api_key",
+ "category": "ai_provider",
+ "fields": ["api_key"],
+ "base_url": "https://generativelanguage.googleapis.com/v1beta",
+ "api_format": "gemini",
+ "help_url": "https://aistudio.google.com/apikey",
+ "help_text": "Get API key from AI Studio. Models: gemini-2.0-flash, gemini-2.5-pro.",
+ "default_model": "gemini-2.0-flash",
+ },
+ "custom_ai": {
+ "name": "Custom (OpenAI-compatible)",
+ "type": "endpoint",
+ "category": "ai_provider",
+ "fields": ["endpoint_url", "api_key"],
+ "api_format": "openai",
+ "help_url": "",
+ "help_text": "Any server with an OpenAI-compatible /v1/chat/completions endpoint (vLLM, TGI, LiteLLM).",
+ "default_model": "",
+ },
+
+ # ═══ Nutrition & Data ═══
+
"usda": {
"name": "USDA FoodData Central",
"type": "api_key",
+ "category": "nutrition",
"fields": ["api_key"],
"help_url": "https://fdc.nal.usda.gov/api-key-signup",
"help_text": "Free API key, no approval needed. 400K+ foods with research-grade nutrition data.",
@@ -68,22 +216,20 @@ PROVIDER_REGISTRY: dict[str, dict] = {
"nutritionix": {
"name": "Nutritionix",
"type": "api_key",
+ "category": "nutrition",
"fields": ["app_id", "api_key"],
"help_url": "https://developer.nutritionix.com/",
"help_text": "Free tier: 1K calls/month. Natural language food parsing.",
},
- "groq": {
- "name": "Groq (Program Research)",
- "type": "api_key",
- "fields": ["api_key"],
- "help_url": "https://console.groq.com/keys",
- "help_text": "Free API key. Enables AI-powered workout program extraction.",
- },
+
+ # ═══ Notifications ═══
+
"telegram": {
"name": "Telegram Notifications",
"type": "api_key",
+ "category": "notifications",
"fields": ["bot_token", "chat_id"],
"help_url": "https://core.telegram.org/bots#botfather",
- "help_text": "Create a bot via @BotFather, then get your chat ID",
+ "help_text": "Create a bot via @BotFather, then get your chat ID.",
},
}
diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py
index b0835ce..74e9ba2 100644
--- a/backend/app/utils/auth.py
+++ b/backend/app/utils/auth.py
@@ -13,7 +13,8 @@ from app.config import get_settings
from app.database import get_db
settings = get_settings()
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+ALGORITHM = "HS256" # Hardcoded — not configurable for security
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def hash_password(password: str) -> str:
@@ -27,11 +28,11 @@ def verify_password(plain: str, hashed: str) -> bool:
def create_access_token(user_id: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
payload = {"sub": user_id, "exp": expire}
- return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
+ return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
async def get_current_user(
- token: Annotated[str, Depends(oauth2_scheme)],
+ token: Annotated[str | None, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
credentials_exception = HTTPException(
@@ -39,6 +40,30 @@ async def get_current_user(
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
+
+ if not token:
+ raise credentials_exception
+
+ from app.models.user import User
+
+ # Check if this is an API token (MCP connector auth)
+ if settings.api_token and token == settings.api_token:
+ # Map to the first admin user
+ result = await db.execute(
+ select(User).where(User.is_admin == True).order_by(User.created_at.asc()).limit(1)
+ )
+ user = result.scalar_one_or_none()
+ if user is None:
+ # Fall back to first user if no admin exists yet
+ result = await db.execute(
+ select(User).order_by(User.created_at.asc()).limit(1)
+ )
+ user = result.scalar_one_or_none()
+ if user is None:
+ raise credentials_exception
+ return user
+
+ # Standard JWT validation
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
@@ -47,7 +72,6 @@ async def get_current_user(
except JWTError:
raise credentials_exception
- from app.models.user import User
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if user is None:
diff --git a/docker-compose.yml b/docker-compose.yml
index ed1eb51..54633e6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,6 +2,8 @@ services:
frontend:
build: ./frontend
restart: unless-stopped
+ ports:
+ - "80:80"
depends_on:
backend:
condition: service_healthy
@@ -18,6 +20,7 @@ services:
env_file: .env
environment:
- BASE_URL=${BASE_URL:-http://localhost}
+ - API_TOKEN=${API_TOKEN:-}
depends_on:
fitness-db:
condition: service_healthy
@@ -33,6 +36,7 @@ services:
restart: unless-stopped
environment:
- FITNESS_API_URL=http://backend:8000
+ - API_TOKEN=${API_TOKEN:-}
depends_on:
backend:
condition: service_healthy
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 327f3a8..e69de29 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,124 +0,0 @@
-import { Routes, Route, Navigate, NavLink, useNavigate, useLocation } from 'react-router-dom'
-import { useState, useEffect } from 'react'
-import { hasToken, clearToken, api } from './api'
-import Login from './pages/Login'
-import Dashboard from './pages/Dashboard'
-import LogActivity from './pages/LogActivity'
-import LogFood from './pages/LogFood'
-import Nutrition from './pages/Nutrition'
-import Rewards from './pages/Rewards'
-import Settings from './pages/Settings'
-import Onboarding from './pages/Onboarding'
-import WeekView from './pages/WeekView'
-import Welcome from './pages/Welcome'
-import SettingsIntegrations from './pages/SettingsIntegrations'
-import MealPlan from './pages/MealPlan'
-import ProgramSearch from './pages/ProgramSearch'
-import ProgramDetail from './pages/ProgramDetail'
-import CatalogDetail from './pages/CatalogDetail'
-import Support from './pages/Support'
-import SupportAdmin from './pages/SupportAdmin'
-
-function ProtectedRoute({ children }) {
- if (!hasToken()) return
Program not found.