2026-08-12 22:55:36 +02:00
|
|
|
//! 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.
|
2026-07-29 19:38:40 +02:00
|
|
|
#![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.
|
2026-08-24 22:30:28 +02:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:38:40 +02:00
|
|
|
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())
|
|
|
|
|
}
|