From 674485f275d56df0f7eb7aa62cfbe5cb3fe40ae9 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 7 Jul 2026 07:21:22 -0400 Subject: [PATCH] docs: add CLAUDE.md + README Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 81 ++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0545de4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b739a02 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# intervalsicu-mcp-ui + +Web portal for the self-hosted, multi-tenant **Intervals.icu MCP** system. + +Users sign in with Google, connect their Intervals.icu account by entering their athlete ID and API +key (validated live, then stored **encrypted**), and get the setup steps for the MCP connector. +Admins approve accounts before the connector will work for a given user. + +## Where it fits + +Three services share one host (`https://intervalsicu.farhoodlabs.com`) and one Postgres database: + +| Service | Path | Role | +| --- | --- | --- | +| intervalsicu-mcp | `/mcp` | FastMCP server exposing Intervals.icu data as MCP tools (multi-tenant) | +| **intervalsicu-mcp-ui** (this repo) | `/portal` | Sign-in + credential management + admin approval | +| intervalsicu-mcp-auth | `/api/auth` | Better Auth OAuth 2.1 / OIDC provider (Google upstream) | + +The portal authenticates as an **OIDC client of intervalsicu-mcp-auth** (public client, PKCE, +`token_endpoint_auth_method=none`). It writes each user's encrypted Intervals.icu API key into the +shared `users` table; the MCP server reads that same table, decrypts the key, and calls Intervals.icu +on the user's behalf. + +## Shared coupling with the MCP server (read this) + +The portal and the MCP server share the **same database and the same encryption key**: + +- The `users` table schema is **owned by the MCP server's Alembic migrations**. `db.py` here only + *maps* it (matching column names/types). Test runs build a throwaway SQLite copy from that mapping. +- API keys are encrypted with **AES-256-GCM** (`crypto.py`), layout `nonce(12) || ct+tag`, key from + base64 `INTERVALS_ENC_KEY`. The MCP server uses the **identical scheme and key**. Change either the + format or the key and the other service can no longer decrypt. + +## How it works + +- **Sign in** (`/auth/login` → `/auth/callback`): OIDC against `intervalsicu-mcp-auth`. First login + creates a **disabled** user record keyed on the OAuth subject (`sub`). +- **Account** (`/account`): the user submits athlete ID + API key. `intervals.py` validates them with + a live `GET /athlete/{id}` (HTTP Basic, username `API_KEY`); only on success is the key encrypted + and stored. The page also shows the MCP connector URL and Claude setup steps. +- **Admin** (`/admin`): emails in `ADMIN_EMAILS` can approve (`enable`), `disable`, or `delete` + users. `enabled` is the gate the MCP server checks — until an admin approves, the connector won't + work for that user. +- `/healthz` returns `{"status": "ok"}`. + +## Stack + +FastAPI + Uvicorn, Authlib (OIDC), Jinja2 templates, SQLAlchemy async + asyncpg (Postgres), +`cryptography` (AES-GCM). Python 3.12+. + +## Development + +```bash +uv sync --extra dev + +uv run pytest # tests + 80% coverage gate (SQLite + stubbed HTTP; no network) +uv run ruff check . + +# run locally (set the env vars below first); --factory targets app.create_app +uv run uvicorn intervalsicu_mcp_ui.app:create_app --factory --host 0.0.0.0 --port 8080 +``` + +## Environment variables + +Required: `DATABASE_URL` (e.g. `postgresql+asyncpg://…`), `OIDC_ISSUER`, `OIDC_CLIENT_ID`, +`OIDC_REDIRECT_URL`, `SESSION_SECRET`, `INTERVALS_ENC_KEY` (base64 of 32 bytes; shared with the MCP +server). + +Optional: `OIDC_CLIENT_SECRET` (leave empty for the public/PKCE client), `ADMIN_EMAILS` +(comma-separated), `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 to +users). + +Generate an encryption key with `python -c "from intervalsicu_mcp_ui.crypto import generate_key_b64; +print(generate_key_b64())"`. + +## Deployment + +Push to `main` triggers Gitea Actions (`.gitea/workflows/build.yaml` at `git.farh.net`): run tests, +then build and push a Docker image to `git.farh.net/farhoodlabs/intervalsicu-mcp-ui`. Flux deploys it +to Kubernetes, served behind the gateway under `/portal`.