//! Configuration, from the environment only. Secrets never have a default. use std::path::PathBuf; #[derive(Clone, Debug)] pub struct Connect { /// Base URL of the sso-connect instance (no trailing slash). pub base_url: String, pub client_id: String, pub client_secret: String, } #[derive(Clone, Debug)] pub struct Publish { /// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/decision-depot-textes-reference.git`. pub remote: String, pub branch: String, /// Private SSH key allowed to push there (deploy key). pub key: PathBuf, pub author_name: String, pub author_email: String, /// Publish a document even when none of its articles is in force /// (`LD_PUBLISH_WITHOUT_ARTICLES`). Off by default — Yvv, 2026-11-02: « Suspendre /// jusqu'au 1er texte »: the queue waits, and the first text that enters sends it /// (ADR 0011). pub without_articles: bool, /// Publish a document even while a text, an order, a title or a level of inertia /// in effect entered by simulated votes does not hold on the proven votes alone /// (`LD_PUBLISH_SIMULATED`). Off — Yvv, 2026-11-03: « Pas avant des votes /// prouvés »: in force on the site, never published before the proven votes carry /// it (ADR 0012, `publish::waits_for_proven_votes`). pub simulated: bool, } /// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how /// much, and the nodes the page sends the transaction to. Without /// `LD_VOTE_ACCOUNT`, voting through the chain is off, and the interface says so. #[derive(Clone, Debug)] pub struct VoteChain { /// The vote account, as a Ğ1 address (`g1…`), whatever form it was given in. pub account: String, pub account_key: [u8; 32], /// Cents of Ğ1 per vote (`LD_VOTE_AMOUNT`, 10 = 0.1 Ğ1 by default). pub amount: u64, /// WebSocket endpoints of Ğ1 nodes, for the page (`LD_VOTE_NODES`). pub nodes: Vec, /// Tests only, with the dev login: the page sends to this fake node instead /// of building the transaction with polkadot-api (`LD_VOTE_TEST_NODE`). pub test_node: Option, } impl VoteChain { pub const DEFAULT_AMOUNT: u64 = 10; /// From the account in any SS58 form; `None` if it is not an address. pub fn new(account: &str, amount: u64, nodes: Vec, test_node: Option) -> Option { let (_, key) = ld_engine::address::decode(account.trim())?; Some(Self { account: ld_engine::address::encode(ld_engine::address::G1_PREFIX, &key), account_key: key, amount: amount.max(1), nodes, test_node, }) } } #[derive(Clone, Debug)] pub struct Config { pub bind: String, pub data_dir: PathBuf, pub web_dir: PathBuf, pub seed_dir: PathBuf, /// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies. pub public_url: String, pub connect: Option, pub admin_g1_ids: Vec, pub dev_login: bool, pub bootstrap_admin: bool, pub publish: Option, pub rpc_endpoints: Vec, pub indexers: Vec, pub wot_fallback: u32, pub build: String, /// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`). pub vote: Option, /// Whether a simulated vote counts for adoption (ADR 0012): /// `ld_engine::buffer::SIMULATED_ADOPTS` — Yvv, 2026-11-02: « Il compte, puis /// s'efface »: it counts until the act « supprime » sets it aside. pub simulated_adopts: bool, } fn var(name: &str) -> Option { std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()) } fn flag(name: &str) -> bool { var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) } fn list(name: &str, default: &[&str]) -> Vec { match var(name) { Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(), None => default.iter().map(|s| (*s).to_owned()).collect(), } } impl Config { pub fn from_env() -> Self { let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into())); let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) { (Some(client_id), Some(client_secret)) => Some(Connect { base_url: var("LD_CONNECT_URL") .unwrap_or_else(|| "https://connect.monnaie-libre.fr".into()) .trim_end_matches('/') .to_owned(), client_id, client_secret, }), _ => None, }; let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish { remote, branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()), key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")), author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()), author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()), without_articles: flag("LD_PUBLISH_WITHOUT_ARTICLES"), simulated: flag("LD_PUBLISH_SIMULATED"), }); let dev_login = flag("LD_DEV_LOGIN"); let vote = var("LD_VOTE_ACCOUNT").and_then(|account| { let amount = var("LD_VOTE_AMOUNT").and_then(|v| v.parse().ok()).unwrap_or(VoteChain::DEFAULT_AMOUNT); let nodes = list("LD_VOTE_NODES", &["wss://rpc.duniter.org", "wss://g1.axiom-team.fr/ws"]); // A fake node for the page, never outside the local dev login. let test_node = var("LD_VOTE_TEST_NODE").filter(|_| dev_login); let chain = VoteChain::new(&account, amount, nodes, test_node); if chain.is_none() { tracing::error!("LD_VOTE_ACCOUNT is not a Ğ1 address: the vote through the chain stays off"); } chain }); Self { bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()), web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())), seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())), public_url: var("LD_PUBLIC_URL") .unwrap_or_else(|| "http://127.0.0.1:3002".into()) .trim_end_matches('/') .to_owned(), connect, admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]), dev_login, bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"), publish, rpc_endpoints: list( "LD_RPC_ENDPOINTS", &[ "https://rpc.duniter.org", "https://g1.axiom-team.fr/ws/", "https://g1.asycn.io/ws/", "https://g1.coinduf.eu", ], ), indexers: list( "LD_INDEXERS", &["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"], ), wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745), build: var("LD_BUILD").unwrap_or_else(|| "dev".into()), vote, simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, data_dir, } } /// Cookies are `Secure` as soon as the public origin is https. pub fn secure_cookies(&self) -> bool { self.public_url.starts_with("https://") } pub fn connect_callback(&self) -> String { format!("{}/api/auth/g1/callback", self.public_url) } /// For tests: an in-memory configuration with dev login. pub fn for_tests() -> Self { Self { bind: "127.0.0.1:0".into(), data_dir: std::env::temp_dir().join("ld-tests"), web_dir: PathBuf::from("web/build"), seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")), public_url: "http://127.0.0.1:3002".into(), connect: None, admin_g1_ids: vec!["idty:8352".into()], dev_login: true, bootstrap_admin: false, publish: None, rpc_endpoints: vec![], indexers: vec![], wot_fallback: 5745, build: "test".into(), vote: None, simulated_adopts: ld_engine::buffer::SIMULATED_ADOPTS, } } }