1use std::collections::{BTreeSet, HashSet};
16
17use http::Method;
18use schemars::{JsonSchema, Schema, SchemaGenerator};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use crate::config::ConfigError;
23use crate::module::{HARNESS_API, Module};
24use crate::venture::Venture;
25
26pub const SURFACE_API: u32 = 1;
30
31pub const MAX_SIDECAR_SURFACE_BYTES: usize = 256 * 1024;
35pub const MAX_SIDECAR_ACTIONS: usize = 64;
37pub const MAX_SIDECAR_VIEWS: usize = 64;
39
40pub const HINT_KEYWORDS: &[&str] = &[
53 "x-cf-label",
54 "x-cf-placeholder",
55 "x-cf-help",
56 "x-cf-widget",
57 "x-cf-hidden",
58 "x-cf-options",
59];
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum Audience {
66 Public,
69 Admin,
72 Link,
76 Subject,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "kebab-case")]
98pub enum Outcome {
99 Accepted { message: String },
102 Redirect,
104 Json,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Action {
111 pub name: String,
114 #[serde(with = "method_serde")]
116 pub method: Method,
117 pub path: String,
119 pub audience: Audience,
120 #[serde(skip_serializing_if = "Option::is_none")]
123 pub input: Option<Schema>,
124 #[serde(skip_serializing_if = "Option::is_none")]
128 pub output: Option<Schema>,
129 pub outcome: Outcome,
130 #[serde(skip)]
140 pub policy: crate::route_policy::RoutePolicy,
141 pub captcha: bool,
146}
147
148impl Action {
149 #[must_use]
152 pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
153 Self::new(name, Method::POST, path)
154 }
155
156 #[must_use]
159 pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
160 Self::new(name, Method::GET, path)
161 .audience(Audience::Link)
162 .outcome(Outcome::Redirect)
163 }
164
165 #[must_use]
167 pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
168 Self::new(name, Method::DELETE, path)
169 .audience(Audience::Admin)
170 .outcome(Outcome::Json)
171 }
172
173 #[must_use]
174 pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
175 Self {
176 name: name.into(),
177 method,
178 path: path.into(),
179 audience: Audience::Public,
180 input: None,
181 output: None,
182 outcome: Outcome::Accepted {
183 message: "Thanks, you're in.".to_owned(),
184 },
185 policy: crate::route_policy::RoutePolicy::default(),
186 captcha: false,
187 }
188 }
189
190 #[must_use]
191 pub fn audience(mut self, audience: Audience) -> Self {
192 self.audience = audience;
193 self
194 }
195
196 #[must_use]
198 pub fn input<T: JsonSchema>(mut self) -> Self {
199 self.input = Some(schema_for::<T>());
200 self
201 }
202
203 #[must_use]
206 pub fn input_schema(mut self, schema: Schema) -> Self {
207 self.input = Some(schema);
208 self
209 }
210
211 #[must_use]
213 pub fn output<T: JsonSchema>(mut self) -> Self {
214 self.output = Some(schema_for::<T>());
215 self
216 }
217
218 #[must_use]
221 pub fn output_schema(mut self, schema: Schema) -> Self {
222 self.output = Some(schema);
223 self
224 }
225
226 #[must_use]
227 pub fn outcome(mut self, outcome: Outcome) -> Self {
228 self.outcome = outcome;
229 self
230 }
231
232 #[must_use]
234 pub fn accepted(self, message: impl Into<String>) -> Self {
235 self.outcome(Outcome::Accepted {
236 message: message.into(),
237 })
238 }
239
240 #[must_use]
242 pub fn captcha(mut self) -> Self {
243 self.policy = crate::route_policy::RoutePolicy::HumanForm;
244 self.captcha = true;
245 self
246 }
247
248 #[must_use]
253 pub fn policy(mut self, policy: crate::route_policy::RoutePolicy) -> Self {
254 self.policy = policy;
255 self.captcha = policy == crate::route_policy::RoutePolicy::HumanForm;
256 self
257 }
258
259 #[must_use]
268 pub fn demands_captcha(&self) -> bool {
269 self.policy == crate::route_policy::RoutePolicy::HumanForm || self.captcha
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct Column {
276 pub key: String,
278 pub label: String,
279}
280
281impl Column {
282 #[must_use]
283 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
284 Self {
285 key: key.into(),
286 label: label.into(),
287 }
288 }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293#[serde(tag = "kind", rename_all = "kebab-case")]
294pub enum View {
295 Form { action: String },
297 Status { action: String },
299 Table {
301 source: String,
302 columns: Vec<Column>,
303 },
304}
305
306impl View {
307 #[must_use]
308 pub fn form(action: impl Into<String>) -> Self {
309 View::Form {
310 action: action.into(),
311 }
312 }
313
314 #[must_use]
315 pub fn status(action: impl Into<String>) -> Self {
316 View::Status {
317 action: action.into(),
318 }
319 }
320
321 #[must_use]
322 pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
323 View::Table {
324 source: source.into(),
325 columns,
326 }
327 }
328
329 fn action_names(&self) -> Vec<&str> {
330 match self {
331 View::Form { action } | View::Status { action } => vec![action],
332 View::Table { source, .. } => vec![source],
333 }
334 }
335}
336
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct Surface {
340 pub actions: Vec<Action>,
341 pub views: Vec<View>,
342}
343
344impl Surface {
345 #[must_use]
347 pub fn none() -> Self {
348 Self::default()
349 }
350
351 #[must_use]
352 pub fn new() -> Self {
353 Self::default()
354 }
355
356 #[must_use]
357 pub fn action(mut self, action: Action) -> Self {
358 self.actions.push(action);
359 self
360 }
361
362 #[must_use]
363 pub fn view(mut self, view: View) -> Self {
364 self.views.push(view);
365 self
366 }
367
368 #[must_use]
370 pub fn is_empty(&self) -> bool {
371 self.actions.is_empty() && self.views.is_empty()
372 }
373
374 pub fn validate(&self, module: &str, errors: &mut ConfigError) {
381 let mut seen: HashSet<&str> = HashSet::new();
382 for action in &self.actions {
383 let name = action.name.as_str();
384 if !is_kebab(name) {
385 errors.push(format!(
386 "module `{module}` surface action `{name}` must be kebab-case"
387 ));
388 }
389 if !seen.insert(name) {
390 errors.push(format!(
391 "module `{module}` surface declares action `{name}` twice"
392 ));
393 }
394 if !action.path.starts_with('/') {
395 errors.push(format!(
396 "module `{module}` surface action `{name}` path `{}` must start with '/' \
397 (relative to /v1/{module})",
398 action.path
399 ));
400 }
401 if action.path.split('/').any(|segment| segment == "..") {
402 errors.push(format!(
403 "module `{module}` surface action `{name}` path `{}` must not contain '..' \
404 segments (an action stays inside its module's mount)",
405 action.path
406 ));
407 }
408 let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
409 match action.audience {
410 Audience::Admin if !under_admin => errors.push(format!(
411 "module `{module}` surface action `{name}` is admin but its path `{}` \
412 is not under /admin/",
413 action.path
414 )),
415 Audience::Public | Audience::Link | Audience::Subject if under_admin => {
416 errors.push(format!(
417 "module `{module}` surface action `{name}` is under /admin/ but its \
418 audience is not admin",
419 ));
420 }
421 _ => {}
422 }
423 if let Some(schema) = &action.input
424 && !is_object_schema(schema)
425 {
426 errors.push(format!(
427 "module `{module}` surface action `{name}` input schema must describe an \
428 object (a struct with named fields), so a renderer can lay out fields"
429 ));
430 }
431 if let Some(schema) = &action.output {
432 if !is_object_schema(schema) {
433 errors.push(format!(
434 "module `{module}` surface action `{name}` output schema must describe \
435 an object, the JSON body the action answers with"
436 ));
437 }
438 if action.outcome != Outcome::Json {
439 errors.push(format!(
440 "module `{module}` surface action `{name}` declares an output schema \
441 but its outcome is not json; only a JSON body has a shape to publish"
442 ));
443 }
444 }
445 match action.policy {
446 crate::route_policy::RoutePolicy::HumanForm if !action.captcha => {
447 errors.push(format!(
448 "module `{module}` surface action `{name}` declares policy=HumanForm \
449 but captcha=false; the renderer would omit the widget the policy \
450 demands — declare protection with `.captcha()` (issue #133)",
451 ));
452 }
453 crate::route_policy::RoutePolicy::Signature if action.captcha => {
454 errors.push(format!(
455 "module `{module}` surface action `{name}` declares policy=Signature \
456 (a machine caller) but captcha=true; a captcha cannot protect a \
457 webhook — drop `.captcha()` (issue #133)",
458 ));
459 }
460 crate::route_policy::RoutePolicy::ApiKey if action.captcha => {
461 errors.push(format!(
462 "module `{module}` surface action `{name}` declares policy=ApiKey \
463 (a machine caller) but captcha=true; a captcha cannot protect an \
464 API-key route — drop `.captcha()` (issue #532)",
465 ));
466 }
467 _ => {}
471 }
472 }
473 for view in &self.views {
474 for referenced in view.action_names() {
475 if !seen.contains(referenced) {
476 errors.push(format!(
477 "module `{module}` surface view references action `{referenced}` \
478 which the module does not declare"
479 ));
480 }
481 }
482 }
483 }
484
485 #[must_use]
496 pub fn public(&self) -> Surface {
497 let actions: Vec<Action> = self
498 .actions
499 .iter()
500 .filter(|action| {
501 action.audience != Audience::Admin
502 && action.policy != crate::route_policy::RoutePolicy::Signature
503 && action.policy != crate::route_policy::RoutePolicy::ApiKey
504 })
505 .cloned()
506 .collect();
507 let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
508 let views = self
509 .views
510 .iter()
511 .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
512 .cloned()
513 .collect();
514 Surface { actions, views }
515 }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
520pub struct ModuleSurface {
521 pub name: String,
522 pub version: String,
523 #[serde(flatten)]
524 pub surface: Surface,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct VentureSurface {
530 pub name: String,
531 pub public_url: String,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct SurfaceDocument {
538 pub surface_api: u32,
539 pub harness_api: u32,
540 pub venture: VentureSurface,
541 pub modules: Vec<ModuleSurface>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub ui: Option<serde_json::Value>,
545}
546
547impl SurfaceDocument {
548 #[must_use]
550 pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
551 let modules = modules
552 .iter()
553 .map(|module| ModuleSurface {
554 name: module.name().to_owned(),
555 version: module.version().to_owned(),
556 surface: module.surface(),
557 })
558 .filter(|entry| !entry.surface.is_empty())
559 .collect();
560 Self {
561 surface_api: SURFACE_API,
562 harness_api: HARNESS_API,
563 venture: VentureSurface {
564 name: venture.name.clone(),
565 public_url: venture.public_url.clone(),
566 },
567 modules,
568 ui: None,
569 }
570 }
571
572 #[must_use]
575 pub fn public(&self) -> Self {
576 Self {
577 surface_api: self.surface_api,
578 harness_api: self.harness_api,
579 venture: self.venture.clone(),
580 modules: self
581 .modules
582 .iter()
583 .map(|entry| ModuleSurface {
584 name: entry.name.clone(),
585 version: entry.version.clone(),
586 surface: entry.surface.public(),
587 })
588 .filter(|entry| !entry.surface.is_empty())
589 .collect(),
590 ui: self.ui.clone(),
591 }
592 }
593}
594
595pub(crate) fn sanitize_sidecar_document(
605 document: &SurfaceDocument,
606 mount: &str,
607) -> Result<Vec<ModuleSurface>, Vec<String>> {
608 let mut errors = Vec::new();
609 if document.surface_api != SURFACE_API {
610 errors.push(format!(
611 "surface contract {} is not {SURFACE_API}",
612 document.surface_api
613 ));
614 }
615 if document.harness_api != HARNESS_API {
616 errors.push(format!(
617 "harness contract {} is not {HARNESS_API}",
618 document.harness_api
619 ));
620 }
621 let named: Vec<&ModuleSurface> = document
622 .modules
623 .iter()
624 .filter(|entry| entry.name == mount)
625 .collect();
626 match named.len() {
627 0 => {
628 if !document.modules.is_empty() {
629 let others: Vec<&str> = document.modules.iter().map(|m| m.name.as_str()).collect();
630 errors.push(format!(
631 "declares no surface for the mounted module `{mount}` (it names [{}] instead)",
632 others.join(", ")
633 ));
634 }
635 }
636 1 => {}
637 count => errors.push(format!("declares module `{mount}` {count} times")),
638 }
639 if let Some(entry) = named.first() {
640 if entry.surface.actions.len() > MAX_SIDECAR_ACTIONS {
641 errors.push(format!(
642 "declares {} actions, more than the {MAX_SIDECAR_ACTIONS} a merged surface allows",
643 entry.surface.actions.len()
644 ));
645 }
646 if entry.surface.views.len() > MAX_SIDECAR_VIEWS {
647 errors.push(format!(
648 "declares {} views, more than the {MAX_SIDECAR_VIEWS} a merged surface allows",
649 entry.surface.views.len()
650 ));
651 }
652 let mut validation = ConfigError::default();
653 entry.surface.validate(mount, &mut validation);
654 errors.extend(validation.problems);
655 }
656 if !errors.is_empty() {
657 return Err(errors);
658 }
659 Ok(named
660 .into_iter()
661 .map(|entry| ModuleSurface {
662 name: entry.name.clone(),
663 version: entry.version.clone(),
664 surface: entry.surface.public(),
665 })
666 .collect())
667}
668
669pub(crate) fn strip_unguarded_captcha_actions(surface: &mut Surface) -> usize {
678 let kept: Vec<Action> = surface
679 .actions
680 .iter()
681 .filter(|action| !action.demands_captcha())
682 .cloned()
683 .collect();
684 let dropped = surface.actions.len() - kept.len();
685 if dropped > 0 {
686 let names: std::collections::BTreeSet<&str> =
687 kept.iter().map(|a| a.name.as_str()).collect();
688 surface
689 .views
690 .retain(|view| view.action_names().iter().all(|n| names.contains(n)));
691 surface.actions = kept;
692 }
693 dropped
694}
695
696#[async_trait::async_trait]
702pub trait SurfaceSource: Send + Sync {
703 async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
706 fn built(&self) -> std::sync::Arc<SurfaceDocument>;
709 fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
712 let _ = admin;
713 None
714 }
715}
716
717pub struct UiContext {
720 pub surface: std::sync::Arc<dyn SurfaceSource>,
724 pub api: axum::Router,
730 pub config: std::sync::Arc<dyn crate::config::Config>,
731 pub venture: std::sync::Arc<Venture>,
732 pub captcha_configured: bool,
735 pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
738 pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
740}
741
742pub trait UiMount: Send + Sync + 'static {
746 fn router(&self, ctx: UiContext) -> axum::Router;
748 fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
752 let _ = (surface, errors);
753 }
754 fn describe(&self) -> Option<serde_json::Value> {
758 None
759 }
760}
761
762#[derive(Debug, Clone)]
766pub struct RenderedSurface {
767 pub json: String,
768 pub etag: String,
770}
771
772impl RenderedSurface {
773 #[must_use]
774 pub fn render(document: &SurfaceDocument) -> Self {
775 let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
776 let digest = Sha256::digest(json.as_bytes());
777 let mut hex = String::with_capacity(32);
778 for byte in &digest[..16] {
779 use std::fmt::Write as _;
780 let _ = write!(hex, "{byte:02x}");
781 }
782 Self {
783 json,
784 etag: format!("\"{hex}\""),
785 }
786 }
787}
788
789#[must_use]
792pub fn schema_for<T: JsonSchema>() -> Schema {
793 let mut settings = schemars::generate::SchemaSettings::draft2020_12();
794 settings.inline_subschemas = true;
795 SchemaGenerator::new(settings).into_root_schema_for::<T>()
796}
797
798pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
803 if let Some(properties) = schema
804 .as_object_mut()
805 .and_then(|root| root.get_mut("properties"))
806 .and_then(serde_json::Value::as_object_mut)
807 && let Some(property) = properties
808 .get_mut(field)
809 .and_then(serde_json::Value::as_object_mut)
810 {
811 property.insert(key.to_owned(), value);
812 }
813}
814
815fn is_object_schema(schema: &Schema) -> bool {
816 let value = schema.as_value();
817 match value.get("type") {
818 Some(serde_json::Value::String(t)) => t == "object",
819 Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
820 _ => value.get("properties").is_some(),
821 }
822}
823
824fn is_kebab(name: &str) -> bool {
825 !name.is_empty()
826 && name.split('-').all(|part| {
827 !part.is_empty()
828 && part
829 .chars()
830 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
831 })
832}
833
834mod method_serde {
835 use http::Method;
836 use serde::{Deserialize, Deserializer, Serialize, Serializer};
837
838 pub(crate) fn serialize<S: Serializer>(
839 method: &Method,
840 serializer: S,
841 ) -> Result<S::Ok, S::Error> {
842 method.as_str().serialize(serializer)
843 }
844
845 pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
846 deserializer: D,
847 ) -> Result<Method, D::Error> {
848 let text = String::deserialize(deserializer)?;
849 Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[derive(JsonSchema)]
858 #[allow(dead_code)]
859 struct JoinBody {
860 #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
861 email: String,
862 product: String,
863 #[schemars(extend("x-cf-hidden" = true))]
864 #[serde(rename = "captchaToken")]
865 captcha_token: Option<String>,
866 }
867
868 fn join() -> Action {
869 Action::post("join", "/").input::<JoinBody>().captcha()
870 }
871
872 fn errors_of(surface: &Surface) -> Vec<String> {
873 let mut errors = ConfigError::default();
874 surface.validate("waitlist", &mut errors);
875 match errors.into_result() {
876 Ok(()) => Vec::new(),
877 Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
878 }
879 }
880
881 #[test]
882 fn schema_carries_hints_and_is_inlined() {
883 let schema = schema_for::<JoinBody>();
884 let value = schema.as_value();
885 assert_eq!(value["type"], "object");
886 assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
887 assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
888 assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
889 assert!(value.get("$defs").is_none(), "subschemas must be inlined");
890 }
891
892 #[test]
893 fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
894 let mut schema = schema_for::<JoinBody>();
895 hint_field(
896 &mut schema,
897 "product",
898 "enum",
899 serde_json::json!(["a", "b"]),
900 );
901 hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
902 let value = schema.as_value();
903 assert_eq!(
904 value["properties"]["product"]["enum"],
905 serde_json::json!(["a", "b"])
906 );
907 assert!(value["properties"].get("missing").is_none());
908 }
909
910 #[test]
911 fn valid_surface_has_no_errors() {
912 let surface = Surface::new()
913 .action(join())
914 .action(Action::get("confirm", "/confirm"))
915 .action(
916 Action::get("export", "/admin/export.csv")
917 .audience(Audience::Admin)
918 .outcome(Outcome::Json)
919 .output::<JoinBody>(),
920 )
921 .view(View::form("join"))
922 .view(View::table("export", vec![Column::new("email", "Email")]));
923 assert!(errors_of(&surface).is_empty());
924 }
925
926 #[test]
927 fn every_validation_rule_names_the_module_and_action() {
928 #[derive(JsonSchema)]
929 #[allow(dead_code)]
930 struct NotAnObject(Vec<String>);
931
932 let surface = Surface::new()
933 .action(join())
934 .action(join())
935 .action(Action::post("Bad Name", "no-slash"))
936 .action(Action::post("hidden", "/admin/thing"))
937 .action(Action::delete("wipe", "/wipe"))
938 .action(Action::post("list", "/list").input::<NotAnObject>())
939 .action(
940 Action::get("rows", "/rows")
941 .outcome(Outcome::Json)
942 .output::<NotAnObject>(),
943 )
944 .action(Action::post("send", "/send").output::<JoinBody>())
945 .view(View::form("missing"));
946 let errors = errors_of(&surface);
947 let joined = errors.join("\n");
948 for needle in [
949 "declares action `join` twice",
950 "action `Bad Name` must be kebab-case",
951 "path `no-slash` must start with '/'",
952 "action `hidden` is under /admin/ but its audience is not admin",
953 "action `wipe` is admin but its path `/wipe` is not under /admin/",
954 "action `list` input schema must describe an object",
955 "action `rows` output schema must describe an object",
956 "action `send` declares an output schema but its outcome is not json",
957 "view references action `missing`",
958 ] {
959 assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
960 }
961 assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
962 }
963
964 #[test]
965 fn public_subset_drops_admin_actions_and_their_views() {
966 let surface = Surface::new()
967 .action(join())
968 .action(
969 Action::get("export", "/admin/export.csv")
970 .audience(Audience::Admin)
971 .outcome(Outcome::Json),
972 )
973 .view(View::form("join"))
974 .view(View::table("export", vec![]));
975 let public = surface.public();
976 assert_eq!(public.actions.len(), 1);
977 assert_eq!(public.views.len(), 1);
978 assert!(matches!(public.views[0], View::Form { .. }));
979 }
980
981 #[test]
982 fn rendered_surface_etag_is_stable_and_differs_per_variant() {
983 let doc = SurfaceDocument {
984 surface_api: SURFACE_API,
985 harness_api: HARNESS_API,
986 venture: VentureSurface {
987 name: "v".into(),
988 public_url: "https://v.test".into(),
989 },
990 modules: vec![ModuleSurface {
991 name: "waitlist".into(),
992 version: "0.1.0".into(),
993 surface: Surface::new()
994 .action(join())
995 .action(Action::delete("wipe", "/admin/wipe")),
996 }],
997 ui: None,
998 };
999 let full = RenderedSurface::render(&doc);
1000 let again = RenderedSurface::render(&doc);
1001 let public = RenderedSurface::render(&doc.public());
1002 assert_eq!(full.etag, again.etag);
1003 assert_ne!(full.etag, public.etag);
1004 assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
1005 assert_eq!(full.etag.len(), 34);
1006 let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
1007 assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
1008 assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
1009 assert_eq!(parsed["surface_api"], SURFACE_API);
1010 }
1011
1012 #[test]
1013 fn document_omits_modules_without_a_surface() {
1014 struct Silent;
1015 impl Module for Silent {
1016 fn name(&self) -> &'static str {
1017 "silent"
1018 }
1019 fn version(&self) -> &'static str {
1020 "0.0.0"
1021 }
1022 fn requires(&self) -> &'static [crate::ports::Port] {
1023 &[]
1024 }
1025 fn migrations(&self) -> crate::module::Migrations {
1026 crate::module::Migrations::EMPTY
1027 }
1028 fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
1029 Ok(())
1030 }
1031 fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
1032 axum::Router::new()
1033 }
1034 }
1035 let venture = Venture::new("v", "v.test");
1036 let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
1037 let doc = SurfaceDocument::compose(&venture, &modules);
1038 assert!(doc.modules.is_empty());
1039 }
1040
1041 #[test]
1042 fn an_api_key_action_refuses_a_captcha_and_hides_from_the_public_surface() {
1043 let mut action =
1047 Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey);
1048 action.captcha = true;
1049 let errors = errors_of(&Surface::new().action(action));
1050 assert!(
1051 errors.iter().any(|error| error.contains("policy=ApiKey")),
1052 "{errors:?}"
1053 );
1054
1055 let surface = Surface::new()
1059 .action(Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey))
1060 .action(join());
1061 let public = surface.public();
1062 assert!(public.actions.iter().all(|action| action.name != "sync"));
1063 assert!(public.actions.iter().any(|action| action.name == "join"));
1064 }
1065}