Files
Chris Farhood 674485f275
build-image / test (push) Successful in 11s
build-image / build (push) Successful in 24s
docs: add CLAUDE.md + README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:21:22 -04:00

7.7 KiB

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/loginauthorize_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

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.pyConfig 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.