Event-sourced applicant/subscriber/project aggregates, generalized resources
Deploy / deploy (push) Failing after 3s
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:
co-authored by
Claude Sonnet 5
parent
213b1130bb
commit
ebf4bf91b3
+151
-3
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user