1#![forbid(unsafe_code)]
2
3use coil_admin::AdminModule;
4use coil_cms::CmsModule;
5use coil_commerce::{CommerceModule, CommercePaymentsStripeModule};
6use coil_events::EventsModule;
7use coil_media::MediaModule;
8use coil_memberships::MembershipsModule;
9use coil_ops::OpsModule;
10use coil_runtime::{
11 RuntimeBuilder, customer_root_bootstrap_inputs_from_env,
12 customer_root_bootstrap_inputs_from_paths,
13};
14use std::env;
15use std::path::{Path, PathBuf};
16use thiserror::Error;
17
18pub use coil_admin as admin;
19pub use coil_app as app;
20pub use coil_app::CustomerAppManifest;
21pub use coil_auth as auth;
22pub use coil_auth::{AuthModelPackage, DefaultAuthModelPackage};
23pub use coil_cms as cms;
24pub use coil_commerce as commerce;
25pub use coil_config as config;
26pub use coil_config::{Environment, PlatformConfig};
27pub use coil_core::PlatformModule;
28pub use coil_customer_sdk as customer_sdk;
29pub use coil_customer_sdk::*;
30pub use coil_events as events;
31pub use coil_media as media;
32pub use coil_memberships as memberships;
33pub use coil_ops as ops;
34
35#[derive(Debug, Error)]
36pub enum CoilError {
37 #[error("unsupported official module `{module}`")]
38 UnsupportedOfficialModule { module: String },
39 #[error("failed to resolve the current working directory: {0}")]
40 CurrentDirectory(std::io::Error),
41 #[error("customer app manifest `{path}` could not be loaded: {reason}")]
42 ManifestLoad { path: PathBuf, reason: String },
43 #[error("platform config `{path}` could not be loaded: {reason}")]
44 ConfigLoad { path: PathBuf, reason: String },
45 #[error("customer runtime build failed: {reason}")]
46 RuntimeBuild { reason: String },
47 #[error("customer runtime bootstrap failed: {reason}")]
48 Bootstrap { reason: String },
49}
50
51pub const OFFICIAL_MODULE_NAMES: &[&str] = &[
52 "admin",
53 "cms",
54 "commerce",
55 "commerce-payments-stripe",
56 "events",
57 "media",
58 "memberships",
59 "ops",
60];
61
62pub mod modules {
63 use super::*;
64
65 pub fn admin() -> AdminModule {
66 AdminModule::new()
67 }
68
69 pub fn cms() -> CmsModule {
70 CmsModule::new()
71 }
72
73 pub fn commerce() -> CommerceModule {
74 CommerceModule::new()
75 }
76
77 pub fn commerce_payments_stripe() -> CommercePaymentsStripeModule {
78 CommercePaymentsStripeModule::new()
79 }
80
81 pub fn events() -> EventsModule {
82 EventsModule::new()
83 }
84
85 pub fn media() -> MediaModule {
86 MediaModule::new()
87 }
88
89 pub fn memberships() -> MembershipsModule {
90 MembershipsModule::new()
91 }
92
93 pub fn ops() -> OpsModule {
94 OpsModule::new()
95 }
96}
97
98#[derive(Default)]
99pub struct CoilBuilder {
100 customer_plugins: Vec<Box<dyn CustomerBackendPlugin>>,
101}
102
103pub fn builder() -> CoilBuilder {
104 CoilBuilder::default()
105}
106
107impl CoilBuilder {
108 pub fn with_customer_plugin<C>(mut self, plugin: C) -> Self
109 where
110 C: CustomerBackendPlugin,
111 {
112 self.customer_plugins.push(Box::new(plugin));
113 self
114 }
115
116 pub fn run_from_env(self) -> Result<(), CoilError> {
117 let app_root = env::current_dir().map_err(CoilError::CurrentDirectory)?;
118 let bootstrap = customer_root_bootstrap_inputs_from_env()
119 .map_err(|error| match error {
120 coil_runtime::RuntimeBootstrapError::ConfigLoad { path, reason } => {
121 CoilError::ConfigLoad { path, reason }
122 }
123 coil_runtime::RuntimeBootstrapError::ConfigNotFound { app_root } => {
124 CoilError::ConfigLoad {
125 path: app_root.join("platform.toml"),
126 reason:
127 "set `COIL_CONFIG` or add `platform.toml` / `platform.dev.toml` to the customer app root"
128 .to_string(),
129 }
130 }
131 other => CoilError::RuntimeBuild {
132 reason: other.to_string(),
133 },
134 })?;
135 self.run_from_paths(
136 app_root,
137 bootstrap.config_path,
138 env::var("COIL_BIND").ok(),
139 )
140 }
141
142 pub fn run_from_paths(
143 self,
144 app_root: impl AsRef<Path>,
145 config_path: impl AsRef<Path>,
146 bind_override: Option<String>,
147 ) -> Result<(), CoilError> {
148 let app_root = app_root.as_ref();
149 let manifest_path = app_root.join("app.toml");
150 let manifest =
151 coil_app::CustomerAppManifest::from_file(&manifest_path).map_err(|error| {
152 CoilError::ManifestLoad {
153 path: manifest_path.clone(),
154 reason: error.to_string(),
155 }
156 })?;
157
158 let bootstrap = customer_root_bootstrap_inputs_from_paths(
159 app_root,
160 config_path,
161 )
162 .map_err(|error| match error {
163 coil_runtime::RuntimeBootstrapError::ConfigLoad { path, reason } => {
164 CoilError::ConfigLoad { path, reason }
165 }
166 coil_runtime::RuntimeBootstrapError::ConfigNotFound { app_root } => {
167 CoilError::ConfigLoad {
168 path: app_root.join("platform.toml"),
169 reason:
170 "set `COIL_CONFIG` or add `platform.toml` / `platform.dev.toml` to the customer app root"
171 .to_string(),
172 }
173 }
174 other => CoilError::RuntimeBuild {
175 reason: other.to_string(),
176 },
177 })?;
178 let modules = official_modules_from_config(&bootstrap.config).map_err(|error| {
179 CoilError::RuntimeBuild {
180 reason: error.to_string(),
181 }
182 })?;
183 let runtime_plan = manifest
184 .build_customer_root_runtime_plan_with_extensions_and_customer_plugins_at(
185 bootstrap.config,
186 bootstrap.auth_package,
187 modules,
188 Vec::new(),
189 self.customer_plugins,
190 app_root,
191 )
192 .map_err(|error| CoilError::RuntimeBuild {
193 reason: error.to_string(),
194 })?;
195
196 runtime_plan
197 .runtime
198 .serve_from_env(bind_override)
199 .map_err(|error| CoilError::Bootstrap {
200 reason: error.to_string(),
201 })?;
202 Ok(())
203 }
204}
205
206pub fn with_official_modules<P>(builder: RuntimeBuilder<P>) -> RuntimeBuilder<P>
207where
208 P: AuthModelPackage + 'static,
209{
210 builder
211 .with_module(modules::admin())
212 .with_module(modules::cms())
213 .with_module(modules::commerce())
214 .with_module(modules::commerce_payments_stripe())
215 .with_module(modules::events())
216 .with_module(modules::media())
217 .with_module(modules::memberships())
218 .with_module(modules::ops())
219}
220
221pub trait RuntimeBuilderOfficialModulesExt<P> {
222 fn with_official_modules(self) -> Self;
223}
224
225impl<P> RuntimeBuilderOfficialModulesExt<P> for RuntimeBuilder<P>
226where
227 P: AuthModelPackage + 'static,
228{
229 fn with_official_modules(self) -> Self {
230 with_official_modules(self)
231 }
232}
233
234pub fn official_modules_from_config(
235 config: &PlatformConfig,
236) -> Result<Vec<Box<dyn PlatformModule>>, CoilError> {
237 official_modules_from_enabled(&config.modules.enabled)
238}
239
240pub fn official_modules_from_enabled(
241 enabled: &[String],
242) -> Result<Vec<Box<dyn PlatformModule>>, CoilError> {
243 let mut modules = Vec::with_capacity(enabled.len());
244 for module in enabled {
245 modules.push(official_module(module)?);
246 }
247 Ok(modules)
248}
249
250pub fn official_module(
251 module: impl AsRef<str>,
252) -> Result<Box<dyn PlatformModule>, CoilError> {
253 let module = module.as_ref();
254 let boxed: Box<dyn PlatformModule> = match module {
255 "admin" => Box::new(modules::admin()),
256 "commerce" => Box::new(modules::commerce()),
257 "commerce-payments-stripe" => Box::new(modules::commerce_payments_stripe()),
258 "cms" => Box::new(modules::cms()),
259 "events" => Box::new(modules::events()),
260 "media" => Box::new(modules::media()),
261 "memberships" => Box::new(modules::memberships()),
262 "ops" => Box::new(modules::ops()),
263 _ => {
264 return Err(CoilError::UnsupportedOfficialModule {
265 module: module.to_string(),
266 });
267 }
268 };
269 Ok(boxed)
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 const VALID_CONFIG: &str = r#"
277[app]
278name = "customer-root-smoke"
279environment = "production"
280
281[server]
282bind = "0.0.0.0:8080"
283trusted_proxies = ["10.0.0.0/8"]
284
285[http.session]
286store = "redis"
287idle_timeout_secs = 3600
288absolute_timeout_secs = 86400
289
290[http.session_cookie]
291name = "coil_session"
292path = "/"
293same_site = "lax"
294secure = true
295http_only = true
296
297[http.flash_cookie]
298name = "coil_flash"
299path = "/"
300same_site = "lax"
301secure = true
302http_only = true
303
304[http.csrf]
305enabled = true
306field_name = "_csrf"
307header_name = "x-csrf-token"
308
309[tls]
310mode = "acme"
311challenge = "dns-01"
312provider = "cloudflare-dns"
313
314[storage]
315default_class = "public_upload"
316single_node_escape_hatch = "explicit_single_node"
317object_store = "s3"
318object_store_secret = { kind = "env", var = "OBJECT_STORE_URL" }
319local_root = "/tmp/coil-tests"
320deployment = "single_node"
321
322[cache]
323l1 = "moka"
324l2 = "redis"
325
326[i18n]
327default_locale = "en-GB"
328supported_locales = ["en-GB"]
329fallback_locale = "en-GB"
330localized_routes = true
331
332[seo]
333canonical_host = "www.example.com"
334emit_json_ld = true
335
336[auth]
337package = "coil-default-auth"
338explain_api = false
339tenant_id = 1
340
341[modules]
342enabled = ["admin", "cms", "commerce", "commerce-payments-stripe", "events", "media", "memberships", "ops"]
343
344[wasm]
345directory = "extensions"
346default_time_limit_ms = 50
347allow_network = false
348
349[jobs]
350backend = "redis"
351max_attempts = 10
352
353[observability]
354metrics = true
355tracing = true
356
357[assets]
358publish_manifest = true
359cdn_base_url = "https://cdn.example.com"
360"#;
361
362 #[test]
363 fn builder_links_full_official_distribution() {
364 let config = PlatformConfig::from_toml_str(VALID_CONFIG).unwrap();
365
366 let plan = with_official_modules(coil_runtime::RuntimeBuilder::new(
367 config,
368 DefaultAuthModelPackage::default(),
369 ))
370 .build()
371 .unwrap();
372 let names = plan
373 .modules
374 .iter()
375 .map(|manifest| manifest.name.as_str())
376 .collect::<Vec<_>>();
377
378 assert_eq!(names, OFFICIAL_MODULE_NAMES);
379 }
380
381 #[test]
382 fn module_helpers_expose_stable_customer_facing_factories() {
383 assert_eq!(modules::admin().manifest().name, "admin");
384 assert_eq!(modules::cms().manifest().name, "cms");
385 assert_eq!(modules::commerce().manifest().name, "commerce");
386 assert_eq!(
387 modules::commerce_payments_stripe().manifest().name,
388 "commerce-payments-stripe"
389 );
390 assert_eq!(modules::events().manifest().name, "events");
391 assert_eq!(modules::media().manifest().name, "media");
392 assert_eq!(modules::memberships().manifest().name, "memberships");
393 assert_eq!(modules::ops().manifest().name, "ops");
394 }
395
396 #[test]
397 fn official_module_reports_unknown_module_names() {
398 let error = match official_module("not-real") {
399 Ok(_) => panic!("expected unsupported module error"),
400 Err(error) => error,
401 };
402
403 assert_eq!(error.to_string(), "unsupported official module `not-real`");
404 }
405}