Skip to main content

open_chess_server/
config.rs

1//! Settings, all from the environment (containers on Kubernetes and
2//! Cloudflare both pass configuration that way). Every variable has a
3//! working default except `DATABASE_URL`.
4
5use std::time::Duration;
6
7use anyhow::{Context, Result, bail};
8use clap::Args;
9
10#[derive(Debug, Clone, Args)]
11#[allow(clippy::struct_excessive_bools)] // settings switches
12pub struct Config {
13    /// Postgres URL: this server's database (its own; it may share a host
14    /// site's database in its own schema). A pooled (`-pooler`) host is
15    /// fine; notifications use the direct host automatically.
16    #[arg(long, env = "DATABASE_URL", hide_env_values = true)]
17    pub database_url: String,
18
19    /// A separate URL for LISTEN, if the direct host can't be derived.
20    #[arg(long, env = "CHESS_LISTEN_DATABASE_URL", hide_env_values = true)]
21    pub listen_database_url: Option<String>,
22
23    /// A host site's schema in the same database whose chess players (and
24    /// their chess tokens) are imported once, with their ids, the first
25    /// time this version starts. Nothing is read from it afterwards; a
26    /// schema that isn't there is skipped.
27    #[arg(long, env = "CHESS_SITE_SCHEMA", default_value = "esports")]
28    pub site_schema: String,
29
30    /// This server's own schema (games, challenges, ratings, fair play).
31    #[arg(long, env = "CHESS_SCHEMA", default_value = "chess")]
32    pub schema: String,
33
34    #[arg(long, env = "CHESS_POOL_SIZE", default_value_t = 8)]
35    pub pool_size: usize,
36
37    /// Where clients reach this server, including any path prefix
38    /// (`https://chess.example.com`). Used in game URLs and
39    /// to mount the API under the prefix.
40    #[arg(long, env = "CHESS_PUBLIC_URL", default_value = "http://localhost:8090")]
41    pub public_url: String,
42
43    /// The host site (esports-lab) players can sign in with.
44    #[arg(long, env = "ESPORTS_PUBLIC_URL", default_value = "http://localhost:8080")]
45    pub site_url: String,
46
47    /// "Sign in with the host site" (esports-lab) on this server's sign-in
48    /// page: its players sign in here through it. Needs `ESPORTS_SECRET`.
49    /// Off by default; the site's own use of this server (players, their
50    /// tokens, reading games) needs only `ESPORTS_SECRET`, and the server's
51    /// own accounts work either way.
52    #[arg(
53        long,
54        env = "ESPORTS_LOGIN",
55        default_value_t = false,
56        num_args = 0..=1,
57        default_missing_value = "true",
58        action = clap::ArgAction::Set,
59        value_parser = clap::builder::BoolishValueParser::new()
60    )]
61    pub esports_login: bool,
62
63    /// The secret shared with the host site: it signs sign-ins and asks
64    /// for its players' tokens with it (at least 32 characters).
65    #[arg(long, env = "ESPORTS_SECRET", hide_env_values = true)]
66    pub esports_secret: Option<String>,
67
68    /// Anyone may make an account here (a name and a password). Off: only
69    /// accounts that exist (and the host site's players, with
70    /// `ESPORTS_LOGIN`) sign in.
71    #[arg(
72        long,
73        env = "CHESS_SIGNUP",
74        default_value_t = true,
75        num_args = 0..=1,
76        default_missing_value = "true",
77        action = clap::ArgAction::Set,
78        value_parser = clap::builder::BoolishValueParser::new()
79    )]
80    pub signup: bool,
81
82    /// What players know the site as: on the OAuth page, in PGN event
83    /// names and in `GET /` (which the training app shows).
84    #[arg(long, env = "CHESS_SITE_NAME", default_value = "open-chess")]
85    pub site_name: String,
86
87    /// What players know the host site as (esports-lab), when they can sign
88    /// in with it: "Sign in with …", "linked to your … account". The site
89    /// name when unset.
90    #[arg(long, env = "CHESS_HOST_NAME")]
91    pub host_name: Option<String>,
92
93    /// People made admins here, by email (comma-separated): an account gets
94    /// the admin role when it signs in with one of these, once the email is
95    /// confirmed (a host site's sign-in vouches for it) and it has a
96    /// passkey. Accounts made here have unconfirmed emails: make them admins
97    /// with `open-chess admin grant`.
98    #[arg(long, env = "CHESS_ADMIN_EMAILS", value_delimiter = ',', default_value = "")]
99    pub admin_emails: Vec<String>,
100
101    /// Hours an admin's or manager's passkey check lasts.
102    #[arg(long, env = "CHESS_ADMIN_PASSKEY_HOURS", default_value_t = 12)]
103    pub staff_passkey_hours: i32,
104
105    #[arg(long, env = "CHESS_BIND", default_value = "0.0.0.0:8090")]
106    pub bind: String,
107
108    /// /metrics, /healthz and /readyz.
109    #[arg(long, env = "CHESS_METRICS_BIND", default_value = "0.0.0.0:9465")]
110    pub metrics_bind: String,
111
112    /// Milliseconds given back per move for the network round trip.
113    #[arg(long, env = "CHESS_LAG_COMP_MS", default_value_t = 100)]
114    pub lag_comp_ms: i64,
115
116    /// A real-time challenge lapses after this many seconds unless the
117    /// challenger keeps a `keepAliveStream` open.
118    #[arg(long, env = "CHESS_CHALLENGE_TTL_SECS", default_value_t = 20)]
119    pub challenge_ttl_secs: u64,
120
121    /// Each side's first move must come within this many seconds or the
122    /// game is aborted.
123    #[arg(long, env = "CHESS_FIRST_MOVE_SECS", default_value_t = 30)]
124    pub first_move_secs: u64,
125
126    /// A player with no open game stream for this long is "gone".
127    #[arg(long, env = "CHESS_GONE_AFTER_SECS", default_value_t = 20)]
128    pub gone_after_secs: u64,
129
130    /// Then the opponent may claim the win after this many more seconds.
131    #[arg(long, env = "CHESS_CLAIM_WIN_SECS", default_value_t = 10)]
132    pub claim_win_secs: u64,
133
134    /// The public move stream runs this many plies behind a live game, so
135    /// it can't feed an engine helping a player.
136    #[arg(long, env = "CHESS_PUBLIC_DELAY_PLIES", default_value_t = 3)]
137    pub public_delay_plies: usize,
138
139    /// Queue human players' finished games for fair-play analysis.
140    #[arg(long, env = "CHESS_FAIRPLAY", default_value_t = true, action = clap::ArgAction::Set)]
141    pub fairplay: bool,
142
143    /// Accounts marked for engine use can't start new games.
144    #[arg(long, env = "CHESS_BLOCK_MARKED", default_value_t = true, action = clap::ArgAction::Set)]
145    pub block_marked: bool,
146
147    /// How often the clock sweeper looks for flags and lapsed challenges.
148    #[arg(long, env = "CHESS_SWEEP_MS", default_value_t = 500)]
149    pub sweep_ms: u64,
150
151    /// Reading boards from photos: base URL of a vision model behind an
152    /// OpenAI-style `/chat/completions` (Claude via
153    /// `https://api.anthropic.com/v1`, Qwen-VL via Ollama's
154    /// `http://localhost:11434/v1`, or a gateway). Empty: photo reading is
155    /// off.
156    #[arg(long, env = "CHESS_VISION_ENDPOINT", default_value = "")]
157    pub vision_endpoint: String,
158
159    /// The vision model's name as that server knows it.
160    #[arg(long, env = "CHESS_VISION_MODEL", default_value = "qwen3-vl:8b")]
161    pub vision_model: String,
162
163    /// Bearer token for the vision server, if it needs one.
164    #[arg(long, env = "CHESS_VISION_API_KEY", hide_env_values = true)]
165    pub vision_api_key: Option<String>,
166
167    /// Our own board reader (chess-vision): a `.safetensors` weights file
168    /// from `chess-vision-train`. It reads photos first, on this
169    /// server's CPU; the vision model at CHESS_VISION_ENDPOINT (if set) is
170    /// only asked about photos it isn't sure of. Empty: the one the app
171    /// ships (`nets/board-reader.safetensors` in CHESS_APP_DIR), if any.
172    #[arg(long, env = "CHESS_VISION_LOCAL_MODEL", default_value = "")]
173    pub vision_local_model: String,
174
175    /// The board reader's answer is used without review when every square
176    /// is above its unsure threshold and the chance that all 64 are right
177    /// is at least this.
178    #[arg(long, env = "CHESS_VISION_MIN_CONFIDENCE", default_value_t = 0.5)]
179    pub vision_min_confidence: f32,
180
181    /// Keep photos that couldn't be read for sure for the Review page,
182    /// where a person confirms the position; confirmed photos become
183    /// training data (`open-chess photos export`). Players are told.
184    #[arg(long, env = "CHESS_VISION_REVIEW", default_value_t = true, action = clap::ArgAction::Set)]
185    pub vision_review: bool,
186
187    /// At most this many photos wait for review; beyond it, new ones
188    /// aren't kept.
189    #[arg(long, env = "CHESS_VISION_REVIEW_MAX", default_value_t = 5000)]
190    pub vision_review_max: i64,
191
192    /// Where exact endgame results (up to seven pieces) come from: a
193    /// Lichess-compatible tablebase server. Answers are kept in the
194    /// database, so each position is fetched once. Empty: off.
195    #[arg(long, env = "CHESS_TABLEBASE_URL", default_value = "https://tablebase.lichess.ovh")]
196    pub tablebase_url: String,
197
198    /// The built training app (web/chess plus its wasm engine), served at
199    /// `/app/`. The chess image puts it at /usr/share/open-chess/app.
200    #[arg(long, env = "CHESS_APP_DIR")]
201    pub app_dir: Option<String>,
202
203    /// The app's look for this deployment: a theme the app bundles, by name
204    /// (`esports-lab`: web/chess/themes), CSS, or the path of a .css file;
205    /// served as `/app/theme.css`, after the app's own styles, so it can set
206    /// the app's colour and font variables (docs/api.md, "The Chess lab's
207    /// look"). `esports-lab` unless set; `none` for the app's own look.
208    #[arg(long, env = "CHESS_APP_THEME", default_value = "esports-lab")]
209    pub app_theme: Option<String>,
210
211    /// Apple push notifications for the iOS app ("your move" in
212    /// correspondence games, results to confirm): the `.p8` signing key
213    /// (its PEM, or the path of the file) from the Apple Developer account.
214    /// Without it, and the three below, nothing is sent.
215    #[arg(long, env = "CHESS_APNS_KEY", hide_env_values = true)]
216    pub apns_key: Option<String>,
217
218    /// The key's id (10 characters, shown with the key).
219    #[arg(long, env = "CHESS_APNS_KEY_ID")]
220    pub apns_key_id: Option<String>,
221
222    /// The Apple Developer team id.
223    #[arg(long, env = "CHESS_APNS_TEAM_ID")]
224    pub apns_team_id: Option<String>,
225
226    /// The app's bundle id, which notifications are addressed to.
227    #[arg(long, env = "CHESS_APNS_TOPIC", default_value = "org.offenedatenmodellierung.openchess")]
228    pub apns_topic: String,
229
230    /// Sign in with Apple: the `.p8` key made for it in the Apple Developer
231    /// account (Keys › Sign in with Apple; its PEM, or the path of the
232    /// file). With its id below (and the team id), the app's "Sign in with
233    /// Apple" works; without them it's refused.
234    #[arg(long, env = "CHESS_APPLE_KEY", hide_env_values = true)]
235    pub apple_key: Option<String>,
236
237    /// The Sign in with Apple key's id.
238    #[arg(long, env = "CHESS_APPLE_KEY_ID")]
239    pub apple_key_id: Option<String>,
240
241    /// The Apple Developer team id (default: CHESS_APNS_TEAM_ID).
242    #[arg(long, env = "CHESS_APPLE_TEAM_ID")]
243    pub apple_team_id: Option<String>,
244
245    /// Who Apple's sign-ins are for: the app's bundle id (default:
246    /// CHESS_APNS_TOPIC), comma-separated with any others accepted.
247    #[arg(long, env = "CHESS_APPLE_CLIENT_IDS", value_delimiter = ',')]
248    pub apple_client_ids: Vec<String>,
249
250    /// Sign in with Apple on the website (the sign-in and account pages):
251    /// the Services ID made for it, whose return URL is
252    /// `{CHESS_PUBLIC_URL}/auth/apple/callback`. Without it, only the app
253    /// signs in with Apple.
254    #[arg(long, env = "CHESS_APPLE_SERVICES_ID")]
255    pub apple_services_id: Option<String>,
256
257    /// Apple's sign-in service (only tests change it).
258    #[arg(long, env = "CHESS_APPLE_URL", default_value = "https://appleid.apple.com")]
259    pub apple_url: String,
260}
261
262impl Config {
263    /// The path the API lives under (`/chess`, or empty).
264    pub fn base_path(&self) -> Result<String> {
265        let u = url::Url::parse(&self.public_url).context("CHESS_PUBLIC_URL isn't a URL")?;
266        let p = u.path().trim_end_matches('/');
267        Ok(p.to_owned())
268    }
269
270    pub fn public_url(&self) -> &str {
271        self.public_url.trim_end_matches('/')
272    }
273
274    /// What players know the host site as.
275    pub fn host_name(&self) -> &str {
276        self.host_name.as_deref().map(str::trim).filter(|s| !s.is_empty()).unwrap_or(&self.site_name)
277    }
278
279    /// `CHESS_ADMIN_EMAILS`, lower-cased.
280    pub fn admin_emails(&self) -> Vec<String> {
281        self.admin_emails.iter().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')).collect()
282    }
283
284    /// The secret shared with the host site (32 characters or more): the
285    /// site's server uses it to act as the host site.
286    pub fn site_secret(&self) -> Option<&str> {
287        self.esports_secret.as_deref().filter(|s| s.len() >= 32)
288    }
289
290    /// The shared secret, when signing in here with the host site is on.
291    pub fn esports(&self) -> Option<&str> {
292        self.site_secret().filter(|_| self.esports_login)
293    }
294
295    pub fn challenge_ttl(&self) -> Duration {
296        Duration::from_secs(self.challenge_ttl_secs.max(5))
297    }
298
299    pub fn validate(&self) -> Result<()> {
300        for s in [&self.schema, &self.site_schema] {
301            if s.is_empty() || !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
302                bail!("schema names are letters, digits and _ (got {s:?})");
303            }
304        }
305        if self.schema == self.site_schema {
306            bail!("CHESS_SCHEMA must differ from CHESS_SITE_SCHEMA");
307        }
308        self.base_path()?;
309        Ok(())
310    }
311}
312
313/// Settings for the fair-play worker (`open-chess analyse`).
314#[derive(Debug, Clone, Args)]
315pub struct AnalyseConfig {
316    /// The UCI engine (Stockfish).
317    #[arg(long, env = "CHESS_STOCKFISH")]
318    pub stockfish: Option<String>,
319
320    /// Search depth per position.
321    #[arg(long, env = "CHESS_ANALYSIS_DEPTH", default_value_t = 14)]
322    pub depth: u32,
323
324    /// Engine threads.
325    #[arg(long, env = "CHESS_ANALYSIS_THREADS", default_value_t = 1)]
326    pub threads: u32,
327
328    /// Engine hash, MiB.
329    #[arg(long, env = "CHESS_ANALYSIS_HASH_MB", default_value_t = 64)]
330    pub hash_mb: u32,
331
332    /// Plies at the start left out of screening (opening theory).
333    #[arg(long, env = "CHESS_FAIRPLAY_SKIP_PLIES", default_value_t = 16)]
334    pub skip_plies: usize,
335
336    /// The screening model: how the fair-play signals turn into a score
337    /// and a flag (`chess_fairplay::Model`, as JSON). Either a path to a
338    /// JSON file or the JSON itself (handy as a secret). Keep a tuned
339    /// model private: its numbers tell people what screening looks for.
340    /// Unset: an untuned example model, and a warning.
341    #[arg(long, env = "CHESS_FAIRPLAY_MODEL", hide_env_values = true)]
342    pub model: Option<String>,
343
344    /// Overrides the model's flag score (0–100).
345    #[arg(long, env = "CHESS_FAIRPLAY_FLAG_SCORE")]
346    pub flag_score: Option<f64>,
347
348    /// Overrides the model's fewest screened moves before a game can be
349    /// flagged.
350    #[arg(long, env = "CHESS_FAIRPLAY_MIN_MOVES")]
351    pub min_moves: Option<usize>,
352
353    /// The training app's engine settings, for the app fingerprint (the
354    /// app's "Normal" search). Keep in step with web/chess/app.js.
355    #[arg(long, env = "CHESS_FAIRPLAY_APP_DEPTH", default_value_t = 6)]
356    pub app_depth: u8,
357
358    #[arg(long, env = "CHESS_FAIRPLAY_APP_NODES", default_value_t = 400_000)]
359    pub app_nodes: u64,
360
361    /// The NNUE weight file the app ships (web/chess/nets/default.nnue),
362    /// if it ships one, so the fingerprint uses the same evaluation.
363    #[arg(long, env = "CHESS_FAIRPLAY_APP_NET")]
364    pub app_net: Option<String>,
365
366    /// Analyse one game and exit (for testing).
367    #[arg(long)]
368    pub once: bool,
369}
370
371impl AnalyseConfig {
372    /// The screening model: `CHESS_FAIRPLAY_MODEL` (a JSON file or inline
373    /// JSON), else the untuned example, with any overrides applied.
374    /// Returns whether a model was configured, so callers can warn.
375    pub fn model(&self) -> Result<(chess_fairplay::Model, bool)> {
376        let (mut model, configured) = match self.model.as_deref().map(str::trim) {
377            None | Some("") => (chess_fairplay::Model::default(), false),
378            Some(json) if json.starts_with('{') => {
379                (serde_json::from_str(json).context("CHESS_FAIRPLAY_MODEL isn't a valid model")?, true)
380            }
381            Some(path) => {
382                let text = std::fs::read_to_string(path)
383                    .with_context(|| format!("reading the fair-play model {path}"))?;
384                (serde_json::from_str(&text).with_context(|| format!("{path} isn't a valid model"))?, true)
385            }
386        };
387        if let Some(s) = self.flag_score {
388            model.flag_score = s;
389        }
390        if let Some(n) = self.min_moves {
391            model.min_moves = n;
392        }
393        model.validate().map_err(|e| anyhow::anyhow!("fair-play model: {e}"))?;
394        Ok((model, configured))
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use clap::Parser;
401
402    use super::Config;
403
404    #[derive(Parser)]
405    struct Cli {
406        #[command(flatten)]
407        cfg: Config,
408    }
409
410    #[test]
411    fn the_esports_look_unless_set() {
412        if std::env::var_os("CHESS_APP_THEME").is_some() {
413            return; // set where the tests run: the default can't show
414        }
415        let cli = Cli::try_parse_from(["chess-server", "--database-url", "postgres://x"]).unwrap();
416        assert_eq!(cli.cfg.app_theme.as_deref(), Some("esports-lab"));
417        let cli =
418            Cli::try_parse_from(["chess-server", "--database-url", "postgres://x", "--app-theme", "none"])
419                .unwrap();
420        assert_eq!(cli.cfg.app_theme.as_deref(), Some("none"));
421    }
422}