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:
co-authored by
Claude Sonnet 5
parent
20b24f08c2
commit
6932816c42
+46
-95
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user