Files
portal/src/content/validate.rs
T

510 lines
25 KiB
Rust
Raw Normal View History

//! 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
);
}
}
}
}
2026-09-25 21:35:25 +02:00
2026-09-30 06:47:36 +02:00
// 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
);
}
}
}
}
}
2026-09-25 21:35:25 +02:00
// 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)
};
if !has("name") || !has("email") {
anyhow::bail!(
"question {:?} alternative {:?}: an invite must collect required `name` and `email` fields",
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
);
}
2026-09-28 17:48:37 +02:00
for from in &st.from_states(&schema.initial) {
2026-09-25 21:35:25 +02:00
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
);
}
}
}
2026-09-25 21:35:25 +02:00
// 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;
};
2026-09-25 21:35:25 +02:00
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 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(())
}