System Architecture & Actors

July 19, 2026 · View on GitHub

This document describes Questarr's system design: the actors (subsystems and external entities that can influence one another) and the data flows between them. It complements CLAUDE.md, which covers code-level conventions rather than system design, and docs/API.md / docs/SECURITY_ASSESSMENT.md, which cover the external interface and risk-assessment angles of the same system.

Update policy: update this document whenever a PR introduces a new actor (a new external integration, download client, or background job) or changes how data flows between existing actors.

1. Overview

Questarr is a three-layer TypeScript application in a single package.json (not a monorepo):

  • /client — a React 18 single-page app (Wouter routing, TanStack Query for server state).
  • /server — an Express REST API plus a Socket.io WebSocket channel.
  • /shared — the Drizzle ORM schema and Zod validation schemas used by both sides.

The client never talks to the database, external services, or download clients directly — every action is mediated by the server, which is the system's central trust boundary (§8).

2. System actors

An "actor" here is any subsystem or entity that can influence another part of the system — by writing data, triggering a request, or emitting an event.

ActorRoleWhat it can influence
End User (browser)Initiates all user-facing actionsClient SPA, via HTTP requests and Socket.io connection
Client (React SPA)Renders UI, holds a JWTServer, via REST calls with Authorization: Bearer <JWT>
Server — routes (server/routes.ts, server/steam-routes.ts, server/pcgamingwiki-router.ts)Validates input, orchestrates business logicStorage layer, downloaders, search, Socket.io
Server — auth.tsIssues/verifies JWTs, hashes passwordsStorage (system_config for the JWT secret), request req.user
Server — storage.ts (Drizzle ORM)Sole writer/reader of the SQLite DBSQLite database
SQLite databasePersists all app stateRead by every server module via storage.ts
Server — ssrf.ts (safeFetch)Validates and pins outbound URLsEvery outbound HTTP(S) call to indexers, downloaders, and most metadata services
Server — cron.ts (scheduler)Runs unattended background jobsStorage, IGDB, indexers (via search.ts), downloaders, Socket.io
Server — socket.ts (Socket.io)Pushes real-time eventsClient SPA (broadcast to all connected sockets)
Server — search.tsOrchestrates indexer search, applies filtering/dedupTorznab/Newznab indexers (read), routes/cron (results)
Server — downloaders.ts (DownloaderManager)Abstracts the 5 download-client integrationsqBittorrent/Transmission/rTorrent/SABnzbd/NZBGet (write: submit; read: status)
IGDB (via Twitch OAuth)External game-metadata providerServer, via server/igdb.ts (through safeFetch) — read-only queries; also drives cron.ts::checkGameUpdates
HowLongToBeatExternal gameplay-length providerServer, via server/hltb.ts (through safeFetch)
NexusModsExternal mod-listing providerServer, via server/nexusmods.ts (through safeFetch)
Steam Web APIExternal wishlist providerServer, via server/steam.ts / server/steam-routes.ts (through safeFetch), keyed by user-supplied steamId64
PCGamingWikiExternal wiki-lookup providerServer, via server/pcgamingwiki-router.ts (through safeFetch), keyed by Steam App ID
Torznab/Newznab indexers (user-configured)External release-search providersServer, via search.ts (through safeFetch); user-supplied URL/API key
qBittorrent / Transmission / rTorrent / SABnzbd / NZBGet (user-configured)External download clientsServer, via downloaders.ts (through safeFetch); user-supplied host/credentials
xREL.toExternal scene-release monitorServer, via server/xrel.ts, driven by cron.ts::checkXrelReleases

3. High-level data flow

flowchart TB
    User["End User (Browser)"]

    subgraph Client["Client (React SPA)"]
        UI["React Components + TanStack Query"]
    end

    subgraph Server["Server (Express)"]
        Routes["routes.ts / steam-routes.ts / pcgamingwiki-router.ts"]
        Auth["auth.ts (JWT)"]
        Storage["storage.ts (Drizzle ORM)"]
        SSRF["ssrf.ts (safeFetch)"]
        Cron["cron.ts (scheduler)"]
        Socket["socket.ts (Socket.io)"]
        SearchOrch["search.ts (indexer orchestration)"]
        DLManager["downloaders.ts (DownloaderManager)"]
    end

    DB[("SQLite (Drizzle)")]

    subgraph Metadata["Metadata & Discovery Services"]
        IGDB["IGDB (Twitch OAuth)"]
        HLTB["HowLongToBeat"]
        Nexus["NexusMods"]
        Steam["Steam Web API"]
        PCGW["PCGamingWiki"]
    end

    subgraph Indexers["Indexers (user-configured)"]
        Torznab["Torznab / Newznab feeds"]
    end

    subgraph Downloaders["Download Clients (user-configured)"]
        QB["qBittorrent"]
        TR["Transmission"]
        RT["rTorrent"]
        SAB["SABnzbd"]
        NZB["NZBGet"]
    end

    User <--> UI
    UI <--> Routes
    Routes --> Auth
    Routes --> Storage
    Storage <--> DB
    Routes --> SearchOrch
    SearchOrch --> SSRF
    SSRF --> Torznab
    Routes --> DLManager
    DLManager --> SSRF
    SSRF --> QB
    SSRF --> TR
    SSRF --> RT
    SSRF --> SAB
    SSRF --> NZB
    Routes --> SSRF
    SSRF --> HLTB
    SSRF --> Nexus
    SSRF --> Steam
    SSRF --> PCGW
    SSRF --> IGDB

    Cron --> Storage
    Cron -->|checkGameUpdates| IGDB
    Cron -->|checkAutoSearch| SearchOrch
    Cron -->|checkDownloadStatus| DLManager
    Cron -->|checkXrelReleases| Storage
    Cron --> Socket
    Routes --> Socket
    Socket -.->|notification, downloadUpdate| UI

