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.".to_string(),
315 }
316 }
317}
318
319impl Default for AdminConfig {
320 fn default() -> Self {
321 AdminConfig {
322 enabled: true,
323 path: "/admin".to_string(),
324 logo: None,
325 ai_assistance: AdminAiAssistanceConfig::default(),
326 }
327 }
328}
329
330/// Static files served from the app's `public/` directory.
331///
332/// When the directory exists its contents are served at the site root, so
333/// `public/index.html` answers `/` and `public/style.css` answers `/style.css`.
334#[derive(Debug, Clone, Deserialize)]
335#[serde(default)]
336pub struct PublicConfig {
337 /// Serve `dir` at the root when it exists (default true).
338 pub enabled: bool,
339 /// Directory (relative to the app root) holding the static site.
340 pub dir: String,
341 /// Page returned for requests that match nothing, relative to `dir`.
342 /// Defaults to `404.html` when that file exists.
343 pub not_found: Option<String>,
344}
345
346impl Default for PublicConfig {
347 fn default() -> Self {
348 PublicConfig {
349 enabled: true,
350 dir: "public".to_string(),
351 not_found: None,
352 }
353 }
354}
355
356/// Outbound email: which provider sends it, and the credentials to do so.
357///
358/// Off by default (`provider = "none"`): an app that never sends mail carries
359/// no configuration and no client. Turning it on is one line plus a key, and
360/// every provider is reached through the same [`send_email`] call from a
361/// function — swapping SendGrid for SES is a config change, not a code change.
362///
363/// [`send_email`]: https://docs.rs/apiplant-function
364#[derive(Debug, Clone, Deserialize)]
365#[serde(default)]
366pub struct EmailConfig {
367 /// `none` (default), `smtp`, `ses`, `sendgrid`, `brevo` (aka `sendinblue`),
368 /// `mailjet`, `mailgun`, `postmark` or `resend`.
369 pub provider: String,
370 /// Envelope sender, e.g. `no-reply@example.com`. Required once enabled; a
371 /// message may override it per-send.
372 pub from: String,
373 /// Display name shown beside `from`.
374 pub from_name: String,
375 /// Default `Reply-To`. Empty = none.
376 pub reply_to: String,
377 /// The provider's API key. For `ses` this is the AWS access key id; for
378 /// `mailjet` the public key; for `smtp` it is unused (see [`SmtpConfig`]).
379 pub api_key: String,
380 /// The second half of a two-part credential: the AWS secret access key for
381 /// `ses`, the private key for `mailjet`. Unused elsewhere.
382 pub api_secret: String,
383 /// AWS region for `ses`, e.g. `eu-west-1`.
384 pub region: String,
385 /// Sending domain for `mailgun`, e.g. `mg.example.com`.
386 pub domain: String,
387 /// How long one send may take before it is abandoned.
388 pub timeout_secs: u64,
389 /// The mark shown in the banner of the messages the framework sends, as a
390 /// path inside [`PublicConfig::dir`] — `logo.png` or `/img/logo.svg`, both
391 /// of which mean the same file. It is turned into an absolute URL against
392 /// `[server] public_url`, because a mail client fetches it from the
393 /// internet rather than from a page. An empty string, or a path with no
394 /// file behind it, leaves the banner showing the app's name alone.
395 pub logo: String,
396 /// Connection details for `provider = "smtp"`.
397 pub smtp: SmtpConfig,
398}
399
400impl Default for EmailConfig {
401 fn default() -> Self {
402 EmailConfig {
403 provider: "none".to_string(),
404 from: String::new(),
405 from_name: String::new(),
406 reply_to: String::new(),
407 api_key: String::new(),
408 api_secret: String::new(),
409 region: String::new(),
410 domain: String::new(),
411 timeout_secs: 15,
412 logo: "logo.png".to_string(),
413 smtp: SmtpConfig::default(),
414 }
415 }
416}
417
418impl EmailConfig {
419 /// Whether a provider is configured at all. `none` and the empty string
420 /// both mean "this app doesn't send mail".
421 pub fn enabled(&self) -> bool {
422 !matches!(
423 self.provider.trim().to_ascii_lowercase().as_str(),
424 "" | "none"
425 )
426 }
427}
428
429/// SMTP transport settings, used only when `provider = "smtp"`.
430///
431/// Every provider here also speaks SMTP, so this is the escape hatch for one
432/// that has no first-class entry above — or for a company relay that has no API
433/// at all.
434#[derive(Debug, Clone, Deserialize)]
435#[serde(default)]
436pub struct SmtpConfig {
437 pub host: String,
438 /// `0` (the default) picks the port that matches `encryption`: 465 for
439 /// `tls`, 587 for `starttls`, 25 for `none`.
440 pub port: u16,
441 pub username: String,
442 pub password: String,
443 /// `starttls` (default), `tls` (implicit TLS, usually port 465) or `none`.
444 pub encryption: String,
445}
446
447impl Default for SmtpConfig {
448 fn default() -> Self {
449 SmtpConfig {
450 host: String::new(),
451 port: 0,
452 username: String::new(),
453 password: String::new(),
454 encryption: "starttls".to_string(),
455 }
456 }
457}
458
459/// An optional Redis cache.
460///
461/// Nothing in the framework caches through it: resources, permissions and the
462/// admin manifest all behave exactly the same whether it is configured or not.
463/// It exists so a *function* has somewhere to put a rate-limit counter, a
464/// memoised third-party response or a short-lived token — see the `cache_*`
465/// helpers on a function's `Context`.
466///
467/// Off unless `url` is set, so an app that doesn't want one pays nothing.
468#[derive(Debug, Clone, Deserialize)]
469#[serde(default)]
470pub struct CacheConfig {
471 /// Turn the configured cache off without deleting its settings.
472 pub enabled: bool,
473 /// Connection URL, e.g. `redis://127.0.0.1:6379` or `rediss://…/0`. Empty
474 /// (the default) means no cache.
475 pub url: String,
476 /// Prepended to every key a function uses, so several apps can share one
477 /// Redis without colliding.
478 pub prefix: String,
479 /// Expiry applied to a `set` that doesn't ask for one. `0` = keys persist.
480 pub default_ttl_secs: u64,
481 /// How long one cache operation may take before it is abandoned.
482 pub timeout_secs: u64,
483}
484
485impl Default for CacheConfig {
486 fn default() -> Self {
487 CacheConfig {
488 enabled: true,
489 url: String::new(),
490 prefix: String::new(),
491 default_ttl_secs: 0,
492 timeout_secs: 5,
493 }
494 }
495}
496
497impl CacheConfig {
498 /// Whether a cache should be connected: switched on *and* pointed at a
499 /// server.
500 pub fn is_active(&self) -> bool {
501 self.enabled && !self.url.trim().is_empty()
502 }
503}
504
505/// Payments: who takes the money, and how the checkout is set up.
506///
507/// Off by default (`provider = "none"`). Turning it on does three things an
508/// app would otherwise build by hand: it connects a Stripe client, it adds the
509/// `billing_*` [resources](crate::defaults) — catalogue, customers,
510/// subscriptions, payments — so billing state is queryable through the same
511/// permissions and roles as everything else, and it mounts the `/billing`
512/// endpoints that start a checkout and receive Stripe's webhooks.
513///
514/// Nothing here is a price. Prices live in `billing_price` rows, because a
515/// price is data an operator changes on a Tuesday, not configuration that
516/// wants a deployment.
517#[derive(Debug, Clone, Deserialize)]
518#[serde(default)]
519pub struct PaymentsConfig {
520 /// `none` (default) or `stripe`.
521 pub provider: String,
522 /// Stripe secret key (`sk_live_…` / `sk_test_…`). Required once enabled.
523 pub secret_key: String,
524 /// Stripe publishable key (`pk_live_…`). Not a secret: it is handed to the
525 /// browser by `GET <base>/billing/config`, which is how a front end
526 /// mounts Stripe's own elements without hardcoding a key per environment.
527 pub publishable_key: String,
528 /// Signing secret for the webhook endpoint (`whsec_…`).
529 ///
530 /// Without it `POST <base>/billing/webhook` refuses every delivery — an
531 /// unverified webhook is an unauthenticated request that edits
532 /// subscriptions, and accepting one because it is inconvenient not to is
533 /// how somebody else grants themselves a plan.
534 pub webhook_secret: String,
535 /// ISO 4217 currency for prices that don't name one, e.g. `eur`.
536 pub currency: String,
537 /// Let Stripe Tax work out and apply the right tax for the customer's
538 /// location (default true).
539 ///
540 /// On means the amounts here are what you charge *before* tax and Stripe
541 /// adds what the buyer owes. It needs an origin address and active
542 /// registrations in the Stripe dashboard; with none, Stripe adds nothing
543 /// and the charge is the price.
544 pub automatic_tax: bool,
545 /// Ask the buyer for a VAT/GST number at checkout (default true when
546 /// `automatic_tax` is on — a business buyer's number is what makes the
547 /// reverse charge apply).
548 pub tax_id_collection: Option<bool>,
549 /// Collect a full billing address at checkout rather than only what the
550 /// card requires. `auto` (default) or `required`; automatic tax needs an
551 /// address, so `auto` still collects enough to place the customer.
552 pub billing_address: String,
553 /// Where Stripe returns the buyer after a completed checkout. Empty falls
554 /// back to the dashboard's billing screen — see
555 /// [`ServerConfig::public_origin`].
556 pub success_url: String,
557 /// Where Stripe returns a buyer who backed out. Empty falls back to the
558 /// dashboard's billing screen.
559 pub cancel_url: String,
560 /// Where the Stripe customer portal returns to. Empty falls back to the
561 /// dashboard's billing screen.
562 pub portal_return_url: String,
563 /// How long one Stripe API call may take before it is abandoned.
564 pub timeout_secs: u64,
565}
566
567impl Default for PaymentsConfig {
568 fn default() -> Self {
569 PaymentsConfig {
570 provider: "none".to_string(),
571 secret_key: String::new(),
572 publishable_key: String::new(),
573 webhook_secret: String::new(),
574 currency: "usd".to_string(),
575 automatic_tax: true,
576 tax_id_collection: None,
577 billing_address: "auto".to_string(),
578 success_url: String::new(),
579 cancel_url: String::new(),
580 portal_return_url: String::new(),
581 timeout_secs: 20,
582 }
583 }
584}
585
586impl PaymentsConfig {
587 /// Whether a provider is configured at all. `none` and the empty string
588 /// both mean "this app doesn't take money".
589 pub fn enabled(&self) -> bool {
590 !matches!(
591 self.provider.trim().to_ascii_lowercase().as_str(),
592 "" | "none"
593 )
594 }
595
596 /// The currency to use for an amount that didn't name one, lowercased the
597 /// way Stripe wants it.
598 pub fn default_currency(&self) -> String {
599 let currency = self.currency.trim().to_ascii_lowercase();
600 if currency.is_empty() {
601 "usd".to_string()
602 } else {
603 currency
604 }
605 }
606
607 /// Whether checkout asks for a tax number. Unset follows `automatic_tax`:
608 /// collecting a VAT number is only useful to somebody computing tax with
609 /// it, and asking for one you ignore is a field that does nothing.
610 pub fn collects_tax_ids(&self) -> bool {
611 self.tax_id_collection.unwrap_or(self.automatic_tax)
612 }
613
614 /// Whether the webhook endpoint can verify a delivery. Payments still work
615 /// without it — the checkout completes and Stripe has the money — but
616 /// nothing of ours would ever hear about it.
617 pub fn webhooks_enabled(&self) -> bool {
618 self.enabled() && !self.webhook_secret.trim().is_empty()
619 }
620}
621
622/// An AI chat assistant: which service answers, and what to say to it.
623///
624/// Off by default (`provider = "none"`). Turning it on connects one client and
625/// mounts `<base>/ai/chat`, which takes a list of messages and streams the
626/// reply back token by token — and gives every function a `chat` call over the
627/// same provider.
628///
629/// The three providers differ only in wire format. `custom` is the one that
630/// matters most in practice: anything speaking the OpenAI chat-completions
631/// shape — llama.cpp, vLLM, Ollama, LM Studio, a gateway of your own — is
632/// reached by pointing [`endpoint`](Self::endpoint) at it, with no key at all
633/// if it wants none.
634#[derive(Debug, Clone, Deserialize)]
635#[serde(default)]
636pub struct AiConfig {
637 /// `none` (default), `openai`, `anthropic` or `custom`.
638 pub provider: String,
639 /// Where to send the request.
640 ///
641 /// Empty uses the provider's own API (`https://api.openai.com`,
642 /// `https://api.anthropic.com`) and is required for `custom`. A bare origin
643 /// or a base path (`http://localhost:8080`, `.../v1`) gets the provider's
644 /// standard path appended; a URL that already names the full path
645 /// (`…/v1/chat/completions`, `…/v1/messages`) is used exactly as written,
646 /// for a gateway that mounts it somewhere of its own.
647 pub endpoint: String,
648 /// Model to ask for when a request doesn't name one, e.g. `gpt-4o-mini`.
649 /// Some local servers serve a single model and ignore this.
650 pub model: String,
651 /// The provider's API key. **Optional**: a local model behind
652 /// `provider = "custom"` usually wants no credential, and sending an empty
653 /// one is different from sending none — so an empty key means the request
654 /// carries no authorization header at all.
655 pub api_key: String,
656 /// Prepended to every conversation as the system prompt, unless the request
657 /// carries its own. Empty = none.
658 pub system: String,
659 /// Cap on the tokens generated per reply. Anthropic requires one, so this
660 /// is sent to every provider rather than being special-cased.
661 pub max_tokens: u32,
662 /// Sampling temperature sent when a request doesn't name one. Negative
663 /// (the default) sends nothing and lets the provider choose.
664 pub temperature: f32,
665 /// Whether provider reasoning should be surfaced to callers when the
666 /// provider emits it. This is a *display* decision and says nothing about
667 /// whether the model thinks — see `thinking` for that.
668 pub reasoning: bool,
669 /// Whether to ask the provider to think, using its own switch for it.
670 ///
671 /// `None` (the default) sends nothing and leaves the model on whatever its
672 /// template does. `Some(false)` turns thinking off, `Some(true)` turns it
673 /// on. Worth setting: thinking is billed against `max_tokens` like any
674 /// other output, so a thinking model on a small budget can spend the whole
675 /// thing reasoning and answer with nothing at all.
676 ///
677 /// How it is sent depends on the provider: Anthropic has a `thinking`
678 /// parameter, and OpenAI-compatible local servers (llama.cpp, vLLM, SGLang,
679 /// Ollama) take `chat_template_kwargs.enable_thinking`, which is what the
680 /// Qwen-family templates read. OpenAI's own reasoning models expose only
681 /// `reasoning_effort` and cannot be switched off, so this is not sent to
682 /// them.
683 pub thinking: Option<bool>,
684 /// Who may call `<base>/ai/chat`, in the grammar a resource's
685 /// `[permissions]` uses: `public`, `authenticated` (the default), `member`,
686 /// `role:<name>`.
687 ///
688 /// Defaulting to `authenticated` is deliberate. The endpoint spends money
689 /// (or a GPU) on behalf of whoever calls it, and a public one is an open
690 /// proxy to your provider account — which is a decision an app should have
691 /// to write down.
692 pub access: String,
693 /// How long one completion may take before it is abandoned. Generous by
694 /// default: a long answer from a local model is slow, not broken.
695 pub timeout_secs: u64,
696}
697
698impl Default for AiConfig {
699 fn default() -> Self {
700 AiConfig {
701 provider: "none".to_string(),
702 endpoint: String::new(),
703 model: String::new(),
704 api_key: String::new(),
705 system: String::new(),
706 max_tokens: 2048,
707 temperature: -1.0,
708 reasoning: false,
709 thinking: None,
710 access: "authenticated".to_string(),
711 timeout_secs: 300,
712 }
713 }
714}
715
716impl AiConfig {
717 /// Whether a provider is configured at all. `none` and the empty string
718 /// both mean "this app has no assistant".
719 pub fn enabled(&self) -> bool {
720 !matches!(
721 self.provider.trim().to_ascii_lowercase().as_str(),
722 "" | "none"
723 )
724 }
725
726 /// The sampling temperature to send, or `None` to let the provider decide.
727 pub fn default_temperature(&self) -> Option<f32> {
728 (self.temperature >= 0.0).then_some(self.temperature)
729 }
730}
731
732impl Config {
733 /// Load `main.toml` from an app directory, applying defaults for anything
734 /// absent. A missing file is not an error.
735 pub fn load(app_dir: &Path) -> crate::Result<Self> {
736 let path = app_dir.join("main.toml");
737 let mut config = if path.exists() {
738 let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
739 path: path.clone(),
740 source: e,
741 })?;
742 // `$VAR` in any string value is read from the environment here,
743 // which is what keeps credentials out of a committed main.toml.
744 crate::env::parse_toml::<Config>(&text, "main.toml")
745 .map_err(|e| crate::Error::Toml { path, source: e })?
746 } else {
747 tracing::info!("no main.toml found, using defaults");
748 Config::default()
749 };
750 config.normalise();
751 Ok(config)
752 }
753
754 fn normalise(&mut self) {
755 // "bind everywhere" has three spellings people arrive with: leaving it
756 // out, the wildcard, and the address itself. They all mean 0.0.0.0.
757 let host = self.server.host.trim();
758 if host.is_empty() || host == "*" {
759 self.server.host = "0.0.0.0".to_string();
760 } else {
761 self.server.host = host.to_string();
762 }
763
764 // Same idea for the vhost filter: an empty or wildcard `domain` is a
765 // request for no filter at all, not a filter for the empty host. `_` is
766 // there because nginx spells its catch-all `server_name _`. A wildcard
767 // anywhere in the list wins — it already answers every host, so the
768 // named entries beside it can't narrow anything.
769 let domains = std::mem::take(&mut self.server.domain);
770 let mut wildcard = false;
771 for d in domains {
772 match d.trim() {
773 "" | "*" | "_" | "0.0.0.0" => wildcard = true,
774 d => self.server.domain.push(d.to_string()),
775 }
776 }
777 if wildcard {
778 self.server.domain.clear();
779 }
780
781 let bp = self.server.base_path.trim_end_matches('/');
782 self.server.base_path = if bp.is_empty() {
783 String::new()
784 } else if bp.starts_with('/') {
785 bp.to_string()
786 } else {
787 format!("/{bp}")
788 };
789
790 if !self.docs.path.starts_with('/') {
791 self.docs.path = format!("/{}", self.docs.path);
792 }
793
794 let admin = self.admin.path.trim_matches('/');
795 self.admin.path = if admin.is_empty() {
796 AdminConfig::default().path
797 } else {
798 format!("/{admin}")
799 };
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use std::fs;
807 use std::time::{SystemTime, UNIX_EPOCH};
808
809 fn temp_dir(label: &str) -> std::path::PathBuf {
810 let mut dir = std::env::temp_dir();
811 let stamp = SystemTime::now()
812 .duration_since(UNIX_EPOCH)
813 .unwrap()
814 .as_nanos();
815 dir.push(format!(
816 "apiplant-config-{label}-{}-{stamp}",
817 std::process::id()
818 ));
819 fs::create_dir_all(&dir).unwrap();
820 dir
821 }
822
823 #[test]
824 fn missing_main_toml_uses_defaults() {
825 let dir = temp_dir("defaults");
826 let config = Config::load(&dir).unwrap();
827
828 assert_eq!(config.server.host, "0.0.0.0");
829 assert_eq!(config.server.port, 8080);
830 assert_eq!(config.server.base_path, "");
831 assert_eq!(
832 config.database.resolved_url(),
833 "postgres://postgres:postgres@localhost:5432/apiplant"
834 );
835 assert!(config.auth.allow_registration);
836 assert!(config.docs.enabled);
837 assert_eq!(config.docs.path, "/docs");
838 // The dashboard and the public site are on by default; an app opts out.
839 assert!(config.admin.enabled);
840 assert_eq!(config.admin.path, "/admin");
841 assert!(!config.admin.ai_assistance.enabled);
842 assert_eq!(
843 config.admin.ai_assistance.prompt_placeholder,
844 "Describe what you want AI to write for this field."
845 );
846 assert!(config.public.enabled);
847 assert_eq!(config.public.dir, "public");
848 assert_eq!(config.public.not_found, None);
849 // Email, cache and payments are opt-in: an app that says nothing gets
850 // none of them.
851 assert!(!config.email.enabled());
852 assert!(!config.cache.is_active());
853 assert!(!config.payments.enabled());
854
855 fs::remove_dir_all(dir).unwrap();
856 }
857
858 #[test]
859 fn email_and_cache_load_from_their_sections() {
860 let dir = temp_dir("email-cache");
861 fs::write(
862 dir.join("main.toml"),
863 r#"
864[email]
865provider = "sendgrid"
866from = "no-reply@example.com"
867from_name = "Example"
868api_key = "SG.literal"
869
870[cache]
871url = "redis://127.0.0.1:6379"
872prefix = "example:"
873default_ttl_secs = 300
874"#,
875 )
876 .unwrap();
877
878 let config = Config::load(&dir).unwrap();
879
880 assert!(config.email.enabled());
881 assert_eq!(config.email.provider, "sendgrid");
882 assert_eq!(config.email.from, "no-reply@example.com");
883 assert_eq!(config.email.api_key, "SG.literal");
884 // Untouched defaults still apply inside a section that was given.
885 assert_eq!(config.email.timeout_secs, 15);
886 assert_eq!(config.email.smtp.encryption, "starttls");
887
888 assert!(config.cache.is_active());
889 assert_eq!(config.cache.prefix, "example:");
890 assert_eq!(config.cache.default_ttl_secs, 300);
891
892 fs::remove_dir_all(dir).unwrap();
893 }
894
895 #[test]
896 fn payments_load_from_their_section() {
897 let dir = temp_dir("payments");
898 fs::write(
899 dir.join("main.toml"),
900 r#"
901[payments]
902provider = "stripe"
903secret_key = "sk_test_literal"
904webhook_secret = "whsec_literal"
905currency = "EUR"
906"#,
907 )
908 .unwrap();
909
910 let config = Config::load(&dir).unwrap();
911
912 assert!(config.payments.enabled());
913 assert!(config.payments.webhooks_enabled());
914 // Stripe wants a lowercase currency, and nobody writes one.
915 assert_eq!(config.payments.default_currency(), "eur");
916 // Untouched defaults still apply inside a section that was given.
917 assert!(config.payments.automatic_tax);
918 assert_eq!(config.payments.timeout_secs, 20);
919
920 fs::remove_dir_all(dir).unwrap();
921 }
922
923 /// A configured provider with no signing secret still takes money — the
924 /// checkout is Stripe's page — but nothing of ours would hear that it
925 /// worked, so the two questions are answered separately.
926 #[test]
927 fn webhooks_need_their_own_secret() {
928 let payments = PaymentsConfig {
929 provider: "stripe".into(),
930 secret_key: "sk_test".into(),
931 ..PaymentsConfig::default()
932 };
933 assert!(payments.enabled());
934 assert!(!payments.webhooks_enabled());
935 }
936
937 /// Asking for a VAT number is only useful to somebody computing tax with
938 /// it, so the default follows automatic tax — and an app can still say
939 /// otherwise in either direction.
940 #[test]
941 fn tax_id_collection_follows_automatic_tax_unless_told_otherwise() {
942 let with_tax = PaymentsConfig::default();
943 assert!(with_tax.automatic_tax && with_tax.collects_tax_ids());
944
945 let no_tax = PaymentsConfig {
946 automatic_tax: false,
947 ..PaymentsConfig::default()
948 };
949 assert!(!no_tax.collects_tax_ids());
950
951 let explicit = PaymentsConfig {
952 automatic_tax: false,
953 tax_id_collection: Some(true),
954 ..PaymentsConfig::default()
955 };
956 assert!(explicit.collects_tax_ids());
957 }
958
959 /// `enabled = false` has to beat a perfectly good URL, or switching the
960 /// cache off would mean deleting the settings needed to switch it back on.
961 #[test]
962 fn a_disabled_cache_stays_off_even_with_a_url() {
963 let config = CacheConfig {
964 enabled: false,
965 url: "redis://127.0.0.1:6379".into(),
966 ..CacheConfig::default()
967 };
968 assert!(!config.is_active());
969 }
970
971 /// `Config::load` reads its file through the same expansion every other
972 /// app-directory TOML gets — including a URL assembled from several
973 /// variables, which is the case a whole-value substitution can't do.
974 #[test]
975 fn load_expands_environment_references_anywhere_in_the_file() {
976 std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
977 std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
978 std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
979 std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
980 let dir = temp_dir("env");
981 fs::write(
982 dir.join("main.toml"),
983 r#"
984[server]
985domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
986
987[database]
988url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
989
990[auth]
991jwt_secret = "$APIPLANT_TEST_JWT"
992
993[email]
994provider = "brevo"
995api_key = "${APIPLANT_TEST_MAIL}"
996from = "no-reply@example.com"
997"#,
998 )
999 .unwrap();
1000
1001 let config = Config::load(&dir).unwrap();
1002 assert_eq!(
1003 config.database.resolved_url(),
1004 "postgres://alice:s3cret@db:5432/app"
1005 );
1006 assert_eq!(config.auth.jwt_secret, "from-env-jwt");
1007 assert_eq!(config.email.api_key, "from-env-key");
1008 // An unset variable falls back to the default written beside it.
1009 assert_eq!(config.server.domain, ["api.example.com"]);
1010
1011 for name in [
1012 "APIPLANT_TEST_JWT",
1013 "APIPLANT_TEST_MAIL",
1014 "APIPLANT_TEST_DB_USER",
1015 "APIPLANT_TEST_DB_PASS",
1016 ] {
1017 std::env::remove_var(name);
1018 }
1019 fs::remove_dir_all(dir).unwrap();
1020 }
1021
1022 #[test]
1023 fn load_treats_wildcard_host_and_domain_as_everything() {
1024 for (host, domain) in [
1025 ("", "\"\""),
1026 ("*", "\"*\""),
1027 (" 0.0.0.0 ", "\"_\""),
1028 ("*", "[]"),
1029 // A wildcard beside named hosts still means "answer any host".
1030 ("*", "[\"api.example.com\", \"*\"]"),
1031 ] {
1032 let dir = temp_dir("wildcards");
1033 fs::write(
1034 dir.join("main.toml"),
1035 format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
1036 )
1037 .unwrap();
1038
1039 let config = Config::load(&dir).unwrap();
1040
1041 assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
1042 assert!(config.server.domain.is_empty(), "domain {domain}");
1043 fs::remove_dir_all(&dir).unwrap();
1044 }
1045 }
1046
1047 /// `domain` takes a list as readily as a single string, and each entry is
1048 /// trimmed the same way.
1049 #[test]
1050 fn load_accepts_a_list_of_domains() {
1051 let dir = temp_dir("domains");
1052 fs::write(
1053 dir.join("main.toml"),
1054 "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
1055 )
1056 .unwrap();
1057
1058 let config = Config::load(&dir).unwrap();
1059
1060 assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
1061 fs::remove_dir_all(dir).unwrap();
1062 }
1063
1064 #[test]
1065 fn load_normalises_paths_and_prefers_explicit_database_url() {
1066 let dir = temp_dir("normalise");
1067 fs::write(
1068 dir.join("main.toml"),
1069 r#"
1070[server]
1071base_path = "api/"
1072workers = 8
1073
1074[database]
1075url = "postgres://db.example/custom"
1076host = "ignored"
1077port = 9999
1078name = "ignored"
1079user = "ignored"
1080password = "ignored"
1081
1082[docs]
1083path = "swagger"
1084
1085[admin]
1086path = "console/"
1087
1088[admin.ai_assistance]
1089enabled = true
1090system = "Return only the field content."
1091prompt_placeholder = "Tell AI what to draft"
1092
1093[public]
1094dir = "site"
1095not_found = "oops.html"
1096"#,
1097 )
1098 .unwrap();
1099
1100 let config = Config::load(&dir).unwrap();
1101
1102 assert_eq!(config.server.base_path, "/api");
1103 assert_eq!(config.server.workers, Some(8));
1104 assert_eq!(config.docs.path, "/swagger");
1105 assert_eq!(config.admin.path, "/console");
1106 assert!(config.admin.ai_assistance.enabled);
1107 assert_eq!(
1108 config.admin.ai_assistance.system,
1109 "Return only the field content."
1110 );
1111 assert_eq!(
1112 config.admin.ai_assistance.prompt_placeholder,
1113 "Tell AI what to draft"
1114 );
1115 assert_eq!(config.public.dir, "site");
1116 assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
1117 assert_eq!(
1118 config.database.resolved_url(),
1119 "postgres://db.example/custom"
1120 );
1121
1122 fs::remove_dir_all(dir).unwrap();
1123 }
1124
1125 #[test]
1126 fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
1127 let config = DatabaseConfig {
1128 url: String::new(),
1129 host: "db".into(),
1130 port: 5433,
1131 name: "plants".into(),
1132 user: "alice".into(),
1133 password: "secret".into(),
1134 max_connections: 16,
1135 auto_migrate: true,
1136 };
1137
1138 assert_eq!(
1139 config.resolved_url(),
1140 "postgres://alice:secret@db:5433/plants"
1141 );
1142 }
1143}