Add self-service transitions, authorized by item possession not group membership
Deploy / deploy (push) Successful in 33s
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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user