Skip to main content

apiplant_core/
config.rs

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