diff --git a/README.md b/README.md index 62852ad..c9fb6b7 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,10 @@ stream. - **Answer chains** (`src/chain.rs`): each submission hashes its parent(s), so a visitor's path through the questions is a verifiable lineage; `?chain=` links carry it, and self-service transitions - (unsubscribe) authorize by holding one. + (unsubscribe) authorize by holding one. The question nav reads it + too: a `followup` page is offered only when it is on that path (the + page the chain verifiably answered) or further down from the current + page over `action` edges - one followup path never unlocks another. - **Live resources** (`src/resource.rs`): content can pull a KV bucket, Gitea starred/org repos/releases, or any public JSON URL (SSRF fail-closed), reshaped by a content-declared `jq` filter. diff --git a/src/app.rs b/src/app.rs index 052fef5..230fb9f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -213,7 +213,10 @@ fn PortalShell() -> impl IntoView { |(path, chain)| get_question(path, chain), ); let user = Resource::new(|| (), |_| current_user()); - let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions); + let nav = Resource::new( + move || (path.get(), chain.get()), + |(path, chain)| list_qualifying_questions(path, chain), + ); let announcements = Resource::new(|| (), |_| list_announcements()); provide_context(QuestionRes(question)); provide_context(UserRes(user)); @@ -289,7 +292,6 @@ fn QuestionView( site: SiteConfig, ) -> impl IntoView { let question_id = question.id.clone(); - let has_chain = parent_hash.is_some(); // The provenance counterpart to the qualifies gate below: a // requires_chain page whose visitor holds no verifiable lineage to @@ -318,7 +320,7 @@ fn QuestionView( // A gate is never a dead end: the same nav as every page. - + } .into_any(); } @@ -355,7 +357,7 @@ fn QuestionView( }} - + } .into_any(); } @@ -418,7 +420,7 @@ fn QuestionView( .map(|r| { view! { } })} - + } .into_any() } @@ -427,8 +429,7 @@ fn QuestionView( /// small nav - how a concern reaches the people it speaks to without /// claiming front-page space (an owner also sees the gated desks here). #[component] -fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView { - let _ = has_chain; // keyed into the shared resource by PortalShell +fn QuestionNav(current_id: String) -> impl IntoView { let NavRes(nav) = expect_context(); view! { @@ -2029,16 +2030,21 @@ pub async fn get_site() -> Result { Ok(state.site.load().as_ref().clone()) } -/// Every question the current visitor qualifies for, as (id, name) - -/// the site nav's data. Context-dependent: an owner session sees the -/// gated pages too, and `followup` pages only appear once the visitor -/// carries an answer chain. +/// Every question the visitor may be offered from `current`, as (id, +/// name) - the question nav's data. Front-door pages the visitor +/// qualifies for are always in (an owner session sees the gated desks +/// too); `followup` pages and ended events only when they are on the +/// visitor's path - the page their verified `?chain=` answered - or +/// further down from `current` (see `content::outcomes_reachable_from`). +/// A chain unlocks the followup it came from, never every followup on +/// the site; an unverifiable hash unlocks nothing. #[server(endpoint = "list_qualifying_questions")] pub async fn list_qualifying_questions( - has_chain: bool, + current: String, + chain: Option, ) -> Result, ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; - use crate::content::is_qualified; + use crate::content::{is_qualified, outcomes_reachable_from, Question}; use crate::server::AppState; let state = expect_context::(); @@ -2049,14 +2055,27 @@ pub async fn list_qualifying_questions( .map_err(|e| ServerFnError::new(e.to_string()))?; let now_ms = chrono::Utc::now().timestamp_millis(); - let mut out: Vec<(String, String)> = state - .questions - .load() + let questions = state.questions.load(); + // The one page the chain verifiably answered - the same lookup a + // `requires_chain` gate does, so a forged or foreign hash resolves + // to nothing relevant. + let answered: Option = match &chain { + Some(hash) => crate::chain::lookup_node(&state.jetstream, hash) + .await + .map(|node| node.question_id), + None => None, + }; + // An ended event page is a followup from then on. + let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms); + let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome); + let mut out: Vec<(String, String)> = questions .values() .filter(|q| is_qualified(user.as_ref(), q)) - // An ended event page is a followup from then on - only a - // visitor carrying a chain (they answered it) still sees it. - .filter(|q| !(q.is_followup() || q.event_ended_at(now_ms)) || has_chain) + .filter(|q| { + !is_outcome(q) + || answered.as_deref() == Some(q.id.as_str()) + || reach.contains(&q.id) + }) // A live event is announced in the header instead. .filter(|q| !q.event_live_at(now_ms)) // A dynamic page has no URL of its own to link to. diff --git a/src/content.rs b/src/content.rs index dcecfb5..a6fd96e 100644 --- a/src/content.rs +++ b/src/content.rs @@ -42,8 +42,11 @@ pub struct Question { #[serde(default)] pub responsible: Option, /// A page that only makes sense after answering something (the - /// post-submission pages) - kept out of the question nav unless the - /// visitor's context carries an answer chain. Unset means inferred + /// post-submission pages) - kept out of the question nav unless it is + /// on the visitor's path (the page their verified `?chain=` answered) + /// or further down from the page they are on, over `action` edges + /// (see `outcomes_reachable_from`). One followup path never unlocks + /// another. Unset means inferred /// from the file's place in the tree: files nested in a /// subdirectory are followups unless they're the directory's /// `index.yaml`; top-level files keep the historical default @@ -922,6 +925,74 @@ pub fn path_matches(pattern: &str, path: &str) -> Option> Some(captures) } +/// The question an alternative's `action` lands on, as a question id: +/// a plain path resolves directly, a templated one +/// (`/shape/{curve.key}`) to the dynamic page whose pattern has the +/// same shape (`/shape/[key]`). `None` when no page declares it. +pub fn action_target_id( + questions: &std::collections::HashMap, + action: &str, +) -> Option { + if questions.contains_key(action) { + return Some(action.to_string()); + } + let want: Vec<&str> = action.split('/').filter(|s| !s.is_empty()).collect(); + questions + .values() + .filter(|q| q.is_dynamic()) + .map(|q| &q.id) + .find(|id| { + let pat: Vec<&str> = id.split('/').filter(|s| !s.is_empty()).collect(); + pat.len() == want.len() + && pat + .iter() + .zip(&want) + .all(|(p, w)| p == w || (p.starts_with('[') && w.starts_with('{'))) + }) + .cloned() +} + +/// The outcome pages "further down" from `from`: every `is_outcome` +/// question reachable from it over `alternatives[].action` edges, +/// walking only *through* outcome pages. A front-door page (not an +/// outcome) is where the walk ends, so a "back to the start" action +/// never fans out into the whole site. `from` itself is never in the +/// result. This, plus the one page a visitor's verified chain answered, +/// decides which followups the question nav offers - one followup path +/// never unlocks another (an email-link page like redoal's `/watching`, +/// which nothing links to, stays out of every nav). +pub fn outcomes_reachable_from( + questions: &std::collections::HashMap, + from: &str, + is_outcome: &dyn Fn(&Question) -> bool, +) -> std::collections::HashSet { + use std::collections::{HashSet, VecDeque}; + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + if let Some(q) = resolve_question(questions, from) { + queue.push_back(q); + } + while let Some(q) = queue.pop_front() { + for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) { + let Some(id) = action_target_id(questions, action) else { + continue; + }; + // A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`) + // is itself, not somewhere further down. + if id == from || path_matches(&id, from).is_some() || seen.contains(&id) { + continue; + } + let target = &questions[&id]; + if !is_outcome(target) { + continue; + } + seen.insert(id.clone()); + queue.push_back(target.clone()); + } + } + seen +} + /// Resolves a URL path to its question: an exact id first, else the /// one dynamic page whose pattern matches, with each captured segment /// substituted into `{name}` placeholders in the clone's resource keys @@ -1961,6 +2032,48 @@ alternatives: assert!(!questions.contains_key("/review/_draft")); } + #[test] + fn followups_surface_by_reach_not_by_carrying_any_chain() { + use std::collections::HashSet; + let files = vec![ + // Front door with three outcomes, one of them templated onto + // a dynamic page. + ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()), + ("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()), + ("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()), + // Nested -> followup by inference; leads on to /subscribed. + ("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()), + // Reached only by an out-of-band link: nothing points here. + ("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()), + // A second, unrelated path with its own outcome. + ("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()), + ("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()), + ]; + let questions = build_questions(&files).unwrap(); + let is_outcome = |q: &Question| q.is_followup(); + let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome); + let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::>(); + + // Templated actions resolve to the dynamic page's pattern. + assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]")); + assert_eq!(action_target_id(&questions, "/nowhere"), None); + + // From the front door: its own outcomes, and what they lead on + // to - but not the other path's outcome, nor the orphan. + assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"])); + // The other path only sees its own outcome. Its "back to /" + // edge ends the walk at the front door instead of fanning out. + assert_eq!(reach("/kontakt"), set(&["/takk"])); + // Standing on an outcome: nothing further down through outcomes. + assert_eq!(reach("/subscribed"), set(&[])); + assert_eq!(reach("/takk"), set(&[])); + // The email-link page reaches nothing and nothing reaches it. + assert_eq!(reach("/watching"), set(&[])); + // A concrete dynamic path resolves to its pattern and walks on; + // the pattern itself is not "further down" from its own page. + assert_eq!(reach("/shape/abc"), set(&["/subscribed"])); + } + #[test] fn declared_id_wins_over_path() { let files = vec![( diff --git a/style/main.css b/style/main.css index 1329720..25c8063 100644 --- a/style/main.css +++ b/style/main.css @@ -147,6 +147,10 @@ a { main.loading, main.not-found { min-height: 60vh; + /* svh so wrapper browsers that resize the webview as their toolbar + animates (Firefox/Chrome iOS) don't reflow this placeholder - the + small viewport is a fixed value. Same ladder as .hero-piece. */ + min-height: 60svh; display: flex; flex-direction: column; align-items: center;