4. Example flow: search, select a release, download

sequenceDiagram
    actor U as User
    participant C as Client
    participant R as routes.ts
    participant S as search.ts
    participant I as Torznab/Newznab Indexers
    participant D as DownloaderManager
    participant DB as SQLite

    U->>C: Click "Search" on a game
    C->>R: GET /api/indexers/search (JWT)
    R->>S: searchAllIndexers()
    S->>I: fan out parallel queries (via safeFetch)
    I-->>S: release metadata
    S-->>R: normalized/merged/sorted results
    R-->>C: results list
    U->>C: Select a release to download
    C->>R: POST /api/downloaders/:id/downloads
    R->>D: addDownload()
    D->>I: fetch magnet/NZB link (via safeFetch)
    R->>DB: persist game_downloads record
    Note over R,DB: cron.ts::checkDownloadStatus polls the downloader every minute and emits downloadUpdate over Socket.io on status change

5. Request/response flow

Every REST call follows the same path: Client → routes (server/routes.ts et al., validated via express-validator/Zod) → storage.ts (Drizzle ORM queries against shared/schema.ts) → JSON response. Routes never touch the database directly — all reads/writes go through storage.ts, which is the only module importing the Drizzle db client for application data.

6. Out-of-band channel: Socket.io

server/socket.ts exposes a single notifyUser(type, payload) function (server/socket.ts:42-46) that calls io.emit(type, payload) — a broadcast to every connected socket, with no per-user rooms (a TODO in cron.ts notes this should be scoped to per-user rooms once multi-user socket auth is wired up — see server/cron.ts:534,583). Two event types are emitted today:

  • "notification" — emitted from both cron.ts (game updates, download completion, auto-search results, xREL matches) and routes.ts; consumed by client/src/components/NotificationCenter.tsx.
  • "downloadUpdate" — emitted from cron.ts::checkDownloadStatus whenever a tracked download's status changes; consumed by client/src/components/GameDetailsModal.tsx to refresh download state for the affected game.

7. Scheduled/background actors (cron jobs)

server/cron.ts::startCronJobs() schedules seven recurring setInterval jobs. The five primary sync/check jobs below also run once on an initial 10-second delayed startup; startCronJobs() additionally runs logClientVersions (every 12 hours, probes configured indexer/downloader client versions for logging) and a daily import-task cleanup (deletes import_tasks rows older than 30 days), neither of which reads/writes domain data covered by this table:

JobIntervalUpstream readDownstream write
checkGameUpdates24 hoursIGDB (batch fetch by ID)games table (release date/status), notifications table, Socket.io notification
checkDownloadStatus1 minuteConfigured download clients (via DownloaderManager)game_downloads/games status, notifications, Socket.io downloadUpdate/notification
checkAutoSearch1 hour (per user, gated by their configured search interval)Torznab/Newznab indexers (via search.ts), download clients (if auto-download enabled)games search-results flag, game_downloads, notifications, Socket.io notification
checkXrelReleases6 hoursxREL.to latest releasesxrel_notified_releases, notifications, Socket.io notification
checkSteamWishlist1 hour (per user, gated by their configured sync interval, opt-in via steamSyncEnabled)Steam Web API wishlist, IGDB (Steam App ID lookup)games table (new/linked entries), import_tasks, notifications, Socket.io importTaskUpdate/notification

Steam wishlist sync (syncUserSteamWishlist in server/cron.ts) also runs on-demand when a user explicitly triggers it via POST /api/steam/wishlist/sync (server/steam-routes.ts:37-56). The scheduled path is opt-in per user (userSettings.steamSyncEnabled, default false) with a configurable interval (userSettings.steamSyncIntervalHours, default 24) tracked via userSettings.lastSteamSync.

8. Trust boundaries

  • Browser ↔ Server is the primary trust boundary. The client is treated as fully untrusted; every write path is re-validated server-side (express-validator/Zod) regardless of client-side checks, and all non-public routes require a valid JWT (authenticateToken, server/auth.ts:104-126).
  • Server ↔ third-party services is a secondary boundary, mediated by server/ssrf.ts::safeFetch for outbound calls whose target host is wholly or partly user-supplied (indexers, download clients, HowLongToBeat, NexusMods, Steam, PCGamingWiki). safeFetch blocks link-local/cloud- metadata/broadcast ranges unconditionally, and re-validates every resolved IP to guard against DNS rebinding (server/ssrf.ts:4-18,181-249). allowPrivate defaults to true (server/ssrf.ts:19-22,86), i.e. private/ loopback ranges are reachable by design — Questarr is meant to be self-hosted alongside indexers/downloaders that often live on the same LAN.
  • server/igdb.ts also routes its Twitch/IGDB requests through safeFetch, consistent with every other integration, even though the target host (api.igdb.com/id.twitch.tv) is hardcoded rather than user-supplied — applied as defense in depth rather than out of SSRF necessity.

See docs/THREAT_MODEL.md for a more detailed attack-surface analysis of these trust boundaries (per-integration trust table, high-risk data flows, and the unauthenticated-route inventory).