Skip to main content

apiplant_core/
config.rs

1//! `main.toml` — the top-level server configuration.
2//!
3//! Every field is optional: a missing file, or a file missing any given key,
4//! falls back to a safe default. The only piece of configuration that is
5//! *inferred* rather than declared is TLS: if the app directory contains an
6//! `https/` folder with a cert + key, the server serves HTTPS.
7//!
8//! ## Environment variables
9//!
10//! Any string value here — like any string in any of the app's TOML files — may
11//! reference the environment: `url = "$DATABASE_URL"`,
12//! `port = "${PORT:-8080}"`. See [`crate::env`] for the syntax.
13
14use crate::schema::Access;
15use serde::Deserialize;
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::Path;
18
19/// Fully-resolved server configuration.
20#[derive(Debug, Clone, Default, Deserialize)]
21#[serde(default)]
22pub struct Config {
23    pub app: AppConfig,
24    pub server: ServerConfig,
25    pub database: DatabaseConfig,
26    pub auth: AuthConfig,
27    pub rate_limit: RateLimitConfig,
28    pub docs: DocsConfig,
29    pub admin: AdminConfig,
30    pub public: PublicConfig,
31    pub email: EmailConfig,
32    pub cache: CacheConfig,
33    pub storage: StorageConfig,
34    pub queues: QueuesConfig,
35    pub payments: PaymentsConfig,
36    pub ai: AiConfig,
37    pub oauth: OAuthConfig,
38    pub observability: ObservabilityConfig,
39}
40
41/// What the app calls itself.
42///
43/// The directory an app lives in is a developer's filing decision —
44/// `07-functions`, `api-v2`, `backend` — and the dashboard header is read by
45/// people who never see it. This is where an app says the name they should
46/// read instead.
47#[derive(Debug, Clone, Default, Deserialize)]
48#[serde(default)]
49pub struct AppConfig {
50    /// Display name, used wherever the app is named to a person — the admin
51    /// dashboard's header and title. Unset falls back to the directory name.
52    pub name: Option<String>,
53}
54
55/// Accepts either a bare string or a list of them, so a config that names one
56/// domain doesn't have to be written as a one-element list.
57fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
58    #[derive(Deserialize)]
59    #[serde(untagged)]
60    enum OneOrMany {
61        One(String),
62        Many(Vec<String>),
63    }
64    Ok(match OneOrMany::deserialize(de)? {
65        OneOrMany::One(s) => vec![s],
66        OneOrMany::Many(v) => v,
67    })
68}
69
70#[derive(Debug, Clone, Deserialize)]
71#[serde(default)]
72pub struct ServerConfig {
73    /// Interface to bind, e.g. `0.0.0.0`. Empty or `*` means every interface
74    /// and is normalised to `0.0.0.0` on load.
75    pub host: String,
76    /// TCP port.
77    pub port: u16,
78    /// Only answer requests whose `Host:` header is one of these. Written as a
79    /// single string (`domain = "api.example.com"`) or a list
80    /// (`domain = ["api.example.com", "www.example.com"]`). Unset — or the
81    /// catch-all spellings `""`, `*` and `_` (nginx's `server_name _`) —
82    /// answers any host, and all of them normalise to an empty list on load.
83    #[serde(deserialize_with = "one_or_many")]
84    pub domain: Vec<String>,
85    /// Sub-path the API is mounted under, e.g. `/api`. Always starts with `/`
86    /// and never ends with one (normalised on load).
87    pub base_path: String,
88    /// Number of worker threads. `None` = one per CPU.
89    pub workers: Option<usize>,
90    /// The origin this server is reached at from outside — `https://api.example.com`.
91    ///
92    /// Only links that leave the process need it: an invitation email has to
93    /// name a URL, and a request's own `Host:` header is the wrong source for
94    /// one (a message is composed once and read anywhere, possibly behind a
95    /// proxy that rewrote it). Unset falls back to the first configured
96    /// `domain`, then to `http://<host>:<port>`, which is right for local
97    /// development and wrong in front of a load balancer — so set it there.
98    pub public_url: String,
99}
100
101impl Default for ServerConfig {
102    fn default() -> Self {
103        ServerConfig {
104            host: "0.0.0.0".to_string(),
105            port: 8080,
106            domain: Vec::new(),
107            base_path: "/".to_string(),
108            workers: None,
109            public_url: String::new(),
110        }
111    }
112}
113
114impl ServerConfig {
115    /// The origin to put in a link that will be read outside this process.
116    ///
117    /// Prefers what the app declared, then the first domain it answers for,
118    /// and only then the socket it happens to be bound to.
119    pub fn public_origin(&self) -> String {
120        if !self.public_url.is_empty() {
121            return self.public_url.trim_end_matches('/').to_string();
122        }
123        if let Some(domain) = self.domain.first() {
124            // A bare domain is a hostname, not a URL; assume the scheme every
125            // deployment that has a domain name is using.
126            return if domain.contains("://") {
127                domain.trim_end_matches('/').to_string()
128            } else {
129                format!("https://{domain}")
130            };
131        }
132        let host = match self.host.as_str() {
133            "0.0.0.0" | "" | "*" | "::" => "localhost",
134            host => host,
135        };
136        format!("http://{host}:{}", self.port)
137    }
138}
139
140#[derive(Debug, Clone, Deserialize)]
141#[serde(default)]
142pub struct DatabaseConfig {
143    /// Full connection URL. When empty it is assembled from the parts below.
144    pub url: String,
145    pub host: String,
146    pub port: u16,
147    pub name: String,
148    pub user: String,
149    pub password: String,
150    /// Max pool connections.
151    pub max_connections: u32,
152    /// Run pending migrations on boot.
153    pub auto_migrate: bool,
154}
155
156impl Default for DatabaseConfig {
157    fn default() -> Self {
158        DatabaseConfig {
159            url: String::new(),
160            host: "localhost".to_string(),
161            port: 5432,
162            name: "apiplant".to_string(),
163            user: "postgres".to_string(),
164            password: "postgres".to_string(),
165            max_connections: 16,
166            auto_migrate: true,
167        }
168    }
169}
170
171impl DatabaseConfig {
172    /// The connection URL, assembled from parts when `url` was left empty.
173    pub fn resolved_url(&self) -> String {
174        if !self.url.is_empty() {
175            return self.url.clone();
176        }
177        format!(
178            "postgres://{}:{}@{}:{}/{}",
179            self.user, self.password, self.host, self.port, self.name
180        )
181    }
182}
183
184#[derive(Debug, Clone, Deserialize)]
185#[serde(default)]
186pub struct AuthConfig {
187    /// Secret used to sign session JWTs. Auto-generated (and warned about) when
188    /// left empty — set it in production so tokens survive restarts.
189    pub jwt_secret: String,
190    /// Session token lifetime in seconds.
191    pub session_ttl_secs: u64,
192    /// Allow self-service signup on `POST /auth/register`.
193    pub allow_registration: bool,
194
195    // --- the three features that need a mailbox to reach ------------------
196    //
197    // Each is `Option<bool>` rather than `bool` because their honest default is
198    // not a constant: it is "yes, if this app can send email". Leaving one unset
199    // means it follows `[email]`, so configuring a provider turns all three on
200    // and configuring none leaves them off — and neither one asks the developer
201    // to keep two sections in step. Setting one explicitly always wins, which is
202    // how an app that sends mail can still refuse, say, open registration.
203    /// Require a new account to confirm its address before it can sign in.
204    /// Unset follows `[email]`.
205    pub require_email_verification: Option<bool>,
206    /// Offer `POST /auth/invitations`, so an admin can add someone who has no
207    /// account yet. Unset follows `[email]`.
208    pub allow_invitations: Option<bool>,
209    /// Offer `POST /auth/password/forgot` and `/auth/password/reset`. Unset
210    /// follows `[email]`.
211    pub allow_password_reset: Option<bool>,
212
213    /// How long an organisation invitation stays valid (default 7 days).
214    pub invite_ttl_secs: u64,
215    /// How long an address-confirmation link stays valid (default 24 hours).
216    pub verification_ttl_secs: u64,
217    /// How long a password-reset link stays valid (default 1 hour). Short on
218    /// purpose: it is a live credential sitting in a mailbox.
219    pub password_reset_ttl_secs: u64,
220}
221
222impl Default for AuthConfig {
223    fn default() -> Self {
224        AuthConfig {
225            jwt_secret: String::new(),
226            session_ttl_secs: 60 * 60 * 24 * 7,
227            allow_registration: true,
228            require_email_verification: None,
229            allow_invitations: None,
230            allow_password_reset: None,
231            invite_ttl_secs: 60 * 60 * 24 * 7,
232            verification_ttl_secs: 60 * 60 * 24,
233            password_reset_ttl_secs: 60 * 60,
234        }
235    }
236}
237
238impl AuthConfig {
239    /// Whether new accounts must confirm their address, given whether the app
240    /// can send mail at all. An unset flag follows the mailer: asking for a
241    /// confirmation nobody can deliver would lock every new account out.
242    pub fn requires_email_verification(&self, email_enabled: bool) -> bool {
243        self.require_email_verification.unwrap_or(email_enabled) && email_enabled
244    }
245
246    /// Whether invitations are offered. See
247    /// [`requires_email_verification`](Self::requires_email_verification) for
248    /// why an explicit `true` still needs a mailer.
249    pub fn invitations_enabled(&self, email_enabled: bool) -> bool {
250        self.allow_invitations.unwrap_or(email_enabled) && email_enabled
251    }
252
253    /// Whether password reset is offered.
254    pub fn password_reset_enabled(&self, email_enabled: bool) -> bool {
255        self.allow_password_reset.unwrap_or(email_enabled) && email_enabled
256    }
257}
258
259/// How many requests one client may make, before the API starts answering
260/// `429 Too Many Requests`.
261///
262/// Off until asked for: `default = "off"` means an app that says nothing here
263/// is limited nowhere, and an upgrade cannot start refusing traffic that used
264/// to be served. Naming a rate switches it on for every endpoint at once:
265///
266/// ```toml
267/// [rate_limit]
268/// default = "100/1m"
269/// ```
270///
271/// A resource narrows or lifts that per action in its own `[rate_limit]`
272/// section, and a function does the same with a `rate_limit` key in its
273/// `functions/<name>.toml` — see [`crate::RateLimits`].
274///
275/// ## Who "one client" is
276///
277/// The peer socket address, which a caller cannot forge. Behind a reverse
278/// proxy that is the *proxy's* address for every request — one bucket for
279/// everybody, throttling all callers together — so a deployment behind one has
280/// to set `trust_proxy_headers = true` and make sure the proxy *overwrites*
281/// `X-Forwarded-For` rather than appending to it. Trusting that header with
282/// nothing in front of the server hands every caller their own rate limit for
283/// the price of a header line, which is the same as having none.
284#[derive(Debug, Clone, Deserialize)]
285#[serde(default, deny_unknown_fields)]
286pub struct RateLimitConfig {
287    /// Turn every limit off — the app's, the resources' and the functions' —
288    /// without deleting what they say. The switch to flip while an incident is
289    /// being diagnosed.
290    pub enabled: bool,
291    /// The rule every endpoint gets unless something narrower says otherwise.
292    /// `"off"` (the default) limits nothing.
293    pub default: crate::schema::RateLimitRule,
294    /// Read the client address from `X-Forwarded-For` / `X-Real-IP` when
295    /// present, instead of the peer socket. Only true behind a proxy you
296    /// control; see the section note above.
297    pub trust_proxy_headers: bool,
298    /// How often the tracked clients are swept for buckets nobody has used.
299    pub cleanup_interval_secs: u64,
300    /// How long a client's bucket is kept after their last request. Bounds
301    /// what a flood of one-request-each addresses can cost in memory.
302    pub stale_after_secs: u64,
303}
304
305impl Default for RateLimitConfig {
306    fn default() -> Self {
307        RateLimitConfig {
308            enabled: true,
309            default: crate::schema::RateLimitRule::Off,
310            trust_proxy_headers: false,
311            cleanup_interval_secs: 60,
312            stale_after_secs: 600,
313        }
314    }
315}
316
317/// Interactive API documentation (OpenAPI spec + Swagger UI).
318#[derive(Debug, Clone, Deserialize)]
319#[serde(default)]
320pub struct DocsConfig {
321    /// Serve the OpenAPI spec and Swagger UI (default true).
322    pub enabled: bool,
323    /// Path (under `base_path`) the Swagger UI is served at.
324    pub path: String,
325    /// Title shown in the UI and the spec's `info.title`. Unset falls back to
326    /// the app's name — see [`App::docs_title`](crate::App::docs_title) — so an
327    /// app that renames itself renames its docs too.
328    pub title: Option<String>,
329}
330
331impl Default for DocsConfig {
332    fn default() -> Self {
333        DocsConfig {
334            enabled: true,
335            path: "/docs".to_string(),
336            title: None,
337        }
338    }
339}
340
341/// The built-in admin dashboard, served from the binary itself.
342///
343/// Every app gets one, and there is only one: the interface is embedded in
344/// `apiplant` and its manifest is derived from the app on boot. Turn it off for
345/// a deployment that shouldn't expose an operator console at all — an app that
346/// wants its own can serve one from `public/` like any other page.
347#[derive(Debug, Clone, Deserialize)]
348#[serde(default)]
349pub struct AdminConfig {
350    /// Serve the admin dashboard (default true).
351    pub enabled: bool,
352    /// Path the dashboard is served at, outside `base_path`.
353    pub path: String,
354    /// Image shown in place of the apiplant mark, as a URL the browser can
355    /// fetch — usually a file in `public/`. Unset keeps the apiplant mark.
356    pub logo: Option<String>,
357    /// Fall back to [Gravatar] for an account with no `avatar_url` of its own,
358    /// using the hash of its email address (default false).
359    ///
360    /// Off by default because it is a request to a third party for every face
361    /// the dashboard draws, and the hash of an address is enough to confirm a
362    /// guess at it. A deployment that would rather not tell gravatar.com who
363    /// its users are should leave this alone; the dashboard falls back to
364    /// initials, which need nobody's help.
365    ///
366    /// Turning this on is not enough on its own: the dashboard hashes the
367    /// address with WebCrypto, which browsers only expose in a secure context.
368    /// Reached over plain http at anything other than `localhost` or
369    /// `127.0.0.1` — `http://0.0.0.0:8099/admin/`, a LAN address — there is no
370    /// `crypto.subtle` and every face falls back to initials. What the server
371    /// binds to does not matter; the address in the URL bar does.
372    ///
373    /// [Gravatar]: https://gravatar.com
374    pub gravatar: bool,
375    /// Optional AI help for writing text in the admin dashboard.
376    pub ai_assistance: AdminAiAssistanceConfig,
377}
378
379/// Extra browser-side prompting that fills text fields through the app's
380/// configured AI provider.
381#[derive(Debug, Clone, Deserialize)]
382#[serde(default)]
383pub struct AdminAiAssistanceConfig {
384    /// Show the "fill with AI" control in the dashboard.
385    pub enabled: bool,
386    /// Optional system prompt sent only by the dashboard's own field helper.
387    pub system: String,
388    /// Placeholder shown in the helper's prompt box.
389    pub prompt_placeholder: String,
390}
391
392impl Default for AdminAiAssistanceConfig {
393    fn default() -> Self {
394        AdminAiAssistanceConfig {
395            enabled: false,
396            system: String::new(),
397            prompt_placeholder: "Describe what you want AI to write for this field.".to_string(),
398        }
399    }
400}
401
402impl Default for AdminConfig {
403    fn default() -> Self {
404        AdminConfig {
405            enabled: true,
406            path: "/admin".to_string(),
407            logo: None,
408            gravatar: false,
409            ai_assistance: AdminAiAssistanceConfig::default(),
410        }
411    }
412}
413
414/// Static files served from the app's `public/` directory.
415///
416/// When the directory exists its contents are served at the site root, so
417/// `public/index.html` answers `/` and `public/style.css` answers `/style.css`.
418#[derive(Debug, Clone, Deserialize)]
419#[serde(default)]
420pub struct PublicConfig {
421    /// Serve `dir` at the root when it exists (default true).
422    pub enabled: bool,
423    /// Directory (relative to the app root) holding the static site.
424    pub dir: String,
425    /// Page returned for requests that match nothing, relative to `dir`.
426    /// Defaults to `404.html` when that file exists.
427    pub not_found: Option<String>,
428}
429
430impl Default for PublicConfig {
431    fn default() -> Self {
432        PublicConfig {
433            enabled: true,
434            dir: "public".to_string(),
435            not_found: None,
436        }
437    }
438}
439
440/// Outbound email: which provider sends it, and the credentials to do so.
441///
442/// Off by default (`provider = "none"`): an app that never sends mail carries
443/// no configuration and no client. Turning it on is one line plus a key, and
444/// every provider is reached through the same [`send_email`] call from a
445/// function — swapping SendGrid for SES is a config change, not a code change.
446///
447/// [`send_email`]: https://docs.rs/apiplant-function
448#[derive(Debug, Clone, Deserialize)]
449#[serde(default)]
450pub struct EmailConfig {
451    /// `none` (default), `smtp`, `ses`, `sendgrid`, `brevo` (aka `sendinblue`),
452    /// `mailjet`, `mailgun`, `postmark` or `resend`.
453    pub provider: String,
454    /// Envelope sender, e.g. `no-reply@example.com`. Required once enabled; a
455    /// message may override it per-send.
456    pub from: String,
457    /// Display name shown beside `from`.
458    pub from_name: String,
459    /// Default `Reply-To`. Empty = none.
460    pub reply_to: String,
461    /// The provider's API key. For `ses` this is the AWS access key id; for
462    /// `mailjet` the public key; for `smtp` it is unused (see [`SmtpConfig`]).
463    pub api_key: String,
464    /// The second half of a two-part credential: the AWS secret access key for
465    /// `ses`, the private key for `mailjet`. Unused elsewhere.
466    pub api_secret: String,
467    /// AWS region for `ses`, e.g. `eu-west-1`.
468    pub region: String,
469    /// Sending domain for `mailgun`, e.g. `mg.example.com`.
470    pub domain: String,
471    /// How long one send may take before it is abandoned.
472    pub timeout_secs: u64,
473    /// The mark shown in the banner of the messages the framework sends, as a
474    /// path inside [`PublicConfig::dir`] — `logo.png` or `/img/logo.svg`, both
475    /// of which mean the same file. It is turned into an absolute URL against
476    /// `[server] public_url`, because a mail client fetches it from the
477    /// internet rather than from a page. An empty string, or a path with no
478    /// file behind it, leaves the banner showing the app's name alone.
479    pub logo: String,
480    /// Connection details for `provider = "smtp"`.
481    pub smtp: SmtpConfig,
482}
483
484impl Default for EmailConfig {
485    fn default() -> Self {
486        EmailConfig {
487            provider: "none".to_string(),
488            from: String::new(),
489            from_name: String::new(),
490            reply_to: String::new(),
491            api_key: String::new(),
492            api_secret: String::new(),
493            region: String::new(),
494            domain: String::new(),
495            timeout_secs: 15,
496            logo: "logo.png".to_string(),
497            smtp: SmtpConfig::default(),
498        }
499    }
500}
501
502impl EmailConfig {
503    /// Whether a provider is configured at all. `none` and the empty string
504    /// both mean "this app doesn't send mail".
505    pub fn enabled(&self) -> bool {
506        !matches!(
507            self.provider.trim().to_ascii_lowercase().as_str(),
508            "" | "none"
509        )
510    }
511}
512
513/// SMTP transport settings, used only when `provider = "smtp"`.
514///
515/// Every provider here also speaks SMTP, so this is the escape hatch for one
516/// that has no first-class entry above — or for a company relay that has no API
517/// at all.
518#[derive(Debug, Clone, Deserialize)]
519#[serde(default)]
520pub struct SmtpConfig {
521    pub host: String,
522    /// `0` (the default) picks the port that matches `encryption`: 465 for
523    /// `tls`, 587 for `starttls`, 25 for `none`.
524    pub port: u16,
525    pub username: String,
526    pub password: String,
527    /// `starttls` (default), `tls` (implicit TLS, usually port 465) or `none`.
528    pub encryption: String,
529}
530
531impl Default for SmtpConfig {
532    fn default() -> Self {
533        SmtpConfig {
534            host: String::new(),
535            port: 0,
536            username: String::new(),
537            password: String::new(),
538            encryption: "starttls".to_string(),
539        }
540    }
541}
542
543/// An optional Redis cache.
544///
545/// Nothing in the framework caches through it: resources, permissions and the
546/// admin manifest all behave exactly the same whether it is configured or not.
547/// It exists so a *function* has somewhere to put a rate-limit counter, a
548/// memoised third-party response or a short-lived token — see the `cache_*`
549/// helpers on a function's `Context`.
550///
551/// Off unless `url` is set, so an app that doesn't want one pays nothing.
552#[derive(Debug, Clone, Deserialize)]
553#[serde(default)]
554pub struct CacheConfig {
555    /// Turn the configured cache off without deleting its settings.
556    pub enabled: bool,
557    /// Connection URL, e.g. `redis://127.0.0.1:6379` or `rediss://…/0`. Empty
558    /// (the default) means no cache.
559    pub url: String,
560    /// Prepended to every key a function uses, so several apps can share one
561    /// Redis without colliding.
562    pub prefix: String,
563    /// Expiry applied to a `set` that doesn't ask for one. `0` = keys persist.
564    pub default_ttl_secs: u64,
565    /// How long one cache operation may take before it is abandoned.
566    pub timeout_secs: u64,
567}
568
569impl Default for CacheConfig {
570    fn default() -> Self {
571        CacheConfig {
572            enabled: true,
573            url: String::new(),
574            prefix: String::new(),
575            default_ttl_secs: 0,
576            timeout_secs: 5,
577        }
578    }
579}
580
581impl CacheConfig {
582    /// Whether a cache should be connected: switched on *and* pointed at a
583    /// server.
584    pub fn is_active(&self) -> bool {
585        self.enabled && !self.url.trim().is_empty()
586    }
587}
588
589/// Where uploaded files go.
590///
591/// A `file` field holds a *relative* URL — `/files/2026/…/logo.png` — never a
592/// bucket address, and the server answers that URL from whichever backend is
593/// configured. That is the whole point of the indirection: an app that starts
594/// on a mounted volume and later moves to S3 changes four lines of TOML and
595/// nothing else. No row is rewritten, because no row ever named the backend.
596///
597/// ```toml
598/// # A directory — a Docker volume, in practice.
599/// [storage]
600/// backend = "local"
601/// dir     = "storage"
602///
603/// # Or block storage. `r2` is `s3` with an endpoint, and so is MinIO.
604/// [storage]
605/// backend           = "s3"
606/// bucket            = "app-uploads"
607/// region            = "auto"
608/// endpoint          = "https://${R2_ACCOUNT}.r2.cloudflarestorage.com"
609/// access_key_id     = "${R2_ACCESS_KEY_ID}"
610/// secret_access_key = "${R2_SECRET_ACCESS_KEY}"
611/// ```
612#[derive(Debug, Clone, Deserialize)]
613#[serde(default)]
614pub struct StorageConfig {
615    /// `local` (the default), `s3`, or `none` to refuse uploads outright.
616    pub backend: String,
617    /// `local`: the directory uploads are written to, relative to the app root
618    /// unless absolute. In a container this is what you mount a volume at.
619    pub dir: String,
620    /// URL prefix the stored links carry and the server answers on. Always
621    /// starts with `/` and never ends with one (normalised on load).
622    pub public_base: String,
623    /// Largest upload accepted, in megabytes.
624    pub max_size_mb: u64,
625    /// Content types an upload may declare, as exact types (`image/png`) or
626    /// wildcards (`image/*`). Empty (the default) accepts anything — an
627    /// authenticated caller is already trusted to write a row.
628    pub allowed_types: Vec<String>,
629    /// `s3`: the bucket. Required when `backend = "s3"`.
630    pub bucket: String,
631    /// `s3`: the region. R2 and most S3-compatibles want `auto`.
632    pub region: String,
633    /// `s3`: the API origin. Empty uses AWS's own
634    /// (`https://<bucket>.s3.<region>.amazonaws.com`); set it for R2, MinIO,
635    /// Backblaze or any other S3-compatible service.
636    pub endpoint: String,
637    /// `s3`: credentials.
638    pub access_key_id: String,
639    pub secret_access_key: String,
640    /// `s3`: address objects as `<endpoint>/<bucket>/<key>` rather than putting
641    /// the bucket in the hostname. Required by MinIO and by R2 (the default
642    /// when an `endpoint` is set).
643    pub path_style: Option<bool>,
644    /// Key prefix inside the bucket or directory, so several apps can share one.
645    pub prefix: String,
646    /// Serve files from somewhere else entirely — a CDN, or a public bucket —
647    /// by storing absolute URLs under this origin instead of relative ones.
648    ///
649    /// Empty (the default) keeps links relative and proxies reads through the
650    /// server, which is what makes a private bucket work. Setting it is a
651    /// deliberate trade: faster, but the objects must be publicly readable and
652    /// the links stop being portable.
653    pub base_url: String,
654}
655
656impl Default for StorageConfig {
657    fn default() -> Self {
658        StorageConfig {
659            backend: "local".to_string(),
660            dir: "storage".to_string(),
661            public_base: "/files".to_string(),
662            max_size_mb: 10,
663            allowed_types: Vec::new(),
664            bucket: String::new(),
665            region: "auto".to_string(),
666            endpoint: String::new(),
667            access_key_id: String::new(),
668            secret_access_key: String::new(),
669            path_style: None,
670            prefix: String::new(),
671            base_url: String::new(),
672        }
673    }
674}
675
676impl StorageConfig {
677    /// Whether uploads are accepted at all.
678    pub fn is_active(&self) -> bool {
679        !matches!(self.backend.trim().to_lowercase().as_str(), "none" | "")
680    }
681
682    /// `/files` — leading slash, no trailing slash, whatever was written.
683    pub fn normalized_public_base(&self) -> String {
684        let trimmed = self.public_base.trim().trim_matches('/');
685        match trimmed.is_empty() {
686            true => "/files".to_string(),
687            false => format!("/{trimmed}"),
688        }
689    }
690
691    /// Whether objects are addressed as `<endpoint>/<bucket>/<key>`. Explicit
692    /// when written down; otherwise path-style exactly when a custom endpoint
693    /// is set, since that is what every S3-compatible service but AWS wants.
694    pub fn uses_path_style(&self) -> bool {
695        self.path_style.unwrap_or(!self.endpoint.trim().is_empty())
696    }
697
698    pub fn max_size_bytes(&self) -> u64 {
699        self.max_size_mb.saturating_mul(1024 * 1024)
700    }
701}
702
703/// Background work: a message published now, handled by a function shortly
704/// after, outside the request that caused it.
705///
706/// The transport is Postgres and nothing else — no broker to run, no second
707/// thing that can be down. A `publish` writes a row to `queue_message` and
708/// fires a `NOTIFY`; a subscriber wakes on that notification and claims the row
709/// with `FOR UPDATE SKIP LOCKED`. The two halves matter for different reasons:
710/// the *row* is what makes the message survive a restart and lets a failure be
711/// retried, and the *notification* is what makes it happen in milliseconds
712/// rather than on the next poll.
713///
714/// Because a message is a row, the guarantee is **at-least-once**: a handler
715/// that succeeds but crashes before its row is marked done runs again. Write
716/// handlers that can be run twice — the same reason `billing_event` exists.
717///
718/// ```toml
719/// [queues]
720/// # Which function handles which topic. One name, or several.
721/// [queues.subscribe]
722/// "user.signed_up" = "send_welcome"
723/// "order.paid"     = ["fulfil_order", "notify_ops"]
724/// ```
725#[derive(Debug, Clone, Deserialize)]
726#[serde(default)]
727pub struct QueuesConfig {
728    /// Turn message handling off without deleting the subscriptions. Publishing
729    /// still records rows, so nothing is lost while it is off — it is a pause,
730    /// not a drain.
731    pub enabled: bool,
732    /// Prepended to the Postgres `NOTIFY` channel this app wakes on, so two
733    /// apps sharing one database don't wake each other for nothing.
734    pub prefix: String,
735    /// Topic → the function(s) that handle it. Written as one name or a list:
736    ///
737    /// ```toml
738    /// [queues.subscribe]
739    /// "user.signed_up" = "send_welcome"
740    /// "order.paid"     = ["fulfil_order", "notify_ops"]
741    /// ```
742    ///
743    /// Each subscriber gets its **own** row and its own retries, so a failing
744    /// `notify_ops` never re-runs `fulfil_order`.
745    #[serde(deserialize_with = "topic_subscriptions")]
746    pub subscribe: BTreeMap<String, Vec<String>>,
747    /// How often to sweep for work regardless of notifications. The `NOTIFY` is
748    /// what makes delivery immediate; this is the safety net that picks up a
749    /// message published while this process was starting, a retry whose backoff
750    /// has expired, and anything a dropped connection lost the wakeup for.
751    pub poll_secs: u64,
752    /// Most messages claimed in one go. Larger batches trade latency on the
753    /// last message for fewer round trips.
754    pub batch: u32,
755    /// How many times a message is tried before it is left `failed` for a person
756    /// to look at. `1` means no retries at all.
757    pub max_attempts: u32,
758    /// Base of the retry backoff, in seconds: attempt *n* waits
759    /// `retry_backoff_secs * 2^(n-1)`, so the default retries after 10s, 20s,
760    /// 40s, 80s and then gives up.
761    pub retry_backoff_secs: u64,
762    /// How long a claimed message may be worked on before another subscriber
763    /// is allowed to take it.
764    ///
765    /// This is what makes a killed process — an OOM, a rolling deploy, a lost
766    /// node — recoverable rather than a message stuck forever in `running`.
767    /// Set it comfortably above the slowest handler: expiring the lease early
768    /// is what turns at-least-once into "twice, concurrently".
769    pub lease_secs: u64,
770    /// Delete handled messages after this many hours, on the same sweep. `0`
771    /// keeps them forever, which is a reasonable choice for a low-volume app
772    /// that wants the ledger.
773    pub retain_hours: u64,
774    /// Who may publish over HTTP at `POST <base>/queues/{topic}`, in the same
775    /// grammar a resource's `[permissions]` uses.
776    ///
777    /// `private` — the default — means there is no such endpoint at all. A
778    /// topic is an internal name that triggers real work, so it is not
779    /// something to expose without deciding to.
780    pub publish: String,
781}
782
783impl Default for QueuesConfig {
784    fn default() -> Self {
785        QueuesConfig {
786            enabled: true,
787            prefix: "apiplant".to_string(),
788            subscribe: BTreeMap::new(),
789            poll_secs: 30,
790            batch: 10,
791            max_attempts: 5,
792            retry_backoff_secs: 10,
793            lease_secs: 300,
794            retain_hours: 24,
795            publish: "private".to_string(),
796        }
797    }
798}
799
800/// Accepts `"topic" = "fn"` and `"topic" = ["fn", "fn"]` in the same table, for
801/// the same reason [`one_or_many`] exists: the common case is one subscriber,
802/// and it shouldn't have to be written as a one-element list.
803fn topic_subscriptions<'de, D: serde::Deserializer<'de>>(
804    de: D,
805) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
806    #[derive(Deserialize)]
807    #[serde(untagged)]
808    enum OneOrMany {
809        One(String),
810        Many(Vec<String>),
811    }
812    let raw = BTreeMap::<String, OneOrMany>::deserialize(de)?;
813    Ok(raw
814        .into_iter()
815        .map(|(topic, subscribers)| {
816            let subscribers = match subscribers {
817                OneOrMany::One(name) => vec![name],
818                OneOrMany::Many(names) => names,
819            };
820            (
821                topic.trim().to_string(),
822                subscribers
823                    .into_iter()
824                    .map(|name| name.trim().to_string())
825                    .filter(|name| !name.is_empty())
826                    .collect(),
827            )
828        })
829        .filter(|(topic, subscribers): &(String, Vec<String>)| {
830            !topic.is_empty() && !subscribers.is_empty()
831        })
832        .collect())
833}
834
835impl QueuesConfig {
836    /// The `NOTIFY` channel this app's publishers and subscribers meet on.
837    ///
838    /// One channel for the whole app rather than one per topic: the payload
839    /// carries the topic, a listener has a single subscription to re-establish
840    /// after a reconnect, and adding a topic needs no new `LISTEN`. It also
841    /// sidesteps Postgres's 63-byte limit on a channel name, which an app's own
842    /// topic names would otherwise have to live inside.
843    pub fn channel(&self) -> String {
844        let prefix = self.prefix.trim().trim_matches('_');
845        match prefix.is_empty() {
846            true => "apiplant_queue".to_string(),
847            false => format!("{prefix}_queue"),
848        }
849    }
850
851    /// Whether `topic` is a name this app will carry.
852    ///
853    /// Deliberately narrow — letters, digits, and `. _ - :` — because a topic
854    /// is an identifier that ends up in config keys, log lines and dashboard
855    /// filters, and a topic with a space or a quote in it reads as a mistake
856    /// everywhere it appears. Checked when publishing rather than trusted, since
857    /// a topic can arrive from a function's runtime string.
858    pub fn valid_topic(topic: &str) -> bool {
859        let topic = topic.trim();
860        !topic.is_empty()
861            && topic.len() <= 200
862            && topic
863                .chars()
864                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
865    }
866
867    /// The functions subscribed to a topic, in the order they were declared.
868    pub fn subscribers(&self, topic: &str) -> &[String] {
869        self.subscribe
870            .get(topic.trim())
871            .map(Vec::as_slice)
872            .unwrap_or(&[])
873    }
874
875    /// Every function name any topic points at, deduplicated. Used at boot to
876    /// report a subscription whose function isn't loaded.
877    pub fn subscribed_functions(&self) -> BTreeSet<&str> {
878        self.subscribe
879            .values()
880            .flatten()
881            .map(String::as_str)
882            .collect()
883    }
884
885    /// Whether a subscriber loop should run: switched on *and* something to
886    /// listen for. Publishing does not depend on this — a message published
887    /// with no subscriber is still recorded, which is what makes "why didn't my
888    /// handler run?" answerable.
889    pub fn is_active(&self) -> bool {
890        self.enabled && !self.subscribe.is_empty()
891    }
892
893    /// The resolved policy for the HTTP publish endpoint. An unparseable
894    /// `publish` closes the door, matching how every other access string here
895    /// treats a typo — and so does `owner`, which names a column on a row and
896    /// means nothing for a topic.
897    pub fn publish_access(&self) -> Access {
898        match Access::parse(self.publish.trim()) {
899            Access::Owner => Access::Private,
900            access => access,
901        }
902    }
903
904    /// Seconds to wait before retrying a message that has failed `attempts`
905    /// times, doubling each time and capped at an hour so a poisoned message
906    /// doesn't schedule itself past the retention sweep.
907    pub fn retry_delay_secs(&self, attempts: u32) -> u64 {
908        let doubling = 1u64
909            .checked_shl(attempts.saturating_sub(1))
910            .unwrap_or(u64::MAX);
911        self.retry_backoff_secs
912            .saturating_mul(doubling)
913            .min(60 * 60)
914    }
915}
916
917/// Signing in with somebody else's account.
918///
919/// Each `[oauth.<provider>]` block turns one provider on, and a block needs
920/// only the two credentials that provider issued:
921///
922/// ```toml
923/// [oauth.github]
924/// client_id     = "${GITHUB_CLIENT_ID}"
925/// client_secret = "${GITHUB_CLIENT_SECRET}"
926///
927/// [oauth.google]
928/// client_id     = "${GOOGLE_CLIENT_ID}"
929/// client_secret = "${GOOGLE_CLIENT_SECRET}"
930/// ```
931///
932/// Everything else — the authorize URL, the token URL, where the profile is
933/// read from, which scopes ask for an email, whether the provider wants PKCE,
934/// whether it insists on the client secret as HTTP Basic — apiplant knows for
935/// `github`, `google`, `linkedin` and `x`. A provider it does not know is
936/// configured in full (see [`OAuthProviderConfig::style`]), which is how a
937/// fifth one is added without waiting for a release.
938///
939/// Turning any of this on mounts `<base>/auth/oauth/…` and adds the
940/// `oauth_state` [resource](crate::defaults). With no block at all, none of it
941/// exists.
942#[derive(Debug, Clone, Deserialize)]
943#[serde(default)]
944pub struct OAuthConfig {
945    /// Whether a **verified** address from a provider may sign somebody in to
946    /// an existing account carrying the same address (default true).
947    ///
948    /// This is the convenience that makes "I registered with a password, then
949    /// came back through Google" work, and it is safe only because the address
950    /// must be one the provider says it verified. An unverified address is
951    /// never matched, whatever this is set to — that is not a policy, it is the
952    /// difference between signing in and taking over. Set it false and a
953    /// matching address is refused with an answer that says how to connect the
954    /// two deliberately — sign in the way you already can, then link the
955    /// provider from an authenticated session. Inconvenient, and never wrong.
956    ///
957    /// The same refusal is what an *unverified* matching address always gets,
958    /// whatever this is set to.
959    pub link_by_verified_email: bool,
960    /// How long a started sign-in stays completable, in seconds (default 600).
961    /// Long enough to read a consent screen, short enough that an abandoned
962    /// flow is not a lasting hole. Clamped to 60–3600.
963    pub state_ttl_secs: u64,
964    /// Where the browser lands after a successful sign-in through the
965    /// *redirecting* endpoint, as a path on this site (default `/`).
966    ///
967    /// A caller can override it per flow with `?return_to=/somewhere`, which is
968    /// accepted only as a path — never a full URL — because a redirect target
969    /// somebody else chooses is how a sign-in page becomes a phishing hop.
970    pub success_redirect: String,
971    /// Where a *failed* sign-in lands, as a path. Empty (the default) answers
972    /// with a plain JSON error instead, which is what you want while setting
973    /// providers up and not what you want in front of users.
974    pub failure_redirect: String,
975    /// How the session token reaches the browser on the redirecting endpoint:
976    ///
977    /// | Value | Effect |
978    /// |---|---|
979    /// | `fragment` (default) | `…/#token=…` — a fragment is never sent to a server, so it stays out of proxy logs and `Referer` headers |
980    /// | `query` | `…?token=…` — easier to read from a server-rendered page, and it *is* in those logs |
981    /// | `json` | no redirect at all: the callback answers `{ "token": …, "user": … }`, which is what a single-page app posting the code itself wants |
982    pub token_delivery: String,
983    /// The `user` column a provider's name is written to on sign-in, or empty
984    /// to write none. `display_name` is in the built-in model; an app that
985    /// calls it something else names it here, and one that would rather keep
986    /// its own copy of a name sets this to `""`.
987    pub name_field: String,
988    /// The `user` column a provider's picture is written to, or empty for none.
989    /// Same bargain as `name_field`.
990    ///
991    /// Both are written on *every* sign-in, not only the first: people change
992    /// their name and their picture, and a copy that is only ever right on the
993    /// day the account was created is worse than no copy.
994    pub avatar_field: String,
995    /// The providers, keyed by name. Written as `[oauth.github]` rather than
996    /// `[oauth.providers.github]` — the flattening is what buys that, and the
997    /// cost is that a mistyped setting above becomes a provider nobody asked
998    /// for, which is refused at boot rather than ignored.
999    #[serde(flatten)]
1000    pub providers: std::collections::BTreeMap<String, OAuthProviderConfig>,
1001}
1002
1003impl Default for OAuthConfig {
1004    fn default() -> Self {
1005        OAuthConfig {
1006            link_by_verified_email: true,
1007            state_ttl_secs: 600,
1008            success_redirect: "/".to_string(),
1009            failure_redirect: String::new(),
1010            token_delivery: "fragment".to_string(),
1011            name_field: "display_name".to_string(),
1012            avatar_field: "avatar_url".to_string(),
1013            providers: std::collections::BTreeMap::new(),
1014        }
1015    }
1016}
1017
1018impl OAuthConfig {
1019    /// Whether any provider is usable — which is what mounts the routes.
1020    pub fn enabled(&self) -> bool {
1021        self.providers.values().any(OAuthProviderConfig::is_active)
1022    }
1023
1024    /// The names of the providers that are on, in a stable order.
1025    pub fn active_providers(&self) -> Vec<&str> {
1026        self.providers
1027            .iter()
1028            .filter(|(_, p)| p.is_active())
1029            .map(|(name, _)| name.as_str())
1030            .collect()
1031    }
1032
1033    /// `state_ttl_secs`, clamped to something a sign-in can actually happen in.
1034    pub fn state_ttl(&self) -> u64 {
1035        self.state_ttl_secs.clamp(60, 3600)
1036    }
1037}
1038
1039/// One provider's credentials, and the overrides an unknown provider needs.
1040#[derive(Debug, Clone, Default, Deserialize)]
1041#[serde(default)]
1042pub struct OAuthProviderConfig {
1043    /// The client id the provider issued. An empty one leaves the provider off,
1044    /// which is what lets a committed config name every provider and a
1045    /// deployment supply only the credentials it has.
1046    pub client_id: String,
1047    /// The client secret. Required for every provider apiplant ships, all of
1048    /// which are confidential clients.
1049    pub client_secret: String,
1050    /// Space-separated scopes, overriding the built-in default. The defaults
1051    /// ask for the least that identifies somebody; widen this only for scopes
1052    /// the app will actually use, since every one is another line on a consent
1053    /// screen and another reason to press Cancel.
1054    pub scopes: String,
1055    /// Where the browser is sent to consent. Required for an unknown provider.
1056    pub authorize_url: String,
1057    /// Where the code is redeemed. Required for an unknown provider.
1058    pub token_url: String,
1059    /// Where the profile is read. Required for an unknown provider.
1060    pub userinfo_url: String,
1061    /// How to read that profile, for a provider apiplant does not ship:
1062    /// `oidc` (default — standard `sub`/`email`/`email_verified`/`name`/
1063    /// `picture` claims, which is what almost everything speaks today) or
1064    /// `github` (GitHub's older shape).
1065    pub style: String,
1066    /// What the sign-in button should say. Defaults to the built-in label, or
1067    /// to the provider's own name capitalised.
1068    pub label: String,
1069    /// The redirect URI registered with the provider. Empty (the default)
1070    /// derives it — `<public_url><base_path>/auth/oauth/<provider>/callback` —
1071    /// which is right unless something in front of this server rewrites paths.
1072    pub redirect_uri: String,
1073    /// Whether PKCE is used. Unset follows what the provider supports; X
1074    /// *requires* it, GitHub does not offer it.
1075    pub pkce: Option<bool>,
1076    /// Set false to keep a fully credentialed provider switched off — the way
1077    /// to take a sign-in button away for a while without deleting the secrets.
1078    pub enabled: Option<bool>,
1079    /// A logo for the sign-in button, as a URL a browser can fetch — usually a
1080    /// file in [`public/`](PublicConfig), such as `/oauth/gitlab.svg`.
1081    ///
1082    /// apiplant draws GitHub, Google, LinkedIn and X itself, so this is for the
1083    /// providers it does not ship: without it their button gets the provider's
1084    /// initial on a plain tile, which works and looks like what it is.
1085    ///
1086    /// <https://github.com/edent/SuperTinyIcons> is a good place to get one —
1087    /// several hundred brand marks, each a few hundred bytes of hand-drawn SVG,
1088    /// MIT licensed. They are what apiplant's own four are drawn from. Save the
1089    /// file into `public/` and point this at it.
1090    pub icon: String,
1091}
1092
1093impl OAuthProviderConfig {
1094    /// Whether this block is complete enough to sign anybody in.
1095    pub fn is_active(&self) -> bool {
1096        self.enabled.unwrap_or(true) && !self.client_id.trim().is_empty()
1097    }
1098}
1099
1100/// Payments: who takes the money, and how the checkout is set up.
1101///
1102/// Off by default (`provider = "none"`). Turning it on does three things an
1103/// app would otherwise build by hand: it connects a Stripe client, it adds the
1104/// `billing_*` [resources](crate::defaults) — catalogue, customers,
1105/// subscriptions, payments — so billing state is queryable through the same
1106/// permissions and roles as everything else, and it mounts the `/billing`
1107/// endpoints that start a checkout and receive Stripe's webhooks.
1108///
1109/// Nothing here is a price. Prices live in `billing_price` rows, because a
1110/// price is data an operator changes on a Tuesday, not configuration that
1111/// wants a deployment.
1112#[derive(Debug, Clone, Deserialize)]
1113#[serde(default)]
1114pub struct PaymentsConfig {
1115    /// `none` (default) or `stripe`.
1116    pub provider: String,
1117    /// Stripe secret key (`sk_live_…` / `sk_test_…`). Required once enabled.
1118    pub secret_key: String,
1119    /// Stripe publishable key (`pk_live_…`). Not a secret: it is handed to the
1120    /// browser by `GET <base>/billing/config`, which is how a front end
1121    /// mounts Stripe's own elements without hardcoding a key per environment.
1122    pub publishable_key: String,
1123    /// Signing secret for the webhook endpoint (`whsec_…`).
1124    ///
1125    /// Without it `POST <base>/billing/webhook` refuses every delivery — an
1126    /// unverified webhook is an unauthenticated request that edits
1127    /// subscriptions, and accepting one because it is inconvenient not to is
1128    /// how somebody else grants themselves a plan.
1129    pub webhook_secret: String,
1130    /// ISO 4217 currency for prices that don't name one, e.g. `eur`.
1131    pub currency: String,
1132    /// Let Stripe Tax work out and apply the right tax for the customer's
1133    /// location (default true).
1134    ///
1135    /// On means the amounts here are what you charge *before* tax and Stripe
1136    /// adds what the buyer owes. It needs an origin address and active
1137    /// registrations in the Stripe dashboard; with none, Stripe adds nothing
1138    /// and the charge is the price.
1139    pub automatic_tax: bool,
1140    /// Ask the buyer for a VAT/GST number at checkout (default true when
1141    /// `automatic_tax` is on — a business buyer's number is what makes the
1142    /// reverse charge apply).
1143    pub tax_id_collection: Option<bool>,
1144    /// Collect a full billing address at checkout rather than only what the
1145    /// card requires. `auto` (default) or `required`; automatic tax needs an
1146    /// address, so `auto` still collects enough to place the customer.
1147    pub billing_address: String,
1148    /// Where Stripe returns the buyer after a completed checkout. Empty falls
1149    /// back to the dashboard's billing screen — see
1150    /// [`ServerConfig::public_origin`].
1151    pub success_url: String,
1152    /// Where Stripe returns a buyer who backed out. Empty falls back to the
1153    /// dashboard's billing screen.
1154    pub cancel_url: String,
1155    /// Where the Stripe customer portal returns to. Empty falls back to the
1156    /// dashboard's billing screen.
1157    pub portal_return_url: String,
1158    /// How long one Stripe API call may take before it is abandoned.
1159    pub timeout_secs: u64,
1160}
1161
1162impl Default for PaymentsConfig {
1163    fn default() -> Self {
1164        PaymentsConfig {
1165            provider: "none".to_string(),
1166            secret_key: String::new(),
1167            publishable_key: String::new(),
1168            webhook_secret: String::new(),
1169            currency: "usd".to_string(),
1170            automatic_tax: true,
1171            tax_id_collection: None,
1172            billing_address: "auto".to_string(),
1173            success_url: String::new(),
1174            cancel_url: String::new(),
1175            portal_return_url: String::new(),
1176            timeout_secs: 20,
1177        }
1178    }
1179}
1180
1181impl PaymentsConfig {
1182    /// Whether a provider is configured at all. `none` and the empty string
1183    /// both mean "this app doesn't take money".
1184    pub fn enabled(&self) -> bool {
1185        !matches!(
1186            self.provider.trim().to_ascii_lowercase().as_str(),
1187            "" | "none"
1188        )
1189    }
1190
1191    /// The currency to use for an amount that didn't name one, lowercased the
1192    /// way Stripe wants it.
1193    pub fn default_currency(&self) -> String {
1194        let currency = self.currency.trim().to_ascii_lowercase();
1195        if currency.is_empty() {
1196            "usd".to_string()
1197        } else {
1198            currency
1199        }
1200    }
1201
1202    /// Whether checkout asks for a tax number. Unset follows `automatic_tax`:
1203    /// collecting a VAT number is only useful to somebody computing tax with
1204    /// it, and asking for one you ignore is a field that does nothing.
1205    pub fn collects_tax_ids(&self) -> bool {
1206        self.tax_id_collection.unwrap_or(self.automatic_tax)
1207    }
1208
1209    /// Whether the webhook endpoint can verify a delivery. Payments still work
1210    /// without it — the checkout completes and Stripe has the money — but
1211    /// nothing of ours would ever hear about it.
1212    pub fn webhooks_enabled(&self) -> bool {
1213        self.enabled() && !self.webhook_secret.trim().is_empty()
1214    }
1215}
1216
1217/// An AI chat assistant: which service answers, and what to say to it.
1218///
1219/// Off by default (`provider = "none"`). Turning it on connects one client and
1220/// mounts `<base>/ai/chat`, which takes a list of messages and streams the
1221/// reply back token by token — and gives every function a `chat` call over the
1222/// same provider.
1223///
1224/// The three providers differ only in wire format. `custom` is the one that
1225/// matters most in practice: anything speaking the OpenAI chat-completions
1226/// shape — llama.cpp, vLLM, Ollama, LM Studio, a gateway of your own — is
1227/// reached by pointing [`endpoint`](Self::endpoint) at it, with no key at all
1228/// if it wants none.
1229#[derive(Debug, Clone, Deserialize)]
1230#[serde(default)]
1231pub struct AiConfig {
1232    /// `none` (default), `openai`, `anthropic` or `custom`.
1233    pub provider: String,
1234    /// Where to send the request.
1235    ///
1236    /// Empty uses the provider's own API (`https://api.openai.com`,
1237    /// `https://api.anthropic.com`) and is required for `custom`. A bare origin
1238    /// or a base path (`http://localhost:8080`, `.../v1`) gets the provider's
1239    /// standard path appended; a URL that already names the full path
1240    /// (`…/v1/chat/completions`, `…/v1/messages`) is used exactly as written,
1241    /// for a gateway that mounts it somewhere of its own.
1242    pub endpoint: String,
1243    /// Model to ask for when a request doesn't name one, e.g. `gpt-4o-mini`.
1244    /// Some local servers serve a single model and ignore this.
1245    pub model: String,
1246    /// The provider's API key. **Optional**: a local model behind
1247    /// `provider = "custom"` usually wants no credential, and sending an empty
1248    /// one is different from sending none — so an empty key means the request
1249    /// carries no authorization header at all.
1250    pub api_key: String,
1251    /// Prepended to every conversation as the system prompt, unless the request
1252    /// carries its own. Empty = none.
1253    pub system: String,
1254    /// Cap on the tokens generated per reply. Anthropic requires one, so this
1255    /// is sent to every provider rather than being special-cased.
1256    pub max_tokens: u32,
1257    /// Sampling temperature sent when a request doesn't name one. Negative
1258    /// (the default) sends nothing and lets the provider choose.
1259    pub temperature: f32,
1260    /// Whether provider reasoning should be surfaced to callers when the
1261    /// provider emits it. This is a *display* decision and says nothing about
1262    /// whether the model thinks — see `thinking` for that.
1263    pub reasoning: bool,
1264    /// Whether to ask the provider to think, using its own switch for it.
1265    ///
1266    /// `None` (the default) sends nothing and leaves the model on whatever its
1267    /// template does. `Some(false)` turns thinking off, `Some(true)` turns it
1268    /// on. Worth setting: thinking is billed against `max_tokens` like any
1269    /// other output, so a thinking model on a small budget can spend the whole
1270    /// thing reasoning and answer with nothing at all.
1271    ///
1272    /// How it is sent depends on the provider: Anthropic has a `thinking`
1273    /// parameter, and OpenAI-compatible local servers (llama.cpp, vLLM, SGLang,
1274    /// Ollama) take `chat_template_kwargs.enable_thinking`, which is what the
1275    /// Qwen-family templates read. OpenAI's own reasoning models expose only
1276    /// `reasoning_effort` and cannot be switched off, so this is not sent to
1277    /// them.
1278    pub thinking: Option<bool>,
1279    /// Who may call `<base>/ai/chat`, in the grammar a resource's
1280    /// `[permissions]` uses: `public`, `authenticated` (the default), `member`,
1281    /// `role:<name>`.
1282    ///
1283    /// Defaulting to `authenticated` is deliberate. The endpoint spends money
1284    /// (or a GPU) on behalf of whoever calls it, and a public one is an open
1285    /// proxy to your provider account — which is a decision an app should have
1286    /// to write down.
1287    pub access: String,
1288    /// How long one completion may take before it is abandoned. Generous by
1289    /// default: a long answer from a local model is slow, not broken.
1290    pub timeout_secs: u64,
1291}
1292
1293impl Default for AiConfig {
1294    fn default() -> Self {
1295        AiConfig {
1296            provider: "none".to_string(),
1297            endpoint: String::new(),
1298            model: String::new(),
1299            api_key: String::new(),
1300            system: String::new(),
1301            max_tokens: 2048,
1302            temperature: -1.0,
1303            reasoning: false,
1304            thinking: None,
1305            access: "authenticated".to_string(),
1306            timeout_secs: 300,
1307        }
1308    }
1309}
1310
1311impl AiConfig {
1312    /// Whether a provider is configured at all. `none` and the empty string
1313    /// both mean "this app has no assistant".
1314    pub fn enabled(&self) -> bool {
1315        !matches!(
1316            self.provider.trim().to_ascii_lowercase().as_str(),
1317            "" | "none"
1318        )
1319    }
1320
1321    /// The sampling temperature to send, or `None` to let the provider decide.
1322    pub fn default_temperature(&self) -> Option<f32> {
1323        (self.temperature >= 0.0).then_some(self.temperature)
1324    }
1325}
1326
1327/// Logs, traces and metrics — what the server says about itself, and where it
1328/// says it.
1329///
1330/// Everything here is off-by-default except the logs, which every process has
1331/// always written to the terminal. Turning `enabled` on does not by itself send
1332/// anything anywhere: it arms the section, and an `[observability.otlp]`
1333/// `endpoint` is what makes traces and metrics leave the process. Without one
1334/// the spans are still built and still carried through the logs — so a
1335/// deployment gets request ids and structured errors for free, and an OTLP
1336/// collector only when it has somewhere to put the data.
1337///
1338/// ## Why OTLP and nothing else
1339///
1340/// OTLP is the wire format every backend now speaks — Jaeger, Tempo, Honeycomb,
1341/// Datadog, New Relic, the OpenTelemetry Collector — so one exporter reaches
1342/// all of them, and a deployment that wants something exotic points this at a
1343/// Collector and translates there rather than here. The transport is HTTP
1344/// (`:4318`), not gRPC: it goes through the `reqwest` client this binary
1345/// already links, where gRPC would compile a second RPC stack for the same
1346/// bytes.
1347///
1348/// ## Environment
1349///
1350/// The standard `OTEL_*` variables are read when the corresponding key is
1351/// unset — `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`,
1352/// `OTEL_SERVICE_NAME`, `OTEL_TRACES_SAMPLER_ARG` — because that is how a
1353/// sidecar-injected collector configures the pods around it, and an app should
1354/// not have to be rebuilt to be scraped.
1355#[derive(Debug, Clone, Default, Deserialize)]
1356#[serde(default, deny_unknown_fields)]
1357pub struct ObservabilityConfig {
1358    /// Arm the section. Off means: log to the terminal as always, build no
1359    /// spans, export nothing.
1360    pub enabled: bool,
1361    /// What this service calls itself in a trace. Unset falls back to
1362    /// `OTEL_SERVICE_NAME`, then to the app's name, then to `apiplant`.
1363    pub service_name: Option<String>,
1364    /// The build being traced. Unset falls back to the `apiplant` version,
1365    /// which is right until an app starts shipping a version of its own.
1366    pub service_version: Option<String>,
1367    /// `production`, `staging`, … Exported as `deployment.environment.name`,
1368    /// which is the attribute every backend groups by first.
1369    pub environment: Option<String>,
1370    /// Extra resource attributes attached to every span and metric —
1371    /// `region`, `tenant`, `k8s.pod.name`. Values may reference the
1372    /// environment like any other string here.
1373    pub resource_attributes: BTreeMap<String, String>,
1374    pub logs: LogsConfig,
1375    pub traces: TracesConfig,
1376    pub metrics: MetricsConfig,
1377    pub otlp: OtlpConfig,
1378}
1379
1380/// How the process writes to its own stdout.
1381///
1382/// This one applies whether or not `enabled` is set: a server writes logs
1383/// before it has been told to be observable.
1384#[derive(Debug, Clone, Deserialize)]
1385#[serde(default, deny_unknown_fields)]
1386pub struct LogsConfig {
1387    /// `pretty` (the default) for a terminal, `json` for anything that will
1388    /// parse the line — a log shipper, `kubectl logs | jq`, CloudWatch.
1389    pub format: LogFormat,
1390    /// The `RUST_LOG` filter to use when the environment does not set one.
1391    /// `RUST_LOG` always wins, because it is what someone reaches for while
1392    /// debugging a running container.
1393    pub level: String,
1394    /// Include the current span's fields — request id, method, route — on
1395    /// every line written inside it. This is what makes a JSON log searchable
1396    /// by request without a trace backend.
1397    pub span_fields: bool,
1398}
1399
1400impl Default for LogsConfig {
1401    fn default() -> Self {
1402        LogsConfig {
1403            format: LogFormat::Pretty,
1404            // ntex logs a line per worker at INFO, which drowns out the
1405            // startup output on machines with many cores.
1406            level: "info,apiplant=debug,ntex_server=warn".to_string(),
1407            span_fields: true,
1408        }
1409    }
1410}
1411
1412#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
1413#[serde(rename_all = "lowercase")]
1414pub enum LogFormat {
1415    #[default]
1416    Pretty,
1417    /// One line per event, fields inline — the terminal format without the
1418    /// indentation, for a log file a person still reads.
1419    Compact,
1420    /// One JSON object per line.
1421    Json,
1422}
1423
1424/// Distributed traces: one span per request, children for the work inside it.
1425#[derive(Debug, Clone, Deserialize)]
1426#[serde(default, deny_unknown_fields)]
1427pub struct TracesConfig {
1428    /// Build spans at all. On (with `[observability] enabled`) even when no
1429    /// exporter is configured, because the request id and the error fields a
1430    /// span carries are worth having in the logs alone.
1431    pub enabled: bool,
1432    /// Fraction of *root* requests recorded, `0.0`–`1.0`. This is *head*
1433    /// sampling — the decision is made before the request runs, so it cannot
1434    /// prefer the ones that will fail. Keeping every failure and a fraction of
1435    /// the rest is tail sampling, which is a Collector processor's job, not
1436    /// this server's: sample everything here and decide there.
1437    ///
1438    /// A sampled trace is sampled whole — a child never disagrees with its parent — and an
1439    /// incoming `traceparent` decides for its own trace, so a request arriving
1440    /// from an already-sampled caller is kept regardless of this number.
1441    pub sample_ratio: f64,
1442    /// Return the trace id to the caller as `X-Trace-Id`. What turns "it was
1443    /// slow at 14:02" from a support ticket into a lookup.
1444    pub response_header: bool,
1445    /// Request headers to copy onto the span. Never include anything that
1446    /// carries a credential — `authorization` and `cookie` are refused even if
1447    /// they are listed here.
1448    pub capture_headers: Vec<String>,
1449    /// Paths that are never traced, matched as prefixes after `base_path`.
1450    /// Health checks and asset requests are noise that costs money per span.
1451    pub exclude_paths: Vec<String>,
1452}
1453
1454impl Default for TracesConfig {
1455    fn default() -> Self {
1456        TracesConfig {
1457            enabled: true,
1458            sample_ratio: 1.0,
1459            response_header: true,
1460            capture_headers: Vec::new(),
1461            exclude_paths: vec!["/_health".to_string()],
1462        }
1463    }
1464}
1465
1466/// Metrics: the four numbers you page on, on the OpenTelemetry HTTP semantic
1467/// conventions so a stock dashboard reads them without being taught the app.
1468#[derive(Debug, Clone, Deserialize)]
1469#[serde(default, deny_unknown_fields)]
1470pub struct MetricsConfig {
1471    /// Record and export metrics. Needs an OTLP endpoint to go anywhere.
1472    pub enabled: bool,
1473    /// How often the accumulated measurements are pushed to the collector.
1474    pub interval_secs: u64,
1475}
1476
1477impl Default for MetricsConfig {
1478    fn default() -> Self {
1479        MetricsConfig {
1480            enabled: true,
1481            interval_secs: 60,
1482        }
1483    }
1484}
1485
1486/// Where the data goes.
1487#[derive(Debug, Clone, Deserialize)]
1488#[serde(default, deny_unknown_fields)]
1489pub struct OtlpConfig {
1490    /// Base URL of an OTLP/HTTP receiver — `http://localhost:4318`, or a
1491    /// vendor's ingest URL. The signal paths (`/v1/traces`, `/v1/metrics`) are
1492    /// appended. Unset falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`; unset in
1493    /// both places exports nothing.
1494    pub endpoint: Option<String>,
1495    /// `http/protobuf` (the default, and what every collector accepts) or
1496    /// `http/json` for a receiver that only speaks JSON.
1497    pub protocol: OtlpProtocol,
1498    /// Sent with every export request — this is where a vendor's API key goes.
1499    /// Use `$VAR` rather than writing the key into a committed file.
1500    pub headers: BTreeMap<String, String>,
1501    /// How long one export may take before it is abandoned. The exporter drops
1502    /// the batch rather than blocking the process behind a collector that has
1503    /// stopped answering.
1504    pub timeout_secs: u64,
1505}
1506
1507impl Default for OtlpConfig {
1508    fn default() -> Self {
1509        OtlpConfig {
1510            endpoint: None,
1511            protocol: OtlpProtocol::HttpProtobuf,
1512            headers: BTreeMap::new(),
1513            timeout_secs: 10,
1514        }
1515    }
1516}
1517
1518#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
1519pub enum OtlpProtocol {
1520    #[default]
1521    #[serde(rename = "http/protobuf")]
1522    HttpProtobuf,
1523    #[serde(rename = "http/json")]
1524    HttpJson,
1525}
1526
1527impl ObservabilityConfig {
1528    /// The endpoint to export to, config first and then the environment.
1529    ///
1530    /// `None` means nothing is exported — which is a supported way to run:
1531    /// spans still carry the logs, they simply stay in the process.
1532    pub fn endpoint(&self) -> Option<String> {
1533        self.otlp
1534            .endpoint
1535            .clone()
1536            .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
1537            .map(|e| e.trim().trim_end_matches('/').to_string())
1538            .filter(|e| !e.is_empty())
1539    }
1540
1541    /// The name this service reports itself under.
1542    pub fn service_name(&self, app_name: &str) -> String {
1543        self.service_name
1544            .clone()
1545            .or_else(|| std::env::var("OTEL_SERVICE_NAME").ok())
1546            .map(|n| n.trim().to_string())
1547            .filter(|n| !n.is_empty())
1548            .unwrap_or_else(|| app_name.to_string())
1549    }
1550
1551    /// Every header sent with an export, the config's merged over anything
1552    /// `OTEL_EXPORTER_OTLP_HEADERS` supplied — the file is the more specific
1553    /// statement, so it wins a collision.
1554    pub fn export_headers(&self) -> BTreeMap<String, String> {
1555        let mut headers = BTreeMap::new();
1556        if let Ok(from_env) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") {
1557            // `key=value,key=value`, as the OTel specification defines it.
1558            for pair in from_env.split(',') {
1559                if let Some((key, value)) = pair.split_once('=') {
1560                    headers.insert(key.trim().to_string(), value.trim().to_string());
1561                }
1562            }
1563        }
1564        headers.extend(self.otlp.headers.clone());
1565        headers
1566    }
1567
1568    /// Whether anything at all is being collected.
1569    pub fn is_active(&self) -> bool {
1570        self.enabled && (self.traces.enabled || self.metrics.enabled)
1571    }
1572}
1573
1574impl Config {
1575    /// Load `main.toml` from an app directory, applying defaults for anything
1576    /// absent. A missing file is not an error.
1577    pub fn load(app_dir: &Path) -> crate::Result<Self> {
1578        let path = app_dir.join("main.toml");
1579        let mut config = if path.exists() {
1580            let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
1581                path: path.clone(),
1582                source: e,
1583            })?;
1584            // `$VAR` in any string value is read from the environment here,
1585            // which is what keeps credentials out of a committed main.toml.
1586            crate::env::parse_toml::<Config>(&text, "main.toml")
1587                .map_err(|e| crate::Error::Toml { path, source: e })?
1588        } else {
1589            tracing::info!("no main.toml found, using defaults");
1590            Config::default()
1591        };
1592        config.normalise();
1593        Ok(config)
1594    }
1595
1596    fn normalise(&mut self) {
1597        // "bind everywhere" has three spellings people arrive with: leaving it
1598        // out, the wildcard, and the address itself. They all mean 0.0.0.0.
1599        let host = self.server.host.trim();
1600        if host.is_empty() || host == "*" {
1601            self.server.host = "0.0.0.0".to_string();
1602        } else {
1603            self.server.host = host.to_string();
1604        }
1605
1606        // Same idea for the vhost filter: an empty or wildcard `domain` is a
1607        // request for no filter at all, not a filter for the empty host. `_` is
1608        // there because nginx spells its catch-all `server_name _`. A wildcard
1609        // anywhere in the list wins — it already answers every host, so the
1610        // named entries beside it can't narrow anything.
1611        let domains = std::mem::take(&mut self.server.domain);
1612        let mut wildcard = false;
1613        for d in domains {
1614            match d.trim() {
1615                "" | "*" | "_" | "0.0.0.0" => wildcard = true,
1616                d => self.server.domain.push(d.to_string()),
1617            }
1618        }
1619        if wildcard {
1620            self.server.domain.clear();
1621        }
1622
1623        let bp = self.server.base_path.trim_end_matches('/');
1624        self.server.base_path = if bp.is_empty() {
1625            String::new()
1626        } else if bp.starts_with('/') {
1627            bp.to_string()
1628        } else {
1629            format!("/{bp}")
1630        };
1631
1632        if !self.docs.path.starts_with('/') {
1633            self.docs.path = format!("/{}", self.docs.path);
1634        }
1635
1636        // A zero sweep interval is a timer that fires forever; a zero staleness
1637        // discards every bucket the moment it is written, which is the same as
1638        // having no rate limit at all. Neither is what `0` was meant to say.
1639        let defaults = RateLimitConfig::default();
1640        if self.rate_limit.cleanup_interval_secs == 0 {
1641            self.rate_limit.cleanup_interval_secs = defaults.cleanup_interval_secs;
1642        }
1643        if self.rate_limit.stale_after_secs == 0 {
1644            self.rate_limit.stale_after_secs = defaults.stale_after_secs;
1645        }
1646
1647        // A ratio outside 0..1 is a typo for one of the ends — "10" for ten
1648        // percent is the common one — and silently sampling nothing is the
1649        // worst way to find out.
1650        self.observability.traces.sample_ratio =
1651            self.observability.traces.sample_ratio.clamp(0.0, 1.0);
1652        if self.observability.metrics.interval_secs == 0 {
1653            self.observability.metrics.interval_secs = MetricsConfig::default().interval_secs;
1654        }
1655        if self.observability.otlp.timeout_secs == 0 {
1656            self.observability.otlp.timeout_secs = OtlpConfig::default().timeout_secs;
1657        }
1658        // Matched as prefixes against a path that always starts with `/`.
1659        for path in &mut self.observability.traces.exclude_paths {
1660            if !path.starts_with('/') {
1661                *path = format!("/{path}");
1662            }
1663        }
1664        // Header names are compared lowercase, because HTTP/2 sends them that
1665        // way and a config written in `Title-Case` should still match.
1666        for header in &mut self.observability.traces.capture_headers {
1667            *header = header.trim().to_ascii_lowercase();
1668        }
1669
1670        let admin = self.admin.path.trim_matches('/');
1671        self.admin.path = if admin.is_empty() {
1672            AdminConfig::default().path
1673        } else {
1674            format!("/{admin}")
1675        };
1676    }
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681    use super::*;
1682    use std::fs;
1683    use std::time::{SystemTime, UNIX_EPOCH};
1684
1685    fn temp_dir(label: &str) -> std::path::PathBuf {
1686        let mut dir = std::env::temp_dir();
1687        let stamp = SystemTime::now()
1688            .duration_since(UNIX_EPOCH)
1689            .unwrap()
1690            .as_nanos();
1691        dir.push(format!(
1692            "apiplant-config-{label}-{}-{stamp}",
1693            std::process::id()
1694        ));
1695        fs::create_dir_all(&dir).unwrap();
1696        dir
1697    }
1698
1699    #[test]
1700    fn missing_main_toml_uses_defaults() {
1701        let dir = temp_dir("defaults");
1702        let config = Config::load(&dir).unwrap();
1703
1704        assert_eq!(config.server.host, "0.0.0.0");
1705        assert_eq!(config.server.port, 8080);
1706        assert_eq!(config.server.base_path, "");
1707        assert_eq!(
1708            config.database.resolved_url(),
1709            "postgres://postgres:postgres@localhost:5432/apiplant"
1710        );
1711        assert!(config.auth.allow_registration);
1712        assert!(config.docs.enabled);
1713        assert_eq!(config.docs.path, "/docs");
1714        // The dashboard and the public site are on by default; an app opts out.
1715        assert!(config.admin.enabled);
1716        assert_eq!(config.admin.path, "/admin");
1717        assert!(!config.admin.ai_assistance.enabled);
1718        assert_eq!(
1719            config.admin.ai_assistance.prompt_placeholder,
1720            "Describe what you want AI to write for this field."
1721        );
1722        assert!(config.public.enabled);
1723        assert_eq!(config.public.dir, "public");
1724        assert_eq!(config.public.not_found, None);
1725        // Email, cache and payments are opt-in: an app that says nothing gets
1726        // none of them.
1727        assert!(!config.email.enabled());
1728        assert!(!config.cache.is_active());
1729        assert!(!config.payments.enabled());
1730
1731        fs::remove_dir_all(dir).unwrap();
1732    }
1733
1734    #[test]
1735    fn email_and_cache_load_from_their_sections() {
1736        let dir = temp_dir("email-cache");
1737        fs::write(
1738            dir.join("main.toml"),
1739            r#"
1740[email]
1741provider = "sendgrid"
1742from = "no-reply@example.com"
1743from_name = "Example"
1744api_key = "SG.literal"
1745
1746[cache]
1747url = "redis://127.0.0.1:6379"
1748prefix = "example:"
1749default_ttl_secs = 300
1750"#,
1751        )
1752        .unwrap();
1753
1754        let config = Config::load(&dir).unwrap();
1755
1756        assert!(config.email.enabled());
1757        assert_eq!(config.email.provider, "sendgrid");
1758        assert_eq!(config.email.from, "no-reply@example.com");
1759        assert_eq!(config.email.api_key, "SG.literal");
1760        // Untouched defaults still apply inside a section that was given.
1761        assert_eq!(config.email.timeout_secs, 15);
1762        assert_eq!(config.email.smtp.encryption, "starttls");
1763
1764        assert!(config.cache.is_active());
1765        assert_eq!(config.cache.prefix, "example:");
1766        assert_eq!(config.cache.default_ttl_secs, 300);
1767
1768        fs::remove_dir_all(dir).unwrap();
1769    }
1770
1771    #[test]
1772    fn payments_load_from_their_section() {
1773        let dir = temp_dir("payments");
1774        fs::write(
1775            dir.join("main.toml"),
1776            r#"
1777[payments]
1778provider = "stripe"
1779secret_key = "sk_test_literal"
1780webhook_secret = "whsec_literal"
1781currency = "EUR"
1782"#,
1783        )
1784        .unwrap();
1785
1786        let config = Config::load(&dir).unwrap();
1787
1788        assert!(config.payments.enabled());
1789        assert!(config.payments.webhooks_enabled());
1790        // Stripe wants a lowercase currency, and nobody writes one.
1791        assert_eq!(config.payments.default_currency(), "eur");
1792        // Untouched defaults still apply inside a section that was given.
1793        assert!(config.payments.automatic_tax);
1794        assert_eq!(config.payments.timeout_secs, 20);
1795
1796        fs::remove_dir_all(dir).unwrap();
1797    }
1798
1799    /// A configured provider with no signing secret still takes money — the
1800    /// checkout is Stripe's page — but nothing of ours would hear that it
1801    /// worked, so the two questions are answered separately.
1802    #[test]
1803    fn webhooks_need_their_own_secret() {
1804        let payments = PaymentsConfig {
1805            provider: "stripe".into(),
1806            secret_key: "sk_test".into(),
1807            ..PaymentsConfig::default()
1808        };
1809        assert!(payments.enabled());
1810        assert!(!payments.webhooks_enabled());
1811    }
1812
1813    /// Asking for a VAT number is only useful to somebody computing tax with
1814    /// it, so the default follows automatic tax — and an app can still say
1815    /// otherwise in either direction.
1816    #[test]
1817    fn tax_id_collection_follows_automatic_tax_unless_told_otherwise() {
1818        let with_tax = PaymentsConfig::default();
1819        assert!(with_tax.automatic_tax && with_tax.collects_tax_ids());
1820
1821        let no_tax = PaymentsConfig {
1822            automatic_tax: false,
1823            ..PaymentsConfig::default()
1824        };
1825        assert!(!no_tax.collects_tax_ids());
1826
1827        let explicit = PaymentsConfig {
1828            automatic_tax: false,
1829            tax_id_collection: Some(true),
1830            ..PaymentsConfig::default()
1831        };
1832        assert!(explicit.collects_tax_ids());
1833    }
1834
1835    /// `enabled = false` has to beat a perfectly good URL, or switching the
1836    /// cache off would mean deleting the settings needed to switch it back on.
1837    #[test]
1838    fn a_disabled_cache_stays_off_even_with_a_url() {
1839        let config = CacheConfig {
1840            enabled: false,
1841            url: "redis://127.0.0.1:6379".into(),
1842            ..CacheConfig::default()
1843        };
1844        assert!(!config.is_active());
1845    }
1846
1847    /// `Config::load` reads its file through the same expansion every other
1848    /// app-directory TOML gets — including a URL assembled from several
1849    /// variables, which is the case a whole-value substitution can't do.
1850    #[test]
1851    fn load_expands_environment_references_anywhere_in_the_file() {
1852        std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
1853        std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
1854        std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
1855        std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
1856        let dir = temp_dir("env");
1857        fs::write(
1858            dir.join("main.toml"),
1859            r#"
1860[server]
1861domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
1862
1863[database]
1864url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
1865
1866[auth]
1867jwt_secret = "$APIPLANT_TEST_JWT"
1868
1869[email]
1870provider = "brevo"
1871api_key = "${APIPLANT_TEST_MAIL}"
1872from = "no-reply@example.com"
1873"#,
1874        )
1875        .unwrap();
1876
1877        let config = Config::load(&dir).unwrap();
1878        assert_eq!(
1879            config.database.resolved_url(),
1880            "postgres://alice:s3cret@db:5432/app"
1881        );
1882        assert_eq!(config.auth.jwt_secret, "from-env-jwt");
1883        assert_eq!(config.email.api_key, "from-env-key");
1884        // An unset variable falls back to the default written beside it.
1885        assert_eq!(config.server.domain, ["api.example.com"]);
1886
1887        for name in [
1888            "APIPLANT_TEST_JWT",
1889            "APIPLANT_TEST_MAIL",
1890            "APIPLANT_TEST_DB_USER",
1891            "APIPLANT_TEST_DB_PASS",
1892        ] {
1893            std::env::remove_var(name);
1894        }
1895        fs::remove_dir_all(dir).unwrap();
1896    }
1897
1898    #[test]
1899    fn load_treats_wildcard_host_and_domain_as_everything() {
1900        for (host, domain) in [
1901            ("", "\"\""),
1902            ("*", "\"*\""),
1903            (" 0.0.0.0 ", "\"_\""),
1904            ("*", "[]"),
1905            // A wildcard beside named hosts still means "answer any host".
1906            ("*", "[\"api.example.com\", \"*\"]"),
1907        ] {
1908            let dir = temp_dir("wildcards");
1909            fs::write(
1910                dir.join("main.toml"),
1911                format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
1912            )
1913            .unwrap();
1914
1915            let config = Config::load(&dir).unwrap();
1916
1917            assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
1918            assert!(config.server.domain.is_empty(), "domain {domain}");
1919            fs::remove_dir_all(&dir).unwrap();
1920        }
1921    }
1922
1923    /// `domain` takes a list as readily as a single string, and each entry is
1924    /// trimmed the same way.
1925    #[test]
1926    fn load_accepts_a_list_of_domains() {
1927        let dir = temp_dir("domains");
1928        fs::write(
1929            dir.join("main.toml"),
1930            "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
1931        )
1932        .unwrap();
1933
1934        let config = Config::load(&dir).unwrap();
1935
1936        assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
1937        fs::remove_dir_all(dir).unwrap();
1938    }
1939
1940    #[test]
1941    fn load_normalises_paths_and_prefers_explicit_database_url() {
1942        let dir = temp_dir("normalise");
1943        fs::write(
1944            dir.join("main.toml"),
1945            r#"
1946[server]
1947base_path = "api/"
1948workers = 8
1949
1950[database]
1951url = "postgres://db.example/custom"
1952host = "ignored"
1953port = 9999
1954name = "ignored"
1955user = "ignored"
1956password = "ignored"
1957
1958[docs]
1959path = "swagger"
1960
1961[admin]
1962path = "console/"
1963
1964[admin.ai_assistance]
1965enabled = true
1966system = "Return only the field content."
1967prompt_placeholder = "Tell AI what to draft"
1968
1969[public]
1970dir = "site"
1971not_found = "oops.html"
1972"#,
1973        )
1974        .unwrap();
1975
1976        let config = Config::load(&dir).unwrap();
1977
1978        assert_eq!(config.server.base_path, "/api");
1979        assert_eq!(config.server.workers, Some(8));
1980        assert_eq!(config.docs.path, "/swagger");
1981        assert_eq!(config.admin.path, "/console");
1982        assert!(config.admin.ai_assistance.enabled);
1983        assert_eq!(
1984            config.admin.ai_assistance.system,
1985            "Return only the field content."
1986        );
1987        assert_eq!(
1988            config.admin.ai_assistance.prompt_placeholder,
1989            "Tell AI what to draft"
1990        );
1991        assert_eq!(config.public.dir, "site");
1992        assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
1993        assert_eq!(
1994            config.database.resolved_url(),
1995            "postgres://db.example/custom"
1996        );
1997
1998        fs::remove_dir_all(dir).unwrap();
1999    }
2000
2001    #[test]
2002    fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
2003        let config = DatabaseConfig {
2004            url: String::new(),
2005            host: "db".into(),
2006            port: 5433,
2007            name: "plants".into(),
2008            user: "alice".into(),
2009            password: "secret".into(),
2010            max_connections: 16,
2011            auto_migrate: true,
2012        };
2013
2014        assert_eq!(
2015            config.resolved_url(),
2016            "postgres://alice:secret@db:5433/plants"
2017        );
2018    }
2019
2020    #[test]
2021    fn observability_is_off_until_it_is_asked_for() {
2022        let config = Config::default();
2023        assert!(!config.observability.enabled);
2024        assert!(!config.observability.is_active());
2025        // Off, but the logs still have a format and a level — a process writes
2026        // to its terminal before anyone configures monitoring.
2027        assert_eq!(config.observability.logs.format, LogFormat::Pretty);
2028        assert!(config.observability.logs.level.contains("info"));
2029    }
2030
2031    #[test]
2032    fn an_observability_section_is_read_whole() {
2033        let dir = temp_dir("observability");
2034        fs::write(
2035            dir.join("main.toml"),
2036            r#"
2037[observability]
2038enabled = true
2039service_name = "checkout"
2040environment = "production"
2041resource_attributes = { region = "eu-west-1" }
2042
2043[observability.logs]
2044format = "json"
2045
2046[observability.traces]
2047sample_ratio = 0.25
2048capture_headers = ["X-Request-Id"]
2049exclude_paths = ["_health", "/metrics"]
2050
2051[observability.otlp]
2052endpoint = "http://collector:4318/"
2053protocol = "http/json"
2054headers = { authorization = "Bearer t" }
2055"#,
2056        )
2057        .unwrap();
2058        let config = Config::load(&dir).unwrap();
2059        let observability = &config.observability;
2060
2061        assert!(observability.is_active());
2062        assert_eq!(observability.logs.format, LogFormat::Json);
2063        assert_eq!(observability.otlp.protocol, OtlpProtocol::HttpJson);
2064        // The trailing slash goes, because the signal path is appended to this.
2065        assert_eq!(
2066            observability.endpoint().as_deref(),
2067            Some("http://collector:4318")
2068        );
2069        assert_eq!(observability.service_name("fallback"), "checkout");
2070        assert_eq!(
2071            observability.export_headers().get("authorization").unwrap(),
2072            "Bearer t"
2073        );
2074        // Both spellings of an excluded path end up matchable against a
2075        // request path, and a header is lowercased to match what HTTP/2 sends.
2076        assert_eq!(observability.traces.exclude_paths, ["/_health", "/metrics"]);
2077        assert_eq!(observability.traces.capture_headers, ["x-request-id"]);
2078        assert_eq!(observability.traces.sample_ratio, 0.25);
2079    }
2080
2081    #[test]
2082    fn a_sample_ratio_outside_the_range_is_a_typo_for_one_of_the_ends() {
2083        let dir = temp_dir("sampling");
2084        fs::write(
2085            dir.join("main.toml"),
2086            "[observability.traces]\nsample_ratio = 10.0\n",
2087        )
2088        .unwrap();
2089        // "10" meant "ten percent"; sampling nothing would be the worst
2090        // possible reading of it, so it clamps to "everything" instead.
2091        assert_eq!(
2092            Config::load(&dir)
2093                .unwrap()
2094                .observability
2095                .traces
2096                .sample_ratio,
2097            1.0
2098        );
2099    }
2100
2101    #[test]
2102    fn the_app_name_is_the_service_name_when_nothing_else_says_otherwise() {
2103        let observability = ObservabilityConfig::default();
2104        assert!(
2105            observability.endpoint().is_none()
2106                || std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok()
2107        );
2108        assert_eq!(observability.service_name("my-app"), "my-app");
2109    }
2110}