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