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