Lighten the comment load
Deploy / deploy (push) Successful in 1m1s

Drops porting-history narratives (dodrenett), superseded-behavior
explanations, and restatements of what the next line does. Constraint
notes (fail-closed policies, CAS semantics, cascade behavior, id
uniqueness) stay, just shorter. No code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 22:55:36 +02:00
co-authored by Claude Sonnet 5
parent 20b24f08c2
commit 6932816c42
8 changed files with 105 additions and 222 deletions
+46 -95
View File
@@ -71,25 +71,15 @@ pub struct Alternative {
pub images: Vec<String>,
#[serde(default)]
pub features: Vec<Feature>,
/// Names a NATS KV bucket to also durably store this submission
/// into, beyond the ephemeral NATS event every submission already
/// gets published as - just a bucket name, not a keyword the
/// runtime special-cases (see `src/answers.rs`). Content-driven
/// rather than matching on the alternative's name/copy, which would
/// silently break if the wording changes later.
/// NATS KV bucket to durably store this submission into - just a
/// bucket name, never a keyword the runtime special-cases.
#[serde(default)]
pub record_as: Option<String>,
/// A transition anyone holding the *right item's own reference* can
/// fire - not gated by `requires_group` like `ResourceSpec.transitions`,
/// which is for signed-in owners browsing a whole bucket. This is
/// the opposite shape: an anonymous visitor who already holds one
/// specific chain hash (from a `?chain=` link - see `chain.rs`,
/// the same reference `/subscribed?chain=...` already carries) may
/// transition *that one item*, and only that one, without ever
/// being able to enumerate or touch anyone else's. `email` is a
/// second factor checked against the stored item's own `email`
/// response field - not the lookup key, just cheap defense in depth
/// against a leaked/guessed chain hash alone being sufficient.
/// A transition fireable by anyone holding one specific item's own
/// reference (`?chain=` link) plus a matching `email` - the
/// anonymous, single-item counterpart to `ResourceSpec.transitions`'
/// group-gated bucket browsing. The email is a second factor
/// checked against the stored item, not the lookup key.
#[serde(default)]
pub self_transition: Option<SelfTransition>,
}
@@ -106,21 +96,14 @@ pub struct Feature {
pub name: String,
#[serde(default)]
pub description: String,
/// Any valid CSS color (hex, named, rgb()/oklch()/...) - set as this
/// feature's own `--feature-accent` custom property rather than
/// interpolated into a stylesheet, so a bad value just fails to
/// apply instead of being live CSS content could inject arbitrary
/// rules into. Unset means no accent border at all (see
/// style/main.css's `.feature`), not a silent fallback to the
/// global `--accent` - a feature that never asked for a color
/// shouldn't suddenly gain a visible border.
/// Any valid CSS color - set as the feature's `--feature-accent`
/// custom property (never interpolated into a stylesheet, so a bad
/// value fails to apply instead of injecting CSS). Unset means no
/// accent border at all.
#[serde(default)]
pub color: Option<String>,
/// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`),
/// rendered via Iconify's public SVG API
/// (`https://api.iconify.design/{icon}.svg`) - no icon library
/// bundled here, matching this app's zero-JS-dependency content
/// otherwise keeps to.
/// rendered via Iconify's public SVG API - no icon library bundled.
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
@@ -132,15 +115,11 @@ pub struct Feature {
pub resource: Option<ResourceSpec>,
}
/// Declares a NATS KV-backed live-data read. The bucket/key here are
/// only ever read server-side, from content the server itself loaded at
/// startup - `get_resource` (`src/resource.rs`) takes a question id +
/// feature name from the client, never a bucket/key directly, so a
/// visitor can never probe an arbitrary bucket by naming it. There is
/// no render-mode/"kind" tag here on purpose - what a resource displays
/// as follows from its data's own shape (a plain value, or a list of
/// `answers::Answer`s), not from a Rust-side special case per content
/// item.
/// A live-data read declared in content. The bucket/key are only ever
/// resolved server-side from trusted content - a client names a
/// question + feature, never a bucket directly, so a visitor can't
/// probe arbitrary buckets. No render-mode tag: what a resource
/// displays as follows from its data's shape.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResourceSpec {
pub source: ResourceSource,
@@ -152,60 +131,43 @@ pub struct ResourceSpec {
#[serde(default)]
pub requires_group: Option<String>,
/// Must be explicitly set for an anonymous-readable resource - a
/// spec with neither this nor `requires_group` is unreachable by
/// design (fail closed, not fail open). Reads only - mutations
/// (`transitions` below) always require `requires_group` regardless
/// of this flag.
/// spec with neither this nor `requires_group` is unreachable
/// (fail closed). Reads only: mutations always require
/// `requires_group` regardless of this flag.
#[serde(default)]
pub public: bool,
/// 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. Only
/// meaningful for a `Kv` source - a live external pull is always
/// read-only.
/// The moves a listed answer may make, one button each - empty
/// means read-only. Server calls are checked against this
/// allow-list, so a client can never fire a transition content
/// didn't declare. `Kv` sources 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.
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
/// fetched value before it reaches the frontend - e.g.
/// `.[] | {name, url: .html_url}`. `None` returns it 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.
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
/// this server owns) is the only mutable source; the rest are
/// read-only live pulls.
#[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.
/// Any other HTTPS JSON endpoint. Scheme-restricted and checked
/// against loopback/private/link-local addresses at fetch time
/// (`resource::fetch_url_resource`) - a server-side fetch of a
/// content-supplied URL is SSRF surface, so it fails closed.
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.
/// The KV bucket this resource reads/writes - `None` for a live
/// external pull.
pub fn bucket(&self) -> Option<&str> {
match &self.source {
ResourceSource::Kv { bucket } => Some(bucket),
@@ -218,10 +180,8 @@ impl ResourceSpec {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
/// The state a row must currently be in for this transition's
/// button to render and its server call to be accepted - defaults
/// to `"open"`, which is what every transition meant before graphs
/// grew deeper than one decision (invited -> active, etc.).
/// The state a row must be in for this transition's button to
/// render and its server call to be accepted. Defaults to "open".
#[serde(default = "default_transition_from")]
pub from: String,
pub to: String,
@@ -253,17 +213,12 @@ pub struct Requirement {
/// boundary - the upload handler re-checks content-type itself).
#[serde(default)]
pub accept: Option<String>,
/// `type: select` only - where the selectable options come from.
/// Reuses the exact same `ResourceSpec`/`ResourceSource`/`jq`
/// mechanism a `Feature.resource` uses (`resource::get_requirement_options`)
/// - a resource is a resource regardless of whether it's displayed
/// read-only or offered as choices to pick from.
/// `type: select` only - where the options come from; the same
/// `ResourceSpec` mechanism a `Feature.resource` uses.
#[serde(default)]
pub resource: Option<ResourceSpec>,
/// `type: select` only - which field in each resource item is that
/// option's stable identifier, submitted as the requirement's value
/// (or one entry of it, if `multiple`). Defaults to trying `_id`
/// then `id` if unset.
/// `type: select` only - which field in each item is the option's
/// stable id. Defaults to trying `_id` then `id`.
#[serde(default)]
pub id_field: Option<String>,
}
@@ -361,14 +316,10 @@ async fn fetch_gitea_file(
.map_err(|e| anyhow::anyhow!("reading {path}: {e}"))
}
/// Fetches and parses `aggregates.yaml` from a Gitea repo's root -
/// sibling to the pages `subdir`, not inside it, so
/// `load_questions_from_gitea`'s "every `*.yaml` under `subdir` is a
/// page" directory-listing convention needs no special-case exclusion.
/// Validation (well-formed states/transitions, no duplicate buckets or
/// event-type strings) happens inside `aggregates::parse_aggregates_yaml`
/// itself - a malformed file fails here, before ever reaching
/// `AppState`.
/// Fetches and parses `aggregates.yaml` from the repo root (sibling to
/// the pages `subdir`, so the "every yaml under subdir is a page"
/// convention needs no exclusion). A malformed file fails here, before
/// ever reaching `AppState`.
#[cfg(feature = "ssr")]
pub async fn load_aggregates_from_gitea(
repo_url: &str,