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