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 serde::Deserialize;
15use std::path::Path;
16
17/// Fully-resolved server configuration.
18#[derive(Debug, Clone, Default, Deserialize)]
19#[serde(default)]
20pub struct Config {
21    pub app: AppConfig,
22    pub server: ServerConfig,
23    pub database: DatabaseConfig,
24    pub auth: AuthConfig,
25    pub docs: DocsConfig,
26    pub admin: AdminConfig,
27    pub public: PublicConfig,
28    pub email: EmailConfig,
29    pub cache: CacheConfig,
30    pub payments: PaymentsConfig,
31    pub ai: AiConfig,
32}
33
34/// What the app calls itself.
35///
36/// The directory an app lives in is a developer's filing decision —
37/// `07-functions`, `api-v2`, `backend` — and the dashboard header is read by
38/// people who never see it. This is where an app says the name they should
39/// read instead.
40#[derive(Debug, Clone, Default, Deserialize)]
41#[serde(default)]
42pub struct AppConfig {
43    /// Display name, used wherever the app is named to a person — the admin
44    /// dashboard's header and title. Unset falls back to the directory name.
45    pub name: Option<String>,
46}
47
48/// Accepts either a bare string or a list of them, so a config that names one
49/// domain doesn't have to be written as a one-element list.
50fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
51    #[derive(Deserialize)]
52    #[serde(untagged)]
53    enum OneOrMany {
54        One(String),
55        Many(Vec<String>),
56    }
57    Ok(match OneOrMany::deserialize(de)? {
58        OneOrMany::One(s) => vec![s],
59        OneOrMany::Many(v) => v,
60    })
61}
62
63#[derive(Debug, Clone, Deserialize)]
64#[serde(default)]
65pub struct ServerConfig {
66    /// Interface to bind, e.g. `0.0.0.0`. Empty or `*` means every interface
67    /// and is normalised to `0.0.0.0` on load.
68    pub host: String,
69    /// TCP port.
70    pub port: u16,
71    /// Only answer requests whose `Host:` header is one of these. Written as a
72    /// single string (`domain = "api.example.com"`) or a list
73    /// (`domain = ["api.example.com", "www.example.com"]`). Unset — or the
74    /// catch-all spellings `""`, `*` and `_` (nginx's `server_name _`) —
75    /// answers any host, and all of them normalise to an empty list on load.
76    #[serde(deserialize_with = "one_or_many")]
77    pub domain: Vec<String>,
78    /// Sub-path the API is mounted under, e.g. `/api`. Always starts with `/`
79    /// and never ends with one (normalised on load).
80    pub base_path: String,
81    /// Number of worker threads. `None` = one per CPU.
82    pub workers: Option<usize>,
83    /// The origin this server is reached at from outside — `https://api.example.com`.
84    ///
85    /// Only links that leave the process need it: an invitation email has to
86    /// name a URL, and a request's own `Host:` header is the wrong source for
87    /// one (a message is composed once and read anywhere, possibly behind a
88    /// proxy that rewrote it). Unset falls back to the first configured
89    /// `domain`, then to `http://<host>:<port>`, which is right for local
90    /// development and wrong in front of a load balancer — so set it there.
91    pub public_url: String,
92}
93
94impl Default for ServerConfig {
95    fn default() -> Self {
96        ServerConfig {
97            host: "0.0.0.0".to_string(),
98            port: 8080,
99            domain: Vec::new(),
100            base_path: "/".to_string(),
101            workers: None,
102            public_url: String::new(),
103        }
104    }
105}
106
107impl ServerConfig {
108    /// The origin to put in a link that will be read outside this process.
109    ///
110    /// Prefers what the app declared, then the first domain it answers for,
111    /// and only then the socket it happens to be bound to.
112    pub fn public_origin(&self) -> String {
113        if !self.public_url.is_empty() {
114            return self.public_url.trim_end_matches('/').to_string();
115        }
116        if let Some(domain) = self.domain.first() {
117            // A bare domain is a hostname, not a URL; assume the scheme every
118            // deployment that has a domain name is using.
119            return if domain.contains("://") {
120                domain.trim_end_matches('/').to_string()
121            } else {
122                format!("https://{domain}")
123            };
124        }
125        let host = match self.host.as_str() {
126            "0.0.0.0" | "" | "*" | "::" => "localhost",
127            host => host,
128        };
129        format!("http://{host}:{}", self.port)
130    }
131}
132
133#[derive(Debug, Clone, Deserialize)]
134#[serde(default)]
135pub struct DatabaseConfig {
136    /// Full connection URL. When empty it is assembled from the parts below.
137    pub url: String,
138    pub host: String,
139    pub port: u16,
140    pub name: String,
141    pub user: String,
142    pub password: String,
143    /// Max pool connections.
144    pub max_connections: u32,
145    /// Run pending migrations on boot.
146    pub auto_migrate: bool,
147}
148
149impl Default for DatabaseConfig {
150    fn default() -> Self {
151        DatabaseConfig {
152            url: String::new(),
153            host: "localhost".to_string(),
154            port: 5432,
155            name: "apiplant".to_string(),
156            user: "postgres".to_string(),
157            password: "postgres".to_string(),
158            max_connections: 16,
159            auto_migrate: true,
160        }
161    }
162}
163
164impl DatabaseConfig {
165    /// The connection URL, assembled from parts when `url` was left empty.
166    pub fn resolved_url(&self) -> String {
167        if !self.url.is_empty() {
168            return self.url.clone();
169        }
170        format!(
171            "postgres://{}:{}@{}:{}/{}",
172            self.user, self.password, self.host, self.port, self.name
173        )
174    }
175}
176
177#[derive(Debug, Clone, Deserialize)]
178#[serde(default)]
179pub struct AuthConfig {
180    /// Secret used to sign session JWTs. Auto-generated (and warned about) when
181    /// left empty — set it in production so tokens survive restarts.
182    pub jwt_secret: String,
183    /// Session token lifetime in seconds.
184    pub session_ttl_secs: u64,
185    /// Allow self-service signup on `POST /auth/register`.
186    pub allow_registration: bool,
187
188    // --- the three features that need a mailbox to reach ------------------
189    //
190    // Each is `Option<bool>` rather than `bool` because their honest default is
191    // not a constant: it is "yes, if this app can send email". Leaving one unset
192    // means it follows `[email]`, so configuring a provider turns all three on
193    // and configuring none leaves them off — and neither one asks the developer
194    // to keep two sections in step. Setting one explicitly always wins, which is
195    // how an app that sends mail can still refuse, say, open registration.
196    /// Require a new account to confirm its address before it can sign in.
197    /// Unset follows `[email]`.
198    pub require_email_verification: Option<bool>,
199    /// Offer `POST /auth/invitations`, so an admin can add someone who has no
200    /// account yet. Unset follows `[email]`.
201    pub allow_invitations: Option<bool>,
202    /// Offer `POST /auth/password/forgot` and `/auth/password/reset`. Unset
203    /// follows `[email]`.
204    pub allow_password_reset: Option<bool>,
205
206    /// How long an organisation invitation stays valid (default 7 days).
207    pub invite_ttl_secs: u64,
208    /// How long an address-confirmation link stays valid (default 24 hours).
209    pub verification_ttl_secs: u64,
210    /// How long a password-reset link stays valid (default 1 hour). Short on
211    /// purpose: it is a live credential sitting in a mailbox.
212    pub password_reset_ttl_secs: u64,
213}
214
215impl Default for AuthConfig {
216    fn default() -> Self {
217        AuthConfig {
218            jwt_secret: String::new(),
219            session_ttl_secs: 60 * 60 * 24 * 7,
220            allow_registration: true,
221            require_email_verification: None,
222            allow_invitations: None,
223            allow_password_reset: None,
224            invite_ttl_secs: 60 * 60 * 24 * 7,
225            verification_ttl_secs: 60 * 60 * 24,
226            password_reset_ttl_secs: 60 * 60,
227        }
228    }
229}
230
231impl AuthConfig {
232    /// Whether new accounts must confirm their address, given whether the app
233    /// can send mail at all. An unset flag follows the mailer: asking for a
234    /// confirmation nobody can deliver would lock every new account out.
235    pub fn requires_email_verification(&self, email_enabled: bool) -> bool {
236        self.require_email_verification.unwrap_or(email_enabled) && email_enabled
237    }
238
239    /// Whether invitations are offered. See
240    /// [`requires_email_verification`](Self::requires_email_verification) for
241    /// why an explicit `true` still needs a mailer.
242    pub fn invitations_enabled(&self, email_enabled: bool) -> bool {
243        self.allow_invitations.unwrap_or(email_enabled) && email_enabled
244    }
245
246    /// Whether password reset is offered.
247    pub fn password_reset_enabled(&self, email_enabled: bool) -> bool {
248        self.allow_password_reset.unwrap_or(email_enabled) && email_enabled
249    }
250}
251
252/// Interactive API documentation (OpenAPI spec + Swagger UI).
253#[derive(Debug, Clone, Deserialize)]
254#[serde(default)]
255pub struct DocsConfig {
256    /// Serve the OpenAPI spec and Swagger UI (default true).
257    pub enabled: bool,
258    /// Path (under `base_path`) the Swagger UI is served at.
259    pub path: String,
260    /// Title shown in the UI and the spec's `info.title`. Unset falls back to
261    /// the app's name — see [`App::docs_title`](crate::App::docs_title) — so an
262    /// app that renames itself renames its docs too.
263    pub title: Option<String>,
264}
265
266impl Default for DocsConfig {
267    fn default() -> Self {
268        DocsConfig {
269            enabled: true,
270            path: "/docs".to_string(),
271            title: None,
272        }
273    }
274}
275
276/// The built-in admin dashboard, served from the binary itself.
277///
278/// Every app gets one, and there is only one: the interface is embedded in
279/// `apiplant` and its manifest is derived from the app on boot. Turn it off for
280/// a deployment that shouldn't expose an operator console at all — an app that
281/// wants its own can serve one from `public/` like any other page.
282#[derive(Debug, Clone, Deserialize)]
283#[serde(default)]
284pub struct AdminConfig {
285    /// Serve the admin dashboard (default true).
286    pub enabled: bool,
287    /// Path the dashboard is served at, outside `base_path`.
288    pub path: String,
289    /// Image shown in place of the apiplant mark, as a URL the browser can
290    /// fetch — usually a file in `public/`. Unset keeps the apiplant mark.
291    pub logo: Option<String>,
292    /// Optional AI help for writing text in the admin dashboard.
293    pub ai_assistance: AdminAiAssistanceConfig,
294}
295
296/// Extra browser-side prompting that fills text fields through the app's
297/// configured AI provider.
298#[derive(Debug, Clone, Deserialize)]
299#[serde(default)]
300pub struct AdminAiAssistanceConfig {
301    /// Show the "fill with AI" control in the dashboard.
302    pub enabled: bool,
303    /// Optional system prompt sent only by the dashboard's own field helper.
304    pub system: String,
305    /// Placeholder shown in the helper's prompt box.
306    pub prompt_placeholder: String,
307}
308
309impl Default for AdminAiAssistanceConfig {
310    fn default() -> Self {
311        AdminAiAssistanceConfig {
312            enabled: false,
313            system: String::new(),
314            prompt_placeholder: "Describe what you want AI to write for this field."
315                .to_string(),
316        }
317    }
318}
319
320impl Default for AdminConfig {
321    fn default() -> Self {
322        AdminConfig {
323            enabled: true,
324            path: "/admin".to_string(),
325            logo: None,
326            ai_assistance: AdminAiAssistanceConfig::default(),
327        }
328    }
329}
330
331/// Static files served from the app's `public/` directory.
332///
333/// When the directory exists its contents are served at the site root, so
334/// `public/index.html` answers `/` and `public/style.css` answers `/style.css`.
335#[derive(Debug, Clone, Deserialize)]
336#[serde(default)]
337pub struct PublicConfig {
338    /// Serve `dir` at the root when it exists (default true).
339    pub enabled: bool,
340    /// Directory (relative to the app root) holding the static site.
341    pub dir: String,
342    /// Page returned for requests that match nothing, relative to `dir`.
343    /// Defaults to `404.html` when that file exists.
344    pub not_found: Option<String>,
345}
346
347impl Default for PublicConfig {
348    fn default() -> Self {
349        PublicConfig {
350            enabled: true,
351            dir: "public".to_string(),
352            not_found: None,
353        }
354    }
355}
356
357/// Outbound email: which provider sends it, and the credentials to do so.
358///
359/// Off by default (`provider = "none"`): an app that never sends mail carries
360/// no configuration and no client. Turning it on is one line plus a key, and
361/// every provider is reached through the same [`send_email`] call from a
362/// function — swapping SendGrid for SES is a config change, not a code change.
363///
364/// [`send_email`]: https://docs.rs/apiplant-function
365#[derive(Debug, Clone, Deserialize)]
366#[serde(default)]
367pub struct EmailConfig {
368    /// `none` (default), `smtp`, `ses`, `sendgrid`, `brevo` (aka `sendinblue`),
369    /// `mailjet`, `mailgun`, `postmark` or `resend`.
370    pub provider: String,
371    /// Envelope sender, e.g. `no-reply@example.com`. Required once enabled; a
372    /// message may override it per-send.
373    pub from: String,
374    /// Display name shown beside `from`.
375    pub from_name: String,
376    /// Default `Reply-To`. Empty = none.
377    pub reply_to: String,
378    /// The provider's API key. For `ses` this is the AWS access key id; for
379    /// `mailjet` the public key; for `smtp` it is unused (see [`SmtpConfig`]).
380    pub api_key: String,
381    /// The second half of a two-part credential: the AWS secret access key for
382    /// `ses`, the private key for `mailjet`. Unused elsewhere.
383    pub api_secret: String,
384    /// AWS region for `ses`, e.g. `eu-west-1`.
385    pub region: String,
386    /// Sending domain for `mailgun`, e.g. `mg.example.com`.
387    pub domain: String,
388    /// How long one send may take before it is abandoned.
389    pub timeout_secs: u64,
390    /// The mark shown in the banner of the messages the framework sends, as a
391    /// path inside [`PublicConfig::dir`] — `logo.png` or `/img/logo.svg`, both
392    /// of which mean the same file. It is turned into an absolute URL against
393    /// `[server] public_url`, because a mail client fetches it from the
394    /// internet rather than from a page. An empty string, or a path with no
395    /// file behind it, leaves the banner showing the app's name alone.
396    pub logo: String,
397    /// Connection details for `provider = "smtp"`.
398    pub smtp: SmtpConfig,
399}
400
401impl Default for EmailConfig {
402    fn default() -> Self {
403        EmailConfig {
404            provider: "none".to_string(),
405            from: String::new(),
406            from_name: String::new(),
407            reply_to: String::new(),
408            api_key: String::new(),
409            api_secret: String::new(),
410            region: String::new(),
411            domain: String::new(),
412            timeout_secs: 15,
413            logo: "logo.png".to_string(),
414            smtp: SmtpConfig::default(),
415        }
416    }
417}
418
419impl EmailConfig {
420    /// Whether a provider is configured at all. `none` and the empty string
421    /// both mean "this app doesn't send mail".
422    pub fn enabled(&self) -> bool {
423        !matches!(
424            self.provider.trim().to_ascii_lowercase().as_str(),
425            "" | "none"
426        )
427    }
428}
429
430/// SMTP transport settings, used only when `provider = "smtp"`.
431///
432/// Every provider here also speaks SMTP, so this is the escape hatch for one
433/// that has no first-class entry above — or for a company relay that has no API
434/// at all.
435#[derive(Debug, Clone, Deserialize)]
436#[serde(default)]
437pub struct SmtpConfig {
438    pub host: String,
439    /// `0` (the default) picks the port that matches `encryption`: 465 for
440    /// `tls`, 587 for `starttls`, 25 for `none`.
441    pub port: u16,
442    pub username: String,
443    pub password: String,
444    /// `starttls` (default), `tls` (implicit TLS, usually port 465) or `none`.
445    pub encryption: String,
446}
447
448impl Default for SmtpConfig {
449    fn default() -> Self {
450        SmtpConfig {
451            host: String::new(),
452            port: 0,
453            username: String::new(),
454            password: String::new(),
455            encryption: "starttls".to_string(),
456        }
457    }
458}
459
460/// An optional Redis cache.
461///
462/// Nothing in the framework caches through it: resources, permissions and the
463/// admin manifest all behave exactly the same whether it is configured or not.
464/// It exists so a *function* has somewhere to put a rate-limit counter, a
465/// memoised third-party response or a short-lived token — see the `cache_*`
466/// helpers on a function's `Context`.
467///
468/// Off unless `url` is set, so an app that doesn't want one pays nothing.
469#[derive(Debug, Clone, Deserialize)]
470#[serde(default)]
471pub struct CacheConfig {
472    /// Turn the configured cache off without deleting its settings.
473    pub enabled: bool,
474    /// Connection URL, e.g. `redis://127.0.0.1:6379` or `rediss://…/0`. Empty
475    /// (the default) means no cache.
476    pub url: String,
477    /// Prepended to every key a function uses, so several apps can share one
478    /// Redis without colliding.
479    pub prefix: String,
480    /// Expiry applied to a `set` that doesn't ask for one. `0` = keys persist.
481    pub default_ttl_secs: u64,
482    /// How long one cache operation may take before it is abandoned.
483    pub timeout_secs: u64,
484}
485
486impl Default for CacheConfig {
487    fn default() -> Self {
488        CacheConfig {
489            enabled: true,
490            url: String::new(),
491            prefix: String::new(),
492            default_ttl_secs: 0,
493            timeout_secs: 5,
494        }
495    }
496}
497
498impl CacheConfig {
499    /// Whether a cache should be connected: switched on *and* pointed at a
500    /// server.
501    pub fn is_active(&self) -> bool {
502        self.enabled && !self.url.trim().is_empty()
503    }
504}
505
506/// Payments: who takes the money, and how the checkout is set up.
507///
508/// Off by default (`provider = "none"`). Turning it on does three things an
509/// app would otherwise build by hand: it connects a Stripe client, it adds the
510/// `billing_*` [resources](crate::defaults) — catalogue, customers,
511/// subscriptions, payments — so billing state is queryable through the same
512/// permissions and roles as everything else, and it mounts the `/billing`
513/// endpoints that start a checkout and receive Stripe's webhooks.
514///
515/// Nothing here is a price. Prices live in `billing_price` rows, because a
516/// price is data an operator changes on a Tuesday, not configuration that
517/// wants a deployment.
518#[derive(Debug, Clone, Deserialize)]
519#[serde(default)]
520pub struct PaymentsConfig {
521    /// `none` (default) or `stripe`.
522    pub provider: String,
523    /// Stripe secret key (`sk_live_…` / `sk_test_…`). Required once enabled.
524    pub secret_key: String,
525    /// Stripe publishable key (`pk_live_…`). Not a secret: it is handed to the
526    /// browser by `GET <base>/billing/config`, which is how a front end
527    /// mounts Stripe's own elements without hardcoding a key per environment.
528    pub publishable_key: String,
529    /// Signing secret for the webhook endpoint (`whsec_…`).
530    ///
531    /// Without it `POST <base>/billing/webhook` refuses every delivery — an
532    /// unverified webhook is an unauthenticated request that edits
533    /// subscriptions, and accepting one because it is inconvenient not to is
534    /// how somebody else grants themselves a plan.
535    pub webhook_secret: String,
536    /// ISO 4217 currency for prices that don't name one, e.g. `eur`.
537    pub currency: String,
538    /// Let Stripe Tax work out and apply the right tax for the customer's
539    /// location (default true).
540    ///
541    /// On means the amounts here are what you charge *before* tax and Stripe
542    /// adds what the buyer owes. It needs an origin address and active
543    /// registrations in the Stripe dashboard; with none, Stripe adds nothing
544    /// and the charge is the price.
545    pub automatic_tax: bool,
546    /// Ask the buyer for a VAT/GST number at checkout (default true when
547    /// `automatic_tax` is on — a business buyer's number is what makes the
548    /// reverse charge apply).
549    pub tax_id_collection: Option<bool>,
550    /// Collect a full billing address at checkout rather than only what the
551    /// card requires. `auto` (default) or `required`; automatic tax needs an
552    /// address, so `auto` still collects enough to place the customer.
553    pub billing_address: String,
554    /// Where Stripe returns the buyer after a completed checkout. Empty falls
555    /// back to the dashboard's billing screen — see
556    /// [`ServerConfig::public_origin`].
557    pub success_url: String,
558    /// Where Stripe returns a buyer who backed out. Empty falls back to the
559    /// dashboard's billing screen.
560    pub cancel_url: String,
561    /// Where the Stripe customer portal returns to. Empty falls back to the
562    /// dashboard's billing screen.
563    pub portal_return_url: String,
564    /// How long one Stripe API call may take before it is abandoned.
565    pub timeout_secs: u64,
566}
567
568impl Default for PaymentsConfig {
569    fn default() -> Self {
570        PaymentsConfig {
571            provider: "none".to_string(),
572            secret_key: String::new(),
573            publishable_key: String::new(),
574            webhook_secret: String::new(),
575            currency: "usd".to_string(),
576            automatic_tax: true,
577            tax_id_collection: None,
578            billing_address: "auto".to_string(),
579            success_url: String::new(),
580            cancel_url: String::new(),
581            portal_return_url: String::new(),
582            timeout_secs: 20,
583        }
584    }
585}
586
587impl PaymentsConfig {
588    /// Whether a provider is configured at all. `none` and the empty string
589    /// both mean "this app doesn't take money".
590    pub fn enabled(&self) -> bool {
591        !matches!(
592            self.provider.trim().to_ascii_lowercase().as_str(),
593            "" | "none"
594        )
595    }
596
597    /// The currency to use for an amount that didn't name one, lowercased the
598    /// way Stripe wants it.
599    pub fn default_currency(&self) -> String {
600        let currency = self.currency.trim().to_ascii_lowercase();
601        if currency.is_empty() {
602            "usd".to_string()
603        } else {
604            currency
605        }
606    }
607
608    /// Whether checkout asks for a tax number. Unset follows `automatic_tax`:
609    /// collecting a VAT number is only useful to somebody computing tax with
610    /// it, and asking for one you ignore is a field that does nothing.
611    pub fn collects_tax_ids(&self) -> bool {
612        self.tax_id_collection.unwrap_or(self.automatic_tax)
613    }
614
615    /// Whether the webhook endpoint can verify a delivery. Payments still work
616    /// without it — the checkout completes and Stripe has the money — but
617    /// nothing of ours would ever hear about it.
618    pub fn webhooks_enabled(&self) -> bool {
619        self.enabled() && !self.webhook_secret.trim().is_empty()
620    }
621}
622
623/// An AI chat assistant: which service answers, and what to say to it.
624///
625/// Off by default (`provider = "none"`). Turning it on connects one client and
626/// mounts `<base>/ai/chat`, which takes a list of messages and streams the
627/// reply back token by token — and gives every function a `chat` call over the
628/// same provider.
629///
630/// The three providers differ only in wire format. `custom` is the one that
631/// matters most in practice: anything speaking the OpenAI chat-completions
632/// shape — llama.cpp, vLLM, Ollama, LM Studio, a gateway of your own — is
633/// reached by pointing [`endpoint`](Self::endpoint) at it, with no key at all
634/// if it wants none.
635#[derive(Debug, Clone, Deserialize)]
636#[serde(default)]
637pub struct AiConfig {
638    /// `none` (default), `openai`, `anthropic` or `custom`.
639    pub provider: String,
640    /// Where to send the request.
641    ///
642    /// Empty uses the provider's own API (`https://api.openai.com`,
643    /// `https://api.anthropic.com`) and is required for `custom`. A bare origin
644    /// or a base path (`http://localhost:8080`, `.../v1`) gets the provider's
645    /// standard path appended; a URL that already names the full path
646    /// (`…/v1/chat/completions`, `…/v1/messages`) is used exactly as written,
647    /// for a gateway that mounts it somewhere of its own.
648    pub endpoint: String,
649    /// Model to ask for when a request doesn't name one, e.g. `gpt-4o-mini`.
650    /// Some local servers serve a single model and ignore this.
651    pub model: String,
652    /// The provider's API key. **Optional**: a local model behind
653    /// `provider = "custom"` usually wants no credential, and sending an empty
654    /// one is different from sending none — so an empty key means the request
655    /// carries no authorization header at all.
656    pub api_key: String,
657    /// Prepended to every conversation as the system prompt, unless the request
658    /// carries its own. Empty = none.
659    pub system: String,
660    /// Cap on the tokens generated per reply. Anthropic requires one, so this
661    /// is sent to every provider rather than being special-cased.
662    pub max_tokens: u32,
663    /// Sampling temperature sent when a request doesn't name one. Negative
664    /// (the default) sends nothing and lets the provider choose.
665    pub temperature: f32,
666    /// Whether provider reasoning should be surfaced to callers when the
667    /// provider emits it. This is a *display* decision and says nothing about
668    /// whether the model thinks — see `thinking` for that.
669    pub reasoning: bool,
670    /// Whether to ask the provider to think, using its own switch for it.
671    ///
672    /// `None` (the default) sends nothing and leaves the model on whatever its
673    /// template does. `Some(false)` turns thinking off, `Some(true)` turns it
674    /// on. Worth setting: thinking is billed against `max_tokens` like any
675    /// other output, so a thinking model on a small budget can spend the whole
676    /// thing reasoning and answer with nothing at all.
677    ///
678    /// How it is sent depends on the provider: Anthropic has a `thinking`
679    /// parameter, and OpenAI-compatible local servers (llama.cpp, vLLM, SGLang,
680    /// Ollama) take `chat_template_kwargs.enable_thinking`, which is what the
681    /// Qwen-family templates read. OpenAI's own reasoning models expose only
682    /// `reasoning_effort` and cannot be switched off, so this is not sent to
683    /// them.
684    pub thinking: Option<bool>,
685    /// Who may call `<base>/ai/chat`, in the grammar a resource's
686    /// `[permissions]` uses: `public`, `authenticated` (the default), `member`,
687    /// `role:<name>`.
688    ///
689    /// Defaulting to `authenticated` is deliberate. The endpoint spends money
690    /// (or a GPU) on behalf of whoever calls it, and a public one is an open
691    /// proxy to your provider account — which is a decision an app should have
692    /// to write down.
693    pub access: String,
694    /// How long one completion may take before it is abandoned. Generous by
695    /// default: a long answer from a local model is slow, not broken.
696    pub timeout_secs: u64,
697}
698
699impl Default for AiConfig {
700    fn default() -> Self {
701        AiConfig {
702            provider: "none".to_string(),
703            endpoint: String::new(),
704            model: String::new(),
705            api_key: String::new(),
706            system: String::new(),
707            max_tokens: 2048,
708            temperature: -1.0,
709            reasoning: false,
710            thinking: None,
711            access: "authenticated".to_string(),
712            timeout_secs: 300,
713        }
714    }
715}
716
717impl AiConfig {
718    /// Whether a provider is configured at all. `none` and the empty string
719    /// both mean "this app has no assistant".
720    pub fn enabled(&self) -> bool {
721        !matches!(
722            self.provider.trim().to_ascii_lowercase().as_str(),
723            "" | "none"
724        )
725    }
726
727    /// The sampling temperature to send, or `None` to let the provider decide.
728    pub fn default_temperature(&self) -> Option<f32> {
729        (self.temperature >= 0.0).then_some(self.temperature)
730    }
731}
732
733impl Config {
734    /// Load `main.toml` from an app directory, applying defaults for anything
735    /// absent. A missing file is not an error.
736    pub fn load(app_dir: &Path) -> crate::Result<Self> {
737        let path = app_dir.join("main.toml");
738        let mut config = if path.exists() {
739            let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
740                path: path.clone(),
741                source: e,
742            })?;
743            // `$VAR` in any string value is read from the environment here,
744            // which is what keeps credentials out of a committed main.toml.
745            crate::env::parse_toml::<Config>(&text, "main.toml")
746                .map_err(|e| crate::Error::Toml { path, source: e })?
747        } else {
748            tracing::info!("no main.toml found, using defaults");
749            Config::default()
750        };
751        config.normalise();
752        Ok(config)
753    }
754
755    fn normalise(&mut self) {
756        // "bind everywhere" has three spellings people arrive with: leaving it
757        // out, the wildcard, and the address itself. They all mean 0.0.0.0.
758        let host = self.server.host.trim();
759        if host.is_empty() || host == "*" {
760            self.server.host = "0.0.0.0".to_string();
761        } else {
762            self.server.host = host.to_string();
763        }
764
765        // Same idea for the vhost filter: an empty or wildcard `domain` is a
766        // request for no filter at all, not a filter for the empty host. `_` is
767        // there because nginx spells its catch-all `server_name _`. A wildcard
768        // anywhere in the list wins — it already answers every host, so the
769        // named entries beside it can't narrow anything.
770        let domains = std::mem::take(&mut self.server.domain);
771        let mut wildcard = false;
772        for d in domains {
773            match d.trim() {
774                "" | "*" | "_" | "0.0.0.0" => wildcard = true,
775                d => self.server.domain.push(d.to_string()),
776            }
777        }
778        if wildcard {
779            self.server.domain.clear();
780        }
781
782        let bp = self.server.base_path.trim_end_matches('/');
783        self.server.base_path = if bp.is_empty() {
784            String::new()
785        } else if bp.starts_with('/') {
786            bp.to_string()
787        } else {
788            format!("/{bp}")
789        };
790
791        if !self.docs.path.starts_with('/') {
792            self.docs.path = format!("/{}", self.docs.path);
793        }
794
795        let admin = self.admin.path.trim_matches('/');
796        self.admin.path = if admin.is_empty() {
797            AdminConfig::default().path
798        } else {
799            format!("/{admin}")
800        };
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use std::fs;
808    use std::time::{SystemTime, UNIX_EPOCH};
809
810    fn temp_dir(label: &str) -> std::path::PathBuf {
811        let mut dir = std::env::temp_dir();
812        let stamp = SystemTime::now()
813            .duration_since(UNIX_EPOCH)
814            .unwrap()
815            .as_nanos();
816        dir.push(format!(
817            "apiplant-config-{label}-{}-{stamp}",
818            std::process::id()
819        ));
820        fs::create_dir_all(&dir).unwrap();
821        dir
822    }
823
824    #[test]
825    fn missing_main_toml_uses_defaults() {
826        let dir = temp_dir("defaults");
827        let config = Config::load(&dir).unwrap();
828
829        assert_eq!(config.server.host, "0.0.0.0");
830        assert_eq!(config.server.port, 8080);
831        assert_eq!(config.server.base_path, "");
832        assert_eq!(
833            config.database.resolved_url(),
834            "postgres://postgres:postgres@localhost:5432/apiplant"
835        );
836        assert!(config.auth.allow_registration);
837        assert!(config.docs.enabled);
838        assert_eq!(config.docs.path, "/docs");
839        // The dashboard and the public site are on by default; an app opts out.
840        assert!(config.admin.enabled);
841        assert_eq!(config.admin.path, "/admin");
842        assert!(!config.admin.ai_assistance.enabled);
843        assert_eq!(
844            config.admin.ai_assistance.prompt_placeholder,
845            "Describe what you want AI to write for this field."
846        );
847        assert!(config.public.enabled);
848        assert_eq!(config.public.dir, "public");
849        assert_eq!(config.public.not_found, None);
850        // Email, cache and payments are opt-in: an app that says nothing gets
851        // none of them.
852        assert!(!config.email.enabled());
853        assert!(!config.cache.is_active());
854        assert!(!config.payments.enabled());
855
856        fs::remove_dir_all(dir).unwrap();
857    }
858
859    #[test]
860    fn email_and_cache_load_from_their_sections() {
861        let dir = temp_dir("email-cache");
862        fs::write(
863            dir.join("main.toml"),
864            r#"
865[email]
866provider = "sendgrid"
867from = "no-reply@example.com"
868from_name = "Example"
869api_key = "SG.literal"
870
871[cache]
872url = "redis://127.0.0.1:6379"
873prefix = "example:"
874default_ttl_secs = 300
875"#,
876        )
877        .unwrap();
878
879        let config = Config::load(&dir).unwrap();
880
881        assert!(config.email.enabled());
882        assert_eq!(config.email.provider, "sendgrid");
883        assert_eq!(config.email.from, "no-reply@example.com");
884        assert_eq!(config.email.api_key, "SG.literal");
885        // Untouched defaults still apply inside a section that was given.
886        assert_eq!(config.email.timeout_secs, 15);
887        assert_eq!(config.email.smtp.encryption, "starttls");
888
889        assert!(config.cache.is_active());
890        assert_eq!(config.cache.prefix, "example:");
891        assert_eq!(config.cache.default_ttl_secs, 300);
892
893        fs::remove_dir_all(dir).unwrap();
894    }
895
896    #[test]
897    fn payments_load_from_their_section() {
898        let dir = temp_dir("payments");
899        fs::write(
900            dir.join("main.toml"),
901            r#"
902[payments]
903provider = "stripe"
904secret_key = "sk_test_literal"
905webhook_secret = "whsec_literal"
906currency = "EUR"
907"#,
908        )
909        .unwrap();
910
911        let config = Config::load(&dir).unwrap();
912
913        assert!(config.payments.enabled());
914        assert!(config.payments.webhooks_enabled());
915        // Stripe wants a lowercase currency, and nobody writes one.
916        assert_eq!(config.payments.default_currency(), "eur");
917        // Untouched defaults still apply inside a section that was given.
918        assert!(config.payments.automatic_tax);
919        assert_eq!(config.payments.timeout_secs, 20);
920
921        fs::remove_dir_all(dir).unwrap();
922    }
923
924    /// A configured provider with no signing secret still takes money — the
925    /// checkout is Stripe's page — but nothing of ours would hear that it
926    /// worked, so the two questions are answered separately.
927    #[test]
928    fn webhooks_need_their_own_secret() {
929        let payments = PaymentsConfig {
930            provider: "stripe".into(),
931            secret_key: "sk_test".into(),
932            ..PaymentsConfig::default()
933        };
934        assert!(payments.enabled());
935        assert!(!payments.webhooks_enabled());
936    }
937
938    /// Asking for a VAT number is only useful to somebody computing tax with
939    /// it, so the default follows automatic tax — and an app can still say
940    /// otherwise in either direction.
941    #[test]
942    fn tax_id_collection_follows_automatic_tax_unless_told_otherwise() {
943        let with_tax = PaymentsConfig::default();
944        assert!(with_tax.automatic_tax && with_tax.collects_tax_ids());
945
946        let no_tax = PaymentsConfig {
947            automatic_tax: false,
948            ..PaymentsConfig::default()
949        };
950        assert!(!no_tax.collects_tax_ids());
951
952        let explicit = PaymentsConfig {
953            automatic_tax: false,
954            tax_id_collection: Some(true),
955            ..PaymentsConfig::default()
956        };
957        assert!(explicit.collects_tax_ids());
958    }
959
960    /// `enabled = false` has to beat a perfectly good URL, or switching the
961    /// cache off would mean deleting the settings needed to switch it back on.
962    #[test]
963    fn a_disabled_cache_stays_off_even_with_a_url() {
964        let config = CacheConfig {
965            enabled: false,
966            url: "redis://127.0.0.1:6379".into(),
967            ..CacheConfig::default()
968        };
969        assert!(!config.is_active());
970    }
971
972    /// `Config::load` reads its file through the same expansion every other
973    /// app-directory TOML gets — including a URL assembled from several
974    /// variables, which is the case a whole-value substitution can't do.
975    #[test]
976    fn load_expands_environment_references_anywhere_in_the_file() {
977        std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
978        std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
979        std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
980        std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
981        let dir = temp_dir("env");
982        fs::write(
983            dir.join("main.toml"),
984            r#"
985[server]
986domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
987
988[database]
989url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
990
991[auth]
992jwt_secret = "$APIPLANT_TEST_JWT"
993
994[email]
995provider = "brevo"
996api_key = "${APIPLANT_TEST_MAIL}"
997from = "no-reply@example.com"
998"#,
999        )
1000        .unwrap();
1001
1002        let config = Config::load(&dir).unwrap();
1003        assert_eq!(
1004            config.database.resolved_url(),
1005            "postgres://alice:s3cret@db:5432/app"
1006        );
1007        assert_eq!(config.auth.jwt_secret, "from-env-jwt");
1008        assert_eq!(config.email.api_key, "from-env-key");
1009        // An unset variable falls back to the default written beside it.
1010        assert_eq!(config.server.domain, ["api.example.com"]);
1011
1012        for name in [
1013            "APIPLANT_TEST_JWT",
1014            "APIPLANT_TEST_MAIL",
1015            "APIPLANT_TEST_DB_USER",
1016            "APIPLANT_TEST_DB_PASS",
1017        ] {
1018            std::env::remove_var(name);
1019        }
1020        fs::remove_dir_all(dir).unwrap();
1021    }
1022
1023    #[test]
1024    fn load_treats_wildcard_host_and_domain_as_everything() {
1025        for (host, domain) in [
1026            ("", "\"\""),
1027            ("*", "\"*\""),
1028            (" 0.0.0.0 ", "\"_\""),
1029            ("*", "[]"),
1030            // A wildcard beside named hosts still means "answer any host".
1031            ("*", "[\"api.example.com\", \"*\"]"),
1032        ] {
1033            let dir = temp_dir("wildcards");
1034            fs::write(
1035                dir.join("main.toml"),
1036                format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
1037            )
1038            .unwrap();
1039
1040            let config = Config::load(&dir).unwrap();
1041
1042            assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
1043            assert!(config.server.domain.is_empty(), "domain {domain}");
1044            fs::remove_dir_all(&dir).unwrap();
1045        }
1046    }
1047
1048    /// `domain` takes a list as readily as a single string, and each entry is
1049    /// trimmed the same way.
1050    #[test]
1051    fn load_accepts_a_list_of_domains() {
1052        let dir = temp_dir("domains");
1053        fs::write(
1054            dir.join("main.toml"),
1055            "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
1056        )
1057        .unwrap();
1058
1059        let config = Config::load(&dir).unwrap();
1060
1061        assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
1062        fs::remove_dir_all(dir).unwrap();
1063    }
1064
1065    #[test]
1066    fn load_normalises_paths_and_prefers_explicit_database_url() {
1067        let dir = temp_dir("normalise");
1068        fs::write(
1069            dir.join("main.toml"),
1070            r#"
1071[server]
1072base_path = "api/"
1073workers = 8
1074
1075[database]
1076url = "postgres://db.example/custom"
1077host = "ignored"
1078port = 9999
1079name = "ignored"
1080user = "ignored"
1081password = "ignored"
1082
1083[docs]
1084path = "swagger"
1085
1086[admin]
1087path = "console/"
1088
1089[admin.ai_assistance]
1090enabled = true
1091system = "Return only the field content."
1092prompt_placeholder = "Tell AI what to draft"
1093
1094[public]
1095dir = "site"
1096not_found = "oops.html"
1097"#,
1098        )
1099        .unwrap();
1100
1101        let config = Config::load(&dir).unwrap();
1102
1103        assert_eq!(config.server.base_path, "/api");
1104        assert_eq!(config.server.workers, Some(8));
1105        assert_eq!(config.docs.path, "/swagger");
1106        assert_eq!(config.admin.path, "/console");
1107        assert!(config.admin.ai_assistance.enabled);
1108        assert_eq!(
1109            config.admin.ai_assistance.system,
1110            "Return only the field content."
1111        );
1112        assert_eq!(
1113            config.admin.ai_assistance.prompt_placeholder,
1114            "Tell AI what to draft"
1115        );
1116        assert_eq!(config.public.dir, "site");
1117        assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
1118        assert_eq!(
1119            config.database.resolved_url(),
1120            "postgres://db.example/custom"
1121        );
1122
1123        fs::remove_dir_all(dir).unwrap();
1124    }
1125
1126    #[test]
1127    fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
1128        let config = DatabaseConfig {
1129            url: String::new(),
1130            host: "db".into(),
1131            port: 5433,
1132            name: "plants".into(),
1133            user: "alice".into(),
1134            password: "secret".into(),
1135            max_connections: 16,
1136            auto_migrate: true,
1137        };
1138
1139        assert_eq!(
1140            config.resolved_url(),
1141            "postgres://alice:secret@db:5433/plants"
1142        );
1143    }
1144}