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