Files
portal/src/answers.rs
T
Bendik LynghaugandClaude Opus 5 44c7ee6870
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
Publish release / publish (push) Successful in 1m52s
Render an Apple configuration profile from the server's PACC document
iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:18:46 +02:00

673 lines
23 KiB
Rust

//! Read-model storage for submitted answers: the KV projection of the
//! event log (`events::store`, `aggregates`) for every bucket with a
//! state graph in `aggregates.yaml`. A bucket with no graph still
//! works - direct KV mutate, no event log, no CAS - so content isn't
//! forced to declare one before it needs it.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// An answer's starting state - content decides what it may transition
/// to (`ResourceSpec.transitions`), never this file.
pub const OPEN_STATE: &str = "open";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Answer {
/// The submission's chain hash - stable, unique, and already what
/// ties this answer back into the DAG a transition later extends.
pub id: String,
pub question_id: String,
pub alternative: String,
/// The submitted form fields, verbatim - the runtime never inspects
/// these keys, only content (via its own copy/labels) gives them
/// meaning.
pub responses: serde_json::Value,
pub state: String,
pub submitted_ms: i64,
/// The storyline the record was sent from, so the mail it gets
/// tells the same one (see `content/variants.rs`).
#[serde(default)]
pub storyline: u32,
#[serde(default)]
pub decided_ms: Option<i64>,
#[serde(default)]
pub decided_by: Option<String>,
/// Replies that came back by mail, oldest first (see
/// `mail::note_for`). A note never moves a record; it is the
/// conversation beside the case, which is the thing a desk could
/// not see before.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
/// Set by a desk listing, never stored: the record is in a final
/// state (one with no move out). A desk shows the newest few of
/// those and offers the rest on request (`resource::cap_finished`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finished: bool,
}
/// One reply on a record. It lives here rather than with the mail
/// code because it is part of what a record is: the desks render it,
/// and the browser has to know its shape even though it will never
/// speak to a mail server.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Note {
pub id: String,
pub from: String,
#[serde(default)]
pub subject: String,
pub text: String,
pub received_ms: i64,
}
/// Writes a submitted answer into `bucket`, creating it on first use -
/// there's no fixed, pre-declared list of buckets, since content can
/// name any.
#[cfg(feature = "ssr")]
#[allow(clippy::too_many_arguments)]
pub async fn store_answer(
js: &async_nats::jetstream::Context,
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
bucket: &str,
id: String,
question_id: &str,
alternative: &str,
responses: &serde_json::Value,
submitted_ms: i64,
storyline: u32,
) -> anyhow::Result<()> {
let store = match js.get_key_value(bucket).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: bucket.to_string(),
..Default::default()
})
.await?
}
};
// A schema-backed bucket's projection must agree with its event
// log about where a record starts - organizations begin at
// "prospect", not "open". Only schemaless buckets default to open.
let initial_state = aggregates
.get(bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let answer = Answer {
id: id.clone(),
question_id: question_id.to_string(),
alternative: alternative.to_string(),
responses: responses.clone(),
state: initial_state,
submitted_ms,
storyline,
decided_ms: None,
notes: Vec::new(),
finished: false,
decided_by: None,
};
let data = serde_json::to_vec(&answer)?;
store.put(id.clone(), data.into()).await?;
// Also seed the event log for any bucket that has a declared state
// graph - best-effort, same "a hiccup here shouldn't fail a
// submission the NATS notification event has already recorded"
// policy as the KV write above.
if let Some(schema) = aggregates.get(bucket) {
if let Err(e) =
crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await
{
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
}
}
Ok(())
}
/// Whose a record is, on an alternative that keeps one per person
/// (`one_per_person`): the signed-in person's username, written by the
/// server into the record's fields, never taken from the form.
pub const OWNER_FIELD: &str = "by";
/// Every record in `bucket` that belongs to `username`, newest first.
#[cfg(feature = "ssr")]
pub async fn own_records(
js: &async_nats::jetstream::Context,
bucket: &str,
username: &str,
) -> Vec<Answer> {
use futures::TryStreamExt;
let Ok(store) = js.get_key_value(bucket).await else {
return Vec::new();
};
let keys: Vec<String> = match store.keys().await {
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
Err(_) => return Vec::new(),
};
let mut own = Vec::new();
for key in keys {
let Ok(Some(bytes)) = store.get(&key).await else {
continue;
};
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else {
continue;
};
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
own.push(answer);
}
}
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
own
}
/// A person keeps one card: of their records still in the bucket's
/// first state, only the newest stays in the list, and the rest leave
/// it (the event log keeps them). Run after every store, so two sends
/// racing each other - a double click - both settle on the same one.
#[cfg(feature = "ssr")]
pub async fn keep_newest_own(
js: &async_nats::jetstream::Context,
bucket: &str,
initial: &str,
username: &str,
) {
let own = own_records(js, bucket, username).await;
let Ok(store) = js.get_key_value(bucket).await else {
return;
};
for older in own.iter().filter(|a| a.state == initial).skip(1) {
if let Err(e) = store.delete(&older.id).await {
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
}
}
}
/// Fills the form of every `one_per_person` alternative with what the
/// signed-in person's own newest record says, so sending it again is
/// editing their card. A file field shows the file already there, at
/// the address a list would open it from, and may be left empty to
/// keep it.
#[cfg(feature = "ssr")]
pub async fn fill_own(
js: &async_nats::jetstream::Context,
question: &mut crate::content::Question,
user: Option<&crate::auth::User>,
) {
let Some(user) = user else { return };
for alt in &mut question.alternatives {
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else {
continue;
};
let Some(mine) = own_records(js, &bucket, &user.username)
.await
.into_iter()
.next()
else {
continue;
};
for req in alt
.features
.iter_mut()
.flat_map(|f| f.requirements.iter_mut())
{
if req.kind == "hidden" {
continue;
}
let Some(value) = mine
.responses
.get(&req.name)
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
else {
continue;
};
if req.kind == "file" {
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
req.optional = true;
} else {
req.value = Some(value.to_string());
}
}
}
}
/// One selected row's pending transition. `feature_name` is per-item
/// so a batch can span more than one resource feature on the same
/// alternative.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransitionItem {
pub feature_name: String,
pub item_id: String,
pub to: String,
}
/// Moves every selected row to its chosen target state in one call
/// (the shared per-alternative Confirm button). Items are independent
/// - per-item CAS, no cross-item transaction - so one failure doesn't
/// roll back the others; failures are collected and reported together.
#[server(endpoint = "transition_answers")]
pub async fn transition_answers(
question_id: String,
alternative: String,
items: Vec<TransitionItem>,
) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let storyline = crate::server::storyline::current().await;
let alt = state
.questions
.load()
.at(storyline)
.get(&question_id)
.and_then(|q| {
q.alternatives
.iter()
.find(|a| a.name == alternative)
.cloned()
})
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
let mut errors = Vec::new();
for item in items {
let item_id = item.item_id.clone();
if let Err(e) = apply_transition(&state, &question_id, &alt, &user, item).await {
errors.push(format!("{item_id}: {e}"));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(ServerFnError::new(errors.join("; ")))
}
}
/// `aggregates::transition`, with one recovery path: an aggregate the
/// event log has never heard of but whose KV projection exists (the
/// log was purged, or predates event-sourcing) gets a minimal history
/// reseeded from the projection's own current state, then the
/// transition retries. Keeps "zap the whole event stream" a safe
/// operation instead of one that strands every pre-wipe record.
#[cfg(feature = "ssr")]
pub(crate) async fn transition_or_reseed(
js: &async_nats::jetstream::Context,
schema: &crate::aggregates::AggregateSchema,
item_id: &str,
to: &str,
current_state: &str,
payload: serde_json::Value,
occurred_at_ms: i64,
) -> Result<(), crate::aggregates::TransitionError> {
match crate::aggregates::transition(js, schema, item_id, to, payload.clone(), occurred_at_ms)
.await
{
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
tracing::warn!(
bucket = %schema.bucket, item_id, current_state,
"no event history for a projected record - reseeding from the projection"
);
crate::aggregates::reseed(js, schema, item_id, current_state, occurred_at_ms).await?;
crate::aggregates::transition(js, schema, item_id, to, payload, occurred_at_ms)
.await
.map(|_| ())
}
r => r.map(|_| ()),
}
}
/// Same alternative-scoped feature lookup as `get_resource`, and for
/// the same reason - a feature name isn't unique across a whole
/// question, only within its own alternative.
#[cfg(feature = "ssr")]
async fn apply_transition(
state: &crate::server::AppState,
question_id: &str,
alt: &crate::content::Alternative,
user: &crate::auth::User,
item: TransitionItem,
) -> Result<(), ServerFnError> {
use crate::chain::hash_node;
use crate::events::{emit_answer_submitted, AnswerSubmitted};
let feature = alt
.features
.iter()
.find(|f| f.name == item.feature_name)
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
let bucket = resource
.bucket()
.ok_or_else(|| ServerFnError::new("resource is not kv-backed, has no transitions"))?;
let store = state
.jetstream
.get_key_value(bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
let bytes = store
.get(&item.item_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
let mut answer: Answer =
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
// Matched on (from, to), not to alone: two declared transitions may
// share a target (open -> declined, in_dialogue -> declined), and
// only the one starting from the row's actual state may fire.
let transition = resource
.transitions
.iter()
.find(|t| t.to == item.to && t.from == answer.state)
.ok_or_else(|| {
ServerFnError::new(format!(
"no transition to {:?} from this item's current state {:?}",
item.to, answer.state
))
})?
.clone();
// The policy holds exactly the buttons this desk offers to this
// group, keyed on (from, to); it is the one check.
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::from_user(Some(user)),
crate::access::Action::Transition,
&crate::access::Resource::Bucket {
name: bucket.to_string(),
},
serde_json::json!({ "from": answer.state, "to": item.to }),
);
if !decision.allow {
return Err(ServerFnError::new("not authorized"));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username });
// CAS on the aggregate's replayed sequence guards two concurrent
// decisions on the same item; a schemaless bucket falls back to the
// plain KV mutate below.
if let Some(schema) = state.aggregates.load().get(bucket) {
transition_or_reseed(
&state.jetstream,
schema,
&item.item_id,
&item.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
answer.state = item.to.clone();
answer.decided_ms = Some(decided_ms);
answer.decided_by = Some(user.username.clone());
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
store
.put(&item.item_id, data.into())
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
crate::mail::on_state(
state,
bucket,
&item.item_id,
&item.to,
&answer.responses,
answer.storyline,
)
.await;
crate::people::on_state(state, bucket, &item.item_id, &item.to, &answer.responses).await;
// An expired invite sent again: the move back is the asking.
if bucket == crate::people::INVITES_BUCKET && item.to == OPEN_STATE {
crate::people::on_reopened(state, &item.item_id).await;
}
// The decision extends the DAG as a child of the answer's own
// submission hash, published like any other answer.
let parent_hashes = vec![item.item_id.clone()];
let chain_hash = hash_node(
question_id,
&parent_hashes,
&decision_payload_for_event,
decided_ms,
);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id: question_id.to_string(),
alternative: transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
storyline: answer.storyline,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}
/// The self-service counterpart to `transition_answers`: no session,
/// no group check - authorized by holding `item_id` (an unguessable
/// chain hash) plus a matching `email`, both carried in the link
/// itself. Deliberately returns the same generic error for "no such
/// item" and "email doesn't match", so an unsubscribe link can't be
/// used to probe which hashes or emails exist.
#[server(endpoint = "self_transition_answer")]
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()))?;
// The link is the credential and the email its second factor; the
// policy compares them and knows the one move a submitter may make.
// Refusal reads "not found" so the endpoint cannot be used to probe
// which records or emails exist.
let stored_email = answer
.responses
.get("email")
.and_then(|v| v.as_str())
.unwrap_or_default();
let decision = crate::access::decide_and_publish(
&state.nats,
&state.access.load(),
&crate::access::Principal::Submitter {
record: item_id.clone(),
email: email.clone(),
},
crate::access::Action::SelfTransition,
&crate::access::Resource::Bucket {
name: self_transition.bucket.clone(),
},
serde_json::json!({ "from": answer.state, "to": self_transition.to, "stored_email": stored_email }),
);
if !decision.allow {
// Told apart by which half of the rule failed. If the record is
// somewhere this button may be fired from, the email was wrong,
// and the answer must not confirm that the record exists. If it
// is not, the case has moved past this move and saying so is no
// longer a way to learn anything about it.
let initial = state
.aggregates
.load()
.get(&self_transition.bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| OPEN_STATE.to_string());
let in_reach = self_transition
.from_states(&initial)
.iter()
.any(|s| s == &answer.state);
return Err(ServerFnError::new(if in_reach {
"not found"
} else {
"already processed"
}));
}
let decided_ms = chrono::Utc::now().timestamp_millis();
let decision_payload_for_event =
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
transition_or_reseed(
&state.jetstream,
schema,
&item_id,
&self_transition.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
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()))?;
crate::mail::on_state(
&state,
&self_transition.bucket,
&item_id,
&self_transition.to,
&answer.responses,
answer.storyline,
)
.await;
crate::people::on_state(
&state,
&self_transition.bucket,
&item_id,
&self_transition.to,
&answer.responses,
)
.await;
let parent_hashes = vec![item_id.clone()];
let chain_hash = hash_node(
&question_id,
&parent_hashes,
&decision_payload_for_event,
decided_ms,
);
let event = AnswerSubmitted {
chain_hash,
parent_hashes,
question_id,
alternative: self_transition.label,
responses: decision_payload_for_event,
timestamp_ms: decided_ms,
storyline: answer.storyline,
};
emit_answer_submitted(&state.nats, &event)
.await
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// `app.rs`'s list-rendering path only shows the nice
/// button-per-transition UI if a resource's raw JSON value
/// deserializes as `Vec<Answer>` - otherwise it silently falls
/// back to a plain read-only dump with no error. Guards that shape
/// contract directly, since nothing else would catch a drift here.
#[test]
fn answer_list_round_trips_through_the_shape_app_rs_expects() {
let answers = vec![
Answer {
id: "abc123".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Ada"}),
state: OPEN_STATE.to_string(),
submitted_ms: 1_700_000_000_000,
storyline: 0,
decided_ms: None,
decided_by: None,
notes: Vec::new(),
finished: false,
},
Answer {
id: "def456".to_string(),
question_id: "/review".to_string(),
alternative: "Applicants".to_string(),
responses: serde_json::json!({"name": "Grace"}),
state: "invited".to_string(),
submitted_ms: 1_700_000_001_000,
storyline: 0,
decided_ms: Some(1_700_000_002_000),
decided_by: Some("bl".to_string()),
notes: Vec::new(),
finished: false,
},
];
let value = serde_json::Value::Array(
answers
.iter()
.map(|a| serde_json::to_value(a).unwrap())
.collect(),
);
let round_tripped: Vec<Answer> =
serde_json::from_value(value).expect("Answer list round-trips");
assert_eq!(round_tripped.len(), 2);
assert_eq!(round_tripped[1].state, "invited");
assert_eq!(round_tripped[1].decided_by.as_deref(), Some("bl"));
}
}