Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s

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.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-05 18:44:28 +02:00
parent 6df895b1d3
commit 213b1130bb
3 changed files with 181 additions and 2 deletions
+85
View File
@@ -186,3 +186,88 @@ pub async fn transition_answer(
Ok(()) 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::<AppState>();
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(())
}
+76 -2
View File
@@ -7,7 +7,7 @@ use leptos_router::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::answers::{Answer, TransitionAnswer}; use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
use crate::auth::{current_user, User}; use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Transition}; use crate::content::{is_qualified, Alternative, Question, Transition};
use crate::resource::get_resource; 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 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 question = Resource::new(move || path.get(), get_question);
let user = Resource::new(|| (), |_| current_user()); let user = Resource::new(|| (), |_| current_user());
@@ -100,6 +101,7 @@ fn QuestionPage() -> impl IntoView {
<QuestionView <QuestionView
question=q question=q
parent_hash=parent_hash.get() parent_hash=parent_hash.get()
query_email=query_email.get()
user=current user=current
/> />
} }
@@ -116,6 +118,7 @@ fn QuestionPage() -> impl IntoView {
fn QuestionView( fn QuestionView(
question: Question, question: Question,
parent_hash: Option<String>, parent_hash: Option<String>,
query_email: Option<String>,
user: Option<User>, user: Option<User>,
) -> impl IntoView { ) -> impl IntoView {
let question_id = question.id.clone(); let question_id = question.id.clone();
@@ -162,7 +165,24 @@ fn QuestionView(
/> />
<div class="alternatives"> <div class="alternatives">
<For <For
each=move || question.alternatives.clone() each={
let parent_hash = parent_hash.clone();
let query_email = query_email.clone();
move || {
// A self_transition alternative has nothing to show
// (no action to submit, no button target) without
// both link params present - hide it rather than
// render a dead-looking empty card for anyone who
// reaches this question the normal way.
let has_link = parent_hash.is_some() && query_email.is_some();
question
.alternatives
.clone()
.into_iter()
.filter(|a| a.self_transition.is_none() || has_link)
.collect::<Vec<_>>()
}
}
key=|a| a.name.clone() key=|a| a.name.clone()
children=move |alt: Alternative| { children=move |alt: Alternative| {
view! { view! {
@@ -170,6 +190,7 @@ fn QuestionView(
question_id=question_id.clone() question_id=question_id.clone()
alternative=alt alternative=alt
parent_hash=parent_hash.clone() parent_hash=parent_hash.clone()
query_email=query_email.clone()
/> />
} }
.into_any() .into_any()
@@ -298,7 +319,59 @@ fn AlternativeCard(
question_id: String, question_id: String,
alternative: Alternative, alternative: Alternative,
parent_hash: Option<String>, parent_hash: Option<String>,
query_email: Option<String>,
) -> impl IntoView { ) -> 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::<SelfTransitionAnswer>::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! {
<section class="alt-card">
<h2>{alternative.name.clone()}</h2>
<p class="alt-description">{alternative.description.clone()}</p>
{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! {
<button
class="alt-submit"
disabled=move || self_transition.pending().get()
on:click=move |_| {
self_transition.dispatch(SelfTransitionAnswer {
question_id: question_id_for_action.clone(),
alternative: alt_name_for_action.clone(),
item_id: item_id_for_action.clone(),
email: email_for_action.clone(),
});
}
>
{label.clone()}
</button>
}
.into_any(),
Some(Ok(())) => view! { <p>"Done."</p> }.into_any(),
Some(Err(e)) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
}
}}
</section>
}
.into_any();
}
let has_action = alternative.action.is_some(); let has_action = alternative.action.is_some();
// File fields don't fit a live-typed RwSignal<String> - they get // File fields don't fit a live-typed RwSignal<String> - they get
@@ -596,6 +669,7 @@ fn AlternativeCard(
})} })}
</section> </section>
} }
.into_any()
} }
/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`). /// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
+20
View File
@@ -58,6 +58,26 @@ pub struct Alternative {
/// silently break if the wording changes later. /// silently break if the wording changes later.
#[serde(default)] #[serde(default)]
pub record_as: Option<String>, pub record_as: Option<String>,
/// 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<SelfTransition>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SelfTransition {
pub bucket: String,
pub to: String,
pub label: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]