# Architecture A developer-facing map of how Grimoire fits together: what lives where, how a request flows end-to-end, how authentication and OIDC work, and how schema changes are applied at startup. It complements the high-level overview in [`CLAUDE.md`](../CLAUDE.md) and the schema reference in [`docs/data-model.md`](data-model.md). All `file_path` references point at the current code - if you change a module's shape, update the matching section here. ## Stack - **Frontend** - React 18 + Vite 6 SPA, React Router v6, React Context (no Redux/Zustand), i18next (EN/DE/FR), Vitest + React Testing Library. - **Backend** - FastAPI (async) + SQLAlchemy 2.0, SQLite with FTS5 full-text search, PyMuPDF (`fitz`) for PDF→WebP rendering, PyJWT auth. - **Optional** - Valkey/Redis for in-memory page-image caching (falls back to disk). The FastAPI backend serves both the REST API under `/api/*` and the static React build (`frontend/dist/`) via a catch-all route. In development, Vite proxies `/api` to the backend on port 9481. ## Module map ### Backend | Path | Responsibility | | --- | --- | | [`backend/main.py`](../backend/main.py) | FastAPI app construction, router registration, the `lifespan` startup hook (library scan, user seeding, wiki migrations, scheduler), and the SPA catch-all `serve_frontend`. | | [`backend/config.py`](../backend/config.py) | Environment loading, `DATA_PATH`/`LIBRARY_PATH` and cache dirs, logging setup (incl. the in-memory ring buffer for `/api/logs`), the SQLAlchemy `engine` + `SessionLocal`, the `get_db` dependency, and the optional `_valkey` client. | | [`backend/models/`](../backend/models/) | ORM models split by domain (`library.py`, `media.py`, `users.py`, `campaigns.py`, `settings.py`); `db.py` holds `init_db`, runtime migrations, and FTS5 setup. See [`docs/data-model.md`](data-model.md). | | [`backend/auth.py`](../backend/auth.py) | JWT creation/validation, password hashing (`bcrypt_sha256`), the `get_current_user` dependency, and the role-guard dependencies (`require_admin`, `require_gm_or_admin`, `require_not_guest`). | | [`backend/indexer.py`](../backend/indexer.py) | Library scanning, PDF FTS5 indexing, thumbnail generation, and OPF/metadata parsing. PDF text extraction runs in an isolated subprocess (see below). | | [`backend/pdf_worker.py`](../backend/pdf_worker.py) | Standalone entry point for the spawned PDF text-extraction process. Imports only `fitz`/OCR libs - never `backend.config` - so a throwaway extraction child does not open the DB or run migrations. | | [`backend/routers/`](../backend/routers/) | One package per feature area (see the router shape below). | | [`backend/scheduler.py`](../backend/scheduler.py), [`backend/session_creator.py`](../backend/session_creator.py) | Campaign session scheduling - apply recurrence on startup and auto-create upcoming sessions. | | [`backend/seed_users.py`](../backend/seed_users.py) | First-run / env-driven user pre-seeding. | #### Router package shape Every package under `backend/routers/` follows the same layout (larger packages add focused modules like [`books/pages.py`](../backend/routers/books/pages.py) or `campaigns/sessions.py`): - `__init__.py` - builds the `APIRouter` and registers routes via `add_api_route(...)`. Auth-adjacent packages expose **two** routers: a `public_router` (mounted directly on the app, no auth) and a `router` (mounted under the authenticated `/api` parent). See [`routers/auth/__init__.py`](../backend/routers/auth/__init__.py). - `core.py` - the endpoint handler functions (plain functions, no decorators). - `_schemas.py` - Pydantic request/response models (when needed). - `_helpers.py` - shared internal helpers (when needed). ### Frontend | Path | Responsibility | | --- | --- | | [`frontend/src/App.jsx`](../frontend/src/App.jsx) | Router setup, protected routes, and the context-provider tree. | | [`frontend/src/api.js`](../frontend/src/api.js) | Centralized `fetch` wrapper - attaches the JWT, handles errors, exposes the `mediaUrl(...)` helper for `?token=` image/download URLs, and the per-feature API namespaces (`campaigns`, `auth`, `settings`, …). | | [`frontend/src/context/`](../frontend/src/context/) | `AuthContext` (token + current user), `UISettingsContext`, `FavoritesContext`. | | [`frontend/src/views/`](../frontend/src/views/) | Page-level components - one per route. | | [`frontend/src/components/`](../frontend/src/components/) | Shared UI components (one component per file - enforced by ESLint). | | [`frontend/src/hooks/`](../frontend/src/hooks/), [`frontend/src/utils.js`](../frontend/src/utils.js) | Reusable hooks and helpers. | ## Request lifecycle A typical authenticated API call: ```mermaid sequenceDiagram participant UI as React view participant API as api.js participant FA as FastAPI (/api) participant Dep as get_current_user participant H as handler (core.py) participant DB as SQLAlchemy / SQLite UI->>API: campaigns.get(id) API->>FA: fetch('/api/campaigns/:id')
Authorization: Bearer FA->>Dep: resolve dependency Dep-->>FA: CurrentUser (or 401) FA->>H: call handler(user, ...) H->>DB: SessionLocal() query DB-->>H: rows H-->>FA: dict / Pydantic model FA-->>API: JSON response API-->>UI: parsed body (or dispatch grimoire:unauthorized on 401) ``` Key points: - **Frontend** - [`api.js`](../frontend/src/api.js) reads the JWT from `localStorage` (`grimoire_token`) and sends it as an `Authorization: Bearer` header. On any `401` it dispatches a `grimoire:unauthorized` window event; `AuthContext` listens and clears the stored token, bouncing the user to login. - **Auth boundary** - the authenticated API is a single parent router built with `APIRouter(prefix="/api", dependencies=[Depends(get_current_user)])` in [`main.py`](../backend/main.py#L203). Every sub-router mounted under it inherits the JWT requirement. Truly public endpoints (`/api/auth/status|setup|login`, OIDC, health, some library routes) are registered as separate `public_router`s mounted directly on the app. - **Handler → DB** - handlers open a session via `SessionLocal()` (or the `get_db` dependency) and return dicts/Pydantic models that FastAPI serializes to JSON. - **Static assets** - the Vite build's hashed assets are mounted at `/assets` via `StaticFiles`. Any other non-`/api` path falls through to `serve_frontend`, which returns the requested file if it exists (with a path-traversal guard) or `index.html` with `Cache-Control: no-store` so the SPA can client-route. ### Page images and caching Book pages are rendered on demand by [`routers/books/pages.py`](../backend/routers/books/pages.py) (`serve_book_page`): 1. If a rendered WebP is already in **Valkey** (when configured), stream it back. 2. Else if it's on the **disk cache** (`DATA_PATH/page_cache/__.webp`), serve it (and backfill Valkey). 3. Else **render** the page with PyMuPDF, write it to both caches, and stream it. All three paths send `Cache-Control: max-age=31536000, immutable` (`_PAGE_CACHE_HEADERS` in [`config.py`](../backend/config.py)), so browsers and CDNs cache aggressively. Because image/download URLs are used in ``/download contexts that can't set an `Authorization` header, they authenticate via a `?token=` query param (`mediaUrl(...)` on the frontend, the `token` `Query` param in [`auth.py`](../backend/auth.py#L72) on the backend). Thumbnails work the same way, cached under `DATA_PATH/thumbnails/`. ## Authentication & OIDC ### JWT and roles - Tokens are HS256 JWTs signed with `SECRET_KEY`, carrying `sub` (user id), `username`, `role`, and a 30-day `exp` - see `create_token` / `decode_token` in [`auth.py`](../backend/auth.py). - **Roles**: `admin` (full access incl. user management), `gm` (edit metadata, rescan, manage content), `player` (read-only), plus `guest` - a campaign-scoped, code-only account with no library access. - `get_current_user` accepts the token from either the `Authorization: Bearer` header **or** a `?token=` query param, so browser-embedded images and downloads authenticate without a header. Role guards (`require_admin`, `require_gm_or_admin`, `require_not_guest`) layer on top per-route. ### Password / guest login `POST /api/auth/login` verifies a username/password (bcrypt) and returns a JWT. `POST /api/auth/guest-login` exchanges a 10-char campaign invite code for a guest-scoped JWT (only when guest access is enabled). First-run `POST /api/auth/setup` creates the initial admin. All three are public routes - see [`routers/auth/__init__.py`](../backend/routers/auth/__init__.py). ### OIDC (authorization code + PKCE) Implemented in [`routers/oidc/`](../backend/routers/oidc/) against any standards-compliant IdP (Keycloak, Authentik, Authelia, Auth0, Okta, …). The IdP is configured at runtime via **Settings → Authentication**; each field can be pinned/locked by an environment variable (`OIDC_*`, see the `OIDC_ENV` map in [`config.py`](../backend/config.py#L53)). ```mermaid sequenceDiagram participant B as Browser participant G as Grimoire (/api/oidc) participant S as State store participant IdP as OIDC Provider B->>G: GET /api/oidc/login G->>S: put(state → {nonce, code_verifier, return_to}) G-->>B: 302 to IdP authorize (code_challenge S256) B->>IdP: authenticate & consent IdP-->>B: 302 to /api/oidc/callback?code&state B->>G: GET /api/oidc/callback G->>S: pop(state) → verifier + nonce G->>IdP: token exchange (code + code_verifier) IdP-->>G: id_token + access_token G->>IdP: fetch JWKS, validate id_token (iss/aud/nonce) G->>IdP: GET /userinfo (merge groups/roles) G->>G: resolve/register user, create Grimoire JWT G-->>B: 302 return_to#oidc_token= ``` Notes: - **PKCE + nonce** - `oidc_login` generates a `state`, `nonce`, and PKCE verifier/challenge, stashes the per-flow secrets in the state store keyed by `state`, and redirects to the IdP's authorization endpoint (endpoints are read from config or auto-discovered from the issuer's `.well-known/openid-configuration`). - **State store** - `_StateStore` in [`routers/oidc/_helpers.py`](../backend/routers/oidc/_helpers.py) is an in-process dict with a 10-minute TTL. This is sufficient for a single replica; a multi-replica deployment would need a shared store (tracked for the Valkey-backed state/JWKS work). - **Callback** - `oidc_callback` validates `state`, exchanges the code for tokens, validates the `id_token` against the IdP's JWKS (checking issuer/audience/nonce), optionally merges `/userinfo` claims (some IdPs only expose groups there), resolves or auto-registers the user, then mints a Grimoire JWT. - **Token handoff** - the JWT is returned in the redirect **URL fragment** (`#oidc_token=...`) rather than a query param, so it isn't logged by intermediate proxies. The frontend reads it from the fragment and stores it like any other token. ## Startup and migrations The `lifespan` async context manager in [`main.py`](../backend/main.py#L87) runs once per process on startup: 1. **Single-worker guard** - acquire a non-blocking `flock` on `DATA_PATH/.scan.lock`. Only the winning worker runs the startup library scan; others skip it. The scan runs on a background daemon thread so it doesn't block the app coming up. 2. **User seeding** - `seed_users(...)` applies env-driven / first-run accounts. 3. **Wiki migrations** - `wiki_migration` and `wiki_category_migration` roll legacy session notes and flat note-categories into the current nested-page model. 4. **Scheduler** - apply campaign session recurrence and start the session creator (skipped under pytest). ### Crash-isolated PDF indexing PDF text extraction can crash the interpreter natively - a malformed object that segfaults MuPDF, or an out-of-memory kill on a low-RAM host - which no `try/except` can catch. Left unguarded this is catastrophic: the killed worker is respawned by the server, the respawn re-runs the startup scan, hits the same file, and crashes again in an endless loop (see issue #204). Two layers prevent this: 1. **Process isolation** - `index_book_text` runs extraction via `extract_text_isolated`, which spawns [`pdf_worker.py`](../backend/pdf_worker.py) in a separate process (`spawn`, not `fork`, to avoid inheriting threads and the SQLite connection). The child writes its result to a temp file; if it dies without producing one - signal death, OOM-kill, or exceeding the extraction timeout - the parent raises `PdfExtractionCrashError`, marks the book `index_failed`, and moves on. The server never goes down. 2. **Crash-loop guard** - before extraction, `index_book_text` commits `index_failed=True` up front (cleared on success). So even in the worst case where a crash somehow escaped isolation and took the whole worker down, the book is already flagged and is skipped on the next scan instead of re-looping. This mirrors the `scan_failed` guard used for the thumbnail/page-count phase. ### Deferred, checkpointed OCR Scanned (image-only) PDFs have no embedded text layer and must be OCR'd to be searchable. OCR is slow - a large scanned book can take tens of minutes to hours - so it runs in a **separate phase behind its own queue** rather than inline during the scan, where one slow book would stall the whole library (see issue about OCR timeouts on large scanned books). 1. **Fast phase** - `index_book_text` extracts only the embedded text layer (`extract_text_isolated(..., text_only=True)`). Text-layer books and all other media index in seconds and are immediately searchable. A book that comes back with no text is marked `ocr_pending=1` (not `image-only`) and left unindexed. When OCR is unavailable (slim image / `OCR_ENABLED=false`) the pre-OCR behaviour is kept: the book is marked `image-only` and `indexed`. 2. **OCR phase** - after the fast phases, `run_ocr_queue` (in [`routers/library/_helpers.py`](../backend/routers/library/_helpers.py)) drains the `ocr_pending` books. Each book is OCR'd **one page at a time** by `indexer.ocr_book`, which spawns [`pdf_worker.py`](../backend/pdf_worker.py)'s `ocr_page_main` per page (isolated, per-page-timeout bounded), inserts the recognised page into `book_search`, and advances `ocr_pages_done` before moving on. Because progress is committed per page, a restart, crash, or cancel resumes the book at `ocr_pages_done` rather than re-OCRing it - a multi-hour book makes durable progress and never hits a whole-book wall. `OCR_CONCURRENCY` (default 1) drains several books in parallel via a thread pool, each on its own session. The queue lives in the DB (source of truth, survives restarts); live progress (`total_ocr`/`ocr_done`/`ocr_current`, phase `ocr`) mirrors into the scan-status dict/Valkey for the admin UI. 3. **Per-book re-OCR** - `POST /api/books/{id}/reindex` (gm/admin) clears a single book's search rows, resets its OCR checkpoint, and re-queues it, optionally with an `ocr_dpi` override persisted on `books.ocr_dpi` (NULL = global `OCR_DPI`). `ocr_book` threads that DPI through `ocr_page_isolated` → `pdf_worker.ocr_page` to the pixmap render, so a specific faint scan can be re-read at higher resolution without re-OCRing the whole library. ### Schema migrations Schema changes are managed by **Alembic** (revisions in [`backend/migrations/`](../backend/migrations/), config in [`alembic.ini`](../alembic.ini)). `init_db` in [`backend/models/db.py`](../backend/models/db.py) applies them at startup: - **Fresh / already-on-Alembic database** → `alembic upgrade head` runs every revision from the baseline (or just the new ones), landing at the head revision recorded in `alembic_version`. - **Existing pre-Alembic database** (real tables, but no `alembic_version`) → the legacy imperative `ALTER TABLE`/`CREATE INDEX` replay runs one final time to bring the schema exactly to the baseline, then the DB is `stamp`ed at head - the baseline migration is **not** re-run (which would try to recreate existing tables). This keeps upgrades from any prior version transparent, with no manual action. - **Concurrency** - `init_db` runs once per worker process; a blocking file lock (`.migrate.lock`) serializes the migration so, with multiple uvicorn workers, the first migrates and the rest wait and then find the DB already at head. - After migrations, idempotent data fixups run on every startup (tag normalization, resource-visibility backfill from the legacy `shared`/`gm_only` flags) and the FTS5 `book_search` virtual table is created (it's not an ORM table, so Alembic doesn't manage it). SQLite's limited `ALTER TABLE` support means table rebuilds go through Alembic's **batch mode** (`render_as_batch=True`, set in [`env.py`](../backend/migrations/env.py)). **Authoring a migration:** change the models, then autogenerate a revision and review it: ```bash alembic revision --autogenerate -m "describe the change" # writes backend/migrations/versions/ alembic upgrade head # apply locally alembic downgrade -1 # verify it reverses ``` `env.py` targets `Base.metadata`, so autogenerate diffs the models against the DB. The `sqlalchemy.url` isn't in `alembic.ini` - it resolves from the app config (or an `-x url=sqlite:///...` override). Keep the schema-of-record in [`docs/data-model.md`](data-model.md) in sync when you add a revision.