docs: add CLAUDE.md + README
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 24s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 07:21:22 -04:00
parent f13d70b45f
commit 674485f275
2 changed files with 209 additions and 0 deletions
+128
View File
@@ -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
`:<sha>`). 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.
+81
View File
@@ -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`.