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(())
}
/// 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(())
}