Test / test (pull_request) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
539 lines
27 KiB
Rust
539 lines
27 KiB
Rust
//! 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<String, crate::aggregates::AggregateSchema>,
|
|
) -> std::collections::HashMap<String, crate::aggregates::AggregateSchema> {
|
|
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<String, Question>, 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<String, Question>,
|
|
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
|
) -> 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::<f64>().is_ok())
|
|
&& parts[0].parse::<f64>().is_ok_and(|lat| (-90.0..=90.0).contains(&lat))
|
|
&& parts[1].parse::<f64>().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: <bucket>`, 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<String>| {
|
|
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(())
|
|
}
|
|
|