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}