Skip to main content

lenso_contracts/
admin.rs

1//! Contracts for a module's admin surface.
2
3use crate::admin_schema::{AdminSchema, FieldType};
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7/// A module's admin surface.
8///
9/// `Schema` is implemented today. Custom surface variants are data contracts
10/// only until the Runtime Console implements their renderers/policies.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13#[non_exhaustive]
14pub enum AdminSurface {
15    /// Schema-driven CRUD: console renders a generic UI from this declaration.
16    Schema(AdminSchema),
17    /// Host-rendered custom UI built from trusted Runtime Console components.
18    DeclarativeCustom(AdminDeclarativeSurface),
19    /// Module-owned UI embedded behind a sandbox boundary.
20    EmbeddedCustom(AdminEmbeddedSurface),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
24pub struct AdminDeclarativeSurface {
25    #[serde(default)]
26    pub pages: Vec<AdminDeclarativePage>,
27    #[serde(default)]
28    pub actions: Vec<AdminAction>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub fallback_schema: Option<AdminSchema>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
34pub struct AdminDeclarativePage {
35    pub name: String,
36    pub label: String,
37    #[serde(default)]
38    pub sections: Vec<AdminDeclarativeSection>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
42pub struct AdminDeclarativeSection {
43    pub name: String,
44    pub label: String,
45    pub component: AdminDeclarativeComponent,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum AdminDeclarativeComponent {
52    MetricStrip {
53        #[serde(default)]
54        metrics: Vec<AdminMetricBinding>,
55    },
56    EntityTable {
57        entity: String,
58    },
59    EntityDetail {
60        entity: String,
61    },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
65pub struct AdminMetricBinding {
66    pub label: String,
67    pub value_path: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
71pub struct AdminAction {
72    pub name: String,
73    pub label: String,
74    pub capability: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub input_schema: Option<AdminActionInputSchema>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub confirmation: Option<AdminActionConfirmation>,
79    #[serde(default, skip_serializing_if = "AdminActionDangerLevel::is_low")]
80    pub danger_level: AdminActionDangerLevel,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
84pub struct AdminActionInputSchema {
85    #[serde(default)]
86    pub fields: Vec<AdminActionInputField>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
90pub struct AdminActionInputField {
91    pub name: String,
92    pub label: String,
93    pub field_type: FieldType,
94    #[serde(default)]
95    pub required: bool,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub description: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
101pub struct AdminActionConfirmation {
102    pub message: String,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub required_phrase: Option<String>,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
108#[serde(rename_all = "snake_case")]
109#[non_exhaustive]
110pub enum AdminActionDangerLevel {
111    #[default]
112    Low,
113    Medium,
114    High,
115}
116
117impl AdminActionDangerLevel {
118    fn is_low(&self) -> bool {
119        matches!(self, Self::Low)
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
124pub struct AdminEmbeddedSurface {
125    pub runtime: AdminEmbeddedRuntime,
126    pub entry: AdminEmbeddedEntry,
127    pub sandbox: AdminSandboxPolicy,
128    #[serde(default)]
129    pub permissions: Vec<AdminPermission>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub fallback_schema: Option<AdminSchema>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum AdminEmbeddedRuntime {
138    Iframe,
139    Wasm,
140    JsBundle,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
144#[serde(tag = "kind", rename_all = "snake_case")]
145#[non_exhaustive]
146pub enum AdminEmbeddedEntry {
147    Url {
148        url: String,
149        #[serde(default)]
150        allowed_origins: Vec<String>,
151    },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
155pub struct AdminSandboxPolicy {
156    #[serde(default)]
157    pub allow_scripts: bool,
158    #[serde(default)]
159    pub allow_forms: bool,
160    #[serde(default)]
161    pub allow_popups: bool,
162    #[serde(default)]
163    pub allow_same_origin: bool,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168#[non_exhaustive]
169pub enum AdminPermission {
170    ReadEntity { entity: String },
171    InvokeAction { action: String },
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::admin_schema::{AdminSchema, EntitySchema};
178
179    fn fallback_schema() -> AdminSchema {
180        AdminSchema {
181            entities: vec![EntitySchema {
182                name: "contacts".to_owned(),
183                label: "Contacts".to_owned(),
184                fields: vec![],
185                read_capability: "remote_crm.contacts.read".to_owned(),
186            }],
187        }
188    }
189
190    #[test]
191    fn declarative_custom_surface_round_trips_through_json() {
192        let surface = AdminSurface::DeclarativeCustom(AdminDeclarativeSurface {
193            pages: vec![AdminDeclarativePage {
194                name: "dashboard".to_owned(),
195                label: "Dashboard".to_owned(),
196                sections: vec![AdminDeclarativeSection {
197                    name: "contacts".to_owned(),
198                    label: "Contacts".to_owned(),
199                    component: AdminDeclarativeComponent::EntityTable {
200                        entity: "contacts".to_owned(),
201                    },
202                }],
203            }],
204            actions: vec![AdminAction {
205                name: "sync_contacts".to_owned(),
206                label: "Sync contacts".to_owned(),
207                capability: "remote_crm.contacts.sync".to_owned(),
208                input_schema: None,
209                confirmation: None,
210                danger_level: AdminActionDangerLevel::Low,
211            }],
212            fallback_schema: Some(fallback_schema()),
213        });
214
215        let json = serde_json::to_string(&surface).expect("serialize");
216        assert!(
217            json.contains(r#""kind":"declarative_custom""#),
218            "got {json}"
219        );
220        let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
221        assert_eq!(surface, back);
222    }
223
224    #[test]
225    fn embedded_custom_surface_round_trips_through_json() {
226        let surface = AdminSurface::EmbeddedCustom(AdminEmbeddedSurface {
227            runtime: AdminEmbeddedRuntime::Iframe,
228            entry: AdminEmbeddedEntry::Url {
229                url: "https://crm.example.test/admin".to_owned(),
230                allowed_origins: vec!["https://crm.example.test".to_owned()],
231            },
232            sandbox: AdminSandboxPolicy {
233                allow_scripts: true,
234                allow_forms: false,
235                allow_popups: false,
236                allow_same_origin: false,
237            },
238            permissions: vec![AdminPermission::ReadEntity {
239                entity: "contacts".to_owned(),
240            }],
241            fallback_schema: Some(fallback_schema()),
242        });
243
244        let json = serde_json::to_string(&surface).expect("serialize");
245        assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
246        assert!(json.contains(r#""runtime":"iframe""#), "got {json}");
247        let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
248        assert_eq!(surface, back);
249    }
250}