System Architecture & Actors
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 complementsCLAUDE.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 singlepackage.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.
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.3. High-level data flow
4. Example flow: search, select a release, download
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 gates every connection with an io.use handshake check
(server/socket.ts:29-44): 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”. Once connected,
notifyUser(type, payload) (server/socket.ts:143-147) calls
io.emit(type, payload) — a broadcast to every authenticated socket, with
no per-user rooms (a TODO in cron.ts flags this — see
server/cron.ts:978,1045; tracked in #1081). This is consistent with §9:
Questarr’s supported deployment is one trusted operator per instance, so a
cross-account broadcast is not a hardened boundary today and isn’t being
prioritized as one. Two event types are emitted today:
"notification"— emitted from bothcron.ts(game updates, download completion, auto-search results, xREL matches) androutes.ts; consumed byclient/src/components/NotificationCenter.tsx."downloadUpdate"— emitted fromcron.ts::checkDownloadStatuswhenever a tracked download’s status changes; consumed byclient/src/components/GameDetailsModal.tsxto 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:
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::safeFetchfor outbound calls whose target host is wholly or partly user-supplied (indexers, download clients, HowLongToBeat, NexusMods, Steam, PCGamingWiki).safeFetchblocks 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).allowPrivatedefaults totrue(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.tsalso routes its Twitch/IGDB requests throughsafeFetch, 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.
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).
9. Multi-user status
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 (seedocs/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,
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:
- Some code paths do scope by
userId(e.g.resolveOwnedGameinroutes.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. - Others intentionally don’t:
notifyUser()broadcasts Socket.io events to every connected client (§6);library-scanner.ts’s scan state (getAllUnmatched,getAllScanProgress) is global, matchingroot_foldersbeing global; download clients and indexers are shared instance configuration, not per-user. - The trust model is flat with no RBAC/admin split (see
docs/THREAT_MODEL.md§8, “Flat, single-tier trust model” — accepted risk).
userId scoping already present in the schema.