2026-07-29 19:38:40 +02:00
use serde ::{ Deserialize , Serialize };
2026-08-24 22:30:28 +02:00
/// One page: a prompt plus the paths on from it. The `questions/`
/// directory tree IS the URL tree (`index.yaml` names its directory,
/// `develop/proposal.yaml` serves `/develop/proposal`), so `id` is
/// derived from the file's path - declaring it explicitly still works
/// (and wins, with a logged warning when it disagrees) but is only
/// needed by legacy content. Loaded from plain YAML files in a content
/// directory kept in its own git repo - editing content is a
/// content-repo commit, not a Rust rebuild.
2026-07-29 19:38:40 +02:00
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Question {
2026-08-24 22:30:28 +02:00
/// The URL path this page is served at. Derived from the file path
/// when absent. A `[name]` segment (from a `[name].yaml` file)
/// makes this a dynamic page: `/review/[record]` serves any
/// `/review/<value>`, with the value substituted into `{record}`
/// placeholders in the page's resource keys (see
/// `resolve_question`).
2026-07-29 19:38:40 +02:00
#[serde(default)]
2026-08-24 22:30:28 +02:00
pub id : String ,
2026-07-29 19:38:40 +02:00
pub name : String ,
#[serde(default)]
pub description : String ,
/// Kanidm group required to view/submit this question - `None` means
2026-08-24 22:30:28 +02:00
/// open to anyone. Content-driven on purpose: a gated page like
/// "/review" is just a Question with this set, not a bespoke Rust
/// route. Inherited from the nearest ancestor `_section.yaml` when
/// not set on the question itself.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub qualifies : Option < String > ,
2026-08-24 22:30:28 +02:00
/// Question id (relative refs resolve against this file's
/// directory) the visitor must have answered - their `?chain=`
/// lineage's tip - to see this page. The provenance counterpart to
/// `qualifies`' identity check. Inherited from `_section.yaml`
/// like `qualifies`.
#[serde(default)]
pub requires_chain : Option < String > ,
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub alternatives : Vec < Alternative > ,
2026-08-12 20:48:27 +02:00
/// Who to contact if a visitor gets stuck - rendered as a small line
2026-08-24 22:30:28 +02:00
/// on the page. Inherited from `_section.yaml` when not set.
2026-08-06 08:52:29 +02:00
#[serde(default)]
pub responsible : Option < Responsible > ,
2026-08-12 23:25:06 +02:00
/// A page that only makes sense after answering something (the
/// post-submission pages) - kept out of the question nav unless the
2026-08-24 22:30:28 +02:00
/// visitor's context carries an answer chain. Unset means inferred
/// from the file's place in the tree: files nested in a
/// subdirectory are followups unless they're the directory's
/// `index.yaml`; top-level files keep the historical default
/// (not a followup).
2026-08-12 23:25:06 +02:00
#[serde(default)]
2026-08-24 22:30:28 +02:00
pub followup : Option < bool > ,
2026-08-30 12:34:34 +02:00
/// This page announces something with a time window. While the
/// window is open the page is announced in the header (see
/// `app::Announcements`) instead of listed in the footer nav; once
/// it closes the page behaves like a `followup` (visible only to
/// visitors carrying an answer chain) and a "post what happened"
/// task lands in the `portal_events` desk (see `announce.rs`).
#[serde(default)]
pub event : Option < EventConfig > ,
}
/// A page's announcement window. `starts` is RFC 3339 with an offset
/// (`2026-09-12T18:00:00+02:00`); `duration` is a plain span like
/// `3h`, `90m`, `2d`, `1d 6h`. Optional `place` is shown verbatim.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EventConfig {
pub starts : String ,
pub duration : String ,
#[serde(default)]
pub place : Option < String > ,
}
/// `3h`, `90m`, `2d`, `1d 6h 30m` -> milliseconds. Whole units only;
/// the smallest is a minute.
pub fn parse_duration_ms ( text : & str ) -> Result < i64 , String > {
let mut total : i64 = 0 ;
let mut seen = false ;
for token in text . split_whitespace () {
let ( num , unit ) = token . split_at ( token . trim_end_matches ( | c : char | c . is_ascii_alphabetic ()). len ());
let n : i64 = num . parse (). map_err ( | _ | format! ( "bad duration {text:?} : {token:?} " )) ? ;
let per = match unit {
"m" => 60_000 ,
"h" => 3_600_000 ,
"d" => 86_400_000 ,
_ => return Err ( format! ( "bad duration {text:?} : unit {unit:?} (use m, h, d)" )),
};
total += n * per ;
seen = true ;
}
if ! seen || total <= 0 {
return Err ( format! ( "bad duration {text:?} : must be positive" ));
}
Ok ( total )
}
#[cfg(feature = "ssr" )]
impl EventConfig {
/// `(starts_ms, ends_ms)` as Unix milliseconds.
pub fn window_ms ( & self ) -> Result < ( i64 , i64 ), String > {
let starts = chrono ::DateTime ::parse_from_rfc3339 ( & self . starts )
. map_err ( | e | format! ( "bad starts {:?} : {e} (RFC 3339 with offset)" , self . starts )) ?
. timestamp_millis ();
Ok (( starts , starts + parse_duration_ms ( & self . duration ) ? ))
}
2026-08-24 22:30:28 +02:00
}
impl Question {
pub fn is_followup ( & self ) -> bool {
self . followup . unwrap_or ( false )
}
2026-08-30 12:34:34 +02:00
/// The announcement window is still open at `now_ms` (Unix ms).
#[cfg(feature = "ssr" )]
pub fn event_live_at ( & self , now_ms : i64 ) -> bool {
self . event
. as_ref ()
. and_then ( | e | e . window_ms (). ok ())
. is_some_and ( | ( _ , ends ) | now_ms < ends )
}
/// The announcement window has closed at `now_ms`.
#[cfg(feature = "ssr" )]
pub fn event_ended_at ( & self , now_ms : i64 ) -> bool {
self . event
. as_ref ()
. and_then ( | e | e . window_ms (). ok ())
. is_some_and ( | ( _ , ends ) | now_ms >= ends )
}
2026-08-24 22:30:28 +02:00
/// A dynamic page - one whose id still contains a `[name]`
/// segment. Served per-value via `resolve_question`, never listed
/// in nav, never a valid `action` target.
pub fn is_dynamic ( & self ) -> bool {
self . id . contains ( '[' )
}
}
/// Directory-scoped defaults: a `_section.yaml` file applies to every
/// question at or below its directory (nearest ancestor wins per
/// field, and a question's own declaration always overrides). This is
/// what makes a URL prefix a trust boundary - "everything under
/// /review is owner-only" is one line in `review/_section.yaml`
/// instead of a flag per file. Underscore-prefixed files that aren't
/// `_section.yaml` are skipped entirely (drafts).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SectionConfig {
#[serde(default)]
pub qualifies : Option < String > ,
#[serde(default)]
pub requires_chain : Option < String > ,
#[serde(default)]
pub responsible : Option < Responsible > ,
2026-08-06 08:52:29 +02:00
}
#[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 ,
2026-07-29 19:38:40 +02:00
}
/// Whether `user` may view/submit `question`. `true` when the question
/// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room`
/// in cnats - same synchronous, I/O-free shape, same staleness tradeoff
/// (group membership is fixed at login, not re-checked live).
pub fn is_qualified ( user : Option <& crate ::auth ::User > , question : & Question ) -> bool {
match & question . qualifies {
None => true ,
Some ( group ) => user . is_some_and ( | u | u . groups . iter (). any ( | g | g == group )),
}
}
/// One path through a question: a short pitch, an optional next question
/// to advance to on submit, and the form (via `features`) that collects
/// what's needed to get there.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Alternative {
pub name : String ,
#[serde(default)]
pub description : String ,
#[serde(default)]
pub action : Option < String > ,
2026-08-25 20:25:49 +02:00
/// Shown but not takeable yet - the button renders disabled and the
/// server refuses the submission. For advertising a path before it
/// works (lysbue had the same flag).
#[serde(default)]
pub disabled : bool ,
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub consequence : Vec < String > ,
#[serde(default)]
pub encouragements : Vec < String > ,
2026-08-12 22:11:07 +02:00
/// Banner image urls, rendered above the description - one renders
/// as a plain image, several as a swipeable card deck (Swiper
/// Element, vendored in `public/`). Purely decorative, no
/// upload/hosting mechanism of their own, just already-hosted urls
/// the browser fetches directly (unlike `ResourceSource::Url`,
/// never fetched server-side, so none of that variant's SSRF
/// concern).
2026-08-12 17:34:07 +02:00
#[serde(default)]
2026-08-12 22:11:07 +02:00
pub images : Vec < String > ,
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub features : Vec < Feature > ,
2026-08-12 22:55:36 +02:00
/// NATS KV bucket to durably store this submission into - just a
/// bucket name, never a keyword the runtime special-cases.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub record_as : Option < String > ,
2026-08-12 22:55:36 +02:00
/// 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.
2026-08-05 18:44:28 +02:00
#[serde(default)]
pub self_transition : Option < SelfTransition > ,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SelfTransition {
pub bucket : String ,
pub to : String ,
pub label : String ,
2026-07-29 19:38:40 +02:00
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Feature {
pub name : String ,
#[serde(default)]
pub description : String ,
2026-08-12 22:55:36 +02:00
/// 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.
2026-08-12 17:34:07 +02:00
#[serde(default)]
pub color : Option < String > ,
/// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`),
2026-08-12 22:55:36 +02:00
/// rendered via Iconify's public SVG API - no icon library bundled.
2026-08-12 17:34:07 +02:00
#[serde(default)]
pub icon : Option < String > ,
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub requirements : Vec < Requirement > ,
/// Live data this feature pulls in. Read-only unless `transitions`
/// is non-empty, in which case listed answers get one action button
/// per transition (see `src/resource.rs`, `src/answers.rs`).
#[serde(default)]
pub resource : Option < ResourceSpec > ,
}
2026-08-12 22:55:36 +02:00
/// 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.
2026-07-29 19:38:40 +02:00
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ResourceSpec {
2026-08-06 08:52:29 +02:00
pub source : ResourceSource ,
/// A single item; omit to list the whole bucket. Only meaningful
/// for a `Kv` source.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub key : Option < String > ,
/// Kanidm group required to read this resource.
#[serde(default)]
pub requires_group : Option < String > ,
/// Must be explicitly set for an anonymous-readable resource - a
2026-08-12 22:55:36 +02:00
/// spec with neither this nor `requires_group` is unreachable
/// (fail closed). Reads only: mutations always require
/// `requires_group` regardless of this flag.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub public : bool ,
2026-08-12 22:55:36 +02:00
/// 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.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub transitions : Vec < Transition > ,
2026-08-12 22:55:36 +02:00
/// 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.
2026-08-06 08:52:29 +02:00
#[serde(default)]
pub jq : Option < String > ,
2026-08-25 20:04:35 +02:00
/// What to say when the resource yields nothing - an empty list,
/// or `null` (a `.[] | ...` jq over an empty list produces no
/// output at all). Defaults to "Nothing here yet."; content sets
/// it where the silence needs a voice ("Nothing released yet").
#[serde(default)]
pub empty : Option < String > ,
2026-08-06 08:52:29 +02:00
}
2026-08-12 22:55:36 +02:00
/// 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.
2026-08-06 08:52:29 +02:00
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind" , rename_all = "snake_case" )]
pub enum ResourceSource {
Kv { bucket : String },
GiteaStarred { username : String },
GiteaOrgRepos { org : String },
2026-08-23 13:03:29 +02:00
/// A repo's releases - fetched with `GITEA_API_TOKEN`, so it works
/// for private repos too (shape the link with jq: a private
/// release's html_url 404s for anonymous visitors, so map it to
/// null unless the repo is public).
GiteaReleases { owner : String , repo : String },
2026-08-12 22:55:36 +02:00
/// 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.
2026-08-06 08:52:29 +02:00
Url { url : String },
}
impl ResourceSpec {
2026-08-12 22:55:36 +02:00
/// The KV bucket this resource reads/writes - `None` for a live
/// external pull.
2026-08-06 08:52:29 +02:00
pub fn bucket ( & self ) -> Option <& str > {
match & self . source {
ResourceSource ::Kv { bucket } => Some ( bucket ),
ResourceSource ::GiteaStarred { .. }
| ResourceSource ::GiteaOrgRepos { .. }
2026-08-23 13:03:29 +02:00
| ResourceSource ::GiteaReleases { .. }
2026-08-06 08:52:29 +02:00
| ResourceSource ::Url { .. } => None ,
}
}
2026-07-29 19:38:40 +02:00
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
2026-08-12 22:55:36 +02:00
/// The state a row must be in for this transition's button to
/// render and its server call to be accepted. Defaults to "open".
2026-08-12 22:03:43 +02:00
#[serde(default = "default_transition_from" )]
pub from : String ,
2026-07-29 19:38:40 +02:00
pub to : String ,
pub label : String ,
}
2026-08-12 22:03:43 +02:00
fn default_transition_from () -> String {
crate ::answers ::OPEN_STATE . to_string ()
}
2026-07-29 19:38:40 +02:00
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Requirement {
pub name : String ,
#[serde(default)]
pub label : Option < String > ,
#[serde(default)]
pub placeholder : Option < String > ,
#[serde(default = "default_requirement_type" , rename = "type" )]
pub kind : String ,
#[serde(default)]
pub optional : bool ,
2026-08-06 11:57:15 +02:00
/// `type: file` - accept multiple files. `type: select` - pick more
/// than one option (checkbox-style toggle) instead of exactly one
/// (radio-style); the submitted value is a JSON array of ids
/// instead of a single id string.
2026-07-29 19:38:40 +02:00
#[serde(default)]
pub multiple : bool ,
/// `type: file` only - HTML `accept` hint (UX only, not a security
/// boundary - the upload handler re-checks content-type itself).
#[serde(default)]
pub accept : Option < String > ,
2026-08-12 22:55:36 +02:00
/// `type: select` only - where the options come from; the same
/// `ResourceSpec` mechanism a `Feature.resource` uses.
2026-08-06 11:57:15 +02:00
#[serde(default)]
pub resource : Option < ResourceSpec > ,
2026-08-12 22:55:36 +02:00
/// `type: select` only - which field in each item is the option's
/// stable id. Defaults to trying `_id` then `id`.
2026-08-06 11:57:15 +02:00
#[serde(default)]
pub id_field : Option < String > ,
2026-08-13 13:49:42 +02:00
/// Load this field's value from a resource whenever a sibling
/// field changes - e.g. a file select populating a textarea with
/// the selected file's current content.
#[serde(default)]
pub bind : Option < Bind > ,
2026-08-23 13:03:29 +02:00
/// `type: gesture` only - ws(s):// URL of a redoal-relay instance
/// the drawing widget connects to for live echoes of similar
/// strokes. Absent means the widget works offline: the drawn path
/// still submits, it just never gets a network-computed key or
/// echoes.
#[serde(default)]
pub relay : Option < String > ,
2026-08-13 13:49:42 +02:00
}
/// A field's live data source, parameterized by a sibling field's
/// value. When the sibling changes, `resource` is fetched with the
/// value as a parameter and the result replaces this field's value
/// (an empty sibling never fetches, and never clears an edit).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bind {
/// The sibling requirement (same alternative) to watch.
pub field : String ,
/// The parameter name the sibling's value is sent as - `{name}`
/// templates into a Url source's path, otherwise it's appended as
/// a query pair. Defaults to `field`.
#[serde(default)]
pub param : Option < String > ,
pub resource : ResourceSpec ,
}
impl Bind {
pub fn param_name ( & self ) -> & str {
self . param . as_deref (). unwrap_or ( & self . field )
}
2026-07-29 19:38:40 +02:00
}
fn default_requirement_type () -> String {
"text" . to_string ()
}
impl Requirement {
pub fn display_label ( & self ) -> String {
self . label . clone (). unwrap_or_else ( || self . name . clone ())
}
}
2026-08-23 13:03:29 +02:00
/// Site-wide branding declared by the content repo - `site.yaml` at
/// the repo root, sibling of `aggregates.yaml`. An absent file means
/// all defaults, which is exactly the historical uhhm look: existing
/// deployments change nothing without a content edit. Everything the
/// browser needs, so it travels through a server fn (`get_site`).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct SiteConfig {
/// Browser-tab title and wordmark alt text. `None` falls back to
/// the compile-time `SITE_NAME`.
#[serde(default)]
pub title : Option < String > ,
/// Wordmark image URL (absolute or a path this instance serves).
/// `None` falls back to `/wordmark.svg`.
#[serde(default)]
pub wordmark : Option < String > ,
#[serde(default)]
pub hero : HeroConfig ,
}
2026-08-25 17:26:29 +02:00
/// What the landing page's hero is. `plain` is just the copy; `module`
/// hands the hero to a JavaScript module the content repo itself
/// ships (`module: hero.js`, a repo-relative path portal serves
/// same-origin at `/site/<path>`, since Gitea's raw endpoint sends no
/// CORS headers). The module exports `mount(container) -> handle` and
/// the handle has `stop()`; portal starts it at HTML parse time and
/// adopts it on hydration. The piece's look - canvases, SVG, CSS - is
/// entirely the module's: portal only reserves the box.
2026-08-23 13:03:29 +02:00
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct HeroConfig {
#[serde(default = "default_hero_kind" )]
pub kind : String ,
#[serde(default)]
2026-08-25 17:26:29 +02:00
pub module : Option < String > ,
2026-08-23 13:03:29 +02:00
}
impl Default for HeroConfig {
fn default () -> Self {
2026-08-25 17:26:29 +02:00
Self { kind : default_hero_kind (), module : None }
2026-08-23 13:03:29 +02:00
}
}
fn default_hero_kind () -> String {
2026-08-25 17:26:29 +02:00
"plain" . to_string ()
2026-08-23 13:03:29 +02:00
}
#[cfg(feature = "ssr" )]
impl SiteConfig {
2026-08-25 17:26:29 +02:00
/// Same policy as `validate_questions`: content declares, the
/// runtime refuses what it doesn't understand at load time, so a
/// typo'd hero kind is a caught rejection, not a silently plain
/// hero.
2026-08-23 13:03:29 +02:00
pub fn validate ( & self ) -> anyhow ::Result < () > {
2026-08-25 17:26:29 +02:00
match self . hero . kind . as_str () {
"plain" => {
if self . hero . module . is_some () {
anyhow ::bail! ( "site.yaml: hero.module only makes sense with hero.kind: module" );
}
2026-08-23 13:03:29 +02:00
}
2026-08-25 17:26:29 +02:00
"module" => {
let Some ( module ) = & self . hero . module else {
anyhow ::bail! ( "site.yaml: hero.kind: module needs hero.module (a repo-relative path like hero.js)" );
};
if ! is_safe_site_path ( module ) {
anyhow ::bail! (
"site.yaml: hero.module {module:?} must be a plain repo-relative path (no scheme, no .., no leading /)"
);
}
}
other => anyhow ::bail! ( "site.yaml: hero.kind {other:?} is not one of plain | module" ),
2026-08-23 13:03:29 +02:00
}
Ok (())
}
}
2026-08-25 17:26:29 +02:00
/// A repo-relative asset path portal is willing to proxy from the
/// content repo: plain segments only - no traversal, no leading slash,
/// no scheme - so `/site/<path>` can only ever reach the content repo.
pub fn is_safe_site_path ( path : & str ) -> bool {
! path . is_empty ()
&& ! path . starts_with ( '/' )
&& path . split ( '/' ). all ( | seg | {
! seg . is_empty ()
&& seg != ".."
&& seg
. chars ()
. all ( | c | c . is_ascii_alphanumeric () || c == '-' || c == '_' || c == '.' )
})
}
2026-08-23 13:03:29 +02:00
/// A relay must be a ws:// or wss:// URL - shared between site.yaml's
/// hero and `Requirement.relay` validation.
#[cfg(feature = "ssr" )]
pub fn validate_relay_url ( relay : & str ) -> anyhow ::Result < () > {
let parsed = url ::Url ::parse ( relay )
. map_err ( | e | anyhow ::anyhow! ( "relay {relay:?} is not a valid URL: {e}" )) ? ;
if ! matches! ( parsed . scheme (), "ws" | "wss" ) {
anyhow ::bail! ( "relay {relay:?} must use the ws:// or wss:// scheme" );
}
Ok (())
}
/// Fetches and parses `site.yaml` from the content repo root. A fetch
/// failure (typically 404 - the file is optional) yields the default
/// config; a file that exists but doesn't parse or validate is a real
/// error, surfaced at boot rather than papered over.
#[cfg(feature = "ssr" )]
pub async fn load_site_from_gitea ( repo_url : & str , branch : & str ) -> anyhow ::Result < SiteConfig > {
let ( owner , repo ) = parse_owner_repo ( repo_url ) ? ;
let api_base = gitea_api_base ( repo_url ) ? ;
let client = openidconnect ::reqwest ::Client ::new ();
let raw = match fetch_gitea_file ( & client , & api_base , & owner , & repo , branch , "site.yaml" ). await {
Ok ( raw ) => raw ,
Err ( e ) => {
tracing ::info! ( "no site.yaml in content repo ({e}), using default branding" );
return Ok ( SiteConfig ::default ());
}
};
let site : SiteConfig =
serde_yaml ::from_str ( & raw ). map_err ( | e | anyhow ::anyhow! ( "parsing site.yaml: {e}" )) ? ;
site . validate () ? ;
Ok ( site )
}
2026-08-12 23:05:23 +02:00
/// Extracts `scheme://host` from a repo's normal browser URL - the
/// Gitea API base every helper in this module builds requests against.
2026-08-25 17:26:29 +02:00
#[cfg(feature = "ssr" )]
/// `{gitea}/api/v1/repos/{owner}/{repo}/raw` - the base
/// `site_asset_handler` proxies content-shipped files from.
#[cfg(feature = "ssr" )]
pub fn gitea_raw_base ( repo_url : & str ) -> anyhow ::Result < String > {
let ( owner , repo ) = parse_owner_repo ( repo_url ) ? ;
Ok ( format! ( " {} /api/v1/repos/ {owner} / {repo} /raw" , gitea_api_base ( repo_url ) ? ))
}
2026-08-05 13:45:56 +02:00
#[cfg(feature = "ssr" )]
pub fn gitea_api_base ( repo_url : & str ) -> anyhow ::Result < String > {
let parsed = url ::Url ::parse ( repo_url )
. map_err ( | e | anyhow ::anyhow! ( "parsing repo url {repo_url}: {e}" )) ? ;
Ok ( format! (
" {} :// {} " ,
parsed . scheme (),
parsed
. host_str ()
. ok_or_else ( || anyhow ::anyhow! ( "no host in repo url {repo_url}" )) ?
))
}
2026-08-11 21:43:32 +02:00
/// Splits a repo's normal browser URL into `(owner, repo)` - shared by
/// every loader in this module that needs to build a Gitea contents API
/// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`).
#[cfg(feature = "ssr" )]
fn parse_owner_repo ( repo_url : & str ) -> anyhow ::Result < ( String , String ) > {
let parsed = url ::Url ::parse ( repo_url )
. map_err ( | e | anyhow ::anyhow! ( "parsing content repo url {repo_url}: {e}" )) ? ;
let mut segments = parsed
. path_segments ()
. ok_or_else ( || anyhow ::anyhow! ( "no path in content repo url {repo_url}" )) ? ;
let owner = segments
. next ()
. filter ( | s | ! s . is_empty ())
. ok_or_else ( || anyhow ::anyhow! ( "missing owner in content repo url {repo_url}" )) ?
. to_string ();
let repo = segments
. next ()
. filter ( | s | ! s . is_empty ())
. ok_or_else ( || anyhow ::anyhow! ( "missing repo name in content repo url {repo_url}" )) ?
. to_string ();
Ok (( owner , repo ))
}
/// Fetches one file from a Gitea repo's contents API and returns its raw
/// text - the single-file counterpart to `load_questions_from_gitea`'s
/// directory-listing loop, used by `load_aggregates_from_gitea` for the
/// one `aggregates.yaml` file at the repo root.
#[cfg(feature = "ssr" )]
async fn fetch_gitea_file (
client : & openidconnect ::reqwest ::Client ,
api_base : & str ,
owner : & str ,
repo : & str ,
branch : & str ,
path : & str ,
) -> anyhow ::Result < String > {
let meta_url = format! ( " {api_base} /api/v1/repos/ {owner} / {repo} /contents/ {path} ?ref= {branch} " );
let meta_text = client
. get ( & meta_url )
. send ()
. await
. map_err ( | e | anyhow ::anyhow! ( "fetching {meta_url}: {e}" )) ?
. error_for_status ()
. map_err ( | e | anyhow ::anyhow! ( "fetching {meta_url}: {e}" )) ?
. text ()
. await
. map_err ( | e | anyhow ::anyhow! ( "reading contents response from {meta_url}: {e}" )) ? ;
let meta : serde_json ::Value = serde_json ::from_str ( & meta_text )
. map_err ( | e | anyhow ::anyhow! ( "parsing contents response from {meta_url}: {e}" )) ? ;
let download_url = meta
. get ( "download_url" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "no download_url for {path}" )) ? ;
client
. get ( download_url )
. send ()
. await
. map_err ( | e | anyhow ::anyhow! ( "fetching {path}: {e}" )) ?
. error_for_status ()
. map_err ( | e | anyhow ::anyhow! ( "fetching {path}: {e}" )) ?
. text ()
. await
. map_err ( | e | anyhow ::anyhow! ( "reading {path}: {e}" ))
}
2026-08-12 22:55:36 +02:00
/// 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`.
2026-08-11 21:43:32 +02:00
#[cfg(feature = "ssr" )]
pub async fn load_aggregates_from_gitea (
repo_url : & str ,
branch : & str ,
) -> anyhow ::Result < std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema >> {
let ( owner , repo ) = parse_owner_repo ( repo_url ) ? ;
let api_base = gitea_api_base ( repo_url ) ? ;
let client = openidconnect ::reqwest ::Client ::new ();
let raw = fetch_gitea_file ( & client , & api_base , & owner , & repo , branch , "aggregates.yaml" ). await ? ;
crate ::aggregates ::parse_aggregates_yaml ( & raw )
}
2026-07-31 06:10:06 +02:00
/// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a
/// `Question`, keyed by its own `id`. `repo_url` is the repo's normal
/// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the
2026-08-05 07:20:40 +02:00
/// Gitea host, owner and repo name are all read from it. Called once at
/// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see
/// `watch_for_reload`), over Gitea's public contents API (no auth - the
/// content repo is public).
2026-08-24 22:30:28 +02:00
/// The URL a file at `rel` (path relative to the questions dir, forward
/// slashes) serves: `index.yaml` names its directory, everything else
/// appends its stem.
pub fn route_from_path ( rel : & str ) -> String {
let stem = rel . strip_suffix ( ".yaml" ). unwrap_or ( rel );
let mut segments : Vec <& str > = stem . split ( '/' ). collect ();
if segments . last () == Some ( & "index" ) {
segments . pop ();
}
if segments . is_empty () {
"/" . to_string ()
} else {
format! ( "/ {} " , segments . join ( "/" ))
}
}
/// The directory (as a URL prefix) of the file at `rel` - the base
/// relative references resolve against.
pub fn dir_from_path ( rel : & str ) -> String {
match rel . rsplit_once ( '/' ) {
Some (( dir , _ )) => format! ( "/ {dir} " ),
None => "/" . to_string (),
}
}
/// Resolves a possibly-relative question reference (`action:`,
/// `requires_chain:`) against the referencing file's directory:
/// `/x` is absolute, `proposed` names a sibling, `../x` climbs.
pub fn resolve_ref ( base_dir : & str , reference : & str ) -> String {
if reference . starts_with ( '/' ) {
return reference . to_string ();
}
let mut segments : Vec <& str > = base_dir . split ( '/' ). filter ( | s | ! s . is_empty ()). collect ();
for part in reference . split ( '/' ) {
match part {
"" | "." => {}
".." => {
segments . pop ();
}
s => segments . push ( s ),
}
}
if segments . is_empty () {
"/" . to_string ()
} else {
format! ( "/ {} " , segments . join ( "/" ))
}
}
/// Whether `path` matches `pattern`, capturing `[name]` segments.
/// Returns the captured (name, value) pairs on a match - empty for an
/// exact literal match.
pub fn path_matches ( pattern : & str , path : & str ) -> Option < Vec < ( String , String ) >> {
let pat : Vec <& str > = pattern . split ( '/' ). filter ( | s | ! s . is_empty ()). collect ();
let got : Vec <& str > = path . split ( '/' ). filter ( | s | ! s . is_empty ()). collect ();
if pat . len () != got . len () {
return None ;
}
let mut captures = Vec ::new ();
for ( p , g ) in pat . iter (). zip ( got . iter ()) {
if let Some ( name ) = p . strip_prefix ( '[' ). and_then ( | s | s . strip_suffix ( ']' )) {
if g . is_empty () {
return None ;
}
captures . push (( name . to_string (), ( * g ). to_string ()));
} else if p != g {
return None ;
}
}
Some ( captures )
}
/// Resolves a URL path to its question: an exact id first, else the
/// one dynamic page whose pattern matches, with each captured segment
/// substituted into `{name}` placeholders in the clone's resource keys
/// and its id set to the concrete path (so every follow-up server call
/// - resources, submissions - re-resolves the same way). The
/// substituted key is still looked up in the content-declared bucket,
/// so a visitor varies the key, never the bucket - the same trust
/// shape as a `?chain=` link, where knowing a record's key is holding
/// it.
pub fn resolve_question (
questions : & std ::collections ::HashMap < String , Question > ,
path : & str ,
) -> Option < Question > {
if let Some ( q ) = questions . get ( path ) {
return Some ( q . clone ());
}
for q in questions . values () {
if ! q . is_dynamic () {
continue ;
}
if let Some ( captures ) = path_matches ( & q . id , path ) {
let mut resolved = q . clone ();
resolved . id = path . to_string ();
let substitute = | key : & mut Option < String >| {
if let Some ( k ) = key {
for ( name , value ) in & captures {
* k = k . replace ( & format! ( " {{ {name} }} " ), value );
}
}
};
for alt in & mut resolved . alternatives {
for feature in & mut alt . features {
if let Some ( res ) = & mut feature . resource {
substitute ( & mut res . key );
}
for req in & mut feature . requirements {
if let Some ( res ) = & mut req . resource {
substitute ( & mut res . key );
}
}
}
}
return Some ( resolved );
}
}
None
}
/// Builds the question map from raw (path, yaml) files - the shared
/// back half of both loaders (Gitea tree and `question_lint --path`).
/// Applies the whole filesystem-routing contract: derived ids,
/// relative-reference resolution, followup inference, `_section.yaml`
/// inheritance, and the tree-shape rules (no id collisions, at most
/// one dynamic page per directory).
#[cfg(feature = "ssr" )]
pub fn build_questions (
files : & [( String , String )],
) -> anyhow ::Result < std ::collections ::HashMap < String , Question >> {
// Sections first: (directory prefix, config), shallowest first so a
// later (deeper) section overrides an outer one field-by-field.
let mut sections : Vec < ( String , SectionConfig ) > = Vec ::new ();
for ( rel , raw ) in files {
let name = rel . rsplit ( '/' ). next (). unwrap_or ( rel );
if name != "_section.yaml" {
continue ;
}
let mut section : SectionConfig = serde_yaml ::from_str ( raw )
. map_err ( | e | anyhow ::anyhow! ( "parsing {rel}: {e}" )) ? ;
let dir = dir_from_path ( rel );
if let Some ( rc ) = & section . requires_chain {
section . requires_chain = Some ( resolve_ref ( & dir , rc ));
}
sections . push (( dir , section ));
}
sections . sort_by_key ( | ( dir , _ ) | dir . len ());
let mut out = std ::collections ::HashMap ::new ();
let mut dynamic_dirs = std ::collections ::HashSet ::new ();
for ( rel , raw ) in files {
let name = rel . rsplit ( '/' ). next (). unwrap_or ( rel );
// Underscore files are sections or drafts, never pages.
if name . starts_with ( '_' ) {
continue ;
}
let mut question : Question =
serde_yaml ::from_str ( raw ). map_err ( | e | anyhow ::anyhow! ( "parsing {rel}: {e}" )) ? ;
let derived = route_from_path ( rel );
let dir = dir_from_path ( rel );
if question . id . is_empty () {
question . id = derived . clone ();
} else if question . id != derived {
tracing ::warn! (
"{rel}: declared id {:?} disagrees with its path ({derived}) - the declared id wins, but consider moving the file" ,
question . id
);
}
for alt in & mut question . alternatives {
if let Some ( action ) = & alt . action {
alt . action = Some ( resolve_ref ( & dir , action ));
}
}
if let Some ( rc ) = & question . requires_chain {
question . requires_chain = Some ( resolve_ref ( & dir , rc ));
}
if question . followup . is_none () {
// Nested non-index files are flow steps and outcomes -
// followups by construction. Top-level files keep the
// historical flat-repo default.
let nested = rel . contains ( '/' );
let is_index = name == "index.yaml" ;
question . followup = Some ( nested && ! is_index );
}
// Nearest-ancestor section fills whatever the question left
// unset (walk deepest-last, so later matches override earlier
// section values but never the question's own).
for ( sdir , section ) in & sections {
let applies = * sdir == "/" || dir == * sdir || dir . starts_with ( & format! ( " {sdir} /" ));
if ! applies {
continue ;
}
if question . qualifies . is_none () {
question . qualifies = section . qualifies . clone ();
}
if question . requires_chain . is_none () {
question . requires_chain = section . requires_chain . clone ();
}
if question . responsible . is_none () {
question . responsible = section . responsible . clone ();
}
}
if question . is_dynamic () && ! dynamic_dirs . insert ( dir . clone ()) {
anyhow ::bail! (
"{rel}: more than one dynamic ([name].yaml) page in {dir} - matching would be ambiguous"
);
}
if let Some ( previous ) = out . insert ( question . id . clone (), question ) {
anyhow ::bail! (
"{rel}: id {:?} is already declared by another file" ,
previous . id
);
}
}
Ok ( out )
}
2026-07-29 19:38:40 +02:00
#[cfg(feature = "ssr" )]
2026-07-31 06:10:06 +02:00
pub async fn load_questions_from_gitea (
repo_url : & str ,
branch : & str ,
subdir : & str ,
2026-07-29 19:38:40 +02:00
) -> anyhow ::Result < std ::collections ::HashMap < String , Question >> {
2026-08-11 21:43:32 +02:00
let ( owner , repo ) = parse_owner_repo ( repo_url ) ? ;
2026-08-05 13:45:56 +02:00
let api_base = gitea_api_base ( repo_url ) ? ;
2026-07-31 06:10:06 +02:00
let client = openidconnect ::reqwest ::Client ::new ();
2026-08-24 22:30:28 +02:00
// One recursive git-trees call for the whole repo instead of a
// contents listing per directory - the tree IS the router now, so
// nested files matter.
let tree_url =
format! ( " {api_base} /api/v1/repos/ {owner} / {repo} /git/trees/ {branch} ?recursive=true" );
2026-07-31 06:10:06 +02:00
let listing = client
2026-08-24 22:30:28 +02:00
. get ( & tree_url )
2026-07-31 06:10:06 +02:00
. send ()
. await
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "listing {tree_url}: {e}" )) ?
2026-07-31 06:10:06 +02:00
. error_for_status ()
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "listing {tree_url}: {e}" )) ?
2026-07-31 06:10:06 +02:00
. text ()
. await
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "reading tree from {tree_url}: {e}" )) ? ;
let tree : serde_json ::Value = serde_json ::from_str ( & listing )
. map_err ( | e | anyhow ::anyhow! ( "parsing tree from {tree_url}: {e}" )) ? ;
if tree . get ( "truncated" ). and_then ( | v | v . as_bool ()) == Some ( true ) {
anyhow ::bail! ( "git tree listing for {owner}/{repo} was truncated - repo too large" );
}
let prefix = format! ( " {subdir} /" );
2026-07-31 06:10:06 +02:00
2026-08-24 22:30:28 +02:00
let mut files = Vec ::new ();
for entry in tree
. get ( "tree" )
. and_then ( | v | v . as_array ())
. map ( | v | v . as_slice ())
. unwrap_or_default ()
{
let path = entry . get ( "path" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
if entry . get ( "type" ). and_then ( | v | v . as_str ()) != Some ( "blob" )
|| ! path . starts_with ( & prefix )
|| ! path . ends_with ( ".yaml" )
{
2026-07-29 19:38:40 +02:00
continue ;
}
2026-08-24 22:30:28 +02:00
// Brackets (dynamic pages like `[record].yaml`) must be
// percent-encoded in the raw URL's path.
let encoded = path . replace ( '[' , "%5B" ). replace ( ']' , "%5D" );
let raw_url = format! ( " {api_base} /api/v1/repos/ {owner} / {repo} /raw/ {encoded} ?ref= {branch} " );
2026-07-31 06:10:06 +02:00
let raw = client
2026-08-24 22:30:28 +02:00
. get ( & raw_url )
2026-07-31 06:10:06 +02:00
. send ()
. await
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "fetching {path}: {e}" )) ?
2026-07-31 06:10:06 +02:00
. error_for_status ()
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "fetching {path}: {e}" )) ?
2026-07-31 06:10:06 +02:00
. text ()
. await
2026-08-24 22:30:28 +02:00
. map_err ( | e | anyhow ::anyhow! ( "reading {path}: {e}" )) ? ;
files . push (( path [ prefix . len () .. ]. to_string (), raw ));
2026-07-29 19:38:40 +02:00
}
2026-08-24 22:30:28 +02:00
build_questions ( & files )
2026-07-29 19:38:40 +02:00
}
2026-08-05 07:20:40 +02:00
2026-08-06 08:52:29 +02:00
/// Validates every declared transition target (`SelfTransition.to`,
2026-08-11 21:43:32 +02:00
/// `ResourceSpec.transitions[].to`) against `aggregates` - the real
/// state graph loaded from `aggregates.yaml` - for every bucket that
/// has one declared. A bucket with no entry in `aggregates` is left
/// alone entirely (no validation applied): not every bucket needs to
/// be event-sourced to keep working. 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.
2026-08-30 12:34:34 +02:00
/// Bucket the runtime keeps for announced pages (`Question.event`):
/// one record per event question, `announced` while the window is
/// open, `awaiting_summary` once it closes - the "post what happened"
/// task a desk reading this bucket picks up - and `summarized` when an
/// owner marks it done. Declared here, not in content, because the
/// runtime's sweeper (`announce.rs`) is what moves the records.
pub const EVENTS_BUCKET : & str = "portal_events" ;
#[cfg(feature = "ssr" )]
const BUILTIN_AGGREGATES_YAML : & str = " \
aggregates:
- bucket: portal_events
initial: announced
states:
announced: { event: announced }
awaiting_summary: { event: ended }
summarized: { event: summarized }
transitions:
announced: [awaiting_summary, summarized]
awaiting_summary: [summarized]
" ;
/// Adds the runtime's own state graphs to a content repo's. Content
/// wins if it declares the same bucket (say, to add states).
#[cfg(feature = "ssr" )]
pub fn with_builtin_aggregates (
mut aggregates : std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > ,
) -> std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > {
let builtin = crate ::aggregates ::parse_aggregates_yaml ( BUILTIN_AGGREGATES_YAML )
. expect ( "builtin aggregates yaml is valid" );
for ( bucket , schema ) in builtin {
aggregates . entry ( bucket ). or_insert ( schema );
}
aggregates
}
/// True when some kv resource in the content reads `bucket` - the
/// attended-bucket check, exposed so the lint can warn about the
/// runtime's own `portal_events` (a warning, not a failure: a repo
/// with no event pages has nothing to attend).
#[cfg(feature = "ssr" )]
pub fn bucket_is_read ( questions : & std ::collections ::HashMap < String , Question > , bucket : & str ) -> bool {
questions . values (). any ( | q | {
q . alternatives . iter (). any ( | a | {
a . features
. iter ()
. any ( | f | f . resource . as_ref (). and_then ( | r | r . bucket ()). is_some_and ( | b | b == bucket ))
})
})
}
2026-08-06 08:52:29 +02:00
#[cfg(feature = "ssr" )]
pub fn validate_questions (
questions : & std ::collections ::HashMap < String , Question > ,
2026-08-11 21:43:32 +02:00
aggregates : & std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > ,
2026-08-06 08:52:29 +02:00
) -> anyhow ::Result < () > {
for question in questions . values () {
2026-08-30 12:34:34 +02:00
if let Some ( event ) = & question . event {
if question . is_dynamic () {
anyhow ::bail! ( "question {:?}: a dynamic page can't carry an event" , question . id );
}
if let Err ( e ) = event . window_ms () {
anyhow ::bail! ( "question {:?}: event: {e}" , question . id );
}
}
2026-08-24 22:30:28 +02:00
if let Some ( target ) = & question . requires_chain {
if ! questions . contains_key ( target ) {
anyhow ::bail! (
"question {:?}: requires_chain {:?} does not match any declared question id" ,
question . id ,
target
);
}
}
2026-08-06 08:52:29 +02:00
for alternative in & question . alternatives {
2026-08-12 23:05:23 +02:00
// A dangling action is a literal dead end: the submit
// button navigates to "Nothing here".
if let Some ( action ) = & alternative . action {
2026-08-24 22:30:28 +02:00
match questions . get ( action ) {
None => anyhow ::bail! (
2026-08-12 23:05:23 +02:00
"question {:?} alternative {:?}: action {:?} does not match any declared question id" ,
question . id , alternative . name , action
2026-08-24 22:30:28 +02:00
),
// A dynamic page needs a concrete segment value to
// be a URL - a submit button can't supply one.
Some ( target ) if target . is_dynamic () => anyhow ::bail! (
"question {:?} alternative {:?}: action {:?} targets a dynamic page - actions must name a concrete question" ,
question . id , alternative . name , action
),
Some ( _ ) => {}
2026-08-12 23:05:23 +02:00
}
}
2026-08-06 08:52:29 +02:00
if let Some ( st ) = & alternative . self_transition {
2026-08-11 21:43:32 +02:00
if let Some ( schema ) = aggregates . get ( & st . bucket ) {
if ! schema . has_state ( & st . to ) {
2026-08-06 08:52:29 +02:00
anyhow ::bail! (
2026-08-11 21:43:32 +02:00
"question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}" ,
2026-08-06 08:52:29 +02:00
question . id , alternative . name , st . to , st . bucket
);
}
}
}
2026-08-13 13:49:42 +02:00
// Bind targets resolve across the whole alternative, same
// scope as the signal maps the renderer builds.
let sibling_names : std ::collections ::HashSet <& str > = alternative
. features
. iter ()
. flat_map ( | f | f . requirements . iter ())
. map ( | r | r . name . as_str ())
. collect ();
2026-08-06 08:52:29 +02:00
for feature in & alternative . features {
2026-08-06 11:57:15 +02:00
for requirement in & feature . requirements {
if requirement . kind == "select" && requirement . resource . is_none () {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from" ,
question . id , alternative . name , feature . name , requirement . name
);
}
2026-08-23 13:03:29 +02:00
if let Some ( relay ) = & requirement . relay {
if requirement . kind != "gesture" {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} declares relay but is type {:?} - relay only makes sense on type: gesture" ,
question . id , alternative . name , feature . name , requirement . name , requirement . kind
);
}
validate_relay_url ( relay ). map_err ( | e | anyhow ::anyhow! (
"question {:?} alternative {:?} feature {:?}: requirement {:?}: {e}" ,
question . id , alternative . name , feature . name , requirement . name
)) ? ;
}
if requirement . kind == "gesture"
&& ( requirement . resource . is_some () || requirement . bind . is_some ())
{
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: gesture - it draws its value, it can't also load one from a resource or bind" ,
question . id , alternative . name , feature . name , requirement . name
);
}
2026-08-13 13:49:42 +02:00
if let Some ( bind ) = & requirement . bind {
if bind . field == requirement . name
|| ! sibling_names . contains ( bind . field . as_str ())
{
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative" ,
question . id , alternative . name , feature . name , requirement . name , bind . field
);
}
}
2026-08-06 11:57:15 +02:00
}
2026-08-06 08:52:29 +02:00
let Some ( resource ) = & feature . resource else {
continue ;
};
let Some ( bucket ) = resource . bucket () else {
continue ;
};
2026-08-11 21:43:32 +02:00
let Some ( schema ) = aggregates . get ( bucket ) else {
2026-08-06 08:52:29 +02:00
continue ;
};
for transition in & resource . transitions {
2026-08-12 22:03:43 +02:00
if ! schema . has_state ( & transition . from ) {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}" ,
question . id , alternative . name , feature . name , transition . from , bucket
);
}
2026-08-11 21:43:32 +02:00
if ! schema . has_state ( & transition . to ) {
2026-08-06 08:52:29 +02:00
anyhow ::bail! (
2026-08-11 21:43:32 +02:00
"question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}" ,
2026-08-06 08:52:29 +02:00
question . id , alternative . name , feature . name , transition . to , bucket
);
}
2026-08-12 22:03:43 +02:00
if ! schema . allowed ( & transition . from ). iter (). any ( | s | s == & transition . to ) {
anyhow ::bail! (
"question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}" ,
question . id , alternative . name , feature . name , transition . from , transition . to , bucket
);
}
2026-08-06 08:52:29 +02:00
}
}
}
}
2026-08-25 15:36:07 +02:00
// Attended buckets: no publicly collected answer may land somewhere
// nothing reads. Every `record_as` bucket must either be read back
// by some Kv resource in this same content repo (a desk or listing)
// or carry an explicit `attended_by:` annotation in aggregates.yaml
// naming the automation that consumes it. This is a contract, not a
// convention, because convention already failed once: a question
// was dropped and its bucket - answers included - silently fell out
// of every reader's view.
let mut read_buckets = std ::collections ::HashSet ::new ();
let note_resource = | spec : & ResourceSpec , set : & mut std ::collections ::HashSet < String >| {
if let ResourceSource ::Kv { bucket } = & spec . source {
set . insert ( bucket . clone ());
}
};
for question in questions . values () {
for alternative in & question . alternatives {
for feature in & alternative . features {
if let Some ( spec ) = & feature . resource {
note_resource ( spec , & mut read_buckets );
}
for requirement in & feature . requirements {
if let Some ( spec ) = & requirement . resource {
note_resource ( spec , & mut read_buckets );
}
if let Some ( bind ) = & requirement . bind {
note_resource ( & bind . resource , & mut read_buckets );
}
}
}
}
}
for question in questions . values () {
for alternative in & question . alternatives {
if let Some ( bucket ) = & alternative . record_as {
let attended = read_buckets . contains ( bucket )
|| aggregates
. get ( bucket )
. is_some_and ( | schema | schema . attended_by . is_some ());
if ! attended {
anyhow ::bail! (
"question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it." ,
question . id , alternative . name
);
}
}
}
}
2026-08-06 08:52:29 +02:00
Ok (())
}
2026-08-05 07:20:40 +02:00
/// 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
/// `ANSWERS_SUBJECT` pattern.
#[cfg(feature = "ssr" )]
pub const CONTENT_RELOAD_SUBJECT : & str = "portal.content.reload" ;
/// Runs for the life of the process: re-fetches `repo_url`/`branch` and
2026-08-11 21:43:32 +02:00
/// atomically swaps both `questions` and `aggregates` on every
/// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together,
/// after both have loaded and validated successfully, so a reader never
/// sees pages that reference a half-updated state graph. A fetch/parse
/// failure logs and keeps serving the last-good content rather than
/// clearing it - a bad push to the content repo (which should already
/// have been caught by its own lint step) doesn't take the site down.
2026-08-05 07:20:40 +02:00
#[cfg(feature = "ssr" )]
pub async fn watch_for_reload (
nats : async_nats ::Client ,
repo_url : String ,
branch : String ,
subdir : String ,
questions : std ::sync ::Arc < arc_swap ::ArcSwap < std ::collections ::HashMap < String , Question >>> ,
2026-08-11 21:43:32 +02:00
aggregates : std ::sync ::Arc <
arc_swap ::ArcSwap < std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema >> ,
> ,
2026-08-23 13:03:29 +02:00
site : std ::sync ::Arc < arc_swap ::ArcSwap < SiteConfig >> ,
2026-08-05 07:20:40 +02:00
) {
let mut sub = match nats . subscribe ( CONTENT_RELOAD_SUBJECT ). await {
Ok ( sub ) => sub ,
Err ( e ) => {
tracing ::error! ( error = % e , "failed to subscribe to content reload subject" );
return ;
}
};
use futures ::StreamExt ;
while sub . next (). await . is_some () {
2026-08-11 21:43:32 +02:00
let loaded_aggregates = match load_aggregates_from_gitea ( & repo_url , & branch ). await {
2026-08-30 12:34:34 +02:00
Ok ( loaded ) => with_builtin_aggregates ( loaded ),
2026-08-11 21:43:32 +02:00
Err ( e ) => {
tracing ::error! ( error = % e , "aggregates.yaml reload failed, keeping last-good content" );
continue ;
}
};
2026-08-23 13:03:29 +02:00
// Branding swaps with the same all-or-nothing rule: a
// present-but-broken site.yaml keeps last-good everything
// (load_site distinguishes "absent" - a normal default - from
// "present but invalid").
let loaded_site = match load_site_from_gitea ( & repo_url , & branch ). await {
Ok ( loaded ) => loaded ,
Err ( e ) => {
tracing ::error! ( error = % e , "site.yaml reload failed, keeping last-good content" );
continue ;
}
};
2026-08-05 07:20:40 +02:00
match load_questions_from_gitea ( & repo_url , & branch , & subdir ). await {
Ok ( loaded ) => {
2026-08-11 21:43:32 +02:00
if let Err ( e ) = validate_questions ( & loaded , & loaded_aggregates ) {
2026-08-06 08:52:29 +02:00
tracing ::error! ( error = % e , "content reload failed validation, keeping last-good content" );
continue ;
}
2026-08-05 07:20:40 +02:00
let count = loaded . len ();
questions . store ( std ::sync ::Arc ::new ( loaded ));
2026-08-11 21:43:32 +02:00
aggregates . store ( std ::sync ::Arc ::new ( loaded_aggregates ));
2026-08-23 13:03:29 +02:00
site . store ( std ::sync ::Arc ::new ( loaded_site ));
2026-08-05 07:20:40 +02:00
tracing ::info! ( count , "reloaded content" );
}
Err ( e ) => {
tracing ::error! ( error = % e , "content reload failed, keeping last-good content" );
}
}
}
}
2026-08-05 13:45:56 +02:00
/// A public Gitea repo's basic info - what `resolve_gitea_repo` returns
/// for the prosekit editor's repo-embed node to render as a static
/// card, baked in once at embed time rather than re-fetched by every
/// reader (an emailed newsletter can't run JS to do that anyway).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GiteaRepoInfo {
pub owner : String ,
pub repo : String ,
pub description : String ,
pub url : String ,
}
#[derive(Deserialize)]
#[cfg(feature = "ssr" )]
pub struct GiteaRepoQuery {
pub owner : String ,
pub repo : String ,
}
2026-08-25 17:26:29 +02:00
/// `GET /site/{*path}` - serves a file from the content repo's default
/// branch same-origin, so content-shipped assets (the hero module,
/// its stylesheet) are importable by the page: Gitea's raw endpoint
/// sends no CORS headers, and an ES module import across origins is
/// refused without them. Path is validated to plain segments
/// (`is_safe_site_path`), the response revalidates like /pkg
/// (Cache-Control: no-cache) so a content push shows up on reload.
#[cfg(feature = "ssr" )]
pub async fn site_asset_handler (
axum ::extract ::State ( state ) : axum ::extract ::State < crate ::server ::AppState > ,
axum ::extract ::Path ( path ) : axum ::extract ::Path < String > ,
) -> axum ::response ::Response {
use axum ::http ::{ header , StatusCode };
use axum ::response ::IntoResponse ;
if ! is_safe_site_path ( & path ) {
return ( StatusCode ::BAD_REQUEST , "invalid asset path" ). into_response ();
}
let url = format! ( " {} / {path} ?ref= {} " , state . content_raw_base , state . content_branch );
let client = openidconnect ::reqwest ::Client ::new ();
let resp = match client . get ( & url ). send (). await . and_then ( | r | r . error_for_status ()) {
Ok ( r ) => r ,
Err ( e ) => {
tracing ::warn! ( "site asset {path}: {e}" );
return ( StatusCode ::NOT_FOUND , "no such site asset" ). into_response ();
}
};
let bytes = match resp . bytes (). await {
Ok ( b ) => b ,
Err ( e ) => return ( StatusCode ::BAD_GATEWAY , e . to_string ()). into_response (),
};
let mime = match path . rsplit ( '.' ). next () {
Some ( "js" ) | Some ( "mjs" ) => "text/javascript; charset=utf-8" ,
Some ( "css" ) => "text/css; charset=utf-8" ,
Some ( "svg" ) => "image/svg+xml" ,
Some ( "json" ) => "application/json" ,
Some ( "png" ) => "image/png" ,
Some ( "webp" ) => "image/webp" ,
Some ( "woff2" ) => "font/woff2" ,
_ => "application/octet-stream" ,
};
(
[( header ::CONTENT_TYPE , mime ), ( header ::CACHE_CONTROL , "no-cache" )],
bytes ,
)
. into_response ()
}
2026-08-05 13:45:56 +02:00
/// Looks up `owner/repo` on the same Gitea instance content is loaded
/// from (`AppState.gitea_base`) - a raw Axum handler (mounted at
/// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the
/// caller here is the prosekit editor's own paste-to-embed rule (see
/// `prosekit-editor.js`) doing a plain `fetch`, the same reason
/// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]`
/// fn. Keeping this server-resolved (rather than having the browser
/// call Gitea's API directly) is consistent with every other backing
/// store in this app, and sidesteps needing a CORS allowance on Gitea's
/// side just for this. No auth, same as content loading - resolves
/// only what's already public.
#[cfg(feature = "ssr" )]
pub async fn gitea_repo_handler (
axum ::extract ::State ( state ) : axum ::extract ::State < crate ::server ::AppState > ,
axum ::extract ::Query ( query ) : axum ::extract ::Query < GiteaRepoQuery > ,
) -> Result < axum ::Json < GiteaRepoInfo > , ( axum ::http ::StatusCode , String ) > {
let is_safe_segment = | s : & str | {
! s . is_empty ()
&& s . chars ()
. all ( | c | c . is_ascii_alphanumeric () || c == '-' || c == '_' || c == '.' )
};
if ! is_safe_segment ( & query . owner ) || ! is_safe_segment ( & query . repo ) {
return Err (( axum ::http ::StatusCode ::BAD_REQUEST , "invalid owner/repo" . to_string ()));
}
let GiteaRepoQuery { owner , repo } = query ;
let client = openidconnect ::reqwest ::Client ::new ();
let api_url = format! ( " {} /api/v1/repos/ {owner} / {repo} " , state . gitea_base );
let body = client
. get ( & api_url )
. send ()
. await
. and_then ( | r | r . error_for_status ())
. map_err ( | e | ( axum ::http ::StatusCode ::BAD_GATEWAY , format! ( "fetching {api_url} : {e} " ))) ?
. text ()
. await
. map_err ( | e | {
(
axum ::http ::StatusCode ::BAD_GATEWAY ,
format! ( "reading repo info from {api_url} : {e} " ),
)
}) ? ;
let json : serde_json ::Value = serde_json ::from_str ( & body ). map_err ( | e | {
(
axum ::http ::StatusCode ::BAD_GATEWAY ,
format! ( "parsing repo info from {api_url} : {e} " ),
)
}) ? ;
Ok ( axum ::Json ( GiteaRepoInfo {
owner : owner . clone (),
repo : repo . clone (),
description : json
. get ( "description" )
. and_then ( | v | v . as_str ())
. unwrap_or ( "" )
. to_string (),
url : json
. get ( "html_url" )
. and_then ( | v | v . as_str ())
. map ( | s | s . to_string ())
. unwrap_or_else ( || format! ( " {} / {owner} / {repo} " , state . gitea_base )),
}))
}
/// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler
/// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing
/// automations (e.g. an n8n workflow reading `portal_subscribers` to
/// send a newsletter) that aren't a signed-in browser session and so
/// can't go through `resource::get_resource`'s Kanidm-group check.
/// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) -
/// deliberately not per-caller/per-bucket scoped, since every current
/// caller is a trusted internal automation, not a third party. Read
/// only, matching `get_resource`'s own "reads can be public/shared,
/// mutations always need real identity" split - nothing here writes.
#[cfg(feature = "ssr" )]
pub async fn automation_kv_handler (
axum ::extract ::State ( state ) : axum ::extract ::State < crate ::server ::AppState > ,
axum ::extract ::Path ( bucket ) : axum ::extract ::Path < String > ,
headers : axum ::http ::HeaderMap ,
) -> Result < axum ::Json < serde_json ::Value > , ( axum ::http ::StatusCode , String ) > {
let expected = std ::env ::var ( "AUTOMATION_READ_TOKEN" ). unwrap_or_default ();
let presented = headers
. get ( axum ::http ::header ::AUTHORIZATION )
. and_then ( | v | v . to_str (). ok ())
. and_then ( | v | v . strip_prefix ( "Bearer " ))
. unwrap_or ( "" );
if expected . is_empty () || presented != expected {
return Err (( axum ::http ::StatusCode ::UNAUTHORIZED , "unauthorized" . to_string ()));
}
let store = state
. jetstream
. get_key_value ( & bucket )
. await
. map_err ( | e | ( axum ::http ::StatusCode ::BAD_GATEWAY , format! ( "bucket unavailable: {e} " ))) ? ;
use futures ::TryStreamExt ;
let keys : Vec < String > = store
. keys ()
. await
. map_err ( | e | ( axum ::http ::StatusCode ::BAD_GATEWAY , e . to_string ())) ?
. try_collect ()
. await
. map_err ( | e | ( axum ::http ::StatusCode ::BAD_GATEWAY , e . to_string ())) ? ;
let mut items = Vec ::new ();
for key in keys {
if let Ok ( Some ( bytes )) = store . get ( & key ). await {
if let Ok ( value ) = serde_json ::from_slice ::< serde_json ::Value > ( & bytes ) {
items . push ( value );
}
}
}
Ok ( axum ::Json ( serde_json ::Value ::Array ( items )))
}
2026-08-12 22:03:43 +02:00
#[cfg(all(test, feature = "ssr" ))]
mod tests {
use super ::* ;
fn schema_fixture () -> std ::collections ::HashMap < String , crate ::aggregates ::AggregateSchema > {
crate ::aggregates ::parse_aggregates_yaml (
r #"
aggregates:
- bucket: things
initial: open
states:
open: { event: opened }
middle: { event: advanced }
done: { event: finished }
transitions:
open: [middle]
middle: [done]
"# ,
)
. unwrap ()
}
fn question_with_transitions ( transitions_yaml : & str ) -> std ::collections ::HashMap < String , Question > {
let question : Question = serde_yaml ::from_str ( & format! (
r #"
id: /t
name: T
alternatives:
- name: A
features:
- name: ""
resource:
source: {{ kind: kv, bucket: things }}
requires_group: owners
transitions:
{transitions_yaml}
"#
))
. unwrap ();
std ::collections ::HashMap ::from ([( question . id . clone (), question )])
}
#[test]
fn declared_edge_passes () {
let questions = question_with_transitions (
" - { from: open, to: middle, label: Advance } \n - { from: middle, to: done, label: Finish }" ,
);
assert! ( validate_questions ( & questions , & schema_fixture ()). is_ok ());
}
#[test]
fn from_defaults_to_open () {
let questions = question_with_transitions ( " - { to: middle, label: Advance }" );
assert! ( validate_questions ( & questions , & schema_fixture ()). is_ok ());
}
#[test]
fn undeclared_from_state_is_rejected () {
let questions = question_with_transitions ( " - { from: bogus, to: middle, label: X }" );
let err = validate_questions ( & questions , & schema_fixture ()). unwrap_err ();
assert! ( err . to_string (). contains ( "transition.from" ));
}
#[test]
fn undeclared_edge_is_rejected () {
// Both states exist, but open -> done skips a step the graph
// never declared.
let questions = question_with_transitions ( " - { from: open, to: done, label: Skip }" );
let err = validate_questions ( & questions , & schema_fixture ()). unwrap_err ();
assert! ( err . to_string (). contains ( "not a declared edge" ));
}
2026-08-12 23:05:23 +02:00
#[test]
fn dangling_action_is_rejected () {
let question : Question =
serde_yaml ::from_str ( "id: /a \n name: A \n alternatives: \n - name: Go \n action: /nowhere \n " )
. unwrap ();
let questions = std ::collections ::HashMap ::from ([( question . id . clone (), question )]);
let err = validate_questions ( & questions , & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "does not match any declared question id" ));
}
#[test]
fn self_referencing_action_passes () {
let question : Question =
serde_yaml ::from_str ( "id: /a \n name: A \n alternatives: \n - name: Go \n action: /a \n " )
. unwrap ();
let questions = std ::collections ::HashMap ::from ([( question . id . clone (), question )]);
assert! ( validate_questions ( & questions , & Default ::default ()). is_ok ());
}
2026-08-13 13:49:42 +02:00
fn question_with_bind ( bind_field : & str ) -> std ::collections ::HashMap < String , Question > {
let question : Question = serde_yaml ::from_str ( & format! (
r #"
id: /b
name: B
alternatives:
- name: A
features:
- name: ""
requirements:
- name: picker
type: select
resource:
public: true
source: {{ kind: url, url: "https://x.example/list" }}
- name: body
type: textarea
bind:
field: {bind_field}
resource:
public: true
source: {{ kind: url, url: "https://x.example/item/{{picker}}" }}
"#
))
. unwrap ();
std ::collections ::HashMap ::from ([( question . id . clone (), question )])
}
#[test]
fn bind_to_sibling_passes () {
assert! ( validate_questions ( & question_with_bind ( "picker" ), & Default ::default ()). is_ok ());
}
#[test]
fn bind_to_missing_sibling_is_rejected () {
let err =
validate_questions ( & question_with_bind ( "bogus" ), & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "not another requirement" ));
}
#[test]
fn bind_to_itself_is_rejected () {
let err = validate_questions ( & question_with_bind ( "body" ), & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "not another requirement" ));
}
2026-08-23 13:03:29 +02:00
fn question_with_requirement ( req_yaml : & str ) -> std ::collections ::HashMap < String , Question > {
let question : Question = serde_yaml ::from_str ( & format! (
"id: /g \n name: G \n alternatives: \n - name: A \n features: \n - name: \"\"\n requirements: \n {req_yaml} \n "
))
. unwrap ();
std ::collections ::HashMap ::from ([( question . id . clone (), question )])
}
#[test]
fn gesture_with_wss_relay_passes () {
let questions = question_with_requirement (
" - { name: curve, type: gesture, relay: \" wss://relay.redoal.com \" , optional: true }" ,
);
assert! ( validate_questions ( & questions , & Default ::default ()). is_ok ());
}
#[test]
fn gesture_without_relay_passes_offline () {
let questions =
question_with_requirement ( " - { name: curve, type: gesture, optional: true }" );
assert! ( validate_questions ( & questions , & Default ::default ()). is_ok ());
}
#[test]
fn relay_on_non_gesture_kind_is_rejected () {
let questions = question_with_requirement (
" - { name: email, type: email, relay: \" wss://relay.redoal.com \" }" ,
);
let err = validate_questions ( & questions , & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "relay only makes sense on type: gesture" ));
}
#[test]
fn https_relay_is_rejected () {
let questions = question_with_requirement (
" - { name: curve, type: gesture, relay: \" https://relay.redoal.com \" }" ,
);
let err = validate_questions ( & questions , & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "ws:// or wss://" ));
}
#[test]
2026-08-25 17:26:29 +02:00
fn site_config_defaults_to_the_plain_hero () {
2026-08-23 13:03:29 +02:00
let site = SiteConfig ::default ();
2026-08-25 17:26:29 +02:00
assert_eq! ( site . hero . kind , "plain" );
2026-08-23 13:03:29 +02:00
assert! ( site . validate (). is_ok ());
// And an empty file parses to the same thing.
let parsed : SiteConfig = serde_yaml ::from_str ( "{}" ). unwrap ();
assert_eq! ( parsed , site );
}
#[test]
fn site_config_rejects_unknown_hero_kind () {
let site : SiteConfig = serde_yaml ::from_str ( "hero: { kind: fireworks }" ). unwrap ();
let err = site . validate (). unwrap_err ();
assert! ( err . to_string (). contains ( "fireworks" ));
}
#[test]
2026-08-25 17:26:29 +02:00
fn site_config_module_hero_needs_a_safe_path () {
let missing : SiteConfig = serde_yaml ::from_str ( "hero: { kind: module }" ). unwrap ();
assert! ( missing . validate (). is_err ());
let traversal : SiteConfig =
serde_yaml ::from_str ( "hero: { kind: module, module: ../etc/passwd }" ). unwrap ();
assert! ( traversal . validate (). is_err ());
let absolute : SiteConfig =
serde_yaml ::from_str ( "hero: { kind: module, module: https://x/y.js }" ). unwrap ();
assert! ( absolute . validate (). is_err ());
let ok : SiteConfig = serde_yaml ::from_str ( "hero: { kind: module, module: hero.js }" ). unwrap ();
assert! ( ok . validate (). is_ok ());
2026-08-23 13:03:29 +02:00
}
#[test]
fn redoal_site_yaml_shape_parses () {
let site : SiteConfig = serde_yaml ::from_str (
2026-08-25 17:26:29 +02:00
"title: redoal \n wordmark: https://project.uhhm.no/redoal/questions/raw/branch/main/wordmark.svg \n hero: \n kind: module \n module: hero.js \n " ,
2026-08-23 13:03:29 +02:00
)
. unwrap ();
assert! ( site . validate (). is_ok ());
assert_eq! ( site . title . as_deref (), Some ( "redoal" ));
2026-08-25 17:26:29 +02:00
assert_eq! ( site . hero . module . as_deref (), Some ( "hero.js" ));
2026-08-23 13:03:29 +02:00
}
2026-08-24 22:30:28 +02:00
#[test]
fn routes_derive_from_paths () {
assert_eq! ( route_from_path ( "index.yaml" ), "/" );
assert_eq! ( route_from_path ( "applied.yaml" ), "/applied" );
assert_eq! ( route_from_path ( "develop/index.yaml" ), "/develop" );
assert_eq! ( route_from_path ( "develop/proposal.yaml" ), "/develop/proposal" );
assert_eq! ( route_from_path ( "review/[record].yaml" ), "/review/[record]" );
}
#[test]
fn relative_refs_resolve_against_the_file_dir () {
assert_eq! ( resolve_ref ( "/develop" , "proposed" ), "/develop/proposed" );
assert_eq! ( resolve_ref ( "/develop" , "/subscribed" ), "/subscribed" );
assert_eq! ( resolve_ref ( "/develop" , "../applied" ), "/applied" );
assert_eq! ( resolve_ref ( "/" , "applied" ), "/applied" );
assert_eq! ( resolve_ref ( "/" , ".." ), "/" );
}
#[test]
fn dynamic_patterns_capture_segments () {
assert_eq! ( path_matches ( "/review/[record]" , "/review/abc" ),
Some ( vec! [( "record" . into (), "abc" . into ())]));
assert_eq! ( path_matches ( "/review/[record]" , "/review" ), None );
assert_eq! ( path_matches ( "/review/[record]" , "/other/abc" ), None );
assert_eq! ( path_matches ( "/review" , "/review" ), Some ( vec! []));
}
#[test]
fn tree_becomes_router_with_inference_and_sections () {
let files = vec! [
( "index.yaml" . to_string (), "name: Home \n alternatives: \n - name: go \n action: applied \n " . to_string ()),
( "applied.yaml" . to_string (), "name: Applied \n followup: true \n " . to_string ()),
( "develop/index.yaml" . to_string (), "name: Develop \n alternatives: \n - name: propose \n action: proposal \n " . to_string ()),
( "develop/proposal.yaml" . to_string (), "name: Proposal \n alternatives: \n - name: send \n action: proposed \n " . to_string ()),
( "develop/proposed.yaml" . to_string (), "name: Proposed \n " . to_string ()),
( "review/_section.yaml" . to_string (), "qualifies: portal_owners \n " . to_string ()),
( "review/index.yaml" . to_string (), "name: Review \n " . to_string ()),
( "review/_draft.yaml" . to_string (), "not even valid yaml: [ \n " . to_string ()),
];
let questions = build_questions ( & files ). unwrap ();
// Derived ids and resolved relative actions.
assert_eq! ( questions [ "/" ]. alternatives [ 0 ]. action . as_deref (), Some ( "/applied" ));
assert_eq! (
questions [ "/develop" ]. alternatives [ 0 ]. action . as_deref (),
Some ( "/develop/proposal" )
);
assert_eq! (
questions [ "/develop/proposal" ]. alternatives [ 0 ]. action . as_deref (),
Some ( "/develop/proposed" )
);
// Followup inference: nested non-index files are followups,
// index files and top-level files are not (explicit wins).
assert! ( ! questions [ "/" ]. is_followup ());
assert! ( questions [ "/applied" ]. is_followup ());
assert! ( ! questions [ "/develop" ]. is_followup ());
assert! ( questions [ "/develop/proposal" ]. is_followup ());
// Section inheritance gates the directory; drafts are skipped.
assert_eq! ( questions [ "/review" ]. qualifies . as_deref (), Some ( "portal_owners" ));
assert_eq! ( questions [ "/" ]. qualifies , None );
assert! ( ! questions . contains_key ( "/review/_draft" ));
}
#[test]
fn declared_id_wins_over_path () {
let files = vec! [(
"legacy.yaml" . to_string (),
"id: /somewhere-else \n name: Legacy \n " . to_string (),
)];
let questions = build_questions ( & files ). unwrap ();
assert! ( questions . contains_key ( "/somewhere-else" ));
}
#[test]
fn colliding_ids_and_ambiguous_dynamics_are_rejected () {
let collision = vec! [
( "a.yaml" . to_string (), "name: A \n " . to_string ()),
( "b.yaml" . to_string (), "id: /a \n name: B \n " . to_string ()),
];
assert! ( build_questions ( & collision ). is_err ());
let ambiguous = vec! [
( "review/[a].yaml" . to_string (), "name: A \n " . to_string ()),
( "review/[b].yaml" . to_string (), "name: B \n " . to_string ()),
];
assert! ( build_questions ( & ambiguous ). is_err ());
}
#[test]
fn dynamic_pages_resolve_with_key_substitution () {
let files = vec! [(
"review/[record].yaml" . to_string (),
"name: Record \n alternatives: \n - name: view \n features: \n - name: detail \n resource: \n public: true \n key: \" {record} \"\n source: \n kind: kv \n bucket: applicants \n " . to_string (),
)];
let questions = build_questions ( & files ). unwrap ();
let page = resolve_question ( & questions , "/review/abc123" ). unwrap ();
assert_eq! ( page . id , "/review/abc123" );
assert_eq! (
page . alternatives [ 0 ]. features [ 0 ]. resource . as_ref (). unwrap (). key . as_deref (),
Some ( "abc123" )
);
assert! ( resolve_question ( & questions , "/review" ). is_none ());
// The pattern itself still resolves exactly (it IS an id).
assert! ( resolve_question ( & questions , "/review/[record]" ). is_some ());
}
#[test]
fn requires_chain_resolves_and_validates () {
let files = vec! [
( "shape.yaml" . to_string (), "name: Shape \n " . to_string ()),
(
"shaped.yaml" . to_string (),
"name: Shaped \n requires_chain: shape \n " . to_string (),
),
];
let questions = build_questions ( & files ). unwrap ();
assert_eq! ( questions [ "/shaped" ]. requires_chain . as_deref (), Some ( "/shape" ));
assert! ( validate_questions ( & questions , & Default ::default ()). is_ok ());
let dangling = vec! [(
"shaped.yaml" . to_string (),
"name: Shaped \n requires_chain: /nowhere \n " . to_string (),
)];
let questions = build_questions ( & dangling ). unwrap ();
assert! ( validate_questions ( & questions , & Default ::default ()). is_err ());
}
2026-08-25 15:36:07 +02:00
#[test]
fn recorded_buckets_must_be_attended () {
let write_only = vec! [(
"index.yaml" . to_string (),
"name: Home \n alternatives: \n - name: send \n record_as: black_hole \n " . to_string (),
)];
let questions = build_questions ( & write_only ). unwrap ();
let err = validate_questions ( & questions , & Default ::default ()). unwrap_err ();
assert! ( err . to_string (). contains ( "unattended" ), "got: {err}" );
// A desk (any Kv resource over the bucket, anywhere in the
// repo) attends it.
let with_desk = vec! [
(
"index.yaml" . to_string (),
"name: Home \n alternatives: \n - name: send \n record_as: inbox \n " . to_string (),
),
(
"review/index.yaml" . to_string (),
"name: Desk \n alternatives: \n - name: Inbox \n features: \n - name: \"\"\n resource: \n requires_group: owners \n source: \n kind: kv \n bucket: inbox \n " . to_string (),
),
];
let questions = build_questions ( & with_desk ). unwrap ();
assert! ( validate_questions ( & questions , & Default ::default ()). is_ok ());
// ..or an explicit attended_by annotation in aggregates.yaml.
let aggregates = crate ::aggregates ::parse_aggregates_yaml (
"aggregates: \n - bucket: black_hole \n initial: open \n attended_by: n8n nightly digest \n states: \n open: { event: submitted } \n " ,
)
. unwrap ();
let questions = build_questions ( & write_only ). unwrap ();
assert! ( validate_questions ( & questions , & aggregates ). is_ok ());
}
2026-08-24 22:30:28 +02:00
#[test]
fn actions_may_not_target_dynamic_pages () {
let files = vec! [
(
"index.yaml" . to_string (),
"name: Home \n alternatives: \n - name: go \n action: /review/[record] \n " . to_string (),
),
( "review/[record].yaml" . to_string (), "name: Record \n " . to_string ()),
];
let questions = build_questions ( & files ). unwrap ();
assert! ( validate_questions ( & questions , & Default ::default ()). is_err ());
}
2026-08-30 12:34:34 +02:00
// ── events (announced pages) ─────────────────────────────────────────
#[test]
fn duration_parses_whole_units () {
assert_eq! ( parse_duration_ms ( "3h" ). unwrap (), 3 * 3_600_000 );
assert_eq! ( parse_duration_ms ( "90m" ). unwrap (), 90 * 60_000 );
assert_eq! ( parse_duration_ms ( "1d 6h" ). unwrap (), 30 * 3_600_000 );
assert! ( parse_duration_ms ( "0h" ). is_err ());
assert! ( parse_duration_ms ( "3 hours" ). is_err ());
assert! ( parse_duration_ms ( "" ). is_err ());
}
#[test]
fn event_window_and_liveness () {
let q : Question = serde_yaml ::from_str (
"id: /opening \n name: Will you be there? \n event: \n starts: 2026-09-12T18:00:00+02:00 \n duration: 3h \n " ,
)
. unwrap ();
let ( starts , ends ) = q . event . as_ref (). unwrap (). window_ms (). unwrap ();
assert_eq! ( ends - starts , 3 * 3_600_000 );
assert! ( q . event_live_at ( starts - 1 ));
assert! ( q . event_live_at ( ends - 1 ));
assert! ( ! q . event_live_at ( ends ));
assert! ( q . event_ended_at ( ends ));
assert! ( ! q . event_ended_at ( starts ));
let plain : Question = serde_yaml ::from_str ( "id: /x \n name: X \n " ). unwrap ();
assert! ( ! plain . event_live_at ( 0 ) && ! plain . event_ended_at ( i64 ::MAX ));
}
#[test]
fn event_validation () {
let bad : Question = serde_yaml ::from_str (
"id: /e \n name: E \n event: \n starts: next friday \n duration: 3h \n " ,
)
. unwrap ();
let mut qs = std ::collections ::HashMap ::new ();
qs . insert ( bad . id . clone (), bad );
let err = validate_questions ( & qs , & Default ::default ()). unwrap_err (). to_string ();
assert! ( err . contains ( "bad starts" ), "{err}" );
let dynamic : Question = serde_yaml ::from_str (
"id: /e/[slot] \n name: E \n event: \n starts: 2026-09-12T18:00:00Z \n duration: 3h \n " ,
)
. unwrap ();
let mut qs = std ::collections ::HashMap ::new ();
qs . insert ( dynamic . id . clone (), dynamic );
let err = validate_questions ( & qs , & Default ::default ()). unwrap_err (). to_string ();
assert! ( err . contains ( "dynamic page can't carry an event" ), "{err}" );
}
#[test]
fn builtin_events_schema_yields_to_content () {
let merged = with_builtin_aggregates ( Default ::default ());
let schema = & merged [ EVENTS_BUCKET ];
assert_eq! ( schema . initial , "announced" );
assert_eq! ( schema . allowed ( "announced" ), [ "awaiting_summary" , "summarized" ]);
assert_eq! ( schema . allowed ( "awaiting_summary" ), [ "summarized" ]);
let content = crate ::aggregates ::parse_aggregates_yaml (
"aggregates: \n - bucket: portal_events \n initial: mine \n states: \n mine: { event: mine } \n " ,
)
. unwrap ();
assert_eq! ( with_builtin_aggregates ( content )[ EVENTS_BUCKET ]. initial , "mine" );
}
#[test]
fn bucket_is_read_sees_kv_resources () {
let qs = question_with_transitions ( " - { to: middle, label: Go }" );
assert! ( bucket_is_read ( & qs , "things" ));
assert! ( ! bucket_is_read ( & qs , EVENTS_BUCKET ));
}
2026-08-12 22:03:43 +02:00
}