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, PersonalDataSet, Port,
37    SqlMigration,
38};
39use std::sync::Arc;
40
41/// Which collections a venture allows. A content item names a collection, and
42/// a collection outside this set is refused, so a caller cannot invent one.
43#[derive(Debug, Clone)]
44pub(crate) enum Collections {
45    /// Exactly these collection slugs.
46    List(Vec<String>),
47    /// Any collection slug (a venture that does not want an allowlist).
48    Any,
49}
50
51/// The module's build-time configuration.
52#[derive(Debug, Clone)]
53pub(crate) struct Settings {
54    pub collections: Collections,
55}
56
57/// The one migration: the `cms_item` and `cms_revision` tables in the portable
58/// SQL subset.
59const MIGRATION_INIT: SqlMigration = SqlMigration::new(
60    "0001",
61    "init",
62    include_str!("../migrations/sqlite/0001_init.sql"),
63);
64
65/// A small content store with an editor.
66pub struct Cms {
67    settings: Settings,
68}
69
70impl Default for Cms {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl Cms {
77    /// A CMS that, until [`Cms::collections`] or [`Cms::any_collection`] says
78    /// otherwise, allows no collection — so a misconfigured venture refuses
79    /// content rather than accepting anything.
80    #[must_use]
81    pub fn new() -> Self {
82        Self {
83            settings: Settings {
84                collections: Collections::List(Vec::new()),
85            },
86        }
87    }
88
89    /// Exactly these collection slugs may hold content.
90    #[must_use]
91    pub fn collections(mut self, collections: impl IntoIterator<Item = impl Into<String>>) -> Self {
92        self.settings.collections =
93            Collections::List(collections.into_iter().map(Into::into).collect());
94        self
95    }
96
97    /// Accept any collection slug.
98    #[must_use]
99    pub fn any_collection(mut self) -> Self {
100        self.settings.collections = Collections::Any;
101        self
102    }
103}
104
105/// A kebab-case slug: lowercase letters, digits and single hyphens.
106fn is_slug(s: &str) -> bool {
107    !s.is_empty()
108        && s.len() <= 64
109        && s.chars()
110            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
111        && !s.starts_with('-')
112        && !s.ends_with('-')
113        && !s.contains("--")
114}
115
116impl Module for Cms {
117    fn name(&self) -> &'static str {
118        "cms"
119    }
120
121    fn version(&self) -> &'static str {
122        env!("CARGO_PKG_VERSION")
123    }
124
125    fn requires(&self) -> &'static [Port] {
126        &[Port::Db]
127    }
128
129    fn tables(&self) -> &'static [&'static str] {
130        &["cms_item", "cms_revision"]
131    }
132
133    /// Both tables hold the venture's own published content and neither has a
134    /// column that names a person (issue #265).
135    ///
136    /// Declared rather than left silent: an export that skipped these tables
137    /// and an export that had never heard of them look identical from the
138    /// outside, and only one of them is a decision. The reason below is what
139    /// `GET /v1/privacy/manifest` publishes, so it is written for the person
140    /// reading it.
141    ///
142    /// A row is filed under a collection and a slug. What an editor *types*
143    /// into `body` or `data` is the venture's to govern — this module cannot
144    /// know that an article mentions somebody — but the shape of the table
145    /// offers erasure nothing to match on, and inventing a subject column
146    /// here would be inventing a promise. That gap is the one issue #266
147    /// describes for `Outbox`, and it is the same gap for the same reason.
148    fn personal_data(&self) -> &'static [PersonalDataSet] {
149        const SETS: &[PersonalDataSet] = &[
150            PersonalDataSet::none(
151                "cms_item",
152                "The pages and posts this venture publishes, one row each: the title, the body \
153                 and the structured fields an editor is working on. It is filed under a \
154                 collection and a slug, and no column in it names a person.",
155            ),
156            PersonalDataSet::none(
157                "cms_revision",
158                "What each page and post said at every publish, kept so the venture can always \
159                 show what was public on a given day. The same content as above, and the same \
160                 again: no column in it names a person.",
161            ),
162        ];
163        SETS
164    }
165
166    fn migrations(&self) -> Migrations {
167        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
168        // The array is the apply order; this refuses a gap, a duplicate
169        // or an entry out of order at build time (issue #27).
170        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
171        Migrations {
172            sqlite: &MIGRATIONS,
173            postgres: &[],
174        }
175    }
176
177    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
178        let module = ModuleConfig::new("cms", cfg);
179        let mut errors = ConfigError::default();
180        if let Some(list) = cfg.get(&module.key("COLLECTIONS"))
181            && list != "*"
182            && list.split(',').map(str::trim).all(|slug| !is_slug(slug))
183        {
184            errors.push(format!(
185                "cms: {} must be a comma-separated list of kebab-case slugs or `*`",
186                module.key("COLLECTIONS")
187            ));
188        }
189        errors.into_result()
190    }
191
192    fn router(&self, ctx: ModuleContext) -> axum::Router {
193        handlers::router(Arc::new(ctx), self.settings.clone())
194    }
195
196    fn surface(&self) -> cratefield_core::Surface {
197        handlers::surface(&self.settings)
198    }
199}