Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s

Replaces the free-string, direct-KV-mutate state model in answers.rs
with a proper event log (events/store.rs, JetStream-backed, CAS via
expected_last_subject_sequence) and three pure state machines
(aggregates/{applicant,subscriber,project}.rs). Closes a real
lost-update race in the old transition_answer (concurrent decisions on
the same item could both win, publishing contradictory events). KV
buckets become best-effort read-model projections, not the source of
truth. Content-declared transition targets are now validated at
load/reload time against the real compiled transition tables, not
accepted as arbitrary strings.

Buckets renamed to describe their content, not their relation to the
app (portal_applicants -> applicants, etc); "inquiry" folded into a
richer "project" concept.

ResourceSpec generalized beyond a single KV bucket: Kv | GiteaStarred |
GiteaOrgRepos | Url sources, with an optional jq filter (via the jaq
crate) to shape live data for the frontend. Url source is SSRF-guarded
(https-only, rejects loopback/private/link-local, real DNS resolve).

New headless question_lint binary (validates content against compiled
transition tables with no NATS/OIDC/server involved) and a one-time
backfill_events binary (dry-run by default) for migrating existing KV
data onto the new event log.

Questions get an optional `responsible` contact plus a lightweight
"report this question" action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 08:52:29 +02:00
co-authored by Claude Sonnet 5
parent 213b1130bb
commit ebf4bf91b3
16 changed files with 1811 additions and 67 deletions
+151 -3
View File
@@ -21,6 +21,20 @@ pub struct Question {
pub qualifies: Option<String>,
#[serde(default)]
pub alternatives: Vec<Alternative>,
/// Who to contact if a visitor gets stuck or finds this question
/// unhelpful - rendered as a small line on the page, and the person
/// a "report this question" action names in its own event payload
/// (see `events::store`, `question_report` aggregate-less events).
#[serde(default)]
pub responsible: Option<Responsible>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Responsible {
pub name: String,
/// A mailto address or plain email - deliberately just a string,
/// same as everywhere else content keeps contact info simple.
pub contact: String,
}
/// Whether `user` may view/submit `question`. `true` when the question
@@ -105,8 +119,9 @@ pub struct Feature {
/// item.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResourceSpec {
pub bucket: String,
/// A single item; omit to list the whole bucket.
pub source: ResourceSource,
/// A single item; omit to list the whole bucket. Only meaningful
/// for a `Kv` source.
#[serde(default)]
pub key: Option<String>,
/// Kanidm group required to read this resource.
@@ -122,9 +137,59 @@ pub struct ResourceSpec {
/// States a listed answer may move to, and the button label for
/// each - empty means the resource is read-only. The allow-list a
/// `transition_answer` call is checked against, so a client can
/// never move an answer to a state content didn't declare.
/// never move an answer to a state content didn't declare. Only
/// meaningful for a `Kv` source - a live external pull is always
/// read-only.
#[serde(default)]
pub transitions: Vec<Transition>,
/// A jq filter reshaping the fetched value before it reaches the
/// frontend - e.g. `.[] | {name, url: .html_url}` to pick just the
/// fields a showcase card needs out of a raw Gitea API response.
/// Evaluated via the `jaq` crate (`resource::apply_jq`), no shell
/// -out. `None` (or a `Kv` source) returns the fetched value as-is.
#[serde(default)]
pub jq: Option<String>,
}
/// Where a resource's live data actually comes from. `Kv` is the
/// original (and still only mutable) behavior - a NATS KV bucket this
/// server itself owns. The `Gitea*` variants are live pulls from the
/// same Gitea instance content is loaded from (`AppState.gitea_base`),
/// for surfacing things like a portfolio of starred repos without
/// hand-curating a KV bucket for it. Not mutually exclusive with
/// owner-curated resources - a hand-picked feature list is just a `Kv`
/// resource an owner writes into; a `GiteaStarred`/`GiteaOrgRepos` pull
/// is the automatic catalog to choose from. Both can appear as
/// different features on the same page.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResourceSource {
Kv { bucket: String },
GiteaStarred { username: String },
GiteaOrgRepos { org: String },
/// Any other HTTPS JSON endpoint - the general escape hatch for a
/// live resource that isn't this Gitea instance. Deliberately
/// scheme-restricted and checked against loopback/private/link
/// -local addresses at fetch time (`resource::fetch_url_resource`)
/// - content is trusted today (same org owns both repos), but a
/// server-side fetch of a content-supplied URL is real SSRF surface
/// the moment that stops being true, so it fails closed rather than
/// trusting every host by default.
Url { url: String },
}
impl ResourceSpec {
/// The KV bucket this resource reads/writes, if it's `Kv`-sourced -
/// `None` for a live external pull, which has no bucket and (see
/// `transitions` above) can't be mutated through this mechanism.
pub fn bucket(&self) -> Option<&str> {
match &self.source {
ResourceSource::Kv { bucket } => Some(bucket),
ResourceSource::GiteaStarred { .. }
| ResourceSource::GiteaOrgRepos { .. }
| ResourceSource::Url { .. } => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -251,6 +316,85 @@ pub async fn load_questions_from_gitea(
Ok(out)
}
/// Maps a content-declared KV bucket name to the aggregate type
/// (`crate::aggregates::*::State::AGGREGATE_TYPE`) it's event-sourced
/// under - the fixed, three-entry table for this pass (see the
/// event-sourcing plan). A bucket name not listed here is left alone
/// entirely (no validation applied) - deliberate: only buckets actually
/// migrated onto the generic aggregate machinery get their transitions
/// checked.
#[cfg(feature = "ssr")]
pub fn aggregate_type_for_bucket(bucket: &str) -> Option<&'static str> {
match bucket {
"applicants" => Some("applicant"),
"subscribers" => Some("subscriber"),
"projects" => Some("project"),
_ => None,
}
}
#[cfg(feature = "ssr")]
fn is_valid_transition_target(aggregate_type: &str, to: &str) -> bool {
use crate::aggregates::AggregateKind;
fn check<K: AggregateKind>(to: &str) -> bool {
K::INITIAL_STATE.allowed().iter().any(|s| s.event_type() == to)
}
match aggregate_type {
"applicant" => check::<crate::aggregates::applicant::State>(to),
"subscriber" => check::<crate::aggregates::subscriber::State>(to),
"project" => check::<crate::aggregates::project::State>(to),
_ => false,
}
}
/// Validates every declared transition target (`SelfTransition.to`,
/// `ResourceSpec.transitions[].to`) against the real compiled
/// transition table for its bucket's aggregate type, for every bucket
/// this pass actually migrated (see `aggregate_type_for_bucket`).
/// Called on every content load/reload (`watch_for_reload`, `main.rs`'s
/// boot path) and by the standalone `question-lint` binary, so a YAML
/// typo becomes a caught, logged rejection instead of a silently
/// -accepted, later-broken string.
#[cfg(feature = "ssr")]
pub fn validate_questions(
questions: &std::collections::HashMap<String, Question>,
) -> anyhow::Result<()> {
for question in questions.values() {
for alternative in &question.alternatives {
if let Some(st) = &alternative.self_transition {
if let Some(agg) = aggregate_type_for_bucket(&st.bucket) {
if !is_valid_transition_target(agg, &st.to) {
anyhow::bail!(
"question {:?} alternative {:?}: self_transition.to {:?} is not a valid transition for bucket {:?} ({agg})",
question.id, alternative.name, st.to, st.bucket
);
}
}
}
for feature in &alternative.features {
let Some(resource) = &feature.resource else {
continue;
};
let Some(bucket) = resource.bucket() else {
continue;
};
let Some(agg) = aggregate_type_for_bucket(bucket) else {
continue;
};
for transition in &resource.transitions {
if !is_valid_transition_target(agg, &transition.to) {
anyhow::bail!(
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a valid transition for bucket {:?} ({agg})",
question.id, alternative.name, feature.name, transition.to, bucket
);
}
}
}
}
}
Ok(())
}
/// Published by the content repo's own CI (after it lints a push) to
/// tell every running instance to pick up the change - a plain fire
/// and forget NATS publish, no payload, matching `events.rs`'s
@@ -283,6 +427,10 @@ pub async fn watch_for_reload(
while sub.next().await.is_some() {
match load_questions_from_gitea(&repo_url, &branch, &subdir).await {
Ok(loaded) => {
if let Err(e) = validate_questions(&loaded) {
tracing::error!(error = %e, "content reload failed validation, keeping last-good content");
continue;
}
let count = loaded.len();
questions.store(std::sync::Arc::new(loaded));
tracing::info!(count, "reloaded content");