From 213b1130bbd8879a04d0058970eaf29fbd3d1802 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 5 Aug 2026 18:44:28 +0200 Subject: [PATCH] Add self-service transitions, authorized by item possession not group membership New Alternative.self_transition: like ResourceSpec.transitions, but for an anonymous visitor holding one specific item's own chain hash (from a ?chain= link, the same reference /subscribed?chain=... already carries) rather than a signed-in owner browsing a whole bucket. email is a second factor checked against the stored item, not the lookup key - defense in depth against a leaked/guessed chain hash alone. AlternativeCard renders it as a single confirm button when both ?chain= and ?email= are present; QuestionView hides the alternative entirely otherwise, rather than showing a dead card with nothing to click. Powers /subscribed's new "Unsubscribe" alternative, which the newsletter's own recipient links now carry - no email lookup needed, just the same chain_hash the subscription itself already produced. --- src/answers.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 78 +++++++++++++++++++++++++++++++++++++++++++-- src/content.rs | 20 ++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/answers.rs b/src/answers.rs index 07044aa..06350fa 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -186,3 +186,88 @@ pub async fn transition_answer( Ok(()) } + +/// The self-service counterpart to `transition_answer`: no signed-in +/// session, no group check - authorized instead by already holding +/// `item_id` (a chain hash, opaque and unguessable) plus a matching +/// `email`, both carried in the link itself (see +/// `content::SelfTransition`). Deliberately returns the same generic +/// error for "no such item" and "email doesn't match" - a real +/// unsubscribe link should never let someone probe which chain hashes +/// or emails exist. +#[server] +pub async fn self_transition_answer( + question_id: String, + alternative: String, + item_id: String, + email: String, +) -> Result<(), ServerFnError> { + use crate::chain::hash_node; + use crate::events::{emit_answer_submitted, AnswerSubmitted}; + use crate::server::AppState; + + let state = expect_context::(); + let question = state + .questions + .load() + .get(&question_id) + .cloned() + .ok_or_else(|| ServerFnError::new("unknown question"))?; + let self_transition = question + .alternatives + .iter() + .find(|a| a.name == alternative) + .ok_or_else(|| ServerFnError::new("unknown alternative"))? + .self_transition + .clone() + .ok_or_else(|| ServerFnError::new("alternative has no self transition"))?; + + let store = state + .jetstream + .get_key_value(&self_transition.bucket) + .await + .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?; + let bytes = store + .get(&item_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new("not found"))?; + let mut answer: Answer = + serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?; + + let stored_email = answer.responses.get("email").and_then(|v| v.as_str()); + if stored_email != Some(email.as_str()) { + return Err(ServerFnError::new("not found")); + } + if answer.state != OPEN_STATE { + return Err(ServerFnError::new("already processed")); + } + + let decided_ms = chrono::Utc::now().timestamp_millis(); + answer.state = self_transition.to.clone(); + answer.decided_ms = Some(decided_ms); + answer.decided_by = Some(email.clone()); + + let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?; + store + .put(&item_id, data.into()) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let decision_payload = serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email }); + let parent_hashes = vec![item_id.clone()]; + let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms); + let event = AnswerSubmitted { + chain_hash, + parent_hashes, + question_id, + alternative: self_transition.label, + responses: decision_payload, + timestamp_ms: decided_ms, + }; + emit_answer_submitted(&state.nats, &event) + .await + .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?; + + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index c9dea13..d9652cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use leptos_router::{ }; use serde::{Deserialize, Serialize}; -use crate::answers::{Answer, TransitionAnswer}; +use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer}; use crate::auth::{current_user, User}; use crate::content::{is_qualified, Alternative, Question, Transition}; use crate::resource::get_resource; @@ -73,6 +73,7 @@ fn QuestionPage() -> impl IntoView { } }); let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); + let query_email = Memo::new(move |_| query.with(|q| q.get("email"))); let question = Resource::new(move || path.get(), get_question); let user = Resource::new(|| (), |_| current_user()); @@ -100,6 +101,7 @@ fn QuestionPage() -> impl IntoView { } @@ -116,6 +118,7 @@ fn QuestionPage() -> impl IntoView { fn QuestionView( question: Question, parent_hash: Option, + query_email: Option, user: Option, ) -> impl IntoView { let question_id = question.id.clone(); @@ -162,7 +165,24 @@ fn QuestionView( />
>() + } + } key=|a| a.name.clone() children=move |alt: Alternative| { view! { @@ -170,6 +190,7 @@ fn QuestionView( question_id=question_id.clone() alternative=alt parent_hash=parent_hash.clone() + query_email=query_email.clone() /> } .into_any() @@ -298,7 +319,59 @@ fn AlternativeCard( question_id: String, alternative: Alternative, parent_hash: Option, + query_email: Option, ) -> impl IntoView { + // A link carrying both ?chain= and ?email= against an alternative + // that declares a self_transition renders as a one-button + // confirmation instead of the normal form - the visitor already + // holds everything the action needs (see + // `answers::self_transition_answer`), there's nothing left to type. + if let (Some(st), Some(item_id), Some(email)) = + (alternative.self_transition.clone(), parent_hash.clone(), query_email) + { + let self_transition = ServerAction::::new(); + let question_id_for_action = question_id.clone(); + let alt_name_for_action = alternative.name.clone(); + let item_id_for_action = item_id.clone(); + let email_for_action = email.clone(); + let label = st.label.clone(); + return view! { +
+

{alternative.name.clone()}

+

{alternative.description.clone()}

+ {move || { + let question_id_for_action = question_id_for_action.clone(); + let alt_name_for_action = alt_name_for_action.clone(); + let item_id_for_action = item_id_for_action.clone(); + let email_for_action = email_for_action.clone(); + let label = label.clone(); + match self_transition.value().get() { + None => view! { + + } + .into_any(), + Some(Ok(())) => view! {

"Done."

}.into_any(), + Some(Err(e)) => view! {

{e.to_string()}

}.into_any(), + } + }} +
+ } + .into_any(); + } + let has_action = alternative.action.is_some(); // File fields don't fit a live-typed RwSignal - they get @@ -596,6 +669,7 @@ fn AlternativeCard( })} } + .into_any() } /// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`). diff --git a/src/content.rs b/src/content.rs index 3442963..d7c76fc 100644 --- a/src/content.rs +++ b/src/content.rs @@ -58,6 +58,26 @@ pub struct Alternative { /// silently break if the wording changes later. #[serde(default)] pub record_as: Option, + /// A transition anyone holding the *right item's own reference* can + /// fire - not gated by `requires_group` like `ResourceSpec.transitions`, + /// which is for signed-in owners browsing a whole bucket. This is + /// the opposite shape: an anonymous visitor who already holds one + /// specific chain hash (from a `?chain=` link - see `chain.rs`, + /// the same reference `/subscribed?chain=...` already carries) may + /// transition *that one item*, and only that one, without ever + /// being able to enumerate or touch anyone else's. `email` is a + /// second factor checked against the stored item's own `email` + /// response field - not the lookup key, just cheap defense in depth + /// against a leaked/guessed chain hash alone being sufficient. + #[serde(default)] + pub self_transition: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SelfTransition { + pub bucket: String, + pub to: String, + pub label: String, } #[derive(Clone, Debug, Serialize, Deserialize)]