""" config.py: ChessTB Explorer configuration Edit the values below directly, then run `python app.py`. This file contains the primary application configuration. A subset of settings is also readable from the process environment, with the literal value below used when the environment variable is unset, allowing one committed configuration file to serve multiple deployment platforms. - ADMIN_TOKEN / FLASK_SECRET_KEY: always from the environment only (no literal fallback below) so they can be set as platform secrets (e.g. a Render "Environment Variable" marked secret, or a Hugging Face Space "Secret") without ever committing them - see the "Admin" section below. - PORT: read from the environment with a literal fallback, because platforms like Render assign the port dynamically per-deployment (via the platform's PORT variable) rather than using a fixed value; see the "Server" section below. - TABLEBASE_PATH / SOURCE_REPOSITORY_URL: read from the environment with a literal fallback, to allow a deployment (for example, a Render Web Service) to select a different tablebase location or repository URL without modifying the source file; editing the literal value below remains supported. Invalid values (wrong type, out of range) make the app log an error and exit at startup rather than run with a silently-wrong configuration; see AppConfig.from_config() in app.py. """ import os # ── Tablebase location ────────────────────────────────────────────────────── # Bucket URL or local tablebase root. Full tables are preferred over shipping # tables; castling directories are selected automatically for each material. # A list of explicit source directories is also supported. TABLEBASE_PATH = os.environ.get( "TABLEBASE_PATH", "https://huggingface.co/buckets/chesstb/chesstb/resolve", ) # ── Server ─────────────────────────────────────────────────────────────────── # Set to True to run the app via Flask's own dev server (auto-reload, # detailed error pages, interactive debugger) instead of waitress. Leave # False for any deployment reachable from other machines - False is what # makes `python app.py` serve via waitress, the production WSGI server # this app ships with. DEBUG = False # Interface the server (waitress, or the Flask dev server if DEBUG is # True) binds to. Set to "0.0.0.0" so the server accepts connections # arriving at the container's network interface, as required by Render, # Hugging Face Spaces, and any other containerized deployment. Use # "127.0.0.1" instead only for single-machine local use with no other # access control in front of it - see README.md's "Security notes". HOST = "0.0.0.0" # Port the server listens on - applies whether app.py ends up serving via # waitress (DEBUG=False) or the Flask dev server (DEBUG=True). # # Read from a PORT environment variable first, falling back to 7860 # (Hugging Face Docker Spaces' default routed port) when unset. This # matters specifically for Render: a Render web service is assigned its # externally-routed port at deploy time via its own PORT variable # (commonly 10000, but not guaranteed) rather than a fixed value. The # container reads and binds to the injected port. Running locally with no # PORT set uses 7860. PORT = os.environ.get("PORT", "7860") # Number of worker threads waitress uses to handle concurrent requests # (only relevant when DEBUG=False). Needs to be more than 1 so that a # long-lived /probe/stream SSE connection can't block every other request; # see README.md's "Running in production" section. This is separate # from PROBE_THREADS below, which sizes the pool used internally to # parallelise tablebase probing, not to serve HTTP requests. WAITRESS_THREADS = 4 # ── Probing ────────────────────────────────────────────────────────────────── # Worker threads in the probe thread pool for evaluating a position's # legal moves in parallel. Set to match a 2-vCPU host (e.g. Hugging # Face's cpu-basic tier); raise it on a larger host, or leave as None to # scale automatically instead (min(16, cpu_count() * 2)). PROBE_THREADS = 2 # Minimum number of not-yet-cached child positions before a probe batch # switches from sequential to the PROBE_THREADS thread pool. Lower this # (or set to 1 to always parallelize) for workloads with many legal moves # or when profiling shows sequential probing is a bottleneck. Raise it # when thread hand-off overhead outweighs the benefit for the workload. PROBE_PARALLEL_THRESHOLD = 4 # Wall-clock timeout, in seconds, for each probe phase. /probe gives the # root probe and the child-move batch independent budgets, so a worst-case # request can consume up to two PROBE_TIMEOUT_SECS intervals. /probe/stream # uses the same setting for its root probe and child pre-warm/evaluation # phases. Remaining probes are treated as unknown when a phase reaches its # deadline rather than blocking the request indefinitely. PROBE_TIMEOUT_SECS = 30 # ── Caching ────────────────────────────────────────────────────────────────── # Defaults target one process on Hugging Face CPU Basic (2 vCPU, 16 GB # RAM). With the four default remote sources, byte-cache budgets total # 8.5 GiB: four 2 GiB page caches plus one shared 512 MiB block cache. # These fill on demand. Leave the remaining RAM for result caches, # table/index metadata, Python object overhead, and active requests. # Max entries in the root-FEN result cache (full JSON responses served by # evaluate_fen()). Raising this keeps more distinct root positions warm # across a session at the cost of memory. EVALUATE_CACHE_SIZE = 16384 # Max entries in the child-position probe cache (raw WDL/DTZ/DTC/DTM/DTM50 # tuples served by _probe_fen()), shared between root probes and every # child position probed while ranking moves. PROBE_CACHE_SIZE = 262144 # Size (in bytes) of chess.chesstb's own internal cache of decoded/ # decompressed tablebase blocks, shared across the WDL/DTZ/DTC/DTM/DTM50 # tables. # One cache is shared across every source. Keep 512 MiB of decoded # blocks to reduce repeated decompression on the 2-vCPU host. BLOCK_CACHE_BYTES = 512 * 1024 * 1024 # ── Remote (URL) tablebase entries only ───────────────────────────────────── # Everything below is ignored for a local-directory entry; it applies to # every http(s):// entry in TABLEBASE_PATH (including entries inside # a hybrid list - see # above). See the module docstrings of remote/remote_direct.py and # remote/remote_fallback.py for the full design. # How a remote table's bytes reach the prober: # # "direct" -- probe the remote tables in place, fetching only the # page-sized byte ranges each probe reads, nothing written # to disk (remote/remote_direct.py). # "download" -- fetch each table file in full on first touch and cache it # on local disk (remote/remote_fallback.py). Slower first # touch of a material and real disk usage, but every read # after it is a local mmap read. # # "direct" is the preferred mode for browsing across many materials. # "download" is preferable when a small set of materials is accessed # repeatedly for a long session or when per-request network latency is high. # # "direct" is also the right choice for a PaaS deployment (e.g. Render) # fronting a multi-terabyte tablebase set: it needs no persistent disk at # all, so it sidesteps both the platform's per-GB disk cost at that scale # and the fact that most platforms' single-service disks aren't sized for # terabytes in the first place. "download" would need a disk (or enough # ephemeral local storage) large enough for the materials probed during a # session. Storage requirements therefore grow with the number and size of # materials accessed. REMOTE_MODE = "direct" # Soft budget, in bytes, PER remote source in TABLEBASE_PATH, shared # across that source's tables: bounds the in-memory page cache in # "direct" mode, and the on-disk whole-file cache in "download" mode. # Four default sources can retain 8 GiB of pages in total. Revisit this # budget when adding sources or running multiple application processes. REMOTE_PAGE_CACHE_BYTES = 2 * 1024 * 1024 * 1024 # Size, in bytes, of one page. In "direct" mode this is the granularity of # every fetch, so it sets the trade between over-fetching (too large) and # many round trips per probe (too small) -- 256 KiB keeps a table's whole # header + index region within a page or two, which is what makes later # probes of the same table cheap. In "download" mode it is only the chunk # size used while streaming a full file down, and has no effect on which # bytes end up fetched. REMOTE_PAGE_SIZE_BYTES = 256 * 1024 # Per-HTTP-request timeout, in seconds, for both existence/size checks and # the download itself against a remote tablebase. REMOTE_TIMEOUT_SECS = 20.0 # Number of attempts for a single remote request before giving up (the # request that triggered it then fails, surfacing as a probe error). REMOTE_MAX_RETRIES = 3 # Max size of the HTTP connection pool each remote backend's session # keeps open against its entry's host. Left as None so it scales # with PROBE_THREADS automatically (max(PROBE_THREADS * 2, 20)) - set an # explicit integer to override. Too small a pool under-serves concurrent # probing (connections stop being reused once PROBE_THREADS exceeds it); # too large rarely hurts beyond a handful of idle sockets, so there's # little reason to set this below the auto-computed value. REMOTE_POOL_MAXSIZE = None # ── Rate limiting ──────────────────────────────────────────────────────────── # Per-client-IP request limit applied to /probe and /probe/stream - the # two public, unauthenticated, CPU-bound endpoints - using flask-limiter's # string syntax (e.g. "60 per minute", "10 per second"; multiple limits # can be combined with ";"). Backed by an in-process memory store, which # is enough for a single-container deployment (e.g. one Render Web # Service instance) but does not share state across multiple replicas. # Left empty, rate limiting is disabled entirely. # # 60 per minute comfortably covers normal browsing and autoplay at its # default 1.25s delay; raise it if legitimate autoplay at a fast delay # setting gets throttled, or lower it if the deployment sees abuse. PROBE_RATE_LIMIT = "60 per minute" # Per-client-IP limit applied only to requests that identify themselves as # background hover-prefetch via the "X-Prefetch: 1" request header (see # frontend/src/engine/tablebase.js's hover-prefetch). Deliberately separate from, # and tighter than, PROBE_RATE_LIMIT -- a client-supplied header is not # trusted enough to exempt a request from limiting altogether (any # client could tag every request "prefetch" to bypass PROBE_RATE_LIMIT), # only to route it to its own, smaller budget, so a sustained hover sweep # can burn through this budget and start getting silently-dropped 429s on # prefetches without ever touching the user's own click budget above. # Left empty, prefetch-tagged requests fall back to sharing # PROBE_RATE_LIMIT with everything else, as if this setting didn't exist. PREFETCH_RATE_LIMIT = "30 per minute" # Per-client-IP limit on /admin/login (config.py's ADMIN_TOKEN check), # same string syntax as PROBE_RATE_LIMIT above and backed by the same # in-process memory store. Separate from PROBE_RATE_LIMIT because # /admin/login is a brute-forceable shared-secret check, not a CPU-bound # probe -- it needs a tighter limit than "comfortably covers normal # browsing" calls for. Applies to both failed and successful login # attempts. Left empty, login attempts are not rate limited at all. ADMIN_LOGIN_RATE_LIMIT = "5 per minute" # Number of trusted reverse-proxy hops in front of this application. # PROBE_RATE_LIMIT and ADMIN_LOGIN_RATE_LIMIT above both key on the # client's IP address (request.remote_addr) -- behind a reverse proxy # that's the proxy's own address unless it's corrected for, which would # collapse every client behind that proxy onto one shared rate-limit key. # The same trusted-hop count also governs whether X-Forwarded-Proto is # trusted, which corrects request.scheme / request.is_secure (and any # externally-generated URL) when TLS terminates at that reverse proxy # rather than at this app itself. # # In production (DEBUG = False), app.py passes this to waitress's own # trusted_proxy_count, since waitress -- the actual production WSGI # server -- parses X-Forwarded-For/X-Forwarded-Proto and corrects # REMOTE_ADDR/wsgi.url_scheme itself, before Flask ever sees the request. # app.py also applies Werkzeug's ProxyFix with the same count, which # makes the equivalent correction for the DEBUG = True Flask-dev-server # path (waitress isn't involved there at all); under waitress it is # redundant, since waitress performs the same correction itself. # # 1 is correct for this project's own render.yaml / "Deploying to Render" # topology: Render's platform edge/load-balancer sits in front of a web # service exactly once. Increase it when an additional reverse proxy is # present in front of the service. Set to 0 when no reverse proxy is present # -- a value that does not match the actual proxy chain # leaves the rate limiter keying on the wrong address (or the scheme # misdetected), or (set too high for no proxy at all) trusting a # client-supplied X-Forwarded-For/X-Forwarded-Proto header directly, # letting a client spoof its own rate-limit key or apparent scheme. TRUSTED_PROXY_COUNT = os.environ.get("TRUSTED_PROXY_COUNT", "1") # ── Admin ──────────────────────────────────────────────────────────────────── # Shared secret required to reach /admin, /admin/cache/stats, and # /admin/cache/clear. Read from the ADMIN_TOKEN environment variable # rather than hardcoded here - set it as a platform secret (e.g. a # Render "Environment Variable" marked secret, generated automatically by # render.yaml's generateValue: true) rather than committing it here. # Left unset, every admin route responds 503 rather than running without # a password. Visiting /admin with no active session shows a login form; # a correct token there starts a signed session cookie. API clients may # instead send "Authorization: Bearer " directly. ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") # Key Flask uses to sign the admin session cookie. Read from the # FLASK_SECRET_KEY environment variable for the same reason as # ADMIN_TOKEN above. Left unset, a random key is generated at process # startup instead - sessions still work, but every restart invalidates # existing ones, requiring the admin to log in again. Set an explicit, # persistent value if that's undesirable (e.g. behind a load balancer # with multiple replicas, or to survive routine restarts). FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "") # Whether the admin session cookie set by /admin/login requires HTTPS. # Independent of DEBUG above -- DEBUG is a development/runtime behavior # switch, not a reliable indicator of the deployment's transport security, # so a deployment can legitimately run DEBUG=False (production-like # behavior) while still being served over plain HTTP. Leaving this True # for such a deployment sets the cookie Secure, which a browser then # won't send back over a plain HTTP connection -- /admin/login would # appear to succeed while every subsequent authenticated /admin/* request # came back 401. # # Read from the environment, like TRUSTED_PROXY_COUNT above, so a # deployment (e.g. render.yaml's Render blueprint, which sets this to # "true" explicitly) can configure it without editing this file. Defaults # to False (via SESSION_COOKIE_SECURE unset or anything other than # "true") because that's what a local installation needs (README.md's # "Installation"): `python app.py` served over http://127.0.0.1:7860 -- # plain HTTP -- after editing only TABLEBASE_PATH above, with DEBUG and # this setting both left at their defaults. # # Set this to "true" for any deployment the browser reaches over HTTPS, # including one behind a TLS-terminating reverse proxy such as Render's # (the browser's own connection is what the Secure attribute governs, not # the internal hop between the proxy and this process). SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "false").strip().lower() == "true" # ── Links ──────────────────────────────────────────────────────────────────── # Repository URL for the source repository button shown in the header of every page. # Left empty, the button is omitted entirely. Also readable from a # SOURCE_REPOSITORY_URL environment variable (falling back to the literal string # below when unset), so a platform deployment can set it from its own # dashboard without a commit - see render.yaml. SOURCE_REPOSITORY_URL = os.environ.get( "SOURCE_REPOSITORY_URL", "https://huggingface.co/spaces/chesstb/explorer/tree/main", )