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}