Files
portal/src/chain.rs
T
Bendik Aagaard LynghaugandClaude Fable 5 452ea88fbf
Test / test (push) Successful in 23s
Filesystem routes, sections, dynamic segments; instant YES hero
The questions/ tree is the router now: ids derive from file paths
(index.yaml names its directory; explicit id still wins for legacy
content), actions and requires_chain accept relative refs, nested
non-index files infer followup, and _section.yaml applies qualifies/
requires_chain/responsible to everything under its directory. Dynamic
[name].yaml pages serve any /dir/<value> with the segment substituted
into {name} resource-key placeholders; submissions index their chain
node in a portal_chains KV so requires_chain pages can verify a
visitor's ?chain= lineage actually ends at the required question.
Loading uses one recursive git-trees call; question_lint walks
subdirectories the same way. Implements docs/design/filesystem-routes.md.

Also: the YES hero now starts at HTML parse time via an inline module
script (yes.js moved to public/ for a stable /yes.js the wasm binding
raw_module-imports too - snippet paths are per-build-hashed), with
hydration adopting the running instance; and both gesture containers
reserve their box in CSS so mounting doesn't shift content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:30:28 +02:00

85 lines
2.9 KiB
Rust

//! SHA-256 hashed answer chain. `parent_hashes` is DAG-shaped, but
//! nothing produces more than one parent yet - the shape is reserved
//! for follow-up questions posted back into a visitor's chain.
#![cfg(feature = "ssr")]
use sha2::{Digest, Sha256};
/// Hashes one chain node from its question, its parent hash(es), the
/// submitted responses, and a timestamp. Parents are sorted first so the
/// hash doesn't depend on the order multiple parents happened to arrive
/// in.
/// Where submitted chain nodes are indexed - hash → which question was
/// answered. Small on purpose (no responses), and shared by every
/// portal instance on the JetStream (hashes are globally unique, so
/// cross-site collisions can't happen). This is what lets
/// `requires_chain` pages verify a visitor's `?chain=` actually ends
/// at the question they claim to have answered.
pub const CHAIN_BUCKET: &str = "portal_chains";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ChainNode {
pub question_id: String,
pub timestamp_ms: i64,
}
/// Indexes one submitted node. Best-effort by design (the caller logs
/// and continues): the NATS event is the durable record, this is a
/// lookup convenience.
pub async fn record_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
question_id: &str,
timestamp_ms: i64,
) -> anyhow::Result<()> {
let store = match js.get_key_value(CHAIN_BUCKET).await {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: CHAIN_BUCKET.to_string(),
..Default::default()
})
.await?
}
};
let node = ChainNode {
question_id: question_id.to_string(),
timestamp_ms,
};
store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?;
Ok(())
}
/// Looks a chain hash up - `None` covers both "no such node" and
/// "bucket not created yet" (no submissions anywhere), which read the
/// same to a `requires_chain` check: the claimed lineage can't be
/// verified, so the gate stays shut.
pub async fn lookup_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
) -> Option<ChainNode> {
let store = js.get_key_value(CHAIN_BUCKET).await.ok()?;
let bytes = store.get(chain_hash).await.ok()??;
serde_json::from_slice(&bytes).ok()
}
pub fn hash_node(
question_id: &str,
parent_hashes: &[String],
responses: &serde_json::Value,
timestamp_ms: i64,
) -> String {
let mut parents = parent_hashes.to_vec();
parents.sort();
let payload = serde_json::json!({
"questionId": question_id,
"parentHashes": parents,
"responses": responses,
"timestamp": timestamp_ms,
});
let data = serde_json::to_vec(&payload).expect("json values always serialize");
let mut hasher = Sha256::new();
hasher.update(&data);
format!("{:x}", hasher.finalize())
}