# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is The **web portal** ("intervalsicu-mcp-ui") for a self-hosted, multi-tenant *Intervals.icu MCP* system. It is one of three services that share a single host (`https://intervalsicu.farhoodlabs.com`) and a single CloudNativePG Postgres database: - **intervalsicu-mcp** — Python/FastMCP MCP server at `/mcp`; exposes Intervals.icu data as MCP tools, resolving each user's stored credentials by OAuth token subject (`sub`). - **intervalsicu-mcp-ui** (this repo) — the portal, served at `/portal`. Users sign in with Google (via the Better Auth server acting as an OIDC provider), set their Intervals.icu athlete ID + API key (validated live, then stored **encrypted**), and read the connector setup instructions. Admins approve accounts. - **intervalsicu-mcp-auth** — TypeScript Better Auth OAuth 2.1 / OIDC provider at `/api/auth`; the identity provider this portal logs into. The portal is a thin FastAPI app: OIDC login, a per-user credential form, and an admin approval table. It owns almost no schema of its own — see the coupling notes below. ## Big-picture architecture **The app is built by a factory.** `app.py::create_app(config)` reads `Config` (via `config.py::load_config()`), calls `db.configure()`, registers the OIDC client, mounts `SessionMiddleware`, defines all routes as closures, and returns the `FastAPI` app. Uvicorn runs it with `--factory` (see `Dockerfile`). To understand any request you generally need `app.py` + `config.py` + `db.py` together. **Auth = OIDC client, no local passwords.** `app.py` registers an Authlib `OAuth` client named `oidc` against `{OIDC_ISSUER}.well-known/openid-configuration`. It is a **public client with PKCE**: `token_endpoint_auth_method="none"`, `code_challenge_method="S256"`, and the client secret is passed as `None` when empty (Better Auth hashes confidential secrets, so the portal must register as public). Routes: `/auth/login` → `authorize_redirect`; `/auth/callback` exchanges the code, reads `userinfo` claims (`sub`, `email`, `name`), upserts the user, and stores a session dict `{sub, email, name, is_admin}`. **`is_admin` is an email allowlist** (`email.lower() in cfg.admin_emails`) — Better Auth issues no group claims, so admin is *not* a token claim. The `current_user()` dependency reads `request.session["user"]` and is overridden in tests to bypass OIDC. **Routes (all in `app.py`):** `/` (redirect to `/account` or `/login`), `/login`, `/auth/login`, `/auth/callback`, `/logout`, `GET|POST /account`, `GET /admin`, `POST /admin/{sub}/{action}` (`enable`/`disable`/`delete`), `/healthz`. Redirects and links are prefixed with `cfg.root_path` (e.g. `/portal`) because the app runs behind a gateway; `FastAPI(root_path=...)` is also set. **First login creates a *disabled* user; admin must approve.** `db.upsert_login` inserts with `enabled=False`. The portal always lets a user save credentials, but the MCP connector only works for that user once an admin flips `enabled=True` via `/admin/{sub}/enable`. `enabled` is the single approval gate consumed by the MCP server. **Saving credentials validates before storing.** `POST /account` calls `intervals.py::validate_credentials(base, athlete_id, api_key)`, which does a live `GET {INTERVALS_API_BASE_URL}/athlete/{id}` using HTTP Basic auth with username literally `"API_KEY"` and the key as the password (**this must match the MCP server's auth scheme**). Only on HTTP 200 are the credentials written (`db.set_credentials`, which encrypts the key). Non-200s produce a flash message and nothing is stored. ## Shared-DB + shared-encryption coupling (important, non-obvious) This portal and the **MCP server read/write the same `users` table in the same Postgres DB**, and both must agree on two things: 1. **Table shape.** `db.py::User` maps the `users` table but does **not** own it — the schema is created/migrated by the **MCP server's Alembic migrations**. This repo only defines the mapping (and `db.Base.metadata.create_all` is used solely to build a throwaway SQLite DB in tests). Columns: `sub` (PK, OAuth subject), `email`, `name`, `athlete_id`, `api_key_enc` (LargeBinary), `enabled`, `created_at`, `updated_at`, `last_login_at`. If you change this mapping, it must stay in lockstep with the MCP server's migrations or reads will break. 2. **Crypto scheme.** `crypto.py` is **AES-256-GCM**, ciphertext layout `nonce(12) || ct+tag`, key from base64 env var `INTERVALS_ENC_KEY` (must decode to exactly 32 bytes). This is *reversible* encryption (not hashing) because the MCP server must recover the plaintext key to call Intervals.icu. The MCP server uses the identical scheme and the **same `INTERVALS_ENC_KEY`** (mounted from the same Kubernetes secret). Changing the format or key here silently breaks the MCP server's ability to decrypt, and vice-versa. ## Commands ```bash uv sync --extra dev # install incl. dev extras (pytest, ruff, aiosqlite) uv run pytest # tests + coverage gate (fails under 80%; see pyproject addopts) uv run pytest tests/test_app.py::test_name # single test uv run ruff check . # lint # run locally (needs the env vars below); --factory because app.py exposes create_app uv run uvicorn intervalsicu_mcp_ui.app:create_app --factory --host 0.0.0.0 --port 8080 ``` There is no separate mypy step configured. `pytest` enforces `--cov-fail-under=80` via `[tool.pytest.ini_options]`, and `asyncio_mode = "auto"` means async tests need no marker. Tests never hit the network or real Postgres: they run against SQLite (`aiosqlite`), build the schema with `create_all`, and stub Intervals.icu / OIDC (`monkeypatch`, dependency override of `current_user`). Fixtures live inline in each `tests/test_*.py`. ## Config (env vars, read by `config.py`) Required: `DATABASE_URL` (async driver, e.g. `postgresql+asyncpg://…`), `OIDC_ISSUER` (provider base, trailing slash enforced), `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URL`, `SESSION_SECRET`, `INTERVALS_ENC_KEY` (read by `crypto.py`, not `config.py`). Optional: `OIDC_CLIENT_SECRET` (empty → public/PKCE client), `ADMIN_EMAILS` (comma-separated allowlist, lower-cased), `INTERVALS_API_BASE_URL` (default `https://intervals.icu/api/v1`), `OIDC_SCOPES` (default `openid email profile`), `ROOT_PATH` (e.g. `/portal`), `MCP_URL` (connector URL shown on the account page). ## Layout - `app.py` — FastAPI factory, OIDC client, all routes, session middleware, template rendering - `config.py` — `Config` dataclass + `load_config()` (env parsing) - `db.py` — SQLAlchemy async `User` model (maps the shared, MCP-owned `users` table) + operations - `crypto.py` — AES-256-GCM encrypt/decrypt for the stored API key (shared scheme with MCP server) - `intervals.py` — live credential validation against the Intervals.icu API - `templates/` — Jinja2: `base.html`, `login.html`, `account.html` (credential form + connector instructions), `admin.html` (user table) ## Deployment Gitea Actions (`.gitea/workflows/build.yaml`, on push to `main`) runs `pytest` (coverage gate), then builds and pushes a Docker image to `git.farh.net/farhoodlabs/intervalsicu-mcp-ui` (`:latest` and `:`). The image (`Dockerfile`, `python:3.12-slim`) runs uvicorn on port 8080. Flux then deploys it to Kubernetes, served behind the gateway under `/portal` (hence `ROOT_PATH`/`root_path`). ## Note on a stale docstring `app.py`'s module docstring still says "OIDC login via Authentik" and "members of the configured group." That predates the switch to Better Auth: the real IdP is **intervalsicu-mcp-auth** (Better Auth, Google upstream) and admin is the **`ADMIN_EMAILS` allowlist**, not a group claim. Trust the code and this file over that docstring.