# CLAUDE.md Guidance for working in this repo. Read alongside `README.md` (user-facing setup). ## What this is A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth with refresh-token revocation, and RBAC (users → roles → permissions). ## Commands ```bash uv sync --extra dev # install (incl. dev tools) uv run python -m scripts.seed # seed perms/roles/superuser (idempotent) uv run uvicorn app.main:app --reload # dev server on :8000 uv run pytest # tests (in-memory SQLite) uv run ruff check . [--fix] # lint uv run alembic upgrade head # apply migrations uv run alembic revision --autogenerate -m "msg" # new migration after model changes docker compose up --build # app + Postgres, auto-seeded ``` ## Architecture / layering Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access) → `app/models/`. Schemas in `app/schemas/`, cross-cutting DI in `app/deps.py`, token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in `crud/`, not in routers. ## Conventions that matter - **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session. - **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so attributes are safe to read after the session closes — required under async. Preserve this when adding relationships that templates/schemas read. - **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there; they're auto-read from env vars (uppercased field name) or `.env`. Env wins. - **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`; guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass all checks (`User.is_superuser` / `User.has_permission`). Add new permission strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`. - **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`. - **Credential verification** goes through `services/auth.authenticate(db, username, password)` — both login routes call it, not `crud.user.authenticate` directly. When `settings.ldap_enabled`, it binds against LDAP first (`services/ldap.py`, sync `ldap3` offloaded via `run_in_threadpool`), provisions/refreshes the local row via `crud.user.upsert_ldap_user` (unusable local password, `auth_provider="ldap"`, `LDAP_DEFAULT_ROLES`), then falls back to local DB auth. LDAP config lives in `settings.ldap_*`. Tests fake the blocking bind by patching `services.ldap._authenticate_sync`. - **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the `refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues. Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside the token) — pass it straight to the model, don't `fromtimestamp` it. ## Database One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres (`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all` on startup for dev convenience; production should use Alembic instead. `init_db()` imports `app.models` inside the function to avoid a circular import. ## Tests `tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded` (perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*` without decorators. ## When extending Add a feature → model in `models/` (+ register in `models/__init__.py`), schema in `schemas/`, CRUD in `crud/`, routes in `api/` and/or `web/`, then a migration. If it needs access control, add the permission to the seed and guard the routes.