diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 3ac26d2..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,11 +0,0 @@
-.git
-.env
-.env.*
-!.env.example
-__pycache__
-*.pyc
-content/
-node_modules/
-dist/
-*.log
-.DS_Store
diff --git a/.env.example b/.env.example
index 4b0fdbf..32a93d9 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,5 @@
# === REQUIRED (generated automatically by setup.sh) ===
SECRET_KEY=
-API_TOKEN=
# === APP URL (change for production) ===
BASE_URL=http://localhost
@@ -9,11 +8,6 @@ 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 74f9dd5..ddd2dad 100644
--- a/AGENT_GUIDE.md
+++ b/AGENT_GUIDE.md
@@ -97,31 +97,3 @@ 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
deleted file mode 100644
index 8da9836..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# 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
deleted file mode 100644
index c14bc4a..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# 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
deleted file mode 100644
index 331d702..0000000
--- a/OUTSTANDING.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# 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 37fb408..63dd685 100644
--- a/README.md
+++ b/README.md
@@ -6,65 +6,6 @@ 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
@@ -72,30 +13,7 @@ cd diligence
docker compose up -d
```
----
-
-### 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`.
+Open http://localhost and create your account.
## Features
@@ -108,71 +26,17 @@ You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`
- **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.
-## Built-in AI Coach
+## Connecting an AI Agent
-Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
+Point your agent's MCP config to `http://localhost:3001/sse` (development) or `https://your-domain/mcp` (production behind reverse proxy).
-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" }
- }
-}
-```
+Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent.
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.
-
-Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted.
+All integrations are configured through the app UI (Settings → Integrations) or through your AI agent. No `.env` file editing or container restarts needed.
## Data Sovereignty
@@ -180,57 +44,17 @@ 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 (async)
+- Python 3.12, FastAPI, SQLAlchemy
- 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. Please open an issue to discuss before submitting a PR.
+Contributions welcome. Open an issue to discuss before submitting a PR.
## License
diff --git a/backend/.dockerignore b/backend/.dockerignore
deleted file mode 100644
index 3ac26d2..0000000
--- a/backend/.dockerignore
+++ /dev/null
@@ -1,11 +0,0 @@
-.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 3c2be33..4ffe6be 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -10,9 +10,8 @@ class Settings(BaseSettings):
# Auth
secret_key: str = "change-me-in-production"
- crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
+ algorithm: str = "HS256"
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 be39c0e..55a3c36 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
-import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -28,13 +27,6 @@ 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()
@@ -49,17 +41,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Resource seeding failed (non-fatal): {e}")
- # Start background crawl queue scheduler (gated on CRAWL_ENABLED)
+ # Start background crawl queue scheduler
crawl_task = None
- 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)")
+ 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}")
logger.info("Fitness Rewards backend started")
yield
@@ -79,7 +68,7 @@ app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
- allow_credentials=False # Bearer tokens don't need credentials mode,
+ allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -96,7 +85,6 @@ 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)
@@ -110,7 +98,6 @@ 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
deleted file mode 100644
index e9b7b24..0000000
--- a/backend/app/routers/ai_chat.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""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 517998d..fd3e487 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -1,10 +1,11 @@
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, func
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -25,16 +26,11 @@ 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()
@@ -56,8 +52,9 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
except HTTPException:
raise
except Exception as e:
- logger.error(f"Registration failed: {e}", exc_info=True)
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
+ tb = traceback.format_exc()
+ logger.error(f"Registration failed: {e}\n{tb}")
+ return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
@router.post("/login")
@@ -72,8 +69,9 @@ async def login(req: LoginRequest, db: Annotated[AsyncSession, Depends(get_db)])
except HTTPException:
raise
except Exception as e:
- logger.error(f"Login failed: {e}", exc_info=True)
- return JSONResponse(status_code=500, content={"detail": "Internal server error"})
+ tb = traceback.format_exc()
+ logger.error(f"Login failed: {e}\n{tb}")
+ return JSONResponse(status_code=500, content={"detail": str(e), "traceback": tb})
@router.get("/me")
@@ -84,5 +82,4 @@ 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 e3eb075..8562a7b 100644
--- a/backend/app/routers/integrations.py
+++ b/backend/app/routers/integrations.py
@@ -39,10 +39,7 @@ async def integration_status(
# --- Strava ---
@router.get("/strava/auth")
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
- 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)}
+ return {"auth_url": get_strava_auth_url(str(user.id))}
@router.get("/strava/callback")
@@ -51,14 +48,7 @@ async def strava_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
- 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")
+ user_id = uuid_mod.UUID(state)
data = await exchange_strava_code(code)
# Upsert token
@@ -101,10 +91,7 @@ async def strava_sync(
# --- Polar ---
@router.get("/polar/auth")
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
- 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)}
+ return {"auth_url": get_polar_auth_url(str(user.id))}
@router.get("/polar/callback")
@@ -113,14 +100,7 @@ async def polar_callback(
state: str = Query(...),
db: AsyncSession = Depends(get_db),
):
- 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")
+ user_id = uuid_mod.UUID(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 c8cb767..c243602 100644
--- a/backend/app/routers/support.py
+++ b/backend/app/routers/support.py
@@ -24,6 +24,7 @@ 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
@@ -278,8 +279,8 @@ async def get_unread_count(
# ── Admin Endpoints ────────────────────────────────────────────────────────
def require_admin(user: User):
- """Check that the user has admin privileges."""
- if not getattr(user, "is_admin", False):
+ """Check that the user is the admin."""
+ if user.username != ADMIN_USERNAME:
raise HTTPException(status_code=403, detail="Admin access required")
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 9d2b238..5cbd802 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, Field
+from pydantic import BaseModel
class LoginRequest(BaseModel):
- username: str = Field(min_length=3, max_length=50)
- password: str = Field(min_length=8, max_length=128)
+ username: str
+ password: str
class RegisterRequest(BaseModel):
- 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)
+ username: str
+ password: str
+ display_name: str
email: str | None = None
diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py
deleted file mode 100644
index fa1ea7a..0000000
--- a/backend/app/services/ai_provider.py
+++ /dev/null
@@ -1,316 +0,0 @@
-"""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
deleted file mode 100644
index b88bd25..0000000
--- a/backend/app/services/device_sync_base.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""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 0789659..99e2b15 100644
--- a/backend/app/services/provider_registry.py
+++ b/backend/app/services/provider_registry.py
@@ -2,213 +2,65 @@
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 ~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": "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 — 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,
+ "help_text": "Apply at developer.garmin.com — approval takes 1-4 weeks",
},
"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,
+ "help_text": "Register at dev.fitbit.com",
},
"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,
+ "help_text": "Register at developer.withings.com — body metrics, scales, blood pressure",
},
- "suunto": {
- "name": "Suunto",
+ "whoop": {
+ "name": "WHOOP",
"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,
+ "help_url": "https://developer.whoop.com/",
+ "help_text": "Apply at developer.whoop.com — recovery, strain, sleep",
},
-
- # ═══ 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",
+ "oura": {
+ "name": "Oura Ring",
+ "type": "oauth2",
+ "fields": ["client_id", "client_secret"],
+ "help_url": "https://cloud.ouraring.com/v2/docs",
+ "help_text": "Register at cloud.ouraring.com — sleep, readiness, HRV",
},
- "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.",
@@ -216,20 +68,22 @@ 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.",
},
-
- # ═══ Notifications ═══
-
+ "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.",
+ },
"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 74e9ba2..b0835ce 100644
--- a/backend/app/utils/auth.py
+++ b/backend/app/utils/auth.py
@@ -13,8 +13,7 @@ from app.config import get_settings
from app.database import get_db
settings = get_settings()
-ALGORITHM = "HS256" # Hardcoded — not configurable for security
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
@@ -28,11 +27,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=ALGORITHM)
+ return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
async def get_current_user(
- token: Annotated[str | None, Depends(oauth2_scheme)],
+ token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
credentials_exception = HTTPException(
@@ -40,30 +39,6 @@ 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")
@@ -72,6 +47,7 @@ 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 54633e6..ed1eb51 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,8 +2,6 @@ services:
frontend:
build: ./frontend
restart: unless-stopped
- ports:
- - "80:80"
depends_on:
backend:
condition: service_healthy
@@ -20,7 +18,6 @@ services:
env_file: .env
environment:
- BASE_URL=${BASE_URL:-http://localhost}
- - API_TOKEN=${API_TOKEN:-}
depends_on:
fitness-db:
condition: service_healthy
@@ -36,7 +33,6 @@ 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 e69de29..327f3a8 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -0,0 +1,124 @@
+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.