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.
Questarr is a three-layer TypeScript application in a single package.json
(not a monorepo):
/client — a React 19 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).
SQLite is the default database. PostgreSQL is an opt-in dialect selected with
DB_DIALECT=postgres and DATABASE_URL; see DATABASE.md for setup
and migration. The storage facade keeps the same API for both implementations.
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.
| Actor | Role | What it can influence |
|---|---|---|
| End User (browser) | Initiates all user-facing actions | Client SPA, via HTTP requests and Socket.io connection |
| Client (React SPA) | Renders UI, holds a JWT | Server, via REST calls with Authorization: Bearer <JWT> |
Server — routes (server/routes.ts, server/steam-routes.ts, server/pcgamingwiki-router.ts, server/routes/integration.ts, server/routes/api-keys.ts) |
Validates input, orchestrates business logic | Storage layer, downloaders, search, Socket.io |
Server — auth.ts |
Issues/verifies JWTs, hashes passwords, mints/validates integration API keys | Storage (system_config for the JWT secret; api_keys table), request req.user |
Playnite extension (extensions/playnite-questarr/) |
External couch-PC client | Server, via /api/integration (through an API key, not a JWT) — pushes the local library, requests games |
Server — storage.ts (Drizzle ORM) |
Database access facade for the selected dialect | SQLite / PostgreSQL database |
| SQLite / PostgreSQL database | Persists all app state | Read by every server module via storage.ts |
Server — ssrf.ts (safeFetch) |
Validates and pins outbound URLs | Every outbound HTTP(S) call to indexers, downloaders, and most metadata services |
Server — cron.ts (scheduler) |
Runs unattended background jobs | Storage, IGDB, indexers (via search.ts), downloaders, Socket.io |
Server — socket.ts (Socket.io) |
Pushes real-time events | Client SPA (owner’s user:<id> room for notifications, broadcast to authenticated sockets otherwise) |
Server — search.ts |
Orchestrates indexer search, applies filtering/dedup | Torznab/Newznab indexers (read), routes/cron (results) |
Server — downloaders.ts (DownloaderManager) |
Abstracts the 5 download-client integrations | qBittorrent/Transmission/rTorrent/SABnzbd/NZBGet (write: submit; read: status) |
Server — library-scanner.ts / root-folders.ts |
Discovers games already on disk in user-configured root folders (outside the library root) | Reads the local filesystem directly (not via safeFetch — local paths, not URLs); queries IGDB for matching; writes games/game_files/root_folders via storage.ts |
| IGDB (via Twitch OAuth) | External game-metadata provider | Server, via server/igdb.ts (through safeFetch) — read-only queries; also drives cron.ts::checkGameUpdates |
| HowLongToBeat | External gameplay-length provider | Server, via server/hltb.ts (through safeFetch) |
| NexusMods | External mod-listing provider | Server, via server/nexusmods.ts (through safeFetch) |
| Steam Web API | External wishlist provider | Server, via server/steam.ts / server/steam-routes.ts (through safeFetch), keyed by user-supplied steamId64 |
| PCGamingWiki | External wiki-lookup provider | Server, via server/pcgamingwiki-router.ts (through safeFetch), keyed by Steam App ID |
| Torznab/Newznab indexers (user-configured) | External release-search providers | Server, via search.ts (through safeFetch); user-supplied URL/API key |
| qBittorrent / Transmission / rTorrent / SABnzbd / NZBGet (user-configured) | External download clients | Server, via downloaders.ts (through safeFetch); user-supplied host/credentials |
| xREL.to | External scene-release monitor | Server, via server/xrel.ts, driven by cron.ts::checkXrelReleases |
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 / PostgreSQL (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
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 Database
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
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.
server/socket.ts gates every connection with an io.use handshake check
(server/socket.ts:32-50): the socket must carry the auth cookie (from a
trusted Origin) or a bearer token that verifyAuthToken accepts, otherwise
the handshake is rejected with “Authentication required”. On a successful
handshake the socket joins a per-user room, user:<id>, before the
connection completes, so a notification fired right after connect can’t miss
it.
notifyUser(type, payload, userId?) (server/socket.ts:149-162) routes
events in one of two ways:
userId set): io.to("user:<id>").emit(type, payload).
Only that user’s sockets receive the event. Every notification emit
passes the notification’s userId, so in-app notifications reach only the
account that owns them.userId omitted or null): io.emit(type, payload),
a broadcast to every authenticated socket. Ownerless notifications and
app-wide events (downloadUpdate, importTaskUpdate, gameUpdated,
library-scan-progress, and the logLine log stream) still go to
everyone.The remaining broadcasts are consistent with §9: Questarr’s supported deployment is one trusted operator per instance, so cross-account broadcast of app-wide state is not a hardened boundary today. The main event types are:
"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.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:
| Job | Interval | Upstream read | Downstream write |
|---|---|---|---|
checkGameUpdates |
24 hours | IGDB (batch fetch by ID) | games table (release date/status), notifications table, Socket.io notification |
checkDownloadStatus |
1 minute | Configured download clients (via DownloaderManager) |
game_downloads/games status, notifications, Socket.io downloadUpdate/notification |
checkAutoSearch |
1 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 |
checkXrelReleases |
6 hours | xREL.to latest releases | xrel_notified_releases, notifications, Socket.io notification |
checkSteamWishlist |
1 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 |
rssService.refreshFeeds |
1 hour (also 10 seconds after startup) | Enabled RSS feeds via SSRF-safe fetch, IGDB matching | rss_feeds check status and rss_feed_items |
RSS scheduled and manual full refreshes share an in-flight promise. Full and single-feed refreshes use one queue to prevent overlapping writes and duplicate items. New-feed refreshes join that queue rather than racing the hourly check.
Root-folder discovery lives in Library → Library Import. Add/enable/path
changes and enabling post-processing trigger scans; changes during a running
scan queue a follow-up. library-scan-progress carries scan timestamps and
counts. Clients invalidate games after new matches or a scan finishes (including
failure after partial writes), and refresh review candidates when progress changes.
Unmatched-folder IGDB name searches update candidates without importing; explicit
confirmation writes the game and file records. See LIBRARY_IMPORT.md.
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.
express-validator/Zod) regardless of client-side checks, and all
non-public routes require a valid JWT (authenticateToken,
server/auth.ts:104-126).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).
Questarr is not, and is not planned to become, a multi-user application
for the foreseeable future. The supported deployment is one trusted
operator per instance (see docs/PRD.md §6 Non-Goals and §8
Technical Constraints, and ../GOAL-product.md).
The schema and auth layer nonetheless have partial multi-account
plumbing, which predates this decision and should not be read as a
roadmap signal: a users table exists, authenticateToken resolves a
per-request req.user.id from a JWT, and personal-library tables
(games, user_settings, notifications, import_tasks, api_keys,
integration_requests, release_blacklist) carry a userId column. Instance-wide config and
shared runtime state — indexers, downloaders, root_folders,
rss_feeds, game_downloads — deliberately carry no userId, since
they represent one server’s shared configuration, not per-account data.
Because of this, account isolation is inconsistent by design, not a defect to eliminate wholesale:
userId (e.g. resolveOwnedGame in
routes.ts, and the igdbId-reuse checks before reusing an existing game
record), because getting those specific paths right also happens to be
good practice regardless of user count.notifyUser() still broadcasts app-wide
Socket.io events (download, import, and scan progress) to every connected
client, though owned notifications go only to their user’s room (§6);
library-scanner.ts’s scan state
(getAllUnmatched, getAllScanProgress) is global, matching root_folders
being global; download clients and indexers are shared instance
configuration, not per-user.docs/THREAT_MODEL.md §8, “Flat, single-tier trust
model” — accepted risk).For review purposes: a finding that one authenticated account can read
or influence another account’s data on the same instance is not, by
itself, a release-blocking vulnerability under this deployment model —
Questarr has exactly one intended operator per instance. It’s still fine
to close such a gap opportunistically when already touching that code
(consistency and defense-in-depth have value even here), but it should not
be treated as urgent, and should not be used to justify widening a PR’s
scope. If the multi-user goalposts ever move, that will be a deliberate,
separately-scoped product decision — not something to infer from the
partial userId scoping already present in the schema.