Skip to main content

cratefield_module_cms/
lib.rs

1//! `cratefield-module-cms`: a small content store with an editor (Cratefield
2//! control-plane issue #10).
3//!
4//! ```no_run
5//! use cratefield_module_cms::Cms;
6//!
7//! let module = Cms::new().collections(["pages", "posts"]);
8//! ```
9//!
10//! **A content store, not a page builder.** A content item is a titled body
11//! plus a JSON `data` object, addressed by `(collection, slug)`. The editable
12//! draft lives in `cms_item`; every publish appends an immutable snapshot to
13//! `cms_revision`, so an item is versioned and the history of what was public
14//! is recoverable. The look comes from the venture's `UI_SPEC` (the
15//! prompt-to-UiSpec work), not from here.
16//!
17//! **The venture keeps its content.** Everything is in the venture's own
18//! database, edited through the harness admin session, so a customer's content
19//! never leaves their venture and the module inherits the `/ui` surface, the
20//! admin gate, one-database-per-tenant and the audit trail for free. There is
21//! no external credential to connect: selecting the CMS provisions its tables
22//! and admin UI with the venture.
23//!
24//! **Public routes are reads.** `GET /v1/cms/{collection}` and
25//! `GET /v1/cms/{collection}/{slug}` serve *published* content; every write is
26//! an admin action behind the `ADMIN_TOKEN` bearer. So the module has no
27//! public write endpoint and needs no captcha.
28
29#![doc = include_str!("../README.md")]
30#![forbid(unsafe_code)]
31
32mod handlers;
33mod store;
34
35use cratefield_core::{
36    Config, ConfigError, Migrations, Module, ModuleConfig, ModuleContext, Port, SqlMigration,
37};
38use std::sync::Arc;
39
40/// Which collections a venture allows. A content item names a collection, and
41/// a collection outside this set is refused, so a caller cannot invent one.
42#[derive(Debug, Clone)]
43pub(crate) enum Collections {
44    /// Exactly these collection slugs.
45    List(Vec<String>),
46    /// Any collection slug (a venture that does not want an allowlist).
47    Any,
48}
49
50/// The module's build-time configuration.
51#[derive(Debug, Clone)]
52pub(crate) struct Settings {
53    pub collections: Collections,
54}
55
56/// The one migration: the `cms_item` and `cms_revision` tables in the portable
57/// SQL subset.
58const MIGRATION_INIT: SqlMigration = SqlMigration {
59    id: "0001",
60    name: "init",
61    sql: include_str!("../migrations/sqlite/0001_init.sql"),
62};
63
64/// A small content store with an editor.
65pub struct Cms {
66    settings: Settings,
67}
68
69impl Default for Cms {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl Cms {
76    /// A CMS that, until [`Cms::collections`] or [`Cms::any_collection`] says
77    /// otherwise, allows no collection — so a misconfigured venture refuses
78    /// content rather than accepting anything.
79    #[must_use]
80    pub fn new() -> Self {
81        Self {
82            settings: Settings {
83                collections: Collections::List(Vec::new()),
84            },
85        }
86    }
87
88    /// Exactly these collection slugs may hold content.
89    #[must_use]
90    pub fn collections(mut self, collections: impl IntoIterator<Item = impl Into<String>>) -> Self {
91        self.settings.collections =
92            Collections::List(collections.into_iter().map(Into::into).collect());
93        self
94    }
95
96    /// Accept any collection slug.
97    #[must_use]
98    pub fn any_collection(mut self) -> Self {
99        self.settings.collections = Collections::Any;
100        self
101    }
102}
103
104/// A kebab-case slug: lowercase letters, digits and single hyphens.
105fn is_slug(s: &str) -> bool {
106    !s.is_empty()
107        && s.len() <= 64
108        && s.chars()
109            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
110        && !s.starts_with('-')
111        && !s.ends_with('-')
112        && !s.contains("--")
113}
114
115impl Module for Cms {
116    fn name(&self) -> &'static str {
117        "cms"
118    }
119
120    fn version(&self) -> &'static str {
121        env!("CARGO_PKG_VERSION")
122    }
123
124    fn requires(&self) -> &'static [Port] {
125        &[Port::Db]
126    }
127
128    fn tables(&self) -> &'static [&'static str] {
129        &["cms_item", "cms_revision"]
130    }
131
132    fn migrations(&self) -> Migrations {
133        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
134        Migrations {
135            sqlite: &MIGRATIONS,
136            postgres: &[],
137        }
138    }
139
140    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
141        let module = ModuleConfig::new("cms", cfg);
142        let mut errors = ConfigError::default();
143        if let Some(list) = cfg.get(&module.key("COLLECTIONS"))
144            && list != "*"
145            && list.split(',').map(str::trim).all(|slug| !is_slug(slug))
146        {
147            errors.push(format!(
148                "cms: {} must be a comma-separated list of kebab-case slugs or `*`",
149                module.key("COLLECTIONS")
150            ));
151        }
152        errors.into_result()
153    }
154
155    fn router(&self, ctx: ModuleContext) -> axum::Router {
156        handlers::router(Arc::new(ctx), self.settings.clone())
157    }
158
159    fn surface(&self) -> cratefield_core::Surface {
160        handlers::surface(&self.settings)
161    }
162}