Skip to main content

teamcraft/
config.rs

1//! Runtime configuration, read from environment variables.
2//!
3//! Every variable can alternatively be provided as a file by appending
4//! `_FILE` to its name (e.g. `TEAMCRAFT_DB_PASSWORD_FILE=/run/secrets/db_password`),
5//! which is how container secrets are consumed. Reference:
6//! `docs/src/admin/configuration.md`.
7//!
8//! Files that are read on start (variables already set take precedence):
9//! `TEAMCRAFT_ENV_FILE` if set, the local development file [`LOCAL_ENV_FILE`]
10//! (`secrets/.env`, git-ignored) and – only in package builds – the system
11//! file [`SYSTEM_ENV_FILE`].
12#![deny(
13    clippy::unwrap_used,
14    clippy::expect_used,
15    clippy::indexing_slicing,
16    clippy::panic,
17    clippy::unreachable
18)]
19
20use std::{net::SocketAddr, path::PathBuf, str::FromStr, time::Duration};
21
22use anyhow::{anyhow, bail, Context, Result};
23use sqlx::postgres::{PgConnectOptions, PgSslMode};
24
25/// Local configuration file, relative to the working directory (the repository root).
26pub const LOCAL_ENV_FILE: &str = "secrets/.env";
27
28/// Configuration file of a system installation. Set at build time by the
29/// distribution packages (`TEAMCRAFT_SYSTEM_CONFIG=/etc/teamcraft/teamcraft.env`),
30/// so development builds never read a system file.
31pub const SYSTEM_ENV_FILE: Option<&str> = option_env!("TEAMCRAFT_SYSTEM_CONFIG");
32
33/// Loads [`LOCAL_ENV_FILE`] into the process environment if it exists.
34/// Variables that are already set take precedence. Returns whether a file was loaded.
35pub fn load_local_env_file() -> std::result::Result<bool, dotenvy::Error> {
36    load_env_file(LOCAL_ENV_FILE)
37}
38
39fn load_env_file(path: &str) -> std::result::Result<bool, dotenvy::Error> {
40    match dotenvy::from_path(path) {
41        Ok(()) => Ok(true),
42        Err(e) if e.not_found() => Ok(false),
43        Err(e) => Err(e),
44    }
45}
46
47/// Loads every configuration file (see the module documentation). Errors are
48/// returned with the path; the remaining files are still loaded.
49pub fn load_env_files() -> Vec<(String, dotenvy::Error)> {
50    let mut paths: Vec<String> = Vec::new();
51    if let Ok(explicit) = std::env::var("TEAMCRAFT_ENV_FILE") {
52        if !explicit.trim().is_empty() {
53            paths.push(explicit.trim().to_string());
54        }
55    }
56    paths.push(LOCAL_ENV_FILE.to_string());
57    if let Some(system) = SYSTEM_ENV_FILE.filter(|p| !p.is_empty()) {
58        paths.push(system.to_string());
59    }
60    paths
61        .into_iter()
62        .filter_map(|path| load_env_file(&path).err().map(|e| (path, e)))
63        .collect()
64}
65
66/// Every variable the configuration (and the command line) reads, also as
67/// `<NAME>_FILE`. Sorted; guarded by tests against the string literals of
68/// this file and against `docs/src/admin/configuration.md`.
69pub const KNOWN_VARIABLES: &[&str] = &[
70    "DATABASE_URL",
71    "MATTERMOST_ERROR_WEBHOOK",
72    "TEAMCRAFT_ANTHROPIC_API_KEY",
73    "TEAMCRAFT_APPROVAL_REAUTH",
74    "TEAMCRAFT_ASSISTANT_API_KEY",
75    "TEAMCRAFT_ASSISTANT_BASE_URL",
76    "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
77    "TEAMCRAFT_ASSISTANT_MAX_CONCURRENT",
78    "TEAMCRAFT_ASSISTANT_MAX_STEPS",
79    "TEAMCRAFT_ASSISTANT_MAX_TOKENS",
80    "TEAMCRAFT_ASSISTANT_MODEL",
81    "TEAMCRAFT_ASSISTANT_PROVIDER",
82    "TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT",
83    "TEAMCRAFT_ASSISTANT_RETENTION_DAYS",
84    "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT",
85    "TEAMCRAFT_AUDIT_RETENTION_DAYS",
86    "TEAMCRAFT_BACKUP_PASSPHRASE",
87    "TEAMCRAFT_BIND_ADDR",
88    "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD",
89    "TEAMCRAFT_BOOTSTRAP_ADMIN_USERNAME",
90    "TEAMCRAFT_CLAMD_ADDR",
91    "TEAMCRAFT_CLAMD_MAX_MB",
92    "TEAMCRAFT_COOKIE_SECURE",
93    "TEAMCRAFT_DATABASE_URL",
94    "TEAMCRAFT_DB_HOST",
95    "TEAMCRAFT_DB_MAX_CONNECTIONS",
96    "TEAMCRAFT_DB_NAME",
97    "TEAMCRAFT_DB_PASSWORD",
98    "TEAMCRAFT_DB_PORT",
99    "TEAMCRAFT_DB_SSLMODE",
100    "TEAMCRAFT_DB_USER",
101    "TEAMCRAFT_ENV_FILE",
102    "TEAMCRAFT_INSTANCE_NAME",
103    "TEAMCRAFT_LOCAL_AGENTS",
104    "TEAMCRAFT_LOCAL_LOGIN",
105    "TEAMCRAFT_LOG_FORMAT",
106    "TEAMCRAFT_MAIL_FROM",
107    "TEAMCRAFT_MATTERMOST_BOT_TOKEN",
108    "TEAMCRAFT_MATTERMOST_DIRECT",
109    "TEAMCRAFT_MATTERMOST_URL",
110    "TEAMCRAFT_MATTERMOST_WEBHOOK",
111    "TEAMCRAFT_MAX_UPLOAD_MB",
112    "TEAMCRAFT_METRICS_TOKEN",
113    "TEAMCRAFT_OIDC_ADMIN_GROUP",
114    "TEAMCRAFT_OIDC_CLIENT_ID",
115    "TEAMCRAFT_OIDC_CLIENT_SECRET",
116    "TEAMCRAFT_OIDC_DISPLAY_NAME",
117    "TEAMCRAFT_OIDC_EDITOR_GROUP",
118    "TEAMCRAFT_OIDC_GROUPS_CLAIM",
119    "TEAMCRAFT_OIDC_ISSUER_URL",
120    "TEAMCRAFT_OIDC_SCOPES",
121    "TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS",
122    "TEAMCRAFT_OIDC_USERNAME_CLAIM",
123    "TEAMCRAFT_OIDC_VIEWER_GROUP",
124    "TEAMCRAFT_PREVIEW_CACHE_MAX_MB",
125    "TEAMCRAFT_PREVIEW_MAX_MEMORY_MB",
126    "TEAMCRAFT_PREVIEW_MAX_MESH_MB",
127    "TEAMCRAFT_PREVIEW_MAX_RESULT_MB",
128    "TEAMCRAFT_PREVIEW_MAX_STEP_MB",
129    "TEAMCRAFT_PUBLIC_URL",
130    "TEAMCRAFT_REQUEST_DIGEST_HOURS",
131    "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS",
132    "TEAMCRAFT_SERVER",
133    "TEAMCRAFT_SESSION_TTL_HOURS",
134    "TEAMCRAFT_SIGNIN_MAX_FAILURES",
135    "TEAMCRAFT_SMTP_URL",
136    "TEAMCRAFT_TOKEN",
137    "TEAMCRAFT_UPLOAD_CHUNK_MB",
138    "TEAMCRAFT_VAULT_DIR",
139    "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS",
140    "TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE",
141    "TEAMCRAFT_WEBHOOK_RETENTION_DAYS",
142];
143
144/// Variables of the build and the test tooling: documented and never warned
145/// about, but not part of the server configuration.
146pub const TOOLING_VARIABLES: &[&str] = &[
147    "TEAMCRAFT_REQUIRE_DB_TESTS",
148    "TEAMCRAFT_REQUIRE_PG_TOOLS",
149    "TEAMCRAFT_SYSTEM_CONFIG",
150    "TEAMCRAFT_TEST_DATABASE_URL",
151    "TEAMCRAFT_WRITE_SIM_FIXTURES",
152];
153
154/// Edit distance of two names (insertions, deletions, substitutions).
155fn edit_distance(a: &str, b: &str) -> usize {
156    let b: Vec<char> = b.chars().collect();
157    let mut row: Vec<usize> = (0..=b.len()).collect();
158    for (i, ca) in a.chars().enumerate() {
159        let mut diagonal = i;
160        if let Some(first) = row.first_mut() {
161            *first = i + 1;
162        }
163        for (j, cb) in b.iter().enumerate() {
164            let above = row.get(j + 1).copied().unwrap_or(0);
165            let left = row.get(j).copied().unwrap_or(0);
166            let value = (diagonal + usize::from(ca != *cb))
167                .min(above + 1)
168                .min(left + 1);
169            if let Some(slot) = row.get_mut(j + 1) {
170                *slot = value;
171            }
172            diagonal = above;
173        }
174    }
175    row.last().copied().unwrap_or(0)
176}
177
178/// The known name closest to `base`, if it is close enough to be a likely typo.
179fn nearest_known(base: &str) -> Option<&'static str> {
180    let limit = (base.len() / 6).clamp(2, 4);
181    KNOWN_VARIABLES
182        .iter()
183        .map(|known| (edit_distance(base, known), *known))
184        .filter(|(distance, _)| *distance <= limit)
185        .min_by_key(|(distance, _)| *distance)
186        .map(|(_, known)| known)
187}
188
189/// `TEAMCRAFT_*` (and `MATTERMOST_*`) names that TeamCraft does not know,
190/// each with the nearest known name. `<NAME>_FILE` is checked as `NAME`.
191pub fn unknown_variables<I: IntoIterator<Item = String>>(
192    names: I,
193) -> Vec<(String, Option<String>)> {
194    let mut out: Vec<(String, Option<String>)> = names
195        .into_iter()
196        .filter(|n| n.starts_with("TEAMCRAFT_") || n.starts_with("MATTERMOST_"))
197        .filter_map(|name| {
198            let (base, file) = match name.strip_suffix("_FILE") {
199                Some(base) => (base, true),
200                None => (name.as_str(), false),
201            };
202            let is_known = |n: &str| KNOWN_VARIABLES.contains(&n) || TOOLING_VARIABLES.contains(&n);
203            // TEAMCRAFT_ENV_FILE is a name of its own, not a `_FILE` form
204            if is_known(&name) || is_known(base) {
205                return None;
206            }
207            let hint = nearest_known(base).map(|known| {
208                if file {
209                    format!("{known}_FILE")
210                } else {
211                    known.to_string()
212                }
213            });
214            Some((name, hint))
215        })
216        .collect();
217    out.sort();
218    out.dedup();
219    out
220}
221
222/// Logs a warning for every unknown `TEAMCRAFT_*` variable of the process
223/// environment (a typo would otherwise silently leave the default in place).
224/// Call after the tracing subscriber is installed.
225pub fn warn_unknown_variables() {
226    let names = std::env::vars_os().map(|(k, _)| k.to_string_lossy().into_owned());
227    for (name, hint) in unknown_variables(names) {
228        match hint {
229            Some(known) => tracing::warn!(
230                "unknown environment variable {name} is ignored; did you mean {known}?"
231            ),
232            None => tracing::warn!("unknown environment variable {name} is ignored"),
233        }
234    }
235}
236
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238pub enum LogFormat {
239    Text,
240    Json,
241}
242
243#[derive(Clone)]
244pub struct Config {
245    /// Socket address the HTTP server binds to.
246    pub bind_addr: SocketAddr,
247    /// Externally visible base URL without trailing slash, e.g. `https://plm.example.org`.
248    pub public_url: String,
249    /// Name shown in the UI header and page titles.
250    pub instance_name: String,
251    /// PostgreSQL connection options (contains the password – never log it).
252    pub database: PgConnectOptions,
253    /// Password of the database connection, for tools that receive it in
254    /// their environment (`PGPASSWORD` of `pg_dump`/`pg_restore`).
255    /// Secret – never log it.
256    pub database_password: Option<String>,
257    /// Human readable, password-free description of the database target.
258    pub database_display: String,
259    pub db_max_connections: u32,
260    /// Root directory of the file vault (and of the cached 3D previews).
261    pub vault_dir: PathBuf,
262    pub max_upload_bytes: u64,
263    /// Size and memory limits of the 3D preview (`TEAMCRAFT_PREVIEW_MAX_*`).
264    pub preview: crate::preview::PreviewLimits,
265    /// Size the preview cache (`<vault>/previews`) is trimmed to by the
266    /// hourly housekeeping, least recently used first
267    /// (`TEAMCRAFT_PREVIEW_CACHE_MAX_MB`; 0 = no limit).
268    pub preview_cache_max_bytes: u64,
269    /// Largest chunk of a chunked, resumable upload (`PATCH /api/v1/uploads/{id}`).
270    pub upload_chunk_bytes: u64,
271    /// Malware scan of uploads with a ClamAV daemon (`TEAMCRAFT_CLAMD_ADDR`,
272    /// `TEAMCRAFT_CLAMD_MAX_MB`); `None` = no scan.
273    pub clamd: Option<crate::malware::ScannerConfig>,
274    /// Passphrase of encrypted backups (`TEAMCRAFT_BACKUP_PASSPHRASE`, used by
275    /// `teamcraft backup --encrypt` and the restore). Secret – never log it.
276    pub backup_passphrase: Option<String>,
277    pub session_ttl: Duration,
278    /// Whether review decisions in the browser need a fresh sign-in
279    /// (`TEAMCRAFT_APPROVAL_REAUTH`: `off`, `release`, `every_decision`).
280    pub approval_reauth: crate::domain::ApprovalReauth,
281    /// Days the audit log is kept (`TEAMCRAFT_AUDIT_RETENTION_DAYS`; 0 = for
282    /// ever). Older entries are removed by the hourly housekeeping, which
283    /// records the removal as one entry.
284    pub audit_retention_days: u32,
285    /// Days after which the old versions of files with the retention `latest`
286    /// are removed by the hourly housekeeping
287    /// (`TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS`; 0 = only the CLI removes
288    /// them). Files keep every version unless someone sets `latest`.
289    pub retention_latest_grace_days: u32,
290    /// Failed password sign-ins per username within 15 minutes before sign-in
291    /// is refused (`TEAMCRAFT_SIGNIN_MAX_FAILURES`; five times as many per
292    /// client address; 0 = off). See [`crate::auth::SignInThrottle`].
293    pub signin_max_failures: u32,
294    /// Idle lifetime in days of new API and desktop tokens of single sign-on
295    /// users (`TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS`; 0 = off, the default): a token
296    /// without an expiry or with a longer one expires after that many days
297    /// without use, so a leaver removed only at the identity provider does
298    /// not keep a working token (issue #173).
299    pub oidc_token_idle_days: u32,
300    /// Bearer token of `GET /metrics`; `None` = the endpoint is off.
301    pub metrics_token: Option<String>,
302    /// Adds the `Secure` attribute to cookies (required when served via HTTPS).
303    pub cookie_secure: bool,
304    pub log_format: LogFormat,
305    /// Initial administrator that is created when the database has no users.
306    pub bootstrap_admin: Option<BootstrapAdmin>,
307    /// Single sign-on via OpenID Connect (Authentik). `None` = disabled.
308    pub oidc: Option<OidcConfig>,
309    /// Whether username/password login for local accounts is offered.
310    pub local_login: bool,
311    /// Mattermost incoming webhook that receives internal server errors.
312    pub mattermost_error_webhook: Option<String>,
313    /// E-mail notifications via SMTP. `None` = no e-mails.
314    pub smtp: Option<SmtpConfig>,
315    /// Hours between two e-mail digests of the change requests waiting for
316    /// triage (per user and project group); `0` = no digests.
317    pub request_digest_hours: u64,
318    /// Notifications to Mattermost. `None` = disabled.
319    pub mattermost: Option<MattermostConfig>,
320    /// The built-in assistant (Anthropic or an OpenAI-compatible API). `None` = disabled.
321    pub assistant: Option<AssistantConfig>,
322    /// Whether users may connect their own local agents (the desktop app's
323    /// *Agent* view, ACP) to TeamCraft's tools (`TEAMCRAFT_LOCAL_AGENTS`).
324    /// `Off` leaves only the server-side assistant. See [`LocalAgents`].
325    pub local_agents: LocalAgents,
326    /// Which targets outgoing event webhooks may reach.
327    pub webhooks: WebhookConfig,
328}
329
330/// Shortest audit log retention (`TEAMCRAFT_AUDIT_RETENTION_DAYS`): a typo
331/// must not empty the audit trail.
332pub const MIN_AUDIT_RETENTION_DAYS: u32 = 30;
333
334/// Largest preview cache limit in MiB (`TEAMCRAFT_PREVIEW_CACHE_MAX_MB`, 16 TiB).
335const MAX_PREVIEW_CACHE_MB: u64 = 16 * 1024 * 1024;
336
337/// Default for how long finished webhook deliveries are kept.
338pub const DEFAULT_WEBHOOK_RETENTION_DAYS: u32 = 30;
339
340/// Target rules of outgoing event webhooks (the SSRF guard in
341/// `crate::webhooks`).
342#[derive(Clone, Debug, PartialEq, Eq)]
343pub struct WebhookConfig {
344    /// `TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS`: when not empty, webhooks may only
345    /// target these hosts (exact names, IP addresses or `*.example.org`), and
346    /// these hosts may resolve to private addresses (an on-premises ERP).
347    pub allowed_hosts: Vec<String>,
348    /// `TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE`: every host may resolve to private,
349    /// loopback and link-local addresses.
350    pub allow_private: bool,
351    /// Days after which sent and failed deliveries are removed.
352    pub retention_days: u32,
353}
354
355impl Default for WebhookConfig {
356    fn default() -> Self {
357        WebhookConfig {
358            allowed_hosts: Vec::new(),
359            allow_private: false,
360            retention_days: DEFAULT_WEBHOOK_RETENTION_DAYS,
361        }
362    }
363}
364
365/// `TEAMCRAFT_LOCAL_AGENTS`: may users connect their own local AI agents
366/// (the desktop app's *Agent* view, an ACP client for Claude Code, Gemini
367/// CLI …) to TeamCraft's tools?
368///
369/// `Off` turns `POST /api/v1/mcp` off for every token (the built-in assistant
370/// calls the tools in-process, so it is not affected) and is reported to
371/// clients (`GET /api/v1/me` → `capabilities.local_agents` and `mcp`), so the
372/// desktop app's Agent view runs on the server-side assistant (or shows that
373/// AI is off without one). [`LOCAL_AGENT_HEADER`], which the desktop
374/// adds to the MCP connection it hands to an agent, only picks the refusal
375/// message. The REST API stays open to a user's own token – limiting that
376/// needs scoped tokens.
377#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
378pub enum LocalAgents {
379    /// The default: the desktop app's *Agent* view may run a local agent.
380    #[default]
381    Allowed,
382    /// Only the server-side assistant (*Ask TeamCraft*; in the desktop app
383    /// the *Agent* view's company AI).
384    Off,
385}
386
387/// Header the desktop app adds to the MCP connection it hands to a local
388/// agent (value [`LOCAL_AGENT_HEADER_VALUE`]).
389pub const LOCAL_AGENT_HEADER: &str = "x-teamcraft-client";
390/// Value of [`LOCAL_AGENT_HEADER`] on a local agent's MCP calls.
391pub const LOCAL_AGENT_HEADER_VALUE: &str = "desktop-agent";
392
393impl LocalAgents {
394    /// Parses `allowed` or `off` (case-insensitive, surrounding blanks ignored).
395    pub fn parse(raw: &str) -> Result<LocalAgents> {
396        match raw.trim().to_ascii_lowercase().as_str() {
397            "allowed" => Ok(LocalAgents::Allowed),
398            "off" => Ok(LocalAgents::Off),
399            other => bail!("TEAMCRAFT_LOCAL_AGENTS must be 'allowed' or 'off', got '{other}'"),
400        }
401    }
402
403    pub fn as_str(self) -> &'static str {
404        match self {
405            LocalAgents::Allowed => "allowed",
406            LocalAgents::Off => "off",
407        }
408    }
409
410    pub fn allowed(self) -> bool {
411        self == LocalAgents::Allowed
412    }
413}
414
415/// Parses `TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS` (comma or space separated).
416fn parse_allowed_hosts(raw: &str) -> Result<Vec<String>> {
417    let mut hosts = Vec::new();
418    for entry in raw.split([',', ' ', '\n', '\t']).map(str::trim) {
419        if entry.is_empty() {
420            continue;
421        }
422        let host = entry
423            .trim_start_matches('[')
424            .trim_end_matches(']')
425            .trim_end_matches('.')
426            .to_ascii_lowercase();
427        let name = host.strip_prefix("*.").unwrap_or(&host);
428        if name.is_empty()
429            || name.contains('*')
430            || !name
431                .chars()
432                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':'))
433        {
434            bail!(
435                "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS: '{entry}' is not a host name, an IP address or *.domain"
436            );
437        }
438        // IP addresses in the form the URL parser prints them.
439        let host = host
440            .parse::<std::net::IpAddr>()
441            .map(|ip| ip.to_string())
442            .unwrap_or(host);
443        if !hosts.contains(&host) {
444            hosts.push(host);
445        }
446    }
447    Ok(hosts)
448}
449
450/// Default model of the `anthropic` provider.
451pub const DEFAULT_ASSISTANT_MODEL: &str = "claude-opus-5-5";
452/// Default for the most model calls of one assistant run.
453pub const DEFAULT_ASSISTANT_MAX_STEPS: u32 = 12;
454/// Default output tokens per model call.
455pub const DEFAULT_ASSISTANT_MAX_TOKENS: u32 = 16_000;
456/// Default time limit of one HTTP call to the AI service, in seconds.
457pub const DEFAULT_ASSISTANT_REQUEST_TIMEOUT: u64 = 180;
458/// Default time limit of a whole run, in seconds.
459pub const DEFAULT_ASSISTANT_RUN_TIMEOUT: u64 = 240;
460/// Default number of runs at the same time.
461pub const DEFAULT_ASSISTANT_MAX_CONCURRENT: usize = 4;
462/// Default for how long an idle assistant conversation is kept (days).
463pub const DEFAULT_ASSISTANT_RETENTION_DAYS: u32 = 30;
464
465/// Which wire format the assistant speaks.
466#[derive(Clone, Copy, Debug, PartialEq, Eq)]
467pub enum AssistantProvider {
468    /// Anthropic Messages API.
469    Anthropic,
470    /// OpenAI and Azure OpenAI (v1): Chat Completions, `max_completion_tokens`.
471    OpenAi,
472    /// Any other Chat Completions server (Ollama, vLLM, Mistral, OpenRouter,
473    /// Groq, LM Studio): `max_tokens`, key optional.
474    OpenAiCompatible,
475}
476
477impl AssistantProvider {
478    pub fn as_str(self) -> &'static str {
479        match self {
480            AssistantProvider::Anthropic => "anthropic",
481            AssistantProvider::OpenAi => "openai",
482            AssistantProvider::OpenAiCompatible => "openai-compatible",
483        }
484    }
485
486    pub fn parse(s: &str) -> Option<AssistantProvider> {
487        match s.trim().to_ascii_lowercase().as_str() {
488            "anthropic" => Some(AssistantProvider::Anthropic),
489            "openai" => Some(AssistantProvider::OpenAi),
490            "openai-compatible" => Some(AssistantProvider::OpenAiCompatible),
491            _ => None,
492        }
493    }
494
495    /// The API base when `TEAMCRAFT_ASSISTANT_BASE_URL` is not set.
496    pub fn default_base_url(self) -> Option<&'static str> {
497        match self {
498            AssistantProvider::Anthropic => Some("https://api.anthropic.com/v1"),
499            AssistantProvider::OpenAi => Some("https://api.openai.com/v1"),
500            AssistantProvider::OpenAiCompatible => None,
501        }
502    }
503}
504
505#[derive(Clone)]
506pub struct AssistantConfig {
507    pub provider: AssistantProvider,
508    /// API base without a trailing slash; no user name or password in it.
509    pub base_url: String,
510    /// API key (secret – never log it). Optional for `openai-compatible`.
511    pub api_key: Option<String>,
512    /// Model id (or Azure deployment name), e.g. `claude-opus-5-5`.
513    pub model: String,
514    /// Most model calls (tool-use rounds) of one run.
515    pub max_steps: u32,
516    /// Output tokens per model call.
517    pub max_tokens: u32,
518    /// Time limit of one HTTP call.
519    pub request_timeout: Duration,
520    /// Time limit of a whole run.
521    pub run_timeout: Duration,
522    /// Runs at the same time on this server.
523    pub max_concurrent: usize,
524    /// Days an idle conversation (thread) is kept before it is deleted.
525    pub retention_days: u32,
526    /// Base URLs the administrator marked as internal / self-hosted (without a
527    /// trailing slash). Groups that switched `external_llm` off allow the
528    /// assistant only when [`AssistantConfig::is_internal`].
529    pub internal_urls: Vec<String>,
530}
531
532impl std::fmt::Debug for AssistantConfig {
533    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534        f.debug_struct("AssistantConfig")
535            .field("provider", &self.provider)
536            .field("base_url", &self.base_url)
537            .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
538            .field("model", &self.model)
539            .field("max_steps", &self.max_steps)
540            .field("max_tokens", &self.max_tokens)
541            .field("request_timeout", &self.request_timeout)
542            .field("run_timeout", &self.run_timeout)
543            .field("max_concurrent", &self.max_concurrent)
544            .field("retention_days", &self.retention_days)
545            .field("internal_urls", &self.internal_urls)
546            .finish()
547    }
548}
549
550impl AssistantConfig {
551    /// The host of the API base (never the key or a path).
552    pub fn host(&self) -> String {
553        reqwest::Url::parse(&self.base_url)
554            .ok()
555            .and_then(|u| u.host_str().map(str::to_string))
556            .unwrap_or_default()
557    }
558
559    /// `provider:model@host` for logs and the startup summary.
560    pub fn label(&self) -> String {
561        format!("{}:{}@{}", self.provider.as_str(), self.model, self.host())
562    }
563
564    /// Whether the provider base URL is on the administrator's internal list.
565    pub fn is_internal(&self) -> bool {
566        self.internal_urls
567            .iter()
568            .any(|u| url_is_under(&self.base_url, u))
569    }
570
571    /// A warning when the key would travel over plain `http` to a host that
572    /// is not this computer.
573    pub fn insecure_key_warning(&self) -> Option<String> {
574        self.api_key.as_ref()?;
575        let url = reqwest::Url::parse(&self.base_url).ok()?;
576        if url.scheme() != "http" {
577            return None;
578        }
579        let host = url.host_str()?;
580        let bare = host.trim_start_matches('[').trim_end_matches(']');
581        let loopback = host.eq_ignore_ascii_case("localhost")
582            || host.to_ascii_lowercase().ends_with(".localhost")
583            || bare
584                .parse::<std::net::IpAddr>()
585                .is_ok_and(|ip| ip.is_loopback());
586        (!loopback).then(|| {
587            format!(
588                "the assistant API key is sent over plain http to {host}: use https, or a host on this computer"
589            )
590        })
591    }
592}
593
594/// Reads the assistant settings (see `docs/src/admin/configuration.md`).
595/// `None` = disabled.
596fn assistant_config<F: Fn(&str) -> Option<String>>(
597    env: &Env<F>,
598) -> Result<Option<AssistantConfig>> {
599    // A key file with only blanks counts as "not set", like an empty variable.
600    let read_key = |name: &str| -> Result<Option<String>> {
601        let key = env
602            .get(name)?
603            .map(|k| k.trim().to_string())
604            .filter(|k| !k.is_empty());
605        if let Some(k) = &key {
606            if k.chars().any(|c| c.is_whitespace() || c.is_control()) {
607                bail!("{name} must not contain spaces or line breaks");
608            }
609        }
610        Ok(key)
611    };
612    let primary = read_key("TEAMCRAFT_ASSISTANT_API_KEY")?;
613    let alias = read_key("TEAMCRAFT_ANTHROPIC_API_KEY")?;
614    let api_key = match (primary, alias) {
615        (Some(a), Some(b)) if a != b => bail!(
616            "TEAMCRAFT_ASSISTANT_API_KEY and TEAMCRAFT_ANTHROPIC_API_KEY are both set with \
617             different values: set only TEAMCRAFT_ASSISTANT_API_KEY"
618        ),
619        (a, b) => a.or(b),
620    };
621    let explicit = env
622        .get("TEAMCRAFT_ASSISTANT_PROVIDER")?
623        .map(|p| p.trim().to_string())
624        .filter(|p| !p.is_empty());
625    let provider = match &explicit {
626        Some(p) => AssistantProvider::parse(p).ok_or_else(|| {
627            anyhow!("TEAMCRAFT_ASSISTANT_PROVIDER must be anthropic, openai or openai-compatible")
628        })?,
629        None => AssistantProvider::Anthropic,
630    };
631    let base_url = env
632        .get("TEAMCRAFT_ASSISTANT_BASE_URL")?
633        .map(|u| u.trim().to_string())
634        .filter(|u| !u.is_empty());
635    // Enabled: a key, or `openai-compatible` with a base URL (local servers
636    // need no key).
637    let enabled = match provider {
638        AssistantProvider::OpenAiCompatible => api_key.is_some() || base_url.is_some(),
639        _ => api_key.is_some(),
640    };
641    if !enabled {
642        return Ok(None);
643    }
644    let base_url = match base_url.as_deref().or(provider.default_base_url()) {
645        Some(u) => u.to_string(),
646        None => bail!("TEAMCRAFT_ASSISTANT_BASE_URL is required for TEAMCRAFT_ASSISTANT_PROVIDER=openai-compatible"),
647    };
648    let parsed = reqwest::Url::parse(&base_url)
649        .map_err(|e| anyhow!("TEAMCRAFT_ASSISTANT_BASE_URL is not a valid URL: {e}"))?;
650    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
651        bail!("TEAMCRAFT_ASSISTANT_BASE_URL must be an http:// or https:// URL with a host");
652    }
653    if !parsed.username().is_empty() || parsed.password().is_some() {
654        bail!("TEAMCRAFT_ASSISTANT_BASE_URL must not contain a user name or password");
655    }
656    if parsed.query().is_some() || parsed.fragment().is_some() {
657        bail!("TEAMCRAFT_ASSISTANT_BASE_URL must not contain a query or fragment");
658    }
659    let base_url = base_url.trim_end_matches('/').to_string();
660
661    let model = env
662        .get("TEAMCRAFT_ASSISTANT_MODEL")?
663        .map(|m| m.trim().to_string())
664        .filter(|m| !m.is_empty());
665    let model = match (model, provider) {
666        (Some(m), _) => m,
667        (None, AssistantProvider::Anthropic) => DEFAULT_ASSISTANT_MODEL.to_string(),
668        (None, _) => bail!("TEAMCRAFT_ASSISTANT_MODEL is required for the openai providers"),
669    };
670    if !model
671        .chars()
672        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | ':' | '@' | '/'))
673    {
674        bail!("TEAMCRAFT_ASSISTANT_MODEL must be a model id such as {DEFAULT_ASSISTANT_MODEL}");
675    }
676    let max_steps = env.parse_or("TEAMCRAFT_ASSISTANT_MAX_STEPS", DEFAULT_ASSISTANT_MAX_STEPS)?;
677    if !(1..=50).contains(&max_steps) {
678        bail!("TEAMCRAFT_ASSISTANT_MAX_STEPS must be between 1 and 50");
679    }
680    let max_tokens = env.parse_or(
681        "TEAMCRAFT_ASSISTANT_MAX_TOKENS",
682        DEFAULT_ASSISTANT_MAX_TOKENS,
683    )?;
684    if !(256..=200_000).contains(&max_tokens) {
685        bail!("TEAMCRAFT_ASSISTANT_MAX_TOKENS must be between 256 and 200000");
686    }
687    let request_timeout = env.parse_or(
688        "TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT",
689        DEFAULT_ASSISTANT_REQUEST_TIMEOUT,
690    )?;
691    let run_timeout = env.parse_or(
692        "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT",
693        DEFAULT_ASSISTANT_RUN_TIMEOUT.max(request_timeout),
694    )?;
695    if !(5..=3600).contains(&request_timeout) {
696        bail!("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT must be between 5 and 3600 seconds");
697    }
698    if !(5..=7200).contains(&run_timeout) || run_timeout < request_timeout {
699        bail!(
700            "TEAMCRAFT_ASSISTANT_RUN_TIMEOUT must be between 5 and 7200 seconds and at least \
701             TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT"
702        );
703    }
704    let max_concurrent = env.parse_or(
705        "TEAMCRAFT_ASSISTANT_MAX_CONCURRENT",
706        DEFAULT_ASSISTANT_MAX_CONCURRENT,
707    )?;
708    if !(1..=64).contains(&max_concurrent) {
709        bail!("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT must be between 1 and 64");
710    }
711    let retention_days = env.parse_or(
712        "TEAMCRAFT_ASSISTANT_RETENTION_DAYS",
713        DEFAULT_ASSISTANT_RETENTION_DAYS,
714    )?;
715    if !(1..=365).contains(&retention_days) {
716        bail!("TEAMCRAFT_ASSISTANT_RETENTION_DAYS must be between 1 and 365");
717    }
718    let internal_urls = internal_urls(env)?;
719    Ok(Some(AssistantConfig {
720        provider,
721        base_url,
722        api_key,
723        model,
724        max_steps,
725        max_tokens,
726        request_timeout: Duration::from_secs(request_timeout),
727        run_timeout: Duration::from_secs(run_timeout),
728        max_concurrent,
729        retention_days,
730        internal_urls,
731    }))
732}
733
734/// Whether `base_url` is `listed` or below it: same scheme, host and port, and
735/// the path of `listed` is a prefix at a segment boundary.
736pub fn url_is_under(base_url: &str, listed: &str) -> bool {
737    let (Ok(a), Ok(b)) = (reqwest::Url::parse(base_url), reqwest::Url::parse(listed)) else {
738        return false;
739    };
740    if a.scheme() != b.scheme()
741        || a.host_str().map(str::to_ascii_lowercase) != b.host_str().map(str::to_ascii_lowercase)
742        || a.port_or_known_default() != b.port_or_known_default()
743    {
744        return false;
745    }
746    let (pa, pb) = (
747        a.path().trim_end_matches('/'),
748        b.path().trim_end_matches('/'),
749    );
750    pa == pb || pa.starts_with(&format!("{pb}/"))
751}
752
753/// `TEAMCRAFT_ASSISTANT_INTERNAL_URLS`: comma or space separated base URLs of
754/// internal / self-hosted model servers.
755fn internal_urls<F: Fn(&str) -> Option<String>>(env: &Env<F>) -> Result<Vec<String>> {
756    let raw = env
757        .get("TEAMCRAFT_ASSISTANT_INTERNAL_URLS")?
758        .unwrap_or_default();
759    let mut out = Vec::new();
760    for entry in raw
761        .split(|c: char| c == ',' || c.is_whitespace())
762        .filter(|e| !e.is_empty())
763    {
764        let url = reqwest::Url::parse(entry).map_err(|e| {
765            anyhow!("TEAMCRAFT_ASSISTANT_INTERNAL_URLS: '{entry}' is not a valid URL: {e}")
766        })?;
767        if !matches!(url.scheme(), "http" | "https")
768            || url.host_str().is_none()
769            || !url.username().is_empty()
770            || url.password().is_some()
771            || url.query().is_some()
772            || url.fragment().is_some()
773        {
774            bail!(
775                "TEAMCRAFT_ASSISTANT_INTERNAL_URLS: '{entry}' must be an http:// or https:// URL \
776                 with a host and without credentials, query or fragment"
777            );
778        }
779        out.push(entry.trim_end_matches('/').to_string());
780    }
781    Ok(out)
782}
783
784#[derive(Clone)]
785pub struct SmtpConfig {
786    /// `smtps://user:password@host:465` or `smtp://host:587?tls=required`
787    /// (lettre syntax) – contains the password, never log it.
788    pub url: String,
789    /// Sender, e.g. `TeamCraft <teamcraft@example.org>`.
790    pub from: String,
791}
792
793#[derive(Clone)]
794pub struct MattermostConfig {
795    /// Bot account of the REST API: direct messages to the person only.
796    pub bot: Option<MattermostBot>,
797    /// Deprecated: incoming webhook URL (secret). Ignored when `bot` is set.
798    pub webhook: Option<String>,
799    /// Webhook only. `true`: direct message to each user (`"channel": "@username"`);
800    /// `false`: post in the webhook's channel and @-mention the user.
801    pub direct: bool,
802}
803
804impl MattermostConfig {
805    /// Whether a notification reaches only its recipient (never a channel).
806    pub fn is_direct(&self) -> bool {
807        self.bot.is_some() || self.direct
808    }
809
810    /// Only the deprecated incoming webhook is configured.
811    pub fn is_legacy_webhook(&self) -> bool {
812        self.bot.is_none() && self.webhook.is_some()
813    }
814}
815
816#[derive(Clone)]
817pub struct MattermostBot {
818    /// Server URL, e.g. `https://chat.example.org` (no trailing slash).
819    pub url: String,
820    /// Access token of the bot account (secret).
821    pub token: String,
822}
823
824#[derive(Clone)]
825pub struct BootstrapAdmin {
826    pub username: String,
827    pub password: String,
828}
829
830#[derive(Clone)]
831pub struct OidcConfig {
832    /// Issuer URL, e.g. `https://auth.example.org/application/o/teamcraft/`.
833    pub issuer_url: String,
834    pub client_id: String,
835    pub client_secret: String,
836    /// Space separated scopes requested at the authorization endpoint.
837    pub scopes: String,
838    /// Label of the login button, e.g. "Single Sign-On".
839    pub display_name: String,
840    /// Claim that carries the list of group names.
841    pub groups_claim: String,
842    /// Claim used as username for newly provisioned users.
843    pub username_claim: String,
844    /// Members of this group become administrators.
845    pub admin_group: String,
846    /// Members of this group become CAD editors.
847    pub editor_group: String,
848    /// If set, users must be member of this group (or of the admin/editor
849    /// group) to log in at all; they get the viewer role.
850    /// If unset, every user of the IdP application gets at least viewer access.
851    pub viewer_group: Option<String>,
852}
853
854impl OidcConfig {
855    /// The redirect URI that must be registered at the identity provider.
856    pub fn redirect_uri(&self, public_url: &str) -> String {
857        format!("{public_url}/auth/oidc/callback")
858    }
859}
860
861impl Config {
862    /// Reads the configuration from the process environment.
863    pub fn from_env() -> Result<Self> {
864        Self::from_lookup(|key| std::env::var(key).ok())
865    }
866
867    /// Reads the configuration through an arbitrary lookup function (used by tests).
868    pub fn from_lookup<F>(lookup: F) -> Result<Self>
869    where
870        F: Fn(&str) -> Option<String>,
871    {
872        let env = Env { lookup };
873
874        let bind_addr: SocketAddr = env
875            .get("TEAMCRAFT_BIND_ADDR")?
876            .unwrap_or_else(|| "127.0.0.1:8080".to_string())
877            .parse()
878            .context("TEAMCRAFT_BIND_ADDR must be a socket address such as 0.0.0.0:8080")?;
879
880        let public_url = env
881            .get("TEAMCRAFT_PUBLIC_URL")?
882            .unwrap_or_else(|| format!("http://localhost:{}", bind_addr.port()))
883            .trim_end_matches('/')
884            .to_string();
885        if !(public_url.starts_with("http://") || public_url.starts_with("https://")) {
886            bail!("TEAMCRAFT_PUBLIC_URL must start with http:// or https://");
887        }
888
889        let instance_name = env
890            .get("TEAMCRAFT_INSTANCE_NAME")?
891            .unwrap_or_else(|| "TeamCraft".to_string());
892
893        let (database, database_display, database_password) = database_options(&env)?;
894
895        let db_max_connections = env.parse_or("TEAMCRAFT_DB_MAX_CONNECTIONS", 10u32)?;
896        if db_max_connections == 0 {
897            bail!("TEAMCRAFT_DB_MAX_CONNECTIONS must be at least 1");
898        }
899
900        let vault_dir = PathBuf::from(
901            env.get("TEAMCRAFT_VAULT_DIR")?
902                .unwrap_or_else(|| "./data/vault".to_string()),
903        );
904        let max_upload_mb = env.parse_or("TEAMCRAFT_MAX_UPLOAD_MB", 512u64)?;
905        if max_upload_mb == 0 {
906            bail!("TEAMCRAFT_MAX_UPLOAD_MB must be at least 1");
907        }
908        let preview = {
909            let defaults = crate::preview::PreviewLimits::default();
910            let mb = |key: &str, default: u64, min: u64| -> Result<u64> {
911                let value = env.parse_or(key, default / (1024 * 1024))?;
912                if !(min..=1_048_576).contains(&value) {
913                    bail!("{key} must be between {min} and 1048576");
914                }
915                Ok(value * 1024 * 1024)
916            };
917            crate::preview::PreviewLimits {
918                max_result_bytes: mb(
919                    "TEAMCRAFT_PREVIEW_MAX_RESULT_MB",
920                    defaults.max_result_bytes,
921                    1,
922                )?,
923                max_mesh_bytes: mb("TEAMCRAFT_PREVIEW_MAX_MESH_MB", defaults.max_mesh_bytes, 1)?,
924                max_step_bytes: mb("TEAMCRAFT_PREVIEW_MAX_STEP_MB", defaults.max_step_bytes, 1)?,
925                // 0 = no limit
926                max_memory_bytes: mb(
927                    "TEAMCRAFT_PREVIEW_MAX_MEMORY_MB",
928                    defaults.max_memory_bytes,
929                    0,
930                )?,
931            }
932        };
933        // 0 = no limit; at most 16 TiB
934        let preview_cache_max_mb = env.parse_or("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", 0u64)?;
935        if preview_cache_max_mb > MAX_PREVIEW_CACHE_MB {
936            bail!("TEAMCRAFT_PREVIEW_CACHE_MAX_MB must be between 0 (no limit) and {MAX_PREVIEW_CACHE_MB}");
937        }
938        let upload_chunk_mb = env.parse_or("TEAMCRAFT_UPLOAD_CHUNK_MB", 16u64)?;
939        if !(1..=1024).contains(&upload_chunk_mb) {
940            bail!("TEAMCRAFT_UPLOAD_CHUNK_MB must be between 1 and 1024");
941        }
942        let clamd_max_mb = env.parse_or("TEAMCRAFT_CLAMD_MAX_MB", 25u64)?;
943        if !(1..=4096).contains(&clamd_max_mb) {
944            bail!("TEAMCRAFT_CLAMD_MAX_MB must be between 1 and 4096");
945        }
946        let clamd = match env
947            .get("TEAMCRAFT_CLAMD_ADDR")?
948            .map(|a| a.trim().to_string())
949            .filter(|a| !a.is_empty())
950        {
951            Some(addr) => {
952                let unix = addr.strip_prefix("unix:");
953                if unix.is_some_and(|p| !p.starts_with('/'))
954                    || (unix.is_none() && !addr.contains(':'))
955                {
956                    bail!("TEAMCRAFT_CLAMD_ADDR must be host:port or unix:/path/to/clamd.socket");
957                }
958                Some(crate::malware::ScannerConfig {
959                    addr,
960                    max_bytes: clamd_max_mb * 1024 * 1024,
961                    timeout: Duration::from_secs(120),
962                })
963            }
964            None => None,
965        };
966        let backup_passphrase = env
967            .get("TEAMCRAFT_BACKUP_PASSPHRASE")?
968            .map(|p| p.trim_end_matches(['\r', '\n']).to_string())
969            .filter(|p| !p.is_empty());
970        if backup_passphrase
971            .as_ref()
972            .is_some_and(|p| p.chars().count() < 12)
973        {
974            bail!("TEAMCRAFT_BACKUP_PASSPHRASE must have at least 12 characters");
975        }
976
977        let session_ttl_hours = env.parse_or("TEAMCRAFT_SESSION_TTL_HOURS", 72u64)?;
978        if session_ttl_hours == 0 {
979            bail!("TEAMCRAFT_SESSION_TTL_HOURS must be at least 1");
980        }
981
982        let signin_max_failures = env.parse_or("TEAMCRAFT_SIGNIN_MAX_FAILURES", 10u32)?;
983        let metrics_token = env
984            .get("TEAMCRAFT_METRICS_TOKEN")?
985            .map(|t| t.trim().to_string())
986            .filter(|t| !t.is_empty());
987        if metrics_token.as_ref().is_some_and(|t| t.len() < 16) {
988            bail!("TEAMCRAFT_METRICS_TOKEN must be at least 16 characters");
989        }
990
991        let approval_reauth = match env.get("TEAMCRAFT_APPROVAL_REAUTH")? {
992            None => crate::domain::ApprovalReauth::Off,
993            Some(raw) => crate::domain::ApprovalReauth::parse(&raw).ok_or_else(|| {
994                anyhow::anyhow!(
995                    "TEAMCRAFT_APPROVAL_REAUTH must be off, release or every_decision (got '{raw}')"
996                )
997            })?,
998        };
999
1000        let oidc_token_idle_days = env.parse_or("TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS", 0u32)?;
1001        if oidc_token_idle_days > 3650 {
1002            bail!("TEAMCRAFT_OIDC_TOKEN_IDLE_DAYS must be between 0 (off) and 3650");
1003        }
1004
1005        let audit_retention_days = env.parse_or("TEAMCRAFT_AUDIT_RETENTION_DAYS", 0u32)?;
1006        if audit_retention_days != 0
1007            && !(MIN_AUDIT_RETENTION_DAYS..=36_500).contains(&audit_retention_days)
1008        {
1009            bail!(
1010                "TEAMCRAFT_AUDIT_RETENTION_DAYS must be 0 (keep for ever) or between \
1011                 {MIN_AUDIT_RETENTION_DAYS} and 36500"
1012            );
1013        }
1014
1015        let retention_latest_grace_days = env.parse_or(
1016            "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS",
1017            crate::services::retention::DEFAULT_LATEST_GRACE_DAYS,
1018        )?;
1019        if retention_latest_grace_days > crate::services::retention::MAX_LATEST_GRACE_DAYS {
1020            bail!(
1021                "TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS must be between 0 (no automatic removal) and {}",
1022                crate::services::retention::MAX_LATEST_GRACE_DAYS
1023            );
1024        }
1025
1026        let cookie_secure = match env.get("TEAMCRAFT_COOKIE_SECURE")? {
1027            Some(v) => parse_bool(&v).context("TEAMCRAFT_COOKIE_SECURE must be true or false")?,
1028            None => public_url.starts_with("https://"),
1029        };
1030
1031        let log_format = match env
1032            .get("TEAMCRAFT_LOG_FORMAT")?
1033            .unwrap_or_else(|| "text".into())
1034            .to_ascii_lowercase()
1035            .as_str()
1036        {
1037            "text" => LogFormat::Text,
1038            "json" => LogFormat::Json,
1039            other => bail!("TEAMCRAFT_LOG_FORMAT must be 'text' or 'json', got '{other}'"),
1040        };
1041
1042        let bootstrap_admin = env
1043            .get("TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD")?
1044            .map(|password| -> Result<BootstrapAdmin> {
1045                let username = env
1046                    .get("TEAMCRAFT_BOOTSTRAP_ADMIN_USERNAME")?
1047                    .unwrap_or_else(|| "admin".to_string());
1048                Ok(BootstrapAdmin { username, password })
1049            })
1050            .transpose()?;
1051
1052        let oidc = oidc_config(&env)?;
1053        let local_login = match env.get("TEAMCRAFT_LOCAL_LOGIN")? {
1054            Some(v) => parse_bool(&v).context("TEAMCRAFT_LOCAL_LOGIN must be true or false")?,
1055            None => oidc.is_none(),
1056        };
1057        if oidc.is_none() && !local_login {
1058            bail!("no login method enabled: configure TEAMCRAFT_OIDC_ISSUER_URL or set TEAMCRAFT_LOCAL_LOGIN=true");
1059        }
1060
1061        let mattermost_error_webhook = env.get("MATTERMOST_ERROR_WEBHOOK")?;
1062        if let Some(url) = &mattermost_error_webhook {
1063            if !(url.starts_with("https://") || url.starts_with("http://")) {
1064                bail!("MATTERMOST_ERROR_WEBHOOK must be an http(s) URL");
1065            }
1066        }
1067
1068        let smtp = match env.get("TEAMCRAFT_SMTP_URL")? {
1069            Some(url) => {
1070                if !(url.starts_with("smtp://") || url.starts_with("smtps://")) {
1071                    bail!("TEAMCRAFT_SMTP_URL must start with smtp:// or smtps://");
1072                }
1073                let from = env.get("TEAMCRAFT_MAIL_FROM")?.context(
1074                    "TEAMCRAFT_MAIL_FROM (e.g. TeamCraft <teamcraft@example.org>) is required when TEAMCRAFT_SMTP_URL is set",
1075                )?;
1076                if from.parse::<lettre::message::Mailbox>().is_err() {
1077                    bail!("TEAMCRAFT_MAIL_FROM is not a valid address: use 'Name <address>' or 'address'");
1078                }
1079                Some(SmtpConfig { url, from })
1080            }
1081            None => None,
1082        };
1083        let request_digest_hours = env.parse_or("TEAMCRAFT_REQUEST_DIGEST_HOURS", 24u64)?;
1084        if request_digest_hours > 24 * 30 {
1085            bail!("TEAMCRAFT_REQUEST_DIGEST_HOURS must be between 0 (off) and 720");
1086        }
1087        let webhook = match env.get("TEAMCRAFT_MATTERMOST_WEBHOOK")? {
1088            Some(webhook) => {
1089                if !(webhook.starts_with("https://") || webhook.starts_with("http://")) {
1090                    bail!("TEAMCRAFT_MATTERMOST_WEBHOOK must be an http(s) URL");
1091                }
1092                Some(webhook)
1093            }
1094            None => None,
1095        };
1096        let bot = match (
1097            env.get("TEAMCRAFT_MATTERMOST_URL")?,
1098            env.get("TEAMCRAFT_MATTERMOST_BOT_TOKEN")?,
1099        ) {
1100            (Some(url), Some(token)) => {
1101                if !(url.starts_with("https://") || url.starts_with("http://")) {
1102                    bail!("TEAMCRAFT_MATTERMOST_URL must be an http(s) URL");
1103                }
1104                Some(MattermostBot {
1105                    url: url.trim().trim_end_matches('/').to_string(),
1106                    token: token.trim().to_string(),
1107                })
1108            }
1109            (None, None) => None,
1110            _ => bail!(
1111                "TEAMCRAFT_MATTERMOST_URL and TEAMCRAFT_MATTERMOST_BOT_TOKEN must be set together"
1112            ),
1113        };
1114        let direct = match env.get("TEAMCRAFT_MATTERMOST_DIRECT")? {
1115            Some(v) => {
1116                parse_bool(&v).context("TEAMCRAFT_MATTERMOST_DIRECT must be true or false")?
1117            }
1118            None => false,
1119        };
1120        let mattermost = (bot.is_some() || webhook.is_some()).then_some(MattermostConfig {
1121            bot,
1122            webhook,
1123            direct,
1124        });
1125        let assistant = assistant_config(&env)?;
1126        let local_agents = match env.get("TEAMCRAFT_LOCAL_AGENTS")? {
1127            Some(raw) => LocalAgents::parse(&raw)?,
1128            None => LocalAgents::Allowed,
1129        };
1130
1131        let webhooks = WebhookConfig {
1132            allowed_hosts: match env.get("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS")? {
1133                Some(raw) => parse_allowed_hosts(&raw)?,
1134                None => Vec::new(),
1135            },
1136            allow_private: match env.get("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE")? {
1137                Some(v) => parse_bool(&v)
1138                    .context("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE must be true or false")?,
1139                None => false,
1140            },
1141            retention_days: env.parse_or(
1142                "TEAMCRAFT_WEBHOOK_RETENTION_DAYS",
1143                DEFAULT_WEBHOOK_RETENTION_DAYS,
1144            )?,
1145        };
1146        if !(1..=3650).contains(&webhooks.retention_days) {
1147            bail!("TEAMCRAFT_WEBHOOK_RETENTION_DAYS must be between 1 and 3650");
1148        }
1149
1150        Ok(Config {
1151            bind_addr,
1152            public_url,
1153            instance_name,
1154            database,
1155            database_password,
1156            database_display,
1157            db_max_connections,
1158            vault_dir,
1159            max_upload_bytes: max_upload_mb * 1024 * 1024,
1160            preview,
1161            preview_cache_max_bytes: preview_cache_max_mb * 1024 * 1024,
1162            upload_chunk_bytes: upload_chunk_mb * 1024 * 1024,
1163            clamd,
1164            backup_passphrase,
1165            session_ttl: Duration::from_secs(session_ttl_hours * 3600),
1166            approval_reauth,
1167            audit_retention_days,
1168            retention_latest_grace_days,
1169            signin_max_failures,
1170            oidc_token_idle_days,
1171            metrics_token,
1172            cookie_secure,
1173            log_format,
1174            bootstrap_admin,
1175            oidc,
1176            local_login,
1177            mattermost_error_webhook,
1178            smtp,
1179            request_digest_hours,
1180            mattermost,
1181            assistant,
1182            local_agents,
1183            webhooks,
1184        })
1185    }
1186
1187    /// Password-free summary for the startup log.
1188    pub fn summary(&self) -> String {
1189        format!(
1190            "bind={} public_url={} database={} vault={} max_upload={}MiB upload_chunk={}MiB malware_scan={} session_ttl={}h cookie_secure={} oidc={} local_login={} mattermost_errors={} smtp={} request_digest={}h mattermost_notifications={} assistant={} local_agents={} webhook_targets={}",
1191            self.bind_addr,
1192            self.public_url,
1193            self.database_display,
1194            self.vault_dir.display(),
1195            self.max_upload_bytes / (1024 * 1024),
1196            self.upload_chunk_bytes / (1024 * 1024),
1197            self.clamd.as_ref().map_or("off", |c| c.addr.as_str()),
1198            self.session_ttl.as_secs() / 3600,
1199            self.cookie_secure,
1200            self.oidc.as_ref().map(|o| o.issuer_url.as_str()).unwrap_or("disabled"),
1201            self.local_login,
1202            self.mattermost_error_webhook.is_some(),
1203            self.smtp.is_some(),
1204            self.request_digest_hours,
1205            self.mattermost
1206                .as_ref()
1207                .map_or("off", |m| {
1208                    if m.bot.is_some() {
1209                        "bot"
1210                    } else if m.direct {
1211                        "direct"
1212                    } else {
1213                        "channel"
1214                    }
1215                }),
1216            self.assistant.as_ref().map_or_else(|| "off".to_string(), |a| a.label()),
1217            self.local_agents.as_str(),
1218            match (
1219                self.webhooks.allowed_hosts.is_empty(),
1220                self.webhooks.allow_private
1221            ) {
1222                (true, false) => "public",
1223                (true, true) => "any",
1224                (false, false) => "allow-list",
1225                (false, true) => "allow-list+private",
1226            },
1227        )
1228    }
1229}
1230
1231fn oidc_config<F: Fn(&str) -> Option<String>>(env: &Env<F>) -> Result<Option<OidcConfig>> {
1232    let Some(issuer_url) = env.get("TEAMCRAFT_OIDC_ISSUER_URL")? else {
1233        return Ok(None);
1234    };
1235    if !(issuer_url.starts_with("https://") || issuer_url.starts_with("http://")) {
1236        bail!("TEAMCRAFT_OIDC_ISSUER_URL must be an http(s) URL");
1237    }
1238    let client_id = env
1239        .get("TEAMCRAFT_OIDC_CLIENT_ID")?
1240        .context("TEAMCRAFT_OIDC_CLIENT_ID is required when OIDC is enabled")?;
1241    let client_secret = env.get("TEAMCRAFT_OIDC_CLIENT_SECRET")?.context(
1242        "TEAMCRAFT_OIDC_CLIENT_SECRET (or TEAMCRAFT_OIDC_CLIENT_SECRET_FILE) is required when OIDC is enabled",
1243    )?;
1244    Ok(Some(OidcConfig {
1245        issuer_url: issuer_url.trim().to_string(),
1246        client_id,
1247        client_secret,
1248        scopes: env
1249            .get("TEAMCRAFT_OIDC_SCOPES")?
1250            .unwrap_or_else(|| "openid profile email".into()),
1251        display_name: env
1252            .get("TEAMCRAFT_OIDC_DISPLAY_NAME")?
1253            .unwrap_or_else(|| "Single Sign-On".into()),
1254        groups_claim: env
1255            .get("TEAMCRAFT_OIDC_GROUPS_CLAIM")?
1256            .unwrap_or_else(|| "groups".into()),
1257        username_claim: env
1258            .get("TEAMCRAFT_OIDC_USERNAME_CLAIM")?
1259            .unwrap_or_else(|| "preferred_username".into()),
1260        admin_group: env
1261            .get("TEAMCRAFT_OIDC_ADMIN_GROUP")?
1262            .unwrap_or_else(|| "teamcraft-admins".into()),
1263        editor_group: env
1264            .get("TEAMCRAFT_OIDC_EDITOR_GROUP")?
1265            .unwrap_or_else(|| "teamcraft-editors".into()),
1266        viewer_group: env.get("TEAMCRAFT_OIDC_VIEWER_GROUP")?,
1267    }))
1268}
1269
1270struct Env<F: Fn(&str) -> Option<String>> {
1271    lookup: F,
1272}
1273
1274impl<F: Fn(&str) -> Option<String>> Env<F> {
1275    /// Returns the value of `key`, or the contents of the file named by `key_FILE`.
1276    /// Empty values are treated as unset.
1277    fn get(&self, key: &str) -> Result<Option<String>> {
1278        if let Some(path) = (self.lookup)(&format!("{key}_FILE")).filter(|p| !p.trim().is_empty()) {
1279            let content = std::fs::read_to_string(path.trim())
1280                .with_context(|| format!("cannot read {key}_FILE ({path})"))?;
1281            let value = content.trim_end_matches(['\n', '\r']).to_string();
1282            return Ok(Some(value).filter(|v| !v.is_empty()));
1283        }
1284        Ok((self.lookup)(key).filter(|v| !v.trim().is_empty()))
1285    }
1286
1287    fn parse_or<T>(&self, key: &str, default: T) -> Result<T>
1288    where
1289        T: FromStr,
1290        T::Err: std::fmt::Display,
1291    {
1292        match self.get(key)? {
1293            Some(raw) => raw
1294                .trim()
1295                .parse::<T>()
1296                .map_err(|e| anyhow!("{key}: invalid value '{raw}': {e}")),
1297            None => Ok(default),
1298        }
1299    }
1300}
1301
1302fn parse_bool(raw: &str) -> Result<bool> {
1303    match raw.trim().to_ascii_lowercase().as_str() {
1304        "1" | "true" | "yes" | "on" => Ok(true),
1305        "0" | "false" | "no" | "off" => Ok(false),
1306        other => bail!("not a boolean: '{other}'"),
1307    }
1308}
1309
1310/// Builds the PostgreSQL options either from `TEAMCRAFT_DATABASE_URL`
1311/// (falling back to `DATABASE_URL`) or from the individual `TEAMCRAFT_DB_*`
1312/// variables. The latter avoids URL-encoding problems with special characters
1313/// in passwords and works nicely with secret files.
1314/// Decodes `%XX` escapes of a URL component (the user info of the database
1315/// URL). Malformed escapes stay as they are; bytes that do not form UTF-8
1316/// keep the component undecoded.
1317fn percent_decode(component: &str) -> String {
1318    let bytes = component.as_bytes();
1319    let mut out = Vec::with_capacity(bytes.len());
1320    let hex = |b: Option<&u8>| b.and_then(|b| (*b as char).to_digit(16));
1321    let mut i = 0;
1322    while let Some(&b) = bytes.get(i) {
1323        match (b, hex(bytes.get(i + 1)), hex(bytes.get(i + 2))) {
1324            (b'%', Some(h), Some(l)) => {
1325                out.push((h * 16 + l) as u8);
1326                i += 3;
1327            }
1328            (b, _, _) => {
1329                out.push(b);
1330                i += 1;
1331            }
1332        }
1333    }
1334    String::from_utf8(out).unwrap_or_else(|_| component.to_string())
1335}
1336
1337fn database_options<F: Fn(&str) -> Option<String>>(
1338    env: &Env<F>,
1339) -> Result<(PgConnectOptions, String, Option<String>)> {
1340    let url = match env.get("TEAMCRAFT_DATABASE_URL")? {
1341        Some(url) => Some(url),
1342        None => env.get("DATABASE_URL")?,
1343    };
1344    if let Some(url) = url {
1345        let options = PgConnectOptions::from_str(&url)
1346            .context("TEAMCRAFT_DATABASE_URL is not a valid postgres:// URL")?
1347            .application_name("teamcraft");
1348        let display = format!(
1349            "{}:{}/{} (from URL)",
1350            options.get_host(),
1351            options.get_port(),
1352            options.get_database().unwrap_or("?")
1353        );
1354        // The client tools (pg_dump, pg_restore) get the same password as the
1355        // server. The URL keeps it percent-encoded (`p%40ss` for `p@ss`);
1356        // sqlx decodes it, so decode it here as well.
1357        let password = reqwest::Url::parse(&url)
1358            .ok()
1359            .and_then(|parsed| parsed.password().map(percent_decode))
1360            .filter(|password| !password.is_empty());
1361        return Ok((options, display, password));
1362    }
1363
1364    let host = env
1365        .get("TEAMCRAFT_DB_HOST")?
1366        .unwrap_or_else(|| "localhost".into());
1367    let port: u16 = env.parse_or("TEAMCRAFT_DB_PORT", 5432u16)?;
1368    let name = env
1369        .get("TEAMCRAFT_DB_NAME")?
1370        .unwrap_or_else(|| "teamcraft".into());
1371    let user = env
1372        .get("TEAMCRAFT_DB_USER")?
1373        .unwrap_or_else(|| "teamcraft".into());
1374    // No password: the local development database of docker-compose.yml
1375    // (trust authentication on 127.0.0.1) or a password from ~/.pgpass.
1376    let password = env.get("TEAMCRAFT_DB_PASSWORD")?;
1377    let ssl_mode = match env.get("TEAMCRAFT_DB_SSLMODE")? {
1378        Some(mode) => {
1379            PgSslMode::from_str(&mode).map_err(|e| anyhow!("TEAMCRAFT_DB_SSLMODE: {e}"))?
1380        }
1381        None => PgSslMode::Prefer,
1382    };
1383
1384    let mut options = PgConnectOptions::new()
1385        .host(&host)
1386        .port(port)
1387        .username(&user)
1388        .database(&name)
1389        .ssl_mode(ssl_mode)
1390        .application_name("teamcraft");
1391    if let Some(password) = &password {
1392        options = options.password(password);
1393    }
1394    // A directory as host: PostgreSQL's local socket (e.g. /run/postgresql)
1395    // with peer authentication as the operating-system user.
1396    if host.starts_with('/') {
1397        options = options.socket(&host);
1398    }
1399    let display = format!("{user}@{host}:{port}/{name}");
1400    Ok((options, display, password))
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    use super::*;
1406    use std::collections::HashMap;
1407
1408    fn config_from(pairs: &[(&str, &str)]) -> Result<Config> {
1409        let map: HashMap<String, String> = pairs
1410            .iter()
1411            .map(|(k, v)| (k.to_string(), v.to_string()))
1412            .collect();
1413        Config::from_lookup(move |k| map.get(k).cloned())
1414    }
1415
1416    #[test]
1417    fn defaults_with_password() {
1418        let cfg = config_from(&[("TEAMCRAFT_DB_PASSWORD", "secret")]).unwrap();
1419        assert_eq!(cfg.bind_addr.to_string(), "127.0.0.1:8080");
1420        assert_eq!(cfg.public_url, "http://localhost:8080");
1421        assert_eq!(cfg.max_upload_bytes, 512 * 1024 * 1024);
1422        assert_eq!(cfg.upload_chunk_bytes, 16 * 1024 * 1024);
1423        assert!(cfg.clamd.is_none(), "no malware scan by default");
1424        assert_eq!(cfg.request_digest_hours, 24, "daily digests");
1425        assert!(!cfg.cookie_secure);
1426        assert_eq!(cfg.log_format, LogFormat::Text);
1427        assert!(cfg.bootstrap_admin.is_none());
1428        assert_eq!(cfg.database_display, "teamcraft@localhost:5432/teamcraft");
1429        assert_eq!(cfg.database_password.as_deref(), Some("secret"));
1430        assert!(!cfg.summary().contains("secret"));
1431    }
1432
1433    #[test]
1434    fn malware_scanner_settings() {
1435        let cfg = config_from(&[
1436            ("TEAMCRAFT_DB_PASSWORD", "secret"),
1437            ("TEAMCRAFT_CLAMD_ADDR", "clamav:3310"),
1438            ("TEAMCRAFT_CLAMD_MAX_MB", "100"),
1439        ])
1440        .unwrap();
1441        let clamd = cfg.clamd.as_ref().unwrap();
1442        assert_eq!(clamd.addr, "clamav:3310");
1443        assert_eq!(clamd.max_bytes, 100 * 1024 * 1024);
1444        assert!(cfg.summary().contains("malware_scan=clamav:3310"));
1445        let unix = config_from(&[
1446            ("TEAMCRAFT_DB_PASSWORD", "secret"),
1447            ("TEAMCRAFT_CLAMD_ADDR", "unix:/run/clamav/clamd.ctl"),
1448        ])
1449        .unwrap();
1450        assert_eq!(unix.clamd.unwrap().max_bytes, 25 * 1024 * 1024);
1451        for bad in ["clamav", "unix:relative.sock"] {
1452            assert!(
1453                config_from(&[
1454                    ("TEAMCRAFT_DB_PASSWORD", "secret"),
1455                    ("TEAMCRAFT_CLAMD_ADDR", bad)
1456                ])
1457                .is_err(),
1458                "{bad}"
1459            );
1460        }
1461        assert!(config_from(&[
1462            ("TEAMCRAFT_DB_PASSWORD", "secret"),
1463            ("TEAMCRAFT_CLAMD_MAX_MB", "0")
1464        ])
1465        .is_err());
1466    }
1467
1468    #[test]
1469    fn database_url_password_is_decoded_for_the_client_tools() {
1470        let cfg = config_from(&[(
1471            "TEAMCRAFT_DATABASE_URL",
1472            "postgres://tc:p%40ss%3Aw%25rd%2F1@db:5432/teamcraft",
1473        )])
1474        .unwrap();
1475        assert_eq!(cfg.database_password.as_deref(), Some("p@ss:w%rd/1"));
1476        assert!(!cfg.summary().contains("p@ss") && !cfg.summary().contains("p%40ss"));
1477        let cfg = config_from(&[("TEAMCRAFT_DATABASE_URL", "postgres://tc:plain@db/x")]).unwrap();
1478        assert_eq!(cfg.database_password.as_deref(), Some("plain"));
1479        let cfg = config_from(&[("TEAMCRAFT_DATABASE_URL", "postgres://tc@db/x")]).unwrap();
1480        assert!(cfg.database_password.is_none());
1481
1482        assert_eq!(percent_decode("%C3%A4%20x"), "ä x");
1483        assert_eq!(percent_decode("100%"), "100%", "a trailing % stays");
1484        assert_eq!(percent_decode("%zz%4"), "%zz%4", "malformed escapes stay");
1485        assert_eq!(percent_decode("%FF"), "%FF", "no valid UTF-8: undecoded");
1486    }
1487
1488    #[test]
1489    fn notification_channels() {
1490        let base = [
1491            ("TEAMCRAFT_DB_PASSWORD", "secret"),
1492            ("TEAMCRAFT_LOCAL_LOGIN", "true"),
1493        ];
1494        let cfg = config_from(&base).unwrap();
1495        assert!(cfg.smtp.is_none() && cfg.mattermost.is_none(), "optional");
1496
1497        let with = |extra: &[(&'static str, &'static str)]| {
1498            let mut pairs = base.to_vec();
1499            pairs.extend_from_slice(extra);
1500            config_from(&pairs)
1501        };
1502        let cfg = with(&[
1503            (
1504                "TEAMCRAFT_SMTP_URL",
1505                "smtps://tc:mailpass@mail.example.org:465",
1506            ),
1507            ("TEAMCRAFT_MAIL_FROM", "TeamCraft <teamcraft@example.org>"),
1508            (
1509                "TEAMCRAFT_MATTERMOST_WEBHOOK",
1510                "https://chat.example.org/hooks/abc",
1511            ),
1512            ("TEAMCRAFT_MATTERMOST_DIRECT", "true"),
1513        ])
1514        .unwrap();
1515        assert_eq!(
1516            cfg.smtp.as_ref().unwrap().from,
1517            "TeamCraft <teamcraft@example.org>"
1518        );
1519        assert!(cfg.mattermost.as_ref().unwrap().direct);
1520        let summary = cfg.summary();
1521        assert!(
1522            summary.contains("smtp=true") && summary.contains("mattermost_notifications=direct")
1523        );
1524        assert!(
1525            !summary.contains("mailpass") && !summary.contains("hooks/abc"),
1526            "no secrets in the log"
1527        );
1528
1529        let err = with(&[("TEAMCRAFT_SMTP_URL", "smtp://mail.example.org")])
1530            .err()
1531            .unwrap();
1532        assert!(err.to_string().contains("TEAMCRAFT_MAIL_FROM"), "{err}");
1533        let err = with(&[
1534            ("TEAMCRAFT_SMTP_URL", "http://mail.example.org"),
1535            ("TEAMCRAFT_MAIL_FROM", "a@b.de"),
1536        ])
1537        .err()
1538        .unwrap();
1539        assert!(err.to_string().contains("smtp://"), "{err}");
1540        let err = with(&[
1541            ("TEAMCRAFT_SMTP_URL", "smtp://mail.example.org"),
1542            ("TEAMCRAFT_MAIL_FROM", "not an address"),
1543        ])
1544        .err()
1545        .unwrap();
1546        assert!(err.to_string().contains("TEAMCRAFT_MAIL_FROM"), "{err}");
1547        let cfg = with(&[
1548            ("TEAMCRAFT_MATTERMOST_URL", "https://chat.example.org/"),
1549            ("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "bot-token-value"),
1550        ])
1551        .unwrap();
1552        let m = cfg.mattermost.as_ref().unwrap();
1553        assert!(m.is_direct() && !m.is_legacy_webhook());
1554        assert_eq!(m.bot.as_ref().unwrap().url, "https://chat.example.org");
1555        let summary = cfg.summary();
1556        assert!(summary.contains("mattermost_notifications=bot"));
1557        assert!(
1558            !summary.contains("bot-token-value"),
1559            "no secrets in the log"
1560        );
1561        assert!(with(&[("TEAMCRAFT_MATTERMOST_URL", "https://chat.example.org")]).is_err());
1562        assert!(with(&[("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "x")]).is_err());
1563        assert!(with(&[
1564            ("TEAMCRAFT_MATTERMOST_URL", "chat.example.org"),
1565            ("TEAMCRAFT_MATTERMOST_BOT_TOKEN", "x"),
1566        ])
1567        .is_err());
1568        let legacy = with(&[(
1569            "TEAMCRAFT_MATTERMOST_WEBHOOK",
1570            "https://chat.example.org/hooks/x",
1571        )])
1572        .unwrap();
1573        assert!(legacy.mattermost.as_ref().unwrap().is_legacy_webhook());
1574        assert!(with(&[("TEAMCRAFT_MATTERMOST_WEBHOOK", "chat.example.org/hooks/x")]).is_err());
1575        assert!(with(&[
1576            (
1577                "TEAMCRAFT_MATTERMOST_WEBHOOK",
1578                "https://chat.example.org/hooks/x"
1579            ),
1580            ("TEAMCRAFT_MATTERMOST_DIRECT", "maybe"),
1581        ])
1582        .is_err());
1583    }
1584
1585    #[test]
1586    fn webhook_targets() {
1587        let cfg = config_from(&[]).unwrap();
1588        assert_eq!(cfg.webhooks, WebhookConfig::default());
1589        assert!(cfg.summary().contains("webhook_targets=public"));
1590        let cfg = config_from(&[
1591            (
1592                "TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS",
1593                "ERP.internal.example.org., *.ci.example.org [fd00::1] 10.0.0.5,erp.internal.example.org",
1594            ),
1595            ("TEAMCRAFT_WEBHOOK_RETENTION_DAYS", "7"),
1596        ])
1597        .unwrap();
1598        assert_eq!(
1599            cfg.webhooks.allowed_hosts,
1600            [
1601                "erp.internal.example.org",
1602                "*.ci.example.org",
1603                "fd00::1",
1604                "10.0.0.5"
1605            ]
1606        );
1607        assert_eq!(cfg.webhooks.retention_days, 7);
1608        assert!(cfg.summary().contains("webhook_targets=allow-list"));
1609        let cfg = config_from(&[("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE", "yes")]).unwrap();
1610        assert!(cfg.webhooks.allow_private);
1611        for (key, value) in [
1612            ("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS", "a.*.example.org"),
1613            ("TEAMCRAFT_WEBHOOK_ALLOWED_HOSTS", "http://erp/x"),
1614            ("TEAMCRAFT_WEBHOOK_ALLOW_PRIVATE", "sometimes"),
1615            ("TEAMCRAFT_WEBHOOK_RETENTION_DAYS", "0"),
1616        ] {
1617            assert!(config_from(&[(key, value)]).is_err(), "{key}={value}");
1618        }
1619    }
1620
1621    #[test]
1622    fn metrics_token_is_optional_trimmed_and_not_too_short() {
1623        assert!(config_from(&[]).unwrap().metrics_token.is_none());
1624        assert!(config_from(&[("TEAMCRAFT_METRICS_TOKEN", "  ")])
1625            .unwrap()
1626            .metrics_token
1627            .is_none());
1628        let cfg = config_from(&[("TEAMCRAFT_METRICS_TOKEN", " 0123456789abcdef ")]).unwrap();
1629        assert_eq!(cfg.metrics_token.as_deref(), Some("0123456789abcdef"));
1630        assert!(config_from(&[("TEAMCRAFT_METRICS_TOKEN", "short")]).is_err());
1631    }
1632
1633    /// Without any configuration TeamCraft uses the local development
1634    /// database of docker-compose.yml (no password, trust authentication).
1635    #[test]
1636    fn zero_configuration_uses_the_local_database() {
1637        let cfg = config_from(&[]).expect("no configuration needed");
1638        assert_eq!(cfg.database_display, "teamcraft@localhost:5432/teamcraft");
1639        assert!(cfg.local_login);
1640        assert_eq!(cfg.vault_dir, PathBuf::from("./data/vault"));
1641        assert!(cfg.database_password.is_none());
1642    }
1643
1644    /// The packaged configuration: the local PostgreSQL socket, peer authentication.
1645    #[test]
1646    fn unix_socket_database() {
1647        let cfg = config_from(&[
1648            ("TEAMCRAFT_DB_HOST", "/run/postgresql"),
1649            ("TEAMCRAFT_DB_USER", "teamcraft"),
1650        ])
1651        .unwrap();
1652        assert_eq!(
1653            cfg.database.get_socket().map(|p| p.display().to_string()),
1654            Some("/run/postgresql".to_string())
1655        );
1656        assert_eq!(
1657            cfg.database_display,
1658            "teamcraft@/run/postgresql:5432/teamcraft"
1659        );
1660        // the packaged system configuration parses
1661        let file = include_str!("../packaging/linux/teamcraft.env");
1662        let vars: std::collections::HashMap<String, String> =
1663            dotenvy::from_read_iter(file.as_bytes())
1664                .map(|r| r.expect("valid line"))
1665                .collect();
1666        assert_eq!(vars["TEAMCRAFT_VAULT_DIR"], "/var/lib/teamcraft/vault");
1667        let packaged = Config::from_lookup(|k| vars.get(k).cloned()).expect("packaged config");
1668        assert!(
1669            packaged.bind_addr.ip().is_loopback(),
1670            "first-start setup works"
1671        );
1672        assert!(packaged.database.get_socket().is_some());
1673    }
1674
1675    /// The Docker installation: deploy/teamcraft.env.example plus the
1676    /// `environment` of deploy/docker-compose.yml give a valid configuration.
1677    #[test]
1678    fn docker_deployment_configuration() {
1679        let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
1680        std::fs::create_dir_all(&dir).unwrap();
1681        let secret = dir.join("db_password");
1682        std::fs::write(&secret, "generated-password\n").unwrap();
1683
1684        let example = include_str!("../deploy/teamcraft.env.example");
1685        let mut vars: HashMap<String, String> = dotenvy::from_read_iter(example.as_bytes())
1686            .map(|r| r.expect("valid line"))
1687            .collect();
1688        let compose = include_str!("../deploy/docker-compose.yml");
1689        let mut from_compose = 0;
1690        for line in compose.lines().map(str::trim) {
1691            if let Some((key, value)) = line.split_once(": ") {
1692                if key.starts_with("TEAMCRAFT_") {
1693                    vars.insert(key.to_string(), value.trim_matches('"').to_string());
1694                    from_compose += 1;
1695                }
1696            }
1697        }
1698        assert!(from_compose >= 5, "compose environment found");
1699        assert_eq!(
1700            vars["TEAMCRAFT_DB_PASSWORD_FILE"], "/run/secrets/db_password",
1701            "the password comes from the Docker secret"
1702        );
1703        vars.insert(
1704            "TEAMCRAFT_DB_PASSWORD_FILE".into(),
1705            secret.to_str().unwrap().into(),
1706        );
1707
1708        let cfg = Config::from_lookup(|k| vars.get(k).cloned()).expect("docker config");
1709        assert_eq!(cfg.bind_addr.to_string(), "0.0.0.0:8080");
1710        assert_eq!(cfg.database_display, "teamcraft@db:5432/teamcraft");
1711        assert_eq!(cfg.vault_dir, PathBuf::from("/var/lib/teamcraft/vault"));
1712        assert!(cfg.local_login);
1713        assert!(!cfg.summary().contains("generated-password"));
1714        std::fs::remove_dir_all(dir).ok();
1715    }
1716
1717    #[test]
1718    fn url_and_https_detection() {
1719        let cfg = config_from(&[
1720            ("TEAMCRAFT_DATABASE_URL", "postgres://u:p@db:5433/plm"),
1721            ("TEAMCRAFT_PUBLIC_URL", "https://plm.example.org/"),
1722            ("TEAMCRAFT_LOG_FORMAT", "JSON"),
1723        ])
1724        .unwrap();
1725        assert_eq!(cfg.public_url, "https://plm.example.org");
1726        assert!(cfg.cookie_secure);
1727        assert_eq!(cfg.log_format, LogFormat::Json);
1728        assert_eq!(cfg.database_display, "db:5433/plm (from URL)");
1729        assert_eq!(cfg.database_password.as_deref(), Some("p"));
1730    }
1731
1732    #[test]
1733    fn secrets_from_files() {
1734        let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
1735        std::fs::create_dir_all(&dir).unwrap();
1736        let file = dir.join("admin_pw");
1737        std::fs::write(&file, "from-file-password\n").unwrap();
1738        let cfg = config_from(&[
1739            ("TEAMCRAFT_DB_PASSWORD", "x"),
1740            (
1741                "TEAMCRAFT_BOOTSTRAP_ADMIN_PASSWORD_FILE",
1742                file.to_str().unwrap(),
1743            ),
1744        ])
1745        .unwrap();
1746        let admin = cfg.bootstrap_admin.expect("bootstrap admin");
1747        assert_eq!(admin.username, "admin");
1748        assert_eq!(admin.password, "from-file-password");
1749        std::fs::remove_dir_all(dir).ok();
1750    }
1751
1752    #[test]
1753    fn oidc_settings() {
1754        let cfg = config_from(&[
1755            ("TEAMCRAFT_DB_PASSWORD", "x"),
1756            (
1757                "TEAMCRAFT_OIDC_ISSUER_URL",
1758                "https://auth.example.org/application/o/teamcraft/",
1759            ),
1760            ("TEAMCRAFT_OIDC_CLIENT_ID", "client"),
1761            ("TEAMCRAFT_OIDC_CLIENT_SECRET", "s3cret"),
1762        ])
1763        .unwrap();
1764        let oidc = cfg.oidc.as_ref().expect("oidc enabled");
1765        assert_eq!(oidc.admin_group, "teamcraft-admins");
1766        assert_eq!(oidc.editor_group, "teamcraft-editors");
1767        assert!(oidc.viewer_group.is_none());
1768        assert_eq!(
1769            oidc.redirect_uri(&cfg.public_url),
1770            "http://localhost:8080/auth/oidc/callback"
1771        );
1772        // Local login is off by default once SSO is configured.
1773        assert!(!cfg.local_login);
1774        assert!(!cfg.summary().contains("s3cret"));
1775
1776        // OIDC without a client secret is a configuration error.
1777        assert!(config_from(&[
1778            ("TEAMCRAFT_DB_PASSWORD", "x"),
1779            ("TEAMCRAFT_OIDC_ISSUER_URL", "https://auth.example.org/"),
1780            ("TEAMCRAFT_OIDC_CLIENT_ID", "client"),
1781        ])
1782        .is_err());
1783    }
1784
1785    #[test]
1786    fn some_login_method_is_required() {
1787        let defaults = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1788        assert!(defaults.local_login);
1789        assert!(config_from(&[
1790            ("TEAMCRAFT_DB_PASSWORD", "x"),
1791            ("TEAMCRAFT_LOCAL_LOGIN", "false")
1792        ])
1793        .is_err());
1794    }
1795
1796    #[test]
1797    fn invalid_values_are_rejected() {
1798        assert!(config_from(&[
1799            ("TEAMCRAFT_DB_PASSWORD", "x"),
1800            ("TEAMCRAFT_BIND_ADDR", "nope")
1801        ])
1802        .is_err());
1803        assert!(config_from(&[
1804            ("TEAMCRAFT_DB_PASSWORD", "x"),
1805            ("TEAMCRAFT_PUBLIC_URL", "ftp://x")
1806        ])
1807        .is_err());
1808        assert!(config_from(&[
1809            ("TEAMCRAFT_DB_PASSWORD", "x"),
1810            ("TEAMCRAFT_MAX_UPLOAD_MB", "0")
1811        ])
1812        .is_err());
1813        assert!(config_from(&[
1814            ("TEAMCRAFT_DB_PASSWORD", "x"),
1815            ("TEAMCRAFT_UPLOAD_CHUNK_MB", "0")
1816        ])
1817        .is_err());
1818        assert!(config_from(&[
1819            ("TEAMCRAFT_DB_PASSWORD", "x"),
1820            ("TEAMCRAFT_COOKIE_SECURE", "maybe")
1821        ])
1822        .is_err());
1823        assert!(config_from(&[
1824            ("TEAMCRAFT_DB_PASSWORD", "x"),
1825            ("TEAMCRAFT_REQUEST_DIGEST_HOURS", "721")
1826        ])
1827        .is_err());
1828        let off = config_from(&[
1829            ("TEAMCRAFT_DB_PASSWORD", "x"),
1830            ("TEAMCRAFT_REQUEST_DIGEST_HOURS", "0"),
1831        ])
1832        .unwrap();
1833        assert_eq!(off.request_digest_hours, 0, "0 switches the digest off");
1834    }
1835
1836    #[test]
1837    fn preview_limits_from_the_environment() {
1838        let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")])
1839            .unwrap()
1840            .preview;
1841        assert_eq!(d, crate::preview::PreviewLimits::default());
1842        assert_eq!(d.max_result_bytes, 64 * 1024 * 1024);
1843        assert_eq!(d.max_memory_bytes, 1024 * 1024 * 1024);
1844        let c = config_from(&[
1845            ("TEAMCRAFT_DB_PASSWORD", "x"),
1846            ("TEAMCRAFT_PREVIEW_MAX_RESULT_MB", "8"),
1847            ("TEAMCRAFT_PREVIEW_MAX_MEMORY_MB", "0"),
1848        ])
1849        .unwrap()
1850        .preview;
1851        assert_eq!(c.max_result_bytes, 8 * 1024 * 1024);
1852        assert_eq!(c.max_memory_bytes, 0, "0 = no limit");
1853        for bad in [
1854            ("TEAMCRAFT_PREVIEW_MAX_RESULT_MB", "0"),
1855            ("TEAMCRAFT_PREVIEW_MAX_MESH_MB", "x"),
1856            ("TEAMCRAFT_PREVIEW_MAX_STEP_MB", "0"),
1857        ] {
1858            assert!(config_from(&[("TEAMCRAFT_DB_PASSWORD", "x"), bad]).is_err());
1859        }
1860    }
1861
1862    #[test]
1863    fn latest_retention_grace_days() {
1864        let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1865        assert_eq!(d.retention_latest_grace_days, 7);
1866        for (raw, want) in [("0", 0), ("1", 1), ("3650", 3650)] {
1867            let c = config_from(&[
1868                ("TEAMCRAFT_DB_PASSWORD", "x"),
1869                ("TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS", raw),
1870            ])
1871            .unwrap();
1872            assert_eq!(c.retention_latest_grace_days, want, "{raw}");
1873        }
1874        for bad in ["3651", "-1", "x"] {
1875            assert!(
1876                config_from(&[
1877                    ("TEAMCRAFT_DB_PASSWORD", "x"),
1878                    ("TEAMCRAFT_RETENTION_LATEST_GRACE_DAYS", bad)
1879                ])
1880                .is_err(),
1881                "{bad}"
1882            );
1883        }
1884    }
1885
1886    #[test]
1887    fn retention_settings() {
1888        let d = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
1889        assert_eq!(d.audit_retention_days, 0, "the audit log is kept for ever");
1890        assert_eq!(
1891            d.preview_cache_max_bytes, 0,
1892            "the preview cache has no limit"
1893        );
1894        let c = config_from(&[
1895            ("TEAMCRAFT_DB_PASSWORD", "x"),
1896            ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "3650"),
1897            ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "2048"),
1898        ])
1899        .unwrap();
1900        assert_eq!(c.audit_retention_days, 3650);
1901        assert_eq!(c.preview_cache_max_bytes, 2048 * 1024 * 1024);
1902        for bad in [
1903            ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "1"),
1904            ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "29"),
1905            ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "36501"),
1906            ("TEAMCRAFT_AUDIT_RETENTION_DAYS", "-1"),
1907            ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "x"),
1908            ("TEAMCRAFT_PREVIEW_CACHE_MAX_MB", "16777217"),
1909        ] {
1910            assert!(
1911                config_from(&[("TEAMCRAFT_DB_PASSWORD", "x"), bad]).is_err(),
1912                "{bad:?}"
1913            );
1914        }
1915    }
1916
1917    #[test]
1918    fn local_agents_switch() {
1919        let base = ("TEAMCRAFT_DB_PASSWORD", "x");
1920        // the default changes nothing: local agents are allowed
1921        let cfg = config_from(&[base]).unwrap();
1922        assert_eq!(cfg.local_agents, LocalAgents::Allowed);
1923        assert!(cfg.summary().contains("local_agents=allowed"));
1924        for (raw, want) in [
1925            ("allowed", LocalAgents::Allowed),
1926            ("off", LocalAgents::Off),
1927            (" OFF ", LocalAgents::Off),
1928            ("Allowed", LocalAgents::Allowed),
1929            // an empty value counts as unset, like for every variable
1930            ("", LocalAgents::Allowed),
1931        ] {
1932            let cfg = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", raw)]).unwrap();
1933            assert_eq!(cfg.local_agents, want, "{raw:?}");
1934        }
1935        let off = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", "off")]).unwrap();
1936        assert!(!off.local_agents.allowed());
1937        assert!(off.summary().contains("local_agents=off"));
1938        // a typo must not silently leave agents allowed: refused at start
1939        for bad in ["false", "no", "disabled", "on", "of"] {
1940            let err = config_from(&[base, ("TEAMCRAFT_LOCAL_AGENTS", bad)])
1941                .err()
1942                .unwrap_or_else(|| panic!("{bad:?} accepted"));
1943            assert!(err.to_string().contains("TEAMCRAFT_LOCAL_AGENTS"), "{err}");
1944        }
1945    }
1946
1947    #[test]
1948    fn local_agents_file_variant() {
1949        let dir = std::env::temp_dir().join(format!("tc-local-agents-{}", std::process::id()));
1950        std::fs::create_dir_all(&dir).unwrap();
1951        let file = dir.join("local_agents");
1952        std::fs::write(&file, "off\n").unwrap();
1953        let path = file.to_string_lossy().to_string();
1954        let cfg = config_from(&[
1955            ("TEAMCRAFT_DB_PASSWORD", "x"),
1956            ("TEAMCRAFT_LOCAL_AGENTS_FILE", &path),
1957        ])
1958        .unwrap();
1959        assert_eq!(cfg.local_agents, LocalAgents::Off);
1960        let _ = std::fs::remove_dir_all(&dir);
1961    }
1962
1963    fn assistant_from(pairs: &[(&str, &str)]) -> Result<Option<AssistantConfig>> {
1964        let mut all = vec![("TEAMCRAFT_DB_PASSWORD", "x")];
1965        all.extend_from_slice(pairs);
1966        config_from(&all).map(|c| c.assistant)
1967    }
1968
1969    #[test]
1970    fn assistant_providers() {
1971        use AssistantProvider::*;
1972        // the old variable alone: the Anthropic defaults, as before
1973        let a = assistant_from(&[("TEAMCRAFT_ANTHROPIC_API_KEY", "k")])
1974            .unwrap()
1975            .unwrap();
1976        assert_eq!(a.provider, Anthropic);
1977        assert_eq!(a.base_url, "https://api.anthropic.com/v1");
1978        assert_eq!(a.api_key.as_deref(), Some("k"));
1979        assert_eq!(a.model, DEFAULT_ASSISTANT_MODEL);
1980        assert_eq!(a.max_tokens, 16_000);
1981        assert_eq!(a.request_timeout, Duration::from_secs(180));
1982        assert_eq!(a.run_timeout, Duration::from_secs(240));
1983        assert_eq!(a.max_concurrent, 4);
1984        assert_eq!(a.label(), "anthropic:claude-opus-5-5@api.anthropic.com");
1985
1986        // the new key variable, the alias with the same value, a conflict
1987        let new = [("TEAMCRAFT_ASSISTANT_API_KEY", "k")];
1988        assert_eq!(assistant_from(&new).unwrap().unwrap().provider, Anthropic);
1989        assert!(assistant_from(&[
1990            ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
1991            ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
1992        ])
1993        .unwrap()
1994        .is_some());
1995        let conflict = assistant_from(&[
1996            ("TEAMCRAFT_ASSISTANT_API_KEY", "one"),
1997            ("TEAMCRAFT_ANTHROPIC_API_KEY", "two"),
1998        ])
1999        .unwrap_err()
2000        .to_string();
2001        assert!(conflict.contains("different values"));
2002        assert!(
2003            !conflict.contains("one") && !conflict.contains("two"),
2004            "no key in the error"
2005        );
2006
2007        // OpenAI: model required, default base, key required
2008        assert!(assistant_from(&[
2009            ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai"),
2010            ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2011        ])
2012        .is_err());
2013        let a = assistant_from(&[
2014            ("TEAMCRAFT_ASSISTANT_PROVIDER", "OpenAI"),
2015            ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2016            ("TEAMCRAFT_ASSISTANT_MODEL", "gpt-5.4"),
2017        ])
2018        .unwrap()
2019        .unwrap();
2020        assert_eq!(
2021            (a.provider, a.base_url.as_str()),
2022            (OpenAi, "https://api.openai.com/v1")
2023        );
2024        assert!(
2025            assistant_from(&[
2026                ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai"),
2027                ("TEAMCRAFT_ASSISTANT_MODEL", "gpt-5.4"),
2028            ])
2029            .unwrap()
2030            .is_none(),
2031            "no key: disabled"
2032        );
2033
2034        // openai-compatible: a base URL is enough, no key
2035        let local = [
2036            ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2037            (
2038                "TEAMCRAFT_ASSISTANT_BASE_URL",
2039                "http://ollama.internal:11434/v1/",
2040            ),
2041            ("TEAMCRAFT_ASSISTANT_MODEL", "llama3.1:8b"),
2042        ];
2043        let a = assistant_from(&local).unwrap().unwrap();
2044        assert_eq!(a.provider, OpenAiCompatible);
2045        assert_eq!(
2046            a.base_url, "http://ollama.internal:11434/v1",
2047            "trailing slash dropped"
2048        );
2049        assert!(a.api_key.is_none());
2050        assert_eq!(a.label(), "openai-compatible:llama3.1:8b@ollama.internal");
2051        assert!(
2052            a.insecure_key_warning().is_none(),
2053            "no key, nothing to leak"
2054        );
2055        assert!(
2056            assistant_from(&[local[0], ("TEAMCRAFT_ASSISTANT_API_KEY", "k"), local[2]]).is_err(),
2057            "base URL required"
2058        );
2059        assert!(assistant_from(&local[..2]).is_err(), "model required");
2060        assert!(
2061            assistant_from(&[
2062                ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2063                ("TEAMCRAFT_ASSISTANT_MODEL", "m")
2064            ])
2065            .unwrap()
2066            .is_none(),
2067            "neither key nor URL: disabled"
2068        );
2069        assert!(assistant_from(&[("TEAMCRAFT_ASSISTANT_PROVIDER", "gemini")]).is_err());
2070
2071        // every setting; the run timeout follows the request timeout by default
2072        let a = assistant_from(&[
2073            ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2074            ("TEAMCRAFT_ASSISTANT_MAX_TOKENS", "4000"),
2075            ("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT", "600"),
2076            ("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT", "2"),
2077            ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "7"),
2078        ])
2079        .unwrap()
2080        .unwrap();
2081        assert_eq!(a.max_tokens, 4000);
2082        assert_eq!(a.request_timeout, Duration::from_secs(600));
2083        assert_eq!(a.run_timeout, Duration::from_secs(600));
2084        assert_eq!(a.max_concurrent, 2);
2085        assert_eq!(a.retention_days, 7);
2086        for (key, value) in [
2087            ("TEAMCRAFT_ASSISTANT_MAX_TOKENS", "10"),
2088            ("TEAMCRAFT_ASSISTANT_REQUEST_TIMEOUT", "1"),
2089            ("TEAMCRAFT_ASSISTANT_RUN_TIMEOUT", "100"), // below the request timeout of 180
2090            ("TEAMCRAFT_ASSISTANT_MAX_CONCURRENT", "0"),
2091            ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "0"),
2092            ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "366"),
2093        ] {
2094            assert!(
2095                assistant_from(&[("TEAMCRAFT_ASSISTANT_API_KEY", "k"), (key, value)]).is_err(),
2096                "{key}={value}"
2097            );
2098        }
2099    }
2100
2101    #[test]
2102    fn internal_urls_are_parsed_and_matched() {
2103        let base = [
2104            ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2105            ("TEAMCRAFT_ASSISTANT_PROVIDER", "openai-compatible"),
2106            ("TEAMCRAFT_ASSISTANT_MODEL", "m"),
2107            (
2108                "TEAMCRAFT_ASSISTANT_BASE_URL",
2109                "http://ollama.internal:11434/v1",
2110            ),
2111        ];
2112        let a = assistant_from(&base).unwrap().unwrap();
2113        assert!(a.internal_urls.is_empty() && !a.is_internal());
2114        let mut with = base.to_vec();
2115        with.push((
2116            "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
2117            "https://other.example, http://OLLAMA.internal:11434/",
2118        ));
2119        let a = assistant_from(&with).unwrap().unwrap();
2120        assert_eq!(a.internal_urls.len(), 2);
2121        assert!(a.is_internal());
2122        let mut elsewhere = base.to_vec();
2123        elsewhere.push((
2124            "TEAMCRAFT_ASSISTANT_INTERNAL_URLS",
2125            "http://ollama.internal:9999",
2126        ));
2127        assert!(!assistant_from(&elsewhere).unwrap().unwrap().is_internal());
2128        for bad in [
2129            "not a url",
2130            "ftp://x.internal",
2131            "https://u:p@x.internal",
2132            "https://x.internal/?q=1",
2133        ] {
2134            let mut v = base.to_vec();
2135            v.push(("TEAMCRAFT_ASSISTANT_INTERNAL_URLS", bad));
2136            assert!(assistant_from(&v).is_err(), "{bad}");
2137        }
2138    }
2139
2140    #[test]
2141    fn url_prefix_matching_respects_segments_scheme_and_port() {
2142        assert!(url_is_under("https://a.example/v1", "https://a.example"));
2143        assert!(url_is_under(
2144            "https://a.example/v1/x",
2145            "https://a.example/v1/"
2146        ));
2147        assert!(url_is_under(
2148            "https://A.example:443/v1",
2149            "https://a.example/v1"
2150        ));
2151        assert!(!url_is_under(
2152            "https://a.example/v10",
2153            "https://a.example/v1"
2154        ));
2155        assert!(!url_is_under("http://a.example/v1", "https://a.example"));
2156        assert!(!url_is_under(
2157            "https://a.example.evil/v1",
2158            "https://a.example"
2159        ));
2160        assert!(!url_is_under("https://a.example:8443", "https://a.example"));
2161    }
2162
2163    #[test]
2164    fn assistant_base_urls_are_checked() {
2165        let with = |url: &str| {
2166            assistant_from(&[
2167                ("TEAMCRAFT_ASSISTANT_API_KEY", "k"),
2168                ("TEAMCRAFT_ASSISTANT_BASE_URL", url),
2169            ])
2170        };
2171        assert!(with("https://api.example.com/v1").is_ok());
2172        for bad in [
2173            "ftp://example.com",
2174            "file:///etc/passwd",
2175            "https://user:pass@example.com/v1",
2176            "https://user@example.com/v1",
2177            "https://example.com/v1?key=1",
2178            "not a url",
2179            "https://",
2180        ] {
2181            let e = with(bad);
2182            assert!(e.is_err(), "{bad}");
2183            assert!(
2184                !e.unwrap_err().to_string().contains("pass@"),
2185                "no credentials in the error"
2186            );
2187        }
2188
2189        // the key over plain http: a warning unless the host is this computer
2190        let warn = |url: &str| with(url).unwrap().unwrap().insecure_key_warning();
2191        assert!(warn("https://llm.example.com/v1").is_none());
2192        assert!(warn("http://localhost:11434/v1").is_none());
2193        assert!(warn("http://127.0.0.1:8000/v1").is_none());
2194        assert!(warn("http://[::1]:8000/v1").is_none());
2195        let w = warn("http://llm.example.com/v1").unwrap();
2196        assert!(w.contains("plain http") && w.contains("llm.example.com"));
2197        assert!(!w.contains("k "), "no key in the warning");
2198        assert!(
2199            warn("http://10.0.0.5:8000/v1").is_some(),
2200            "private is not loopback"
2201        );
2202    }
2203
2204    #[test]
2205    fn assistant_settings() {
2206        let cfg = config_from(&[("TEAMCRAFT_DB_PASSWORD", "x")]).unwrap();
2207        assert!(cfg.assistant.is_none(), "disabled without a key");
2208        assert!(cfg.summary().contains("assistant=off"));
2209
2210        let cfg = config_from(&[
2211            ("TEAMCRAFT_DB_PASSWORD", "x"),
2212            ("TEAMCRAFT_ANTHROPIC_API_KEY", "sk-test-key-123"),
2213        ])
2214        .unwrap();
2215        let a = cfg.assistant.as_ref().unwrap();
2216        assert_eq!(a.model, DEFAULT_ASSISTANT_MODEL);
2217        assert_eq!(a.max_steps, DEFAULT_ASSISTANT_MAX_STEPS);
2218        assert_eq!(a.retention_days, DEFAULT_ASSISTANT_RETENTION_DAYS);
2219        assert!(
2220            !cfg.summary().contains("sk-test-key-123"),
2221            "no key in the log"
2222        );
2223        assert!(
2224            !format!("{a:?}").contains("sk-test-key-123"),
2225            "no key in Debug"
2226        );
2227
2228        let cfg = config_from(&[
2229            ("TEAMCRAFT_DB_PASSWORD", "x"),
2230            ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2231            ("TEAMCRAFT_ASSISTANT_MODEL", "claude-sonnet-5-5"),
2232            ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "5"),
2233        ])
2234        .unwrap();
2235        let a = cfg.assistant.unwrap();
2236        assert_eq!((a.model.as_str(), a.max_steps), ("claude-sonnet-5-5", 5));
2237
2238        // key and model files with only blanks: "not set", not an empty key
2239        let dir = std::env::temp_dir().join(format!("teamcraft-cfg-{}", uuid::Uuid::new_v4()));
2240        std::fs::create_dir_all(&dir).unwrap();
2241        let blank = dir.join("blank");
2242        std::fs::write(&blank, "  \n").unwrap();
2243        let cfg = config_from(&[
2244            ("TEAMCRAFT_DB_PASSWORD", "x"),
2245            ("TEAMCRAFT_ANTHROPIC_API_KEY_FILE", blank.to_str().unwrap()),
2246        ])
2247        .unwrap();
2248        assert!(cfg.assistant.is_none(), "blank key file = disabled");
2249        let cfg = config_from(&[
2250            ("TEAMCRAFT_DB_PASSWORD", "x"),
2251            ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2252            ("TEAMCRAFT_ASSISTANT_MODEL_FILE", blank.to_str().unwrap()),
2253        ])
2254        .unwrap();
2255        assert_eq!(cfg.assistant.unwrap().model, DEFAULT_ASSISTANT_MODEL);
2256        std::fs::remove_dir_all(dir).ok();
2257
2258        for (key, value) in [
2259            ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "0"),
2260            ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "0"),
2261            ("TEAMCRAFT_ASSISTANT_RETENTION_DAYS", "400"),
2262            ("TEAMCRAFT_ASSISTANT_MAX_STEPS", "500"),
2263            ("TEAMCRAFT_ASSISTANT_MODEL", "claude opus"),
2264        ] {
2265            assert!(
2266                config_from(&[
2267                    ("TEAMCRAFT_DB_PASSWORD", "x"),
2268                    ("TEAMCRAFT_ANTHROPIC_API_KEY", "k"),
2269                    (key, value),
2270                ])
2271                .is_err(),
2272                "{key}={value}"
2273            );
2274        }
2275    }
2276
2277    fn names(list: &[&str]) -> Vec<String> {
2278        list.iter().map(|s| s.to_string()).collect()
2279    }
2280
2281    #[test]
2282    fn unknown_variables_are_found_with_a_suggestion() {
2283        let found = unknown_variables(names(&[
2284            "TEAMCRAFT_PUBLIC_URL",
2285            "TEAMCRAFT_PUBLIC_URL_FILE",
2286            "TEAMCRAFT_PUBLC_URL",
2287            "TEAMCRAFT_DB_PASSWORD_FIL",
2288            "TEAMCRAFT_TOTALLY_DIFFERENT_THING_X",
2289            "TEAMCRAFT_REQUIRE_DB_TESTS",
2290            "TEAMCRAFT_ENV_FILE",
2291            "PATH",
2292            "MATTERMOST_ERROR_WEBHOK",
2293        ]));
2294        let get = |n: &str| found.iter().find(|(k, _)| k == n).map(|(_, h)| h.clone());
2295        assert_eq!(found.len(), 4, "{found:?}");
2296        assert_eq!(
2297            get("TEAMCRAFT_PUBLC_URL"),
2298            Some(Some("TEAMCRAFT_PUBLIC_URL".into()))
2299        );
2300        assert_eq!(
2301            get("TEAMCRAFT_DB_PASSWORD_FIL"),
2302            Some(Some("TEAMCRAFT_DB_PASSWORD".into()))
2303        );
2304        assert_eq!(
2305            get("MATTERMOST_ERROR_WEBHOK"),
2306            Some(Some("MATTERMOST_ERROR_WEBHOOK".into()))
2307        );
2308        assert_eq!(get("TEAMCRAFT_TOTALLY_DIFFERENT_THING_X"), Some(None));
2309        // known, file form of a known, tooling, foreign: no warning
2310        assert_eq!(get("TEAMCRAFT_PUBLIC_URL_FILE"), None);
2311        assert_eq!(get("TEAMCRAFT_REQUIRE_DB_TESTS"), None);
2312        assert_eq!(get("TEAMCRAFT_ENV_FILE"), None);
2313        assert_eq!(get("PATH"), None);
2314    }
2315
2316    #[test]
2317    fn unknown_file_variable_suggests_the_file_form() {
2318        assert_eq!(
2319            unknown_variables(names(&["TEAMCRAFT_DB_PASWORD_FILE"])),
2320            vec![(
2321                "TEAMCRAFT_DB_PASWORD_FILE".to_string(),
2322                Some("TEAMCRAFT_DB_PASSWORD_FILE".to_string())
2323            )]
2324        );
2325    }
2326
2327    #[test]
2328    fn edit_distance_basics() {
2329        assert_eq!(edit_distance("", ""), 0);
2330        assert_eq!(edit_distance("abc", "abc"), 0);
2331        assert_eq!(edit_distance("abc", ""), 3);
2332        assert_eq!(edit_distance("", "abc"), 3);
2333        assert_eq!(edit_distance("kitten", "sitting"), 3);
2334    }
2335
2336    /// Variable-like words (`TEAMCRAFT_…`, `MATTERMOST_…`, `DATABASE_URL`) of
2337    /// `text`; with `quoted` only those that are a whole string literal.
2338    fn variable_words(text: &str, quoted: bool) -> std::collections::BTreeSet<String> {
2339        let is_word = |c: char| c.is_ascii_alphanumeric() || c == '_';
2340        let mut out = std::collections::BTreeSet::new();
2341        let mut word_start: Option<usize> = None;
2342        // a sentinel space flushes a word at the end of the text
2343        for (pos, c) in text.char_indices().chain([(text.len(), ' ')]) {
2344            if is_word(c) {
2345                word_start.get_or_insert(pos);
2346                continue;
2347            }
2348            let Some(start) = word_start.take() else {
2349                continue;
2350            };
2351            let word = text.get(start..pos).unwrap_or("");
2352            let before = text.get(..start).and_then(|t| t.chars().last());
2353            let named = word.starts_with("TEAMCRAFT_")
2354                || word.starts_with("MATTERMOST_")
2355                || word == "DATABASE_URL";
2356            let literal = before == Some('"') && c == '"';
2357            if named && (literal || !quoted) && !word.chars().any(|c| c.is_ascii_lowercase()) {
2358                out.insert(word.to_string());
2359            }
2360        }
2361        out
2362    }
2363
2364    #[test]
2365    fn variable_words_scanner() {
2366        let words = variable_words("a \"TEAMCRAFT_A\" `TEAMCRAFT_B_*` TEAMCRAFT_c x", false);
2367        assert_eq!(
2368            words.into_iter().collect::<Vec<_>>(),
2369            vec!["TEAMCRAFT_A".to_string(), "TEAMCRAFT_B_".to_string()]
2370        );
2371        let words = variable_words("\"TEAMCRAFT_A\" TEAMCRAFT_B TEAMCRAFT_C", true);
2372        assert_eq!(words.into_iter().collect::<Vec<_>>(), vec!["TEAMCRAFT_A"]);
2373    }
2374
2375    #[test]
2376    fn known_variables_match_the_source() {
2377        let source = include_str!("config.rs");
2378        let production = source.split("mod tests {").next().unwrap_or(source);
2379        let read: std::collections::BTreeSet<String> = variable_words(production, true)
2380            .into_iter()
2381            .filter(|w| !w.ends_with('_') && !TOOLING_VARIABLES.contains(&w.as_str()))
2382            .collect();
2383        let known: std::collections::BTreeSet<String> =
2384            KNOWN_VARIABLES.iter().map(|s| s.to_string()).collect();
2385        // the command line reads these two itself (src/main.rs)
2386        let cli_only = ["TEAMCRAFT_SERVER", "TEAMCRAFT_TOKEN"];
2387        let missing: Vec<_> = read.difference(&known).collect();
2388        assert!(
2389            missing.is_empty(),
2390            "read by config.rs, not in KNOWN_VARIABLES: {missing:?}"
2391        );
2392        let stale: Vec<_> = known
2393            .difference(&read)
2394            .filter(|k| !cli_only.contains(&k.as_str()))
2395            .collect();
2396        assert!(
2397            stale.is_empty(),
2398            "in KNOWN_VARIABLES, not read by config.rs: {stale:?}"
2399        );
2400        assert!(
2401            KNOWN_VARIABLES.windows(2).all(|w| w[0] < w[1]),
2402            "keep KNOWN_VARIABLES sorted"
2403        );
2404        for tool in TOOLING_VARIABLES {
2405            assert!(!known.contains(*tool), "{tool} is in both lists");
2406        }
2407    }
2408
2409    #[test]
2410    fn documentation_and_known_variables_agree() {
2411        let docs = include_str!("../docs/src/admin/configuration.md");
2412        let words = variable_words(docs, false);
2413        let (prefixes, full): (Vec<_>, Vec<_>) = words.iter().partition(|w| w.ends_with('_'));
2414        let undocumented: Vec<_> = KNOWN_VARIABLES
2415            .iter()
2416            .filter(|k| {
2417                !full.iter().any(|w| w.as_str() == **k)
2418                    && !prefixes.iter().any(|p| k.starts_with(p.as_str()))
2419            })
2420            .collect();
2421        assert!(
2422            undocumented.is_empty(),
2423            "not in configuration.md: {undocumented:?}"
2424        );
2425        let real =
2426            |name: &str| KNOWN_VARIABLES.contains(&name) || TOOLING_VARIABLES.contains(&name);
2427        let stale: Vec<_> = full
2428            .iter()
2429            .filter(|w| !real(w) && !real(w.strip_suffix("_FILE").unwrap_or(w.as_str())))
2430            .collect();
2431        assert!(stale.is_empty(), "documented, but unknown: {stale:?}");
2432        let stale_prefixes: Vec<_> = prefixes
2433            .iter()
2434            .filter(|p| {
2435                !KNOWN_VARIABLES
2436                    .iter()
2437                    .chain(TOOLING_VARIABLES)
2438                    .any(|k| k.starts_with(p.as_str()))
2439            })
2440            .collect();
2441        assert!(
2442            stale_prefixes.is_empty(),
2443            "documented prefix matches nothing: {stale_prefixes:?}"
2444        );
2445    }
2446}