Skip to main content

ordinary_config/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3#![doc = include_str!("../docs/app-config-reference.md")]
4#![doc = include_str!("../docs/host-config-reference.md")]
5#![warn(clippy::all, clippy::pedantic)]
6#![allow(clippy::missing_errors_doc)]
7
8// Copyright (C) 2026 The Ordinary Authors.
9//
10// SPDX-License-Identifier: BSD-3-Clause
11
12#[cfg(feature = "docs")]
13pub mod jsonschema;
14#[cfg(feature = "docs")]
15pub use schemars;
16
17mod app;
18pub mod auth;
19mod host;
20mod http;
21mod validate;
22
23pub use app::*;
24pub use auth::*;
25pub use host::*;
26pub use http::*;
27
28pub use crate::validate::DOMAIN_REGEX;
29use crate::validate::validate;
30
31use anyhow::bail;
32use hashbrown::{HashMap, HashSet};
33use serde::{Deserialize, Serialize};
34use smallvec::smallvec;
35use std::collections::BTreeMap;
36use std::fmt::Write;
37use std::path::Path;
38use std::process::Command;
39use std::{env, fs};
40use tracing::instrument;
41
42#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
43#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
44#[derive(Deserialize, Serialize, Debug, Clone)]
45pub struct ClientLoggingConfig {
46    /// bottom end of the delayed delivery range (seconds)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    #[serde(default)]
49    min_delay: Option<u32>,
50    /// top end of delayed delivery range (seconds)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    #[serde(default)]
53    max_delay: Option<u32>,
54    /// max number of events to be buffered on the client
55    /// prior to flush.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    #[serde(default)]
58    max_buffer: Option<u16>,
59    /// sets the max number of events in a given request.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    #[serde(default)]
62    max_batch: Option<u16>,
63}
64
65#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
66#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
67#[derive(Deserialize, Serialize, Debug, Clone)]
68pub enum RedactedHashAlg {
69    Blake2,
70    Blake3,
71}
72
73#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
74#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
75#[derive(Deserialize, Serialize, Debug, Clone)]
76pub struct ServerLoggingConfig {
77    #[serde(skip_serializing_if = "Option::is_none")]
78    #[serde(default)]
79    pub ips: Option<bool>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    #[serde(default)]
82    pub headers: Option<bool>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    #[serde(default)]
85    pub credentials: Option<RedactedHashAlg>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    #[serde(default)]
88    pub timing: Option<bool>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    #[serde(default)]
91    pub sizes: Option<bool>,
92}
93
94#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
95#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
96#[derive(Deserialize, Serialize, Debug, Clone)]
97pub struct LoggingConfig {
98    #[serde(skip_serializing_if = "Option::is_none")]
99    #[serde(default)]
100    pub client: Option<ClientLoggingConfig>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    #[serde(default)]
103    pub server: Option<ServerLoggingConfig>,
104}
105
106/// Compression algorithms
107#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
108#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
109#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
110pub enum CompressionAlgorithm {
111    Uncompressed,
112    Gzip,
113    Zstd { level: u8 },
114    Brotli,
115    Deflate,
116    All,
117}
118
119impl CompressionAlgorithm {
120    #[must_use]
121    pub fn as_u8(&self) -> u8 {
122        match self {
123            Self::Uncompressed => 0,
124            Self::Gzip => 1,
125            Self::Zstd { level: _ } => 2,
126            Self::Brotli => 3,
127            Self::Deflate => 4,
128            Self::All => 255,
129        }
130    }
131
132    #[must_use]
133    pub fn from_u8(val: u8, lvl: Option<u8>) -> Self {
134        match val {
135            0 => Self::Uncompressed,
136            1 => Self::Gzip,
137            2 => Self::Zstd {
138                level: lvl.unwrap_or(17),
139            },
140            3 => Self::Brotli,
141            4 => Self::Deflate,
142            _ => Self::All,
143        }
144    }
145
146    #[must_use]
147    pub fn as_char(&self) -> char {
148        match self {
149            Self::Uncompressed => '0',
150            Self::Gzip => '1',
151            Self::Zstd { level: _ } => '2',
152            Self::Brotli => '3',
153            Self::Deflate => '4',
154            Self::All => 'A',
155        }
156    }
157
158    #[must_use]
159    pub fn as_str(&self) -> &'static str {
160        match self {
161            Self::Uncompressed => "uncompressed",
162            Self::Gzip => "gzip",
163            Self::Zstd { level: _ } => "zstd",
164            Self::Brotli => "br",
165            Self::Deflate => "deflate",
166            Self::All => "all",
167        }
168    }
169}
170
171#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
172#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
173#[derive(Deserialize, Serialize, Debug, Clone, Default)]
174pub struct ErrorConfig {
175    /// Refers to the asset by path.
176    ///
177    /// Returns as the fallback when a route is missing.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    #[serde(default)]
180    pub asset: Option<String>,
181}
182
183#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
184#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
185#[derive(Deserialize, Serialize, Debug, Clone)]
186pub enum RuntimeMode {
187    /// Application will run on the shared multithreaded
188    /// tokio runtime.
189    Shared,
190    /// Application will run on a separate thread with its
191    /// own single-threaded tokio runtime.
192    SingleThreaded,
193    /// Application will run on a separate thread with its
194    /// own multithreaded tokio runtime.
195    MultiThreaded,
196}
197
198#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
199#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
200#[derive(Deserialize, Serialize, Debug, Clone)]
201pub struct LifecycleBeforeAfterScripts {
202    #[serde(skip_serializing_if = "Option::is_none")]
203    #[serde(default)]
204    pub before: Option<Vec<Vec<String>>>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    #[serde(default)]
207    pub after: Option<Vec<Vec<String>>>,
208}
209
210#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
211#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
212#[derive(Deserialize, Serialize, Debug, Clone)]
213pub struct TopLevelLifecycle {
214    /// run before every lifecycle operation
215    pub before_all: Option<Vec<Vec<String>>>,
216
217    /// configure build lifecycle hooks
218    #[serde(skip_serializing_if = "Option::is_none")]
219    #[serde(default)]
220    pub build: Option<LifecycleBeforeAfterScripts>,
221}
222
223/// Config definition for an Ordinary Application
224#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
225#[cfg_attr(feature = "docs", derive(schemars::JsonSchema))]
226#[derive(Deserialize, Serialize, Debug, Clone, Default)]
227pub struct OrdinaryConfig {
228    #[serde(skip_serializing_if = "Option::is_none")]
229    #[serde(default)]
230    pub lifecycle: Option<TopLevelLifecycle>,
231
232    /// Domain name for the application to be run from the
233    /// deployment environment.
234    pub domain: String,
235
236    /// Version of the site build.
237    pub version: String,
238
239    /// additional domains with an ALIAS record
240    /// pointing at the primary `OrdinaryConfig::domain`.
241    ///
242    /// add a TXT record in the following format:
243    ///`ordinary=your.config.domain`
244    #[serde(skip_serializing_if = "Option::is_none")]
245    #[serde(default)]
246    pub cnames: Option<Vec<String>>,
247
248    /// specify which of the `domain` or `cnames` is
249    /// the "canonical" location.
250    ///
251    /// this is useful for [indexing](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)
252    /// and situations where you want to display the primary
253    /// URL as text on the page itself (i.e. pick one of `example.some.host`, `example.com`, and `www.example.com`).
254    ///
255    /// defaults to `domain` if `cnames` is empty. defaults to first `cname` in list if `cnames`
256    /// are not empty.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    #[serde(default)]
259    pub canonical: Option<String>,
260
261    #[serde(skip_serializing_if = "Option::is_none")]
262    #[serde(default)]
263    pub http: Option<HttpConfig>,
264
265    #[serde(skip)]
266    #[serde(default)]
267    pub internal_middlewares: Option<HashMap<String, MiddlewareConfig>>,
268
269    /// list of email addresses that can be used to contact
270    /// the application owner or administrators.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    #[serde(default)]
273    pub contacts: Option<Vec<String>>,
274
275    /// whether contacts should be hidden (defaults to `true`)
276    #[serde(skip_serializing_if = "Option::is_none")]
277    #[serde(default)]
278    pub hide_contacts: Option<bool>,
279
280    /// Storage size in bytes (rounded up to nearest OS page size).
281    #[serde(skip_serializing_if = "Option::is_none")]
282    #[serde(default = "OrdinaryConfig::default_storage_size")]
283    pub storage_size: Option<u64>,
284
285    /// Specifies runtime mode for application on the host.
286    ///
287    /// If none is specified, defaults to Shared (or host default).
288    #[serde(skip_serializing_if = "Option::is_none")]
289    #[serde(default)]
290    pub runtime: Option<RuntimeMode>,
291
292    /// When set to true, `{{ domain }}/.ordinary/schema`
293    /// is not addressable.
294    ///
295    /// Note: this can break applications which depend on
296    /// flags, and `function`/`template` query descriptors.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    #[serde(default)]
299    pub hide_schema: Option<bool>,
300
301    /// Include E2EE handler code in the client WASM.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    #[serde(default)]
304    pub client_events: Option<bool>,
305
306    /// Port to be used for standalone "run" instances.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    #[serde(default)]
309    pub port: Option<u16>,
310    /// port used for redirecting from http when
311    /// standalone is running in secure mode.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    #[serde(default)]
314    pub redirect_port: Option<u16>,
315
316    #[serde(skip_serializing_if = "Option::is_none")]
317    #[serde(default)]
318    pub logging: Option<LoggingConfig>,
319    /// Configures error handling.
320    ///
321    /// Note: If not included just the error message will be
322    /// sent back as text.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    #[serde(default)]
325    pub error: Option<ErrorConfig>,
326    /// Auth config for the Ordinary application.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    #[serde(default)]
329    pub auth: Option<AuthConfig>,
330    /// Global constants that can be accessed from functions
331    #[serde(skip_serializing_if = "Option::is_none")]
332    #[serde(default)]
333    pub globals: Option<BTreeMap<String, String>>,
334    /// Secrets that can be used by functions.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    #[serde(default)]
337    pub secrets: Option<Vec<Secret>>,
338    /// Definitions for the models that will be stored in the Ordinary database.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    #[serde(default)]
341    pub database: Option<DatabaseConfig>,
342    /// IO, access and language configuration for functions that
343    /// are compiled to and executed as WebAssembly modules.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    #[serde(default)]
346    pub functions: Option<Vec<FunctionConfig>>,
347    /// Specifies the asset directory and per-path configuration
348    /// details for assets that require preprocessing (TypeScript, SCSS,
349    /// JavaScript minification, etc.)
350    #[serde(skip_serializing_if = "Option::is_none")]
351    #[serde(default)]
352    pub assets: Option<AssetsConfig>,
353}
354
355impl OrdinaryConfig {
356    /// gets ordinary.json from project path and deserializes to struct.
357    pub fn get(proj_path: impl AsRef<Path>, load_refs: bool) -> anyhow::Result<OrdinaryConfig> {
358        let path = proj_path.as_ref().join("ordinary.json");
359        // todo: switch to async
360        let mut config_json = fs::read(&path)?;
361
362        let mut config = match simd_json::from_slice::<OrdinaryConfig>(&mut config_json) {
363            Ok(config) => config,
364            Err(err) => bail!("{}: {err}", path.display()),
365        };
366
367        if load_refs {
368            config.load_refs(proj_path.as_ref())?;
369        }
370
371        config.load_internal();
372
373        if let Some(database_config) = config.database.as_mut() {
374            database_config.models.sort_by_key(|m| m.idx);
375
376            for model_config in &mut database_config.models {
377                model_config.fields.sort_by_key(|m| m.idx);
378
379                for field in &mut model_config.fields {
380                    field.kind.sort_sub_fields();
381                }
382            }
383        }
384
385        Ok(config)
386    }
387
388    pub fn write(&self, proj_path: &Path) -> anyhow::Result<()> {
389        use std::io::Write;
390
391        let ordinary_json = serde_json::to_string_pretty(self)?;
392
393        let mut file = fs::File::create(proj_path.join("ordinary.json"))?;
394        file.write_all(ordinary_json.as_bytes())?;
395
396        Ok(())
397    }
398
399    fn load_refs(&mut self, proj_path: &Path) -> anyhow::Result<()> {
400        if let Some(functions) = self.functions.as_mut() {
401            Self::load_function_refs(proj_path, functions)?;
402        }
403
404        Ok(())
405    }
406
407    fn load_function_refs(
408        proj_path: &Path,
409        function_configs: &mut Vec<FunctionConfig>,
410    ) -> anyhow::Result<()> {
411        for base_function_config in function_configs {
412            if let Some(reference) = &base_function_config.r#ref {
413                let json_path = proj_path.join(reference);
414                let mut json_bytes = fs_err::read(json_path)?;
415
416                let mut ref_function_config: FunctionConfig =
417                    simd_json::from_slice(json_bytes.as_mut_slice())?;
418                ref_function_config.load_bindgen();
419
420                ref_function_config
421                    .r#ref
422                    .clone_from(&base_function_config.r#ref);
423
424                if let Some(name) = &base_function_config.name {
425                    ref_function_config.name = Some(name.clone());
426                }
427
428                if let Some(timeout) = base_function_config.timeout {
429                    ref_function_config.timeout = Some(timeout);
430                }
431
432                *base_function_config = ref_function_config;
433            }
434        }
435
436        Ok(())
437    }
438
439    #[must_use]
440    pub fn database_model_map(&self) -> HashMap<String, DatabaseModelConfig> {
441        let mut model_map = HashMap::new();
442
443        let mut database_config = self.database.clone();
444
445        if let Some(database_config) = database_config.as_mut() {
446            database_config.models.sort_by_key(|m| m.idx);
447
448            for model_config in &mut database_config.models {
449                model_config.fields.sort_by_key(|m| m.idx);
450
451                for field in &mut model_config.fields {
452                    field.kind.sort_sub_fields();
453                }
454            }
455        }
456
457        if let Some(database_config) = database_config {
458            for model_config in &database_config.models {
459                model_map.insert(model_config.name.clone(), model_config.clone());
460            }
461        }
462
463        model_map
464    }
465
466    pub fn load_internal(&mut self) {
467        self.canonical = Some(
468            self.canonical.clone().unwrap_or(
469                self.cnames
470                    .clone()
471                    .unwrap_or_default()
472                    .first()
473                    .map(ToOwned::to_owned)
474                    .unwrap_or(self.domain.clone()),
475            ),
476        );
477
478        self.load_internal_middlewares();
479        self.load_internal_compression();
480        self.load_internal_content_types();
481    }
482
483    fn load_internal_middlewares(&mut self) {
484        if let Some(http_config) = &self.http
485            && let Some(middlewares) = &http_config.middlewares
486        {
487            let mut map = HashMap::new();
488
489            for middleware in middlewares {
490                map.insert(middleware.name.clone(), middleware.clone());
491            }
492
493            self.internal_middlewares = Some(map);
494        }
495    }
496
497    fn load_internal_compression(&mut self) {
498        if let Some(assets) = self.assets.as_mut()
499            && let Some(precompression) = &assets.precompression
500        {
501            assets.internal_precompression = Some(precompression.get_list());
502        }
503
504        if let Some(http_config) = self.http.as_mut()
505            && let Some(http_routes) = http_config.routes.as_mut()
506        {
507            for http_route in http_routes {
508                if let Some(http_config) = http_route.config.as_mut()
509                    && let Some(http_cache) = http_config.cache.as_mut()
510                    && let Some(stored_cache) = http_cache.stored.as_mut()
511                    && let Some(compression) = &stored_cache.compression
512                {
513                    stored_cache.internal_compressions = Some(compression.get_list());
514                }
515            }
516        }
517    }
518
519    fn load_internal_content_types(&mut self) {
520        if let Some(http_config) = self.http.as_mut()
521            && let Some(http_routes) = http_config.routes.as_mut()
522        {
523            for http_route in http_routes {
524                if let Some(http_config) = http_route.config.as_mut()
525                    && let Some(http_cache) = http_config.cache.as_mut()
526                    && let Some(stored_cache) = http_cache.stored.as_mut()
527                {
528                    stored_cache.internal_content_types = Some(
529                        stored_cache
530                            .content_types
531                            .clone()
532                            .unwrap_or(smallvec!["text/html".into(), "application/json".into()]),
533                    );
534                }
535            }
536        }
537    }
538
539    #[must_use]
540    pub fn get_middlewares(&self, middleware_names: &Vec<String>) -> Option<Vec<MiddlewareConfig>> {
541        let mut middleware_configs = vec![];
542
543        if let Some(middleware_map) = &self.internal_middlewares {
544            for middleware in middleware_names {
545                if let Some(middleware_config) = middleware_map.get(middleware) {
546                    middleware_configs.push(middleware_config.clone());
547                }
548            }
549        }
550
551        if !middleware_configs.is_empty() {
552            return Some(middleware_configs);
553        }
554
555        None
556    }
557
558    /// gets ordinary.json from project path, deserializes to struct and
559    /// strips out all client-only values.
560    pub fn for_send(&self) -> anyhow::Result<OrdinaryConfig> {
561        let mut config = self.clone();
562
563        config.lifecycle = None;
564
565        if let Some(assets) = config.assets.as_mut() {
566            assets.dir_path = None;
567        }
568
569        if let Some(function_configs) = config.functions.as_mut() {
570            for function_config in function_configs {
571                function_config.r#ref = None;
572
573                function_config.build = None;
574                function_config.bin = None;
575                function_config.bindgen = None;
576            }
577        }
578
579        Ok(config)
580    }
581
582    /// check that all configuration values are internally consistent
583    /// and no non-existent properties or fields are used.
584    #[instrument(skip_all, err, level = "debug")]
585    pub fn validate(&self) -> anyhow::Result<()> {
586        validate(self)
587    }
588
589    // defaults
590    #[must_use]
591    #[allow(clippy::unnecessary_wraps)]
592    pub fn default_storage_size() -> Option<u64> {
593        Some(5_000_000)
594    }
595    // end defaults
596
597    /// Check that all the configuration properties are within API specified
598    /// limits.
599    ///
600    /// Note: privileged domains are not subject to limitations checks.
601    #[allow(clippy::too_many_lines)]
602    pub fn check_config_against_limits(
603        &self,
604        limits: &OrdinaryHostLimits,
605        privileged_domains: &HashSet<String>,
606    ) -> anyhow::Result<()> {
607        check_config_against_limits(self, limits, privileged_domains)
608    }
609
610    pub fn exec_script(
611        proj_path: &Path,
612        argument: &Option<String>,
613        name: &str,
614        when: &str,
615        scripts: &Vec<Vec<String>>,
616    ) -> anyhow::Result<()> {
617        let span = tracing::info_span!("lifecycle", %when, %name);
618
619        span.in_scope(|| {
620            exec_script(proj_path, argument, scripts)?;
621            anyhow::Ok(())
622        })
623    }
624
625    pub fn check_function_name_exists(&self, name: &str) -> anyhow::Result<()> {
626        if let Some(function_configs) = &self.functions {
627            for function_config in function_configs {
628                if function_config.name.as_deref() == Some(name) {
629                    bail!("function with name {name} already exists");
630                }
631            }
632        }
633
634        Ok(())
635    }
636}
637
638pub fn exec_script(
639    proj_path: &Path,
640    argument: &Option<String>,
641    scripts: &Vec<Vec<String>>,
642) -> anyhow::Result<()> {
643    let curr_dir = env::current_dir()?;
644    env::set_current_dir(proj_path)?;
645
646    for script in scripts {
647        let mut script_iter = script.iter();
648
649        if let Some(command) = script_iter.next() {
650            let mut command_str = command.clone();
651            let mut command = Command::new(command);
652
653            for arg in script_iter {
654                write!(command_str, " {arg}")?;
655                command.arg(arg);
656            }
657
658            tracing::info!(cmd = %command_str, "exec");
659
660            let output = match &argument {
661                Some(arg) => command.arg(arg).output()?,
662                None => command.output()?,
663            };
664
665            if !output.status.success() {
666                let stderr = str::from_utf8(&output.stderr)?;
667                let stdout = str::from_utf8(&output.stdout)?;
668
669                tracing::error!(%stderr, %stdout, "failed");
670                bail!(stderr.to_string());
671            }
672        }
673    }
674
675    env::set_current_dir(curr_dir)?;
676
677    Ok(())
678}