129 lines
7.7 KiB
Markdown
129 lines
7.7 KiB
Markdown
# 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.
|