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    QueryValue {
57        query: String,
58        capability: String,
59        value_path: String,
60    },
61    EntityTable {
62        entity: String,
63    },
64    EntityDetail {
65        entity: String,
66    },
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
70pub struct AdminMetricBinding {
71    pub label: String,
72    pub value_path: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
76pub struct AdminAction {
77    pub name: String,
78    pub label: String,
79    pub capability: String,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub input_schema: Option<AdminActionInputSchema>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub confirmation: Option<AdminActionConfirmation>,
84    #[serde(default, skip_serializing_if = "AdminActionDangerLevel::is_low")]
85    pub danger_level: AdminActionDangerLevel,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub operation: Option<crate::ServiceOperationMetadata>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
91pub struct AdminActionInputSchema {
92    #[serde(default)]
93    pub fields: Vec<AdminActionInputField>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
97pub struct AdminActionInputField {
98    pub name: String,
99    pub label: String,
100    pub field_type: FieldType,
101    #[serde(default)]
102    pub required: bool,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub description: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
108pub struct AdminActionConfirmation {
109    pub message: String,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub required_phrase: Option<String>,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum AdminActionDangerLevel {
118    #[default]
119    Low,
120    Medium,
121    High,
122}
123
124impl AdminActionDangerLevel {
125    fn is_low(&self) -> bool {
126        matches!(self, Self::Low)
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
131pub struct AdminEmbeddedSurface {
132    pub runtime: AdminEmbeddedRuntime,
133    pub entry: AdminEmbeddedEntry,
134    pub sandbox: AdminSandboxPolicy,
135    #[serde(default)]
136    pub permissions: Vec<AdminPermission>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub fallback_schema: Option<AdminSchema>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
142#[serde(rename_all = "snake_case")]
143#[non_exhaustive]
144pub enum AdminEmbeddedRuntime {
145    Iframe,
146    Wasm,
147    JsBundle,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
151#[serde(tag = "kind", rename_all = "snake_case")]
152#[non_exhaustive]
153pub enum AdminEmbeddedEntry {
154    Url {
155        url: String,
156        #[serde(default)]
157        allowed_origins: Vec<String>,
158    },
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
162pub struct AdminSandboxPolicy {
163    #[serde(default)]
164    pub allow_scripts: bool,
165    #[serde(default)]
166    pub allow_forms: bool,
167    #[serde(default)]
168    pub allow_popups: bool,
169    #[serde(default)]
170    pub allow_same_origin: bool,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175#[non_exhaustive]
176pub enum AdminPermission {
177    ReadEntity { entity: String },
178    InvokeAction { action: String },
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::admin_schema::{AdminSchema, EntitySchema};
185
186    fn fallback_schema() -> AdminSchema {
187        AdminSchema {
188            entities: vec![EntitySchema {
189                name: "contacts".to_owned(),
190                label: "Contacts".to_owned(),
191                fields: vec![],
192                read_capability: "remote_crm.contacts.read".to_owned(),
193            }],
194        }
195    }
196
197    #[test]
198    fn declarative_custom_surface_round_trips_through_json() {
199        let surface = AdminSurface::DeclarativeCustom(AdminDeclarativeSurface {
200            pages: vec![AdminDeclarativePage {
201                name: "dashboard".to_owned(),
202                label: "Dashboard".to_owned(),
203                sections: vec![AdminDeclarativeSection {
204                    name: "contacts".to_owned(),
205                    label: "Contacts".to_owned(),
206                    component: AdminDeclarativeComponent::EntityTable {
207                        entity: "contacts".to_owned(),
208                    },
209                }],
210            }],
211            actions: vec![AdminAction {
212                name: "sync_contacts".to_owned(),
213                label: "Sync contacts".to_owned(),
214                capability: "remote_crm.contacts.sync".to_owned(),
215                input_schema: None,
216                confirmation: None,
217                danger_level: AdminActionDangerLevel::Low,
218                operation: None,
219            }],
220            fallback_schema: Some(fallback_schema()),
221        });
222
223        let json = serde_json::to_string(&surface).expect("serialize");
224        assert!(
225            json.contains(r#""kind":"declarative_custom""#),
226            "got {json}"
227        );
228        let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
229        assert_eq!(surface, back);
230    }
231
232    #[test]
233    fn embedded_custom_surface_round_trips_through_json() {
234        let surface = AdminSurface::EmbeddedCustom(AdminEmbeddedSurface {
235            runtime: AdminEmbeddedRuntime::Iframe,
236            entry: AdminEmbeddedEntry::Url {
237                url: "https://crm.example.test/admin".to_owned(),
238                allowed_origins: vec!["https://crm.example.test".to_owned()],
239            },
240            sandbox: AdminSandboxPolicy {
241                allow_scripts: true,
242                allow_forms: false,
243                allow_popups: false,
244                allow_same_origin: false,
245            },
246            permissions: vec![AdminPermission::ReadEntity {
247                entity: "contacts".to_owned(),
248            }],
249            fallback_schema: Some(fallback_schema()),
250        });
251
252        let json = serde_json::to_string(&surface).expect("serialize");
253        assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
254        assert!(json.contains(r#""runtime":"iframe""#), "got {json}");
255        let back: AdminSurface = serde_json::from_str(&json).expect("deserialize");
256        assert_eq!(surface, back);
257    }
258}