Skip to main content

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 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.

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 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: 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).

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 (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, 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. 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.
  • Others intentionally don’t: notifyUser() broadcasts Socket.io events to every connected client (§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.
  • The trust model is flat with no RBAC/admin split (see 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.