Initial commit: content-driven onboarding portal

Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
This commit is contained in:
Bendik Aagaard Lynghaug
2026-07-29 19:38:40 +02:00
commit aa1a7fa572
21 changed files with 7873 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
//! Generic, authorized reads of NATS KV data declared in content
//! (`content::ResourceSpec`, on a `Feature`). The one property that
//! matters: the bucket/key a resource reads is never a client-supplied
//! parameter, only something the server resolves from its own
//! trusted, YAML-loaded content - the client only ever names a
//! question id + feature name, so it can't probe an arbitrary bucket
//! by just naming it.
use leptos::prelude::*;
/// Fetches the live data for `question_id`'s `feature_name` feature.
/// Fails closed: a resource with neither `public: true` nor
/// `requires_group` set is unreachable, not "open" by omission.
#[server]
pub async fn get_resource(
question_id: String,
feature_name: String,
) -> Result<serde_json::Value, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let question = state
.questions
.get(&question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let feature = question
.alternatives
.iter()
.flat_map(|a| &a.features)
.find(|f| f.name == feature_name)
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
let resource = feature
.resource
.as_ref()
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
if !resource.public {
let group = resource
.requires_group
.as_deref()
.ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session
.get::<User>(SESSION_USER_KEY)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("not signed in"))?;
if !user.groups.iter().any(|g| g == group) {
return Err(ServerFnError::new("not authorized"));
}
}
let store = state
.jetstream
.get_key_value(&resource.bucket)
.await
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
match &resource.key {
Some(key) => {
let bytes = store
.get(key)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new("resource key not found"))?;
serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))
}
None => {
use futures::TryStreamExt;
let keys: Vec<String> = store
.keys()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.try_collect()
.await
.map_err(|e| ServerFnError::new(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(serde_json::Value::Array(items))
}
}
}