# Lectio Architecture Lectio is a self-hosted feed reader built around the `reader` Python library. The goal is a fast triage workflow with a clean multi-user architecture and VPS-friendly deployment. ## Contents Design rationale, split by area. Each file is the *why* behind its part of the system — what was tried, what broke, and what the current shape is defending against. - **[tenancy.md](docs/architecture/tenancy.md)** — Per-user isolation, the resolver seam, and what multi-user mode changes. - **[feeds.md](docs/architecture/feeds.md)** — Subscribing, fetching, deduplicating, combining and removing feeds. - **[views.md](docs/architecture/views.md)** — The post list, folders, sorting, layout and page weight. - **[images.md](docs/architecture/images.md)** — Choosing, rejecting, sizing and serving the image for a post. - **[reading.md](docs/architecture/reading.md)** — Read Mode, offline reading, and offline actions. - **[saved.md](docs/architecture/saved.md)** — Read-it-later capture, keeping, and editing a post in place. - **[integrations.md](docs/architecture/integrations.md)** — Per-user OAuth destinations, quotas, and the automation that drives them. - **[apis.md](docs/architecture/apis.md)** — The sync APIs Lectio speaks to third-party clients. ## Layering - UI/API layer: web routes, handlers, presentation state. - Services layer: feed operations, tagging, filtering, refresh, readability. - Storage layer: reader DB, app-data, settings. The layers run in one process today, but the boundaries should stay clean. ### Route modules Routes are split out of `main.py` into `routes/*.py` by URL prefix (`system`, `compat_{fever,greader,v1}`, `tags`, `highlights`, `automation`, `admin`, `saved`, `settings`, `feeds`, `entries`, `home`), each a plain `router = APIRouter()` included from `main.py`'s bottom-of-file block. Shared module-level state (caches, locks, generation counters) lives in `state.py`; route modules import it back rather than redefining it. Gotchas that apply to any further extraction out of `main.py`: a module that imports a singleton cache/lock must not redefine it, and every `invalidate_*` call site has to stay wired to the same instance. `get_reader()`'s thread-local pooling and `lifespan` are startup-order-sensitive. A name late-bound at the bottom of `main.py` (e.g. `_run_automation_after_refresh` from `services.automation_rules`) needs the importing module's own import positioned after that block, or import fails — and this cascades, since a route module that itself gets imported by another route module needing the same treatment must move after it too. A scalar rebound via `global` needs a real accessor function, not a bare imported name: `from state import _some_counter` freezes a snapshot at import time and goes stale on every later write, the same failure mode a leftover `global` write has. Before assuming a helper is single-route-only, grep `routes/*.py` and `scripts/*.py` too, not just `main.py` and `tests/` — several "obviously local" helpers turned out to have script-only callers. ## Reader-first philosophy `reader` is the primary storage/ops primitive. It already covers: - feed retrieval and storage, - read state, - arbitrary tags and metadata, - filtering and search, - statistics, - plugin support. Prefer reader API and plugin behavior first. Add custom logic only when the existing reader model cannot express the behavior cleanly. ## Deployment path Lectio is designed for VPS deployment behind a reverse proxy. Auth is always active; access requires a user account. See `.env.example` for deployment configuration. ## Extension strategy Use plugin/adapter style for non-native behavior instead of hardwired branching. Prefer replaceable pieces and avoid duplicating `reader` capabilities in app code. ## Security direction Keep the local-first path simple. Add auth only when exposing the app beyond trusted local use. The multi-user phase makes per-user identity, per-user API tokens, route-level authorization, and SSRF hardening mandatory — see [tenancy.md → Security posture](docs/architecture/tenancy.md#security-posture). Every redirect built from request data goes through `services.redirects.local_url` (same-origin paths only), and a catch-all `except` that answers the client uses `services.errors.public_error` (fixed message, traceback logged) rather than echoing `str(exc)`. CodeQL treats both as sanitizers via the guard-aware queries in `.github/codeql/queries/`.