//! What a content repo must satisfy before it serves: every action //! lands on a page, every transition is one the state machine allows, //! every recorded bucket is read back somewhere, and the built-in //! aggregates the runtime owns are always present. #[allow(unused_imports)] // the browser build sees fewer items use super::*; /// A relay must be a ws:// or wss:// URL - shared between site.yaml's /// hero and `Requirement.relay` validation. #[cfg(feature = "ssr")] pub fn validate_relay_url(relay: &str) -> anyhow::Result<()> { let parsed = url::Url::parse(relay) .map_err(|e| anyhow::anyhow!("relay {relay:?} is not a valid URL: {e}"))?; if !matches!(parsed.scheme(), "ws" | "wss") { anyhow::bail!("relay {relay:?} must use the ws:// or wss:// scheme"); } Ok(()) } /// Validates every declared transition target (`SelfTransition.to`, /// `ResourceSpec.transitions[].to`) against `aggregates` - the real /// state graph loaded from `aggregates.yaml` - for every bucket that /// has one declared. A bucket with no entry in `aggregates` is left /// alone entirely (no validation applied): not every bucket needs to /// be event-sourced to keep working. Called on every content /// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the /// standalone `question-lint` binary, so a YAML typo becomes a caught, /// logged rejection instead of a silently-accepted, later-broken /// string. /// Bucket the runtime keeps for announced pages (`Question.event`): /// one record per event question, `announced` while the window is /// open, `awaiting_summary` once it closes - the "post what happened" /// task a desk reading this bucket picks up - and `summarized` when an /// owner marks it done. Declared here, not in content, because the /// runtime's sweeper (`announce.rs`) is what moves the records. pub const EVENTS_BUCKET: &str = "portal_events"; /// Bucket for changes suggested by the people the content names as /// responsible (`app::suggest_page_edit`): a case like any other, so a /// suggestion is taken up, carried out or turned down by someone at a /// desk, and the person who made it hears which. pub const EDIT_SUGGESTIONS_BUCKET: &str = "portal_edit_suggestions"; #[cfg(feature = "ssr")] const BUILTIN_AGGREGATES_YAML: &str = "\ aggregates: - bucket: portal_events initial: announced states: announced: { event: announced } awaiting_summary: { event: ended } summarized: { event: summarized } transitions: announced: [awaiting_summary, summarized] awaiting_summary: [summarized] - bucket: portal_invites attended_by: the inviting group's own desk, or the link in the invite event initial: open states: open: { event: invited } expired: { event: expired } done: { event: done } transitions: open: [done, expired] expired: [open, done] - bucket: portal_memberships attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in initial: invited states: invited: { event: invited } active: { event: active } removed: { event: removed } transitions: invited: [active, removed] active: [removed] removed: [invited, active] - bucket: portal_edit_suggestions attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too initial: open states: open: { event: suggested } discussed: { event: discussed } done: { event: done } declined: { event: declined } transitions: open: [discussed, done, declined] discussed: [done, declined] "; /// Adds the runtime's own state graphs to a content repo's. Content /// wins if it declares the same bucket (say, to add states). #[cfg(feature = "ssr")] pub fn with_builtin_aggregates( mut aggregates: std::collections::HashMap, ) -> std::collections::HashMap { let builtin = crate::aggregates::parse_aggregates_yaml(BUILTIN_AGGREGATES_YAML) .expect("builtin aggregates yaml is valid"); for (bucket, schema) in builtin { aggregates.entry(bucket).or_insert(schema); } aggregates } /// True when some kv resource in the content reads `bucket` - the /// attended-bucket check, exposed so the lint can warn about the /// runtime's own `portal_events` (a warning, not a failure: a repo /// with no event pages has nothing to attend). #[cfg(feature = "ssr")] pub fn bucket_is_read(questions: &std::collections::HashMap, bucket: &str) -> bool { questions.values().any(|q| { q.alternatives.iter().any(|a| { a.features .iter() .any(|f| f.resource.as_ref().and_then(|r| r.bucket()).is_some_and(|b| b == bucket)) }) }) } #[cfg(feature = "ssr")] pub fn validate_questions( questions: &std::collections::HashMap, aggregates: &std::collections::HashMap, ) -> anyhow::Result<()> { // A mail to the submitter needs an address to go to: every form // that records into that bucket has to collect a required email. for (bucket, schema) in aggregates { let submitter_states: Vec<&String> = schema.mail_for_state.iter().filter(|(_, m)| m.to_submitter()).map(|(s, _)| s).collect(); if submitter_states.is_empty() { continue; } for question in questions.values() { for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { let collects_email = alternative .features .iter() .flat_map(|f| &f.requirements) .any(|r| r.name == "email" && r.kind == "email" && !r.optional); if !collects_email { anyhow::bail!( "question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} mail the submitter, so it must collect a required `email` field of type email", question.id, alternative.name, submitter_states ); } } } } // A slideshow shows something, at a height that leaves the page // its question: between a fifth and the whole of the screen. for question in questions.values() { if let Some(show) = &question.slideshow { if show.slides.is_empty() { anyhow::bail!("question {:?}: slideshow has no slides", question.id); } if !(20..=100).contains(&show.max_height) { anyhow::bail!("question {:?}: slideshow max_height is {} svh; between 20 and 100", question.id, show.max_height); } for slide in &show.slides { let ok = slide.image.starts_with("https://") || crate::content::is_safe_site_path(slide.image.trim_start_matches("/site/").trim_start_matches('/')); if !ok { anyhow::bail!("question {:?}: slideshow image {:?} is neither an https address nor a path in the content repo", question.id, slide.image); } } } } // A mail to the record this one belongs to goes through a field of // the form: `{notice.email}` needs a `type: record` field called // `notice` on every form that records into the bucket, or the mail // has nowhere to go. for (bucket, schema) in aggregates { for (state, spec) in &schema.mail_for_state { let Some(parent) = crate::mail::parent_of(&spec.to) else { continue }; for question in questions.values() { for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some()); if !has { anyhow::bail!( "question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`", question.id, alternative.name, spec.to ); } } } } } // A state that grants a group makes an account for the person the // record is about, so the record has to know who they are. Same // shape as an invite's own rule, one step further back: the form // that fills the bucket is where the name and the address have to // be asked for, because by the time a desk presses approve there is // nobody left to ask. for (bucket, schema) in aggregates { let mut granting: Vec<&String> = schema.grants_for_state.keys().collect(); if granting.is_empty() { continue; } granting.sort(); for question in questions.values() { for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) { let required = |name: &str, kind: &str| { alternative .features .iter() .flat_map(|f| &f.requirements) .any(|r| r.name == name && r.kind == kind && !r.optional) }; if !required("email", "email") || !required("name", "text") { anyhow::bail!( "question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} grant a group, so it must collect a required `name` and a required `email` field of type email", question.id, alternative.name, granting ); } } } } for question in questions.values() { if let Some(event) = &question.event { if question.is_dynamic() { anyhow::bail!("question {:?}: a dynamic page can't carry an event", question.id); } if let Err(e) = event.window_ms() { anyhow::bail!("question {:?}: event: {e}", question.id); } } if let Some(place) = &question.place { if let Err(e) = place.validate() { anyhow::bail!("question {:?}: place: {e}", question.id); } } if let Some(target) = &question.requires_chain { if !questions.contains_key(target) { anyhow::bail!( "question {:?}: requires_chain {:?} does not match any declared question id", question.id, target ); } } for alternative in &question.alternatives { // A dangling action is a literal dead end: the submit // button navigates to "Nothing here". if let Some(action) = &alternative.action { if action.contains('{') { // Placeholders are filled at submit time; the // shape must land on a dynamic page once filled. let probe = template_action(action, &serde_json::json!({})); let filled = { let mut a = action.clone(); while let (Some(i), Some(j)) = (a.find('{'), a.find('}')) { a.replace_range(i..=j, "x"); } a }; let _ = probe; // Match the pattern itself: resolve_question would // hand back a clone whose id is already concrete. let lands = questions .values() .any(|q| q.is_dynamic() && path_matches(&q.id, &filled).is_some()); if lands { continue; } anyhow::bail!( "question {:?} alternative {:?}: templated action {:?} must land on a dynamic page ([name].yaml)", question.id, alternative.name, action ); } match questions.get(action) { None => anyhow::bail!( "question {:?} alternative {:?}: action {:?} does not match any declared question id", question.id, alternative.name, action ), // A dynamic page needs a concrete segment value to // be a URL - a submit button can't supply one. Some(target) if target.is_dynamic() => anyhow::bail!( "question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question", question.id, alternative.name, action ), Some(_) => {} } } if let Some(invite) = &alternative.invite { if question.qualifies.is_none() { anyhow::bail!( "question {:?} alternative {:?}: an invite into {:?} must sit on a page gated by `qualifies` - that group is who may extend it", question.id, alternative.name, invite.group ); } let has = |name: &str| { alternative .features .iter() .flat_map(|f| &f.requirements) .any(|r| r.name == name && !r.optional) }; // A person is known by a phone number or an address; // either will do, since memberships key on the number // first and Kanidm on the address. if !has("name") || !(has("email") || has("phone")) { anyhow::bail!( "question {:?} alternative {:?}: an invite must collect a required `name` and a required `email` or `phone` field", question.id, alternative.name ); } } if let Some(st) = &alternative.self_transition { if let Some(schema) = aggregates.get(&st.bucket) { if !schema.has_state(&st.to) { anyhow::bail!( "question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}", question.id, alternative.name, st.to, st.bucket ); } for from in &st.from_states(&schema.initial) { if !schema.has_state(from) { anyhow::bail!( "question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}", question.id, alternative.name, from, st.bucket ); } // The policy would otherwise permit a move the // engine refuses: the reader of the content // should hear about that here, not from a // person who clicked the button. if !schema.allowed(from).iter().any(|s| s == &st.to) { anyhow::bail!( "question {:?} alternative {:?}: self_transition {:?} -> {:?} is not a declared edge for bucket {:?}", question.id, alternative.name, from, st.to, st.bucket ); } } } } // Bind targets resolve across the whole alternative, same // scope as the signal maps the renderer builds. let sibling_names: std::collections::HashSet<&str> = alternative .features .iter() .flat_map(|f| f.requirements.iter()) .map(|r| r.name.as_str()) .collect(); for feature in &alternative.features { for requirement in &feature.requirements { if requirement.kind == "select" && requirement.resource.is_none() && requirement.options.is_empty() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares neither a resource nor inline options", question.id, alternative.name, feature.name, requirement.name ); } if let Some(relay) = &requirement.relay { if requirement.kind != "gesture" && requirement.kind != "voice" { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture or voice", question.id, alternative.name, feature.name, requirement.name, requirement.kind ); } validate_relay_url(relay).map_err(|e| anyhow::anyhow!( "question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}", question.id, alternative.name, feature.name, requirement.name ))?; } if requirement.kind == "gesture" && (requirement.resource.is_some() || requirement.bind.is_some()) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind", question.id, alternative.name, feature.name, requirement.name ); } if requirement.kind == "location" { if requirement.resource.is_some() || requirement.bind.is_some() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - the visitor puts the pin, it can't also load one from a resource or bind", question.id, alternative.name, feature.name, requirement.name ); } // `value` opens the map somewhere useful. It is // the content author's own coordinates, so a // typo here is a map of the Atlantic. if let Some(value) = &requirement.value { let parts: Vec<&str> = value.split(',').map(str::trim).collect(); let ok = (2..=3).contains(&parts.len()) && parts.iter().all(|p| p.parse::().is_ok()) && parts[0].parse::().is_ok_and(|lat| (-90.0..=90.0).contains(&lat)) && parts[1].parse::().is_ok_and(|lon| (-180.0..=180.0).contains(&lon)); if !ok { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: location - its `value` opens the map and must be `lat,lon` or `lat,lon,zoom`, not {value:?}", question.id, alternative.name, feature.name, requirement.name ); } } } // One case belonging to another. The bucket has to // be one this repo declares, or the reference could // never resolve; and it is the only thing `of` // means, so it belongs to no other type. if requirement.kind == "record" { let Some(of) = &requirement.of else { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - it must say `of: `, the bucket the record it points at lives in", question.id, alternative.name, feature.name, requirement.name ); }; if !aggregates.contains_key(of) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} points at bucket {of:?}, which has no aggregate declared", question.id, alternative.name, feature.name, requirement.name ); } if requirement.resource.is_some() || requirement.bind.is_some() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - the link it came in on carries the id, it can't also load one from a resource or bind", question.id, alternative.name, feature.name, requirement.name ); } } else if requirement.of.is_some() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} has `of`, which only a type: record field has", question.id, alternative.name, feature.name, requirement.name ); } if let Some(bind) = &requirement.bind { if bind.field == requirement.name || !sibling_names.contains(bind.field.as_str()) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative", question.id, alternative.name, feature.name, requirement.name, bind.field ); } } } let Some(resource) = &feature.resource else { continue; }; let Some(bucket) = resource.bucket() else { continue; }; let Some(schema) = aggregates.get(bucket) else { continue; }; for state in &resource.states { if !schema.has_state(state) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, state, bucket ); } } for transition in &resource.transitions { if !schema.has_state(&transition.from) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, transition.from, bucket ); } if !schema.has_state(&transition.to) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, transition.to, bucket ); } if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}", question.id, alternative.name, feature.name, transition.from, transition.to, bucket ); } } } } } // Attended buckets: no publicly collected answer may land somewhere // nothing reads. Every `record_as` bucket must either be read back // by some Kv resource in this same content repo (a desk or listing) // or carry an explicit `attended_by:` annotation in aggregates.yaml // naming the automation that consumes it. This is a contract, not a // convention, because convention already failed once: a question // was dropped and its bucket - answers included - silently fell out // of every reader's view. let mut read_buckets = std::collections::HashSet::new(); let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet| { if let ResourceSource::Kv { bucket } = &spec.source { set.insert(bucket.clone()); } }; for question in questions.values() { for alternative in &question.alternatives { for feature in &alternative.features { if let Some(spec) = &feature.resource { note_resource(spec, &mut read_buckets); } for requirement in &feature.requirements { if let Some(spec) = &requirement.resource { note_resource(spec, &mut read_buckets); } if let Some(bind) = &requirement.bind { note_resource(&bind.resource, &mut read_buckets); } } } } } for question in questions.values() { for alternative in &question.alternatives { if alternative.one_per_person && alternative.record_as.is_none() { anyhow::bail!( "question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in", question.id, alternative.name ); } if let Some(bucket) = &alternative.record_as { let attended = read_buckets.contains(bucket) || aggregates .get(bucket) .is_some_and(|schema| schema.attended_by.is_some()); if !attended { anyhow::bail!( "question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.", question.id, alternative.name ); } } } } Ok(()) }