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 #[serde(skip)]
157 pub verification: Option<crate::route_policy::SignatureVerification>,
158}
159
160impl Action {
161 #[must_use]
164 pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
165 Self::new(name, Method::POST, path)
166 }
167
168 #[must_use]
171 pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
172 Self::new(name, Method::GET, path)
173 .audience(Audience::Link)
174 .outcome(Outcome::Redirect)
175 }
176
177 #[must_use]
179 pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
180 Self::new(name, Method::DELETE, path)
181 .audience(Audience::Admin)
182 .outcome(Outcome::Json)
183 }
184
185 #[must_use]
186 pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
187 Self {
188 name: name.into(),
189 method,
190 path: path.into(),
191 audience: Audience::Public,
192 input: None,
193 output: None,
194 outcome: Outcome::Accepted {
195 message: "Thanks, you're in.".to_owned(),
196 },
197 policy: crate::route_policy::RoutePolicy::default(),
198 captcha: false,
199 verification: None,
200 }
201 }
202
203 #[must_use]
204 pub fn audience(mut self, audience: Audience) -> Self {
205 self.audience = audience;
206 self
207 }
208
209 #[must_use]
211 pub fn input<T: JsonSchema>(mut self) -> Self {
212 self.input = Some(schema_for::<T>());
213 self
214 }
215
216 #[must_use]
219 pub fn input_schema(mut self, schema: Schema) -> Self {
220 self.input = Some(schema);
221 self
222 }
223
224 #[must_use]
226 pub fn output<T: JsonSchema>(mut self) -> Self {
227 self.output = Some(schema_for::<T>());
228 self
229 }
230
231 #[must_use]
234 pub fn output_schema(mut self, schema: Schema) -> Self {
235 self.output = Some(schema);
236 self
237 }
238
239 #[must_use]
240 pub fn outcome(mut self, outcome: Outcome) -> Self {
241 self.outcome = outcome;
242 self
243 }
244
245 #[must_use]
247 pub fn accepted(self, message: impl Into<String>) -> Self {
248 self.outcome(Outcome::Accepted {
249 message: message.into(),
250 })
251 }
252
253 #[must_use]
255 pub fn captcha(mut self) -> Self {
256 self.policy = crate::route_policy::RoutePolicy::HumanForm;
257 self.captcha = true;
258 self
259 }
260
261 #[must_use]
266 pub fn policy(mut self, policy: crate::route_policy::RoutePolicy) -> Self {
267 self.policy = policy;
268 self.captcha = policy == crate::route_policy::RoutePolicy::HumanForm;
269 self
270 }
271
272 #[must_use]
281 pub fn verification(
282 mut self,
283 verification: crate::route_policy::SignatureVerification,
284 ) -> Self {
285 self.verification = Some(verification);
286 self
287 }
288
289 #[must_use]
298 pub fn demands_captcha(&self) -> bool {
299 self.policy == crate::route_policy::RoutePolicy::HumanForm || self.captcha
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct Column {
306 pub key: String,
308 pub label: String,
309}
310
311impl Column {
312 #[must_use]
313 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
314 Self {
315 key: key.into(),
316 label: label.into(),
317 }
318 }
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
323#[serde(tag = "kind", rename_all = "kebab-case")]
324pub enum View {
325 Form { action: String },
327 Status { action: String },
329 Table {
331 source: String,
332 columns: Vec<Column>,
333 },
334}
335
336impl View {
337 #[must_use]
338 pub fn form(action: impl Into<String>) -> Self {
339 View::Form {
340 action: action.into(),
341 }
342 }
343
344 #[must_use]
345 pub fn status(action: impl Into<String>) -> Self {
346 View::Status {
347 action: action.into(),
348 }
349 }
350
351 #[must_use]
352 pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
353 View::Table {
354 source: source.into(),
355 columns,
356 }
357 }
358
359 fn action_names(&self) -> Vec<&str> {
360 match self {
361 View::Form { action } | View::Status { action } => vec![action],
362 View::Table { source, .. } => vec![source],
363 }
364 }
365}
366
367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369pub struct Surface {
370 pub actions: Vec<Action>,
371 pub views: Vec<View>,
372}
373
374impl Surface {
375 #[must_use]
377 pub fn none() -> Self {
378 Self::default()
379 }
380
381 #[must_use]
382 pub fn new() -> Self {
383 Self::default()
384 }
385
386 #[must_use]
387 pub fn action(mut self, action: Action) -> Self {
388 self.actions.push(action);
389 self
390 }
391
392 #[must_use]
393 pub fn view(mut self, view: View) -> Self {
394 self.views.push(view);
395 self
396 }
397
398 #[must_use]
400 pub fn is_empty(&self) -> bool {
401 self.actions.is_empty() && self.views.is_empty()
402 }
403
404 pub fn validate(&self, module: &str, errors: &mut ConfigError) {
411 let mut seen: HashSet<&str> = HashSet::new();
412 for action in &self.actions {
413 let name = action.name.as_str();
414 if !is_kebab(name) {
415 errors.push(format!(
416 "module `{module}` surface action `{name}` must be kebab-case"
417 ));
418 }
419 if !seen.insert(name) {
420 errors.push(format!(
421 "module `{module}` surface declares action `{name}` twice"
422 ));
423 }
424 if !action.path.starts_with('/') {
425 errors.push(format!(
426 "module `{module}` surface action `{name}` path `{}` must start with '/' \
427 (relative to /v1/{module})",
428 action.path
429 ));
430 }
431 if action.path.split('/').any(|segment| segment == "..") {
432 errors.push(format!(
433 "module `{module}` surface action `{name}` path `{}` must not contain '..' \
434 segments (an action stays inside its module's mount)",
435 action.path
436 ));
437 }
438 let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
439 match action.audience {
440 Audience::Admin if !under_admin => errors.push(format!(
441 "module `{module}` surface action `{name}` is admin but its path `{}` \
442 is not under /admin/",
443 action.path
444 )),
445 Audience::Public | Audience::Link | Audience::Subject if under_admin => {
446 errors.push(format!(
447 "module `{module}` surface action `{name}` is under /admin/ but its \
448 audience is not admin",
449 ));
450 }
451 _ => {}
452 }
453 if let Some(schema) = &action.input
454 && !is_object_schema(schema)
455 {
456 errors.push(format!(
457 "module `{module}` surface action `{name}` input schema must describe an \
458 object (a struct with named fields), so a renderer can lay out fields"
459 ));
460 }
461 if let Some(schema) = &action.output {
462 if !is_object_schema(schema) {
463 errors.push(format!(
464 "module `{module}` surface action `{name}` output schema must describe \
465 an object, the JSON body the action answers with"
466 ));
467 }
468 if action.outcome != Outcome::Json {
469 errors.push(format!(
470 "module `{module}` surface action `{name}` declares an output schema \
471 but its outcome is not json; only a JSON body has a shape to publish"
472 ));
473 }
474 }
475 match action.policy {
476 crate::route_policy::RoutePolicy::HumanForm if !action.captcha => {
477 errors.push(format!(
478 "module `{module}` surface action `{name}` declares policy=HumanForm \
479 but captcha=false; the renderer would omit the widget the policy \
480 demands — declare protection with `.captcha()` (issue #133)",
481 ));
482 }
483 crate::route_policy::RoutePolicy::Signature if action.captcha => {
484 errors.push(format!(
485 "module `{module}` surface action `{name}` declares policy=Signature \
486 (a machine caller) but captcha=true; a captcha cannot protect a \
487 webhook — drop `.captcha()` (issue #133)",
488 ));
489 }
490 crate::route_policy::RoutePolicy::ApiKey if action.captcha => {
491 errors.push(format!(
492 "module `{module}` surface action `{name}` declares policy=ApiKey \
493 (a machine caller) but captcha=true; a captcha cannot protect an \
494 API-key route — drop `.captcha()` (issue #532)",
495 ));
496 }
497 _ => {}
501 }
502 validate_verifier(module, name, action, errors);
503 }
504 for view in &self.views {
505 for referenced in view.action_names() {
506 if !seen.contains(referenced) {
507 errors.push(format!(
508 "module `{module}` surface view references action `{referenced}` \
509 which the module does not declare"
510 ));
511 }
512 }
513 }
514 }
515
516 #[must_use]
527 pub fn public(&self) -> Surface {
528 let actions: Vec<Action> = self
529 .actions
530 .iter()
531 .filter(|action| {
532 action.audience != Audience::Admin
533 && action.policy != crate::route_policy::RoutePolicy::Signature
534 && action.policy != crate::route_policy::RoutePolicy::ApiKey
535 })
536 .cloned()
537 .collect();
538 let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
539 let views = self
540 .views
541 .iter()
542 .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
543 .cloned()
544 .collect();
545 Surface { actions, views }
546 }
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct ModuleSurface {
552 pub name: String,
553 pub version: String,
554 #[serde(flatten)]
555 pub surface: Surface,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct VentureSurface {
561 pub name: String,
562 pub public_url: String,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct SurfaceDocument {
569 pub surface_api: u32,
570 pub harness_api: u32,
571 pub venture: VentureSurface,
572 pub modules: Vec<ModuleSurface>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub ui: Option<serde_json::Value>,
576}
577
578impl SurfaceDocument {
579 #[must_use]
581 pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
582 let modules = modules
583 .iter()
584 .map(|module| ModuleSurface {
585 name: module.name().to_owned(),
586 version: module.version().to_owned(),
587 surface: module.surface(),
588 })
589 .filter(|entry| !entry.surface.is_empty())
590 .collect();
591 Self {
592 surface_api: SURFACE_API,
593 harness_api: HARNESS_API,
594 venture: VentureSurface {
595 name: venture.name.clone(),
596 public_url: venture.public_url.clone(),
597 },
598 modules,
599 ui: None,
600 }
601 }
602
603 #[must_use]
606 pub fn public(&self) -> Self {
607 Self {
608 surface_api: self.surface_api,
609 harness_api: self.harness_api,
610 venture: self.venture.clone(),
611 modules: self
612 .modules
613 .iter()
614 .map(|entry| ModuleSurface {
615 name: entry.name.clone(),
616 version: entry.version.clone(),
617 surface: entry.surface.public(),
618 })
619 .filter(|entry| !entry.surface.is_empty())
620 .collect(),
621 ui: self.ui.clone(),
622 }
623 }
624}
625
626pub(crate) fn sanitize_sidecar_document(
636 document: &SurfaceDocument,
637 mount: &str,
638) -> Result<Vec<ModuleSurface>, Vec<String>> {
639 let mut errors = Vec::new();
640 if document.surface_api != SURFACE_API {
641 errors.push(format!(
642 "surface contract {} is not {SURFACE_API}",
643 document.surface_api
644 ));
645 }
646 if document.harness_api != HARNESS_API {
647 errors.push(format!(
648 "harness contract {} is not {HARNESS_API}",
649 document.harness_api
650 ));
651 }
652 let named: Vec<&ModuleSurface> = document
653 .modules
654 .iter()
655 .filter(|entry| entry.name == mount)
656 .collect();
657 match named.len() {
658 0 => {
659 if !document.modules.is_empty() {
660 let others: Vec<&str> = document.modules.iter().map(|m| m.name.as_str()).collect();
661 errors.push(format!(
662 "declares no surface for the mounted module `{mount}` (it names [{}] instead)",
663 others.join(", ")
664 ));
665 }
666 }
667 1 => {}
668 count => errors.push(format!("declares module `{mount}` {count} times")),
669 }
670 if let Some(entry) = named.first() {
671 if entry.surface.actions.len() > MAX_SIDECAR_ACTIONS {
672 errors.push(format!(
673 "declares {} actions, more than the {MAX_SIDECAR_ACTIONS} a merged surface allows",
674 entry.surface.actions.len()
675 ));
676 }
677 if entry.surface.views.len() > MAX_SIDECAR_VIEWS {
678 errors.push(format!(
679 "declares {} views, more than the {MAX_SIDECAR_VIEWS} a merged surface allows",
680 entry.surface.views.len()
681 ));
682 }
683 let mut validation = ConfigError::default();
684 entry.surface.validate(mount, &mut validation);
685 errors.extend(validation.problems);
686 }
687 if !errors.is_empty() {
688 return Err(errors);
689 }
690 Ok(named
691 .into_iter()
692 .map(|entry| ModuleSurface {
693 name: entry.name.clone(),
694 version: entry.version.clone(),
695 surface: entry.surface.public(),
696 })
697 .collect())
698}
699
700pub(crate) fn strip_unguarded_captcha_actions(surface: &mut Surface) -> usize {
709 let kept: Vec<Action> = surface
710 .actions
711 .iter()
712 .filter(|action| !action.demands_captcha())
713 .cloned()
714 .collect();
715 let dropped = surface.actions.len() - kept.len();
716 if dropped > 0 {
717 let names: std::collections::BTreeSet<&str> =
718 kept.iter().map(|a| a.name.as_str()).collect();
719 surface
720 .views
721 .retain(|view| view.action_names().iter().all(|n| names.contains(n)));
722 surface.actions = kept;
723 }
724 dropped
725}
726
727#[async_trait::async_trait]
733pub trait SurfaceSource: Send + Sync {
734 async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
737 fn built(&self) -> std::sync::Arc<SurfaceDocument>;
740 fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
743 let _ = admin;
744 None
745 }
746}
747
748pub struct UiContext {
751 pub surface: std::sync::Arc<dyn SurfaceSource>,
755 pub api: axum::Router,
761 pub config: std::sync::Arc<dyn crate::config::Config>,
762 pub venture: std::sync::Arc<Venture>,
763 pub captcha_configured: bool,
766 pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
769 pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
771}
772
773pub trait UiMount: Send + Sync + 'static {
777 fn router(&self, ctx: UiContext) -> axum::Router;
779 fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
783 let _ = (surface, errors);
784 }
785 fn describe(&self) -> Option<serde_json::Value> {
789 None
790 }
791}
792
793#[derive(Debug, Clone)]
797pub struct RenderedSurface {
798 pub json: String,
799 pub etag: String,
801}
802
803impl RenderedSurface {
804 #[must_use]
805 pub fn render(document: &SurfaceDocument) -> Self {
806 let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
807 let digest = Sha256::digest(json.as_bytes());
808 let mut hex = String::with_capacity(32);
809 for byte in &digest[..16] {
810 use std::fmt::Write as _;
811 let _ = write!(hex, "{byte:02x}");
812 }
813 Self {
814 json,
815 etag: format!("\"{hex}\""),
816 }
817 }
818}
819
820#[must_use]
823pub fn schema_for<T: JsonSchema>() -> Schema {
824 let mut settings = schemars::generate::SchemaSettings::draft2020_12();
825 settings.inline_subschemas = true;
826 SchemaGenerator::new(settings).into_root_schema_for::<T>()
827}
828
829pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
834 if let Some(properties) = schema
835 .as_object_mut()
836 .and_then(|root| root.get_mut("properties"))
837 .and_then(serde_json::Value::as_object_mut)
838 && let Some(property) = properties
839 .get_mut(field)
840 .and_then(serde_json::Value::as_object_mut)
841 {
842 property.insert(key.to_owned(), value);
843 }
844}
845
846fn is_object_schema(schema: &Schema) -> bool {
847 let value = schema.as_value();
848 match value.get("type") {
849 Some(serde_json::Value::String(t)) => t == "object",
850 Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
851 _ => value.get("properties").is_some(),
852 }
853}
854
855fn validate_verifier(module: &str, name: &str, action: &Action, errors: &mut ConfigError) {
860 if action.verification.is_some() && action.policy != crate::route_policy::RoutePolicy::Signature
861 {
862 errors.push(format!(
863 "module `{module}` surface action `{name}` names a signature verifier but its \
864 policy is not Signature; only a webhook route can say how it verifies deliveries \
865 — drop `.verification(..)` or declare `.policy(RoutePolicy::Signature)` \
866 (issue #595)",
867 ));
868 }
869}
870
871fn is_kebab(name: &str) -> bool {
872 !name.is_empty()
873 && name.split('-').all(|part| {
874 !part.is_empty()
875 && part
876 .chars()
877 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
878 })
879}
880
881mod method_serde {
882 use http::Method;
883 use serde::{Deserialize, Deserializer, Serialize, Serializer};
884
885 pub(crate) fn serialize<S: Serializer>(
886 method: &Method,
887 serializer: S,
888 ) -> Result<S::Ok, S::Error> {
889 method.as_str().serialize(serializer)
890 }
891
892 pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
893 deserializer: D,
894 ) -> Result<Method, D::Error> {
895 let text = String::deserialize(deserializer)?;
896 Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 #[derive(JsonSchema)]
905 #[allow(dead_code)]
906 struct JoinBody {
907 #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
908 email: String,
909 product: String,
910 #[schemars(extend("x-cf-hidden" = true))]
911 #[serde(rename = "captchaToken")]
912 captcha_token: Option<String>,
913 }
914
915 fn join() -> Action {
916 Action::post("join", "/").input::<JoinBody>().captcha()
917 }
918
919 fn errors_of(surface: &Surface) -> Vec<String> {
920 let mut errors = ConfigError::default();
921 surface.validate("waitlist", &mut errors);
922 match errors.into_result() {
923 Ok(()) => Vec::new(),
924 Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
925 }
926 }
927
928 #[test]
929 fn schema_carries_hints_and_is_inlined() {
930 let schema = schema_for::<JoinBody>();
931 let value = schema.as_value();
932 assert_eq!(value["type"], "object");
933 assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
934 assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
935 assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
936 assert!(value.get("$defs").is_none(), "subschemas must be inlined");
937 }
938
939 #[test]
940 fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
941 let mut schema = schema_for::<JoinBody>();
942 hint_field(
943 &mut schema,
944 "product",
945 "enum",
946 serde_json::json!(["a", "b"]),
947 );
948 hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
949 let value = schema.as_value();
950 assert_eq!(
951 value["properties"]["product"]["enum"],
952 serde_json::json!(["a", "b"])
953 );
954 assert!(value["properties"].get("missing").is_none());
955 }
956
957 #[test]
958 fn valid_surface_has_no_errors() {
959 let surface = Surface::new()
960 .action(join())
961 .action(Action::get("confirm", "/confirm"))
962 .action(
963 Action::get("export", "/admin/export.csv")
964 .audience(Audience::Admin)
965 .outcome(Outcome::Json)
966 .output::<JoinBody>(),
967 )
968 .view(View::form("join"))
969 .view(View::table("export", vec![Column::new("email", "Email")]));
970 assert!(errors_of(&surface).is_empty());
971 }
972
973 #[test]
974 fn every_validation_rule_names_the_module_and_action() {
975 #[derive(JsonSchema)]
976 #[allow(dead_code)]
977 struct NotAnObject(Vec<String>);
978
979 let surface = Surface::new()
980 .action(join())
981 .action(join())
982 .action(Action::post("Bad Name", "no-slash"))
983 .action(Action::post("hidden", "/admin/thing"))
984 .action(Action::delete("wipe", "/wipe"))
985 .action(Action::post("list", "/list").input::<NotAnObject>())
986 .action(
987 Action::get("rows", "/rows")
988 .outcome(Outcome::Json)
989 .output::<NotAnObject>(),
990 )
991 .action(Action::post("send", "/send").output::<JoinBody>())
992 .view(View::form("missing"));
993 let errors = errors_of(&surface);
994 let joined = errors.join("\n");
995 for needle in [
996 "declares action `join` twice",
997 "action `Bad Name` must be kebab-case",
998 "path `no-slash` must start with '/'",
999 "action `hidden` is under /admin/ but its audience is not admin",
1000 "action `wipe` is admin but its path `/wipe` is not under /admin/",
1001 "action `list` input schema must describe an object",
1002 "action `rows` output schema must describe an object",
1003 "action `send` declares an output schema but its outcome is not json",
1004 "view references action `missing`",
1005 ] {
1006 assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
1007 }
1008 assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
1009 }
1010
1011 #[test]
1012 fn public_subset_drops_admin_actions_and_their_views() {
1013 let surface = Surface::new()
1014 .action(join())
1015 .action(
1016 Action::get("export", "/admin/export.csv")
1017 .audience(Audience::Admin)
1018 .outcome(Outcome::Json),
1019 )
1020 .view(View::form("join"))
1021 .view(View::table("export", vec![]));
1022 let public = surface.public();
1023 assert_eq!(public.actions.len(), 1);
1024 assert_eq!(public.views.len(), 1);
1025 assert!(matches!(public.views[0], View::Form { .. }));
1026 }
1027
1028 #[test]
1029 fn rendered_surface_etag_is_stable_and_differs_per_variant() {
1030 let doc = SurfaceDocument {
1031 surface_api: SURFACE_API,
1032 harness_api: HARNESS_API,
1033 venture: VentureSurface {
1034 name: "v".into(),
1035 public_url: "https://v.test".into(),
1036 },
1037 modules: vec![ModuleSurface {
1038 name: "waitlist".into(),
1039 version: "0.1.0".into(),
1040 surface: Surface::new()
1041 .action(join())
1042 .action(Action::delete("wipe", "/admin/wipe")),
1043 }],
1044 ui: None,
1045 };
1046 let full = RenderedSurface::render(&doc);
1047 let again = RenderedSurface::render(&doc);
1048 let public = RenderedSurface::render(&doc.public());
1049 assert_eq!(full.etag, again.etag);
1050 assert_ne!(full.etag, public.etag);
1051 assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
1052 assert_eq!(full.etag.len(), 34);
1053 let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
1054 assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
1055 assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
1056 assert_eq!(parsed["surface_api"], SURFACE_API);
1057 }
1058
1059 #[test]
1060 fn document_omits_modules_without_a_surface() {
1061 struct Silent;
1062 impl Module for Silent {
1063 fn name(&self) -> &'static str {
1064 "silent"
1065 }
1066 fn version(&self) -> &'static str {
1067 "0.0.0"
1068 }
1069 fn requires(&self) -> &'static [crate::ports::Port] {
1070 &[]
1071 }
1072 fn migrations(&self) -> crate::module::Migrations {
1073 crate::module::Migrations::EMPTY
1074 }
1075 fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
1076 Ok(())
1077 }
1078 fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
1079 axum::Router::new()
1080 }
1081 }
1082 let venture = Venture::new("v", "v.test");
1083 let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
1084 let doc = SurfaceDocument::compose(&venture, &modules);
1085 assert!(doc.modules.is_empty());
1086 }
1087
1088 #[test]
1089 fn an_api_key_action_refuses_a_captcha_and_hides_from_the_public_surface() {
1090 let mut action =
1094 Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey);
1095 action.captcha = true;
1096 let errors = errors_of(&Surface::new().action(action));
1097 assert!(
1098 errors.iter().any(|error| error.contains("policy=ApiKey")),
1099 "{errors:?}"
1100 );
1101
1102 let surface = Surface::new()
1106 .action(Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey))
1107 .action(join());
1108 let public = surface.public();
1109 assert!(public.actions.iter().all(|action| action.name != "sync"));
1110 assert!(public.actions.iter().any(|action| action.name == "join"));
1111 }
1112
1113 #[test]
1114 fn only_a_signature_action_may_name_a_verifier() {
1115 let stray = Action::post("join", "/").captcha().verification(
1119 crate::route_policy::SignatureVerification::Hmac {
1120 secret: "WEBHOOK_SECRET",
1121 },
1122 );
1123 let errors = errors_of(&Surface::new().action(stray));
1124 assert!(
1125 errors
1126 .iter()
1127 .any(|error| error.contains("policy is not Signature")),
1128 "{errors:?}"
1129 );
1130
1131 let accepted = Surface::new()
1134 .action(
1135 Action::post("stripe", "/webhooks/stripe")
1136 .policy(crate::route_policy::RoutePolicy::Signature),
1137 )
1138 .action(
1139 Action::post("revenuecat", "/webhooks/revenuecat")
1140 .policy(crate::route_policy::RoutePolicy::Signature)
1141 .verification(crate::route_policy::SignatureVerification::Hmac {
1142 secret: "REVENUECAT_WEBHOOK_SECRET",
1143 }),
1144 );
1145 assert!(errors_of(&accepted).is_empty());
1146 }
1147}