Skip to main content

solid_pod_rs_forge/
config.rs

1//! Forge configuration and the chain/mainnet policy guard.
2//!
3//! Field defaults mirror the JSS `forge` plugin config surface
4//! (README §Configuration) — cited by name only. All bounds that keep
5//! the read-time body fetch cheap and DoS-resistant live here.
6
7use crate::error::ForgeError;
8
9/// All tunables for a [`crate::ForgeService`].
10#[derive(Debug, Clone)]
11pub struct ForgeConfig {
12    /// URL prefix the forge is mounted under (default `/forge`). No
13    /// trailing slash.
14    pub prefix: String,
15    /// Time-to-live for a minted forge push token, in seconds.
16    pub token_ttl_secs: u64,
17    /// Per-body byte cap when re-fetching pod-hosted issue/PR bodies
18    /// (default 64 KiB).
19    pub max_body_bytes: usize,
20    /// Parallelism for the read-time body fetch (default 8).
21    pub fetch_concurrency: usize,
22    /// Per-body fetch timeout, seconds (default 8).
23    pub fetch_timeout_secs: u64,
24    /// Hard cap on thread pointers materialised in one read (default 500).
25    pub thread_cap: usize,
26    /// Bitcoin chain the mark trail targets. `"tbtc4"` (testnet4) by
27    /// default; mainnet (`"btc"`/`"mainnet"`) is refused unless
28    /// [`ForgeConfig::allow_mainnet`] is set.
29    pub default_chain: String,
30    /// Fail-closed mainnet guard. When `false`, [`Self::chain_allowed`] rejects
31    /// any mainnet chain string. Mark custody here is testnet-grade.
32    pub allow_mainnet: bool,
33    /// Opt-in relays the NIP-34 announcement is published to. Empty =
34    /// build/sign only, never publish. Only `ws://` and `wss://` entries
35    /// are used (JSS `announceRelays`).
36    pub announce_relays: Vec<String>,
37    /// The forge-instance NIP-34 signing key (JSS `announceKey`): 32-byte
38    /// hex. When unset or malformed the key persisted at
39    /// `plugin_dir/announce-key` is used, generated once. Its `Debug`
40    /// output is redacted.
41    pub announce_key: Option<AnnounceKey>,
42    /// Show the Anchors tab on every repository page, not only on
43    /// repositories that already carry a trail (JSS `anchoringUi`). The
44    /// `/marks` page then carries the Enable pitch where anchoring is off.
45    /// Default `false`. Needs the `anchoring` feature to link anywhere.
46    pub anchoring_ui: bool,
47    /// Sparse marking (JSS `sparseMarks`): a trailing *unfunded* mark is
48    /// re-targeted at each new default-branch tip instead of stacking a new
49    /// one, so anchoring HEAD costs one transaction however many commits
50    /// landed. Default `false`.
51    pub sparse_marks: bool,
52    /// JSS `privateRepos`: when `true` every read (git, HTML, JSON) is
53    /// owner-only. Default `false`.
54    pub private_repos: bool,
55    /// JSS `baseUrl`: public origin used for absolute clone and hosted-body
56    /// URLs (reverse-proxy override). `None` falls back to the request's
57    /// `host_url`.
58    pub base_url: Option<String>,
59    /// JSS `loopbackUrl`: origin of the host's own HTTP surface for pod
60    /// body reads. Informational for embedders that build a
61    /// [`crate::LoopbackFetch`]; `None` means "derive it".
62    pub loopback_url: Option<String>,
63    /// JSS `cspConnect`: extra `http(s)` origins admitted into the
64    /// `connect-src` of the page CSP (see [`crate::xlogin::build_csp`]).
65    pub csp_connect: Vec<String>,
66    /// JSS `openEdit`: when `true`, the web-edit and preview endpoints
67    /// (`POST api/repos/<o>/<n>/edit` and `/preview`) accept **anonymous**
68    /// callers on **any** repository. Demo use only; default `false`, and
69    /// [`crate::ForgeService::new`] logs a loud warning when it is on.
70    pub open_edit: bool,
71}
72
73/// A 32-byte hex secret key supplied through configuration.
74///
75/// The value is held verbatim and is never printed: `Debug` redacts it, so a
76/// logged [`ForgeConfig`] cannot leak the forge's NIP-34 identity.
77///
78/// # Examples
79///
80/// ```
81/// use solid_pod_rs_forge::config::AnnounceKey;
82///
83/// let key = AnnounceKey::new("11".repeat(32));
84/// assert!(!format!("{key:?}").contains("1111"));
85/// assert_eq!(key.valid_hex(), Some("11".repeat(32)));
86/// assert_eq!(AnnounceKey::new("not hex").valid_hex(), None);
87/// ```
88#[derive(Clone)]
89pub struct AnnounceKey(String);
90
91impl AnnounceKey {
92    /// Wrap a configured key string (validated later by [`Self::valid_hex`]).
93    #[must_use]
94    pub fn new(hex: impl Into<String>) -> Self {
95        Self(hex.into())
96    }
97
98    /// The key as lowercase 64-character hex, or `None` when it is not
99    /// exactly 32 bytes of hex (JSS then falls back to the persisted key).
100    #[must_use]
101    pub fn valid_hex(&self) -> Option<String> {
102        let lc = self.0.to_ascii_lowercase();
103        (lc.len() == 64 && lc.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))).then_some(lc)
104    }
105}
106
107impl std::fmt::Debug for AnnounceKey {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str("AnnounceKey(<redacted>)")
110    }
111}
112
113impl Default for ForgeConfig {
114    fn default() -> Self {
115        Self {
116            prefix: "/forge".to_string(),
117            token_ttl_secs: 3600,
118            max_body_bytes: 64 * 1024,
119            fetch_concurrency: 8,
120            fetch_timeout_secs: 8,
121            thread_cap: 500,
122            default_chain: "tbtc4".to_string(),
123            allow_mainnet: false,
124            announce_relays: Vec::new(),
125            announce_key: None,
126            anchoring_ui: false,
127            sparse_marks: false,
128            private_repos: false,
129            base_url: None,
130            loopback_url: None,
131            open_edit: false,
132            csp_connect: Vec::new(),
133        }
134    }
135}
136
137/// Chain strings this build treats as Bitcoin mainnet (custody refused
138/// unless explicitly allowed).
139///
140/// The JSS `MAINNET_CHAINS` set is `btc`, `mainnet`, `bitcoin` and `xbt`;
141/// `main` is kept as an extra fail-closed spelling (it is not a *known* chain,
142/// so [`chain_hrp`] refuses it anyway).
143const MAINNET_ALIASES: &[&str] = &["btc", "bitcoin", "mainnet", "xbt", "main"];
144
145/// The bech32 human-readable part of a chain token's P2TR addresses, or
146/// `None` for a chain this forge does not know (JSS `CHAIN_HRP`).
147///
148/// Matching is case-insensitive.
149///
150/// # Examples
151///
152/// ```
153/// use solid_pod_rs_forge::config::chain_hrp;
154///
155/// assert_eq!(chain_hrp("tbtc4"), Some("tb"));
156/// assert_eq!(chain_hrp("XBT"), Some("bc"));
157/// assert_eq!(chain_hrp("regtest"), Some("bcrt"));
158/// assert_eq!(chain_hrp("dogecoin"), None);
159/// ```
160#[must_use]
161pub fn chain_hrp(chain: &str) -> Option<&'static str> {
162    match chain.to_ascii_lowercase().as_str() {
163        "btc" | "mainnet" | "bitcoin" | "xbt" => Some("bc"),
164        "tbtc4" | "tbtc3" | "signet" | "txbt4" => Some("tb"),
165        "regtest" => Some("bcrt"),
166        _ => None,
167    }
168}
169
170/// The chain tokens [`chain_hrp`] knows, for error messages.
171pub const KNOWN_CHAINS: &[&str] = &[
172    "btc", "mainnet", "bitcoin", "tbtc4", "tbtc3", "signet", "regtest", "txbt4", "xbt",
173];
174
175impl ForgeConfig {
176    /// Normalise the configured prefix: ensure a single leading slash and
177    /// no trailing slash. `""` and `"/"` both normalise to `""` (root
178    /// mount).
179    #[must_use]
180    pub fn normalized_prefix(&self) -> String {
181        let trimmed = self.prefix.trim_matches('/');
182        if trimmed.is_empty() {
183            String::new()
184        } else {
185            format!("/{trimmed}")
186        }
187    }
188
189    /// Is `chain` permitted under the current mainnet policy? Mainnet
190    /// aliases are refused unless [`allow_mainnet`](Self::allow_mainnet).
191    #[must_use]
192    pub fn chain_allowed(&self, chain: &str) -> bool {
193        let lc = chain.to_ascii_lowercase();
194        if MAINNET_ALIASES.contains(&lc.as_str()) {
195            self.allow_mainnet
196        } else {
197            true
198        }
199    }
200
201    /// Check [`Self::default_chain`] the way JSS `activate` does, before any
202    /// other work: a mainnet chain is an error unless
203    /// [`allow_mainnet`](Self::allow_mainnet), and a chain with no known
204    /// address prefix is an error too (no address beats a wrong-network
205    /// address someone might fund).
206    ///
207    /// # Errors
208    ///
209    /// [`ForgeError::BadRequest`] naming the stakes (`MAINNET`, `allow_mainnet`)
210    /// or the unknown chain.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use solid_pod_rs_forge::ForgeConfig;
216    ///
217    /// assert!(ForgeConfig::default().validate_chain().is_ok());
218    /// let mainnet = ForgeConfig { default_chain: "xbt".into(), ..Default::default() };
219    /// assert!(mainnet.validate_chain().unwrap_err().to_string().contains("MAINNET"));
220    /// let unknown = ForgeConfig { default_chain: "dogecoin".into(), ..Default::default() };
221    /// assert!(unknown.validate_chain().unwrap_err().to_string().contains("unknown"));
222    /// ```
223    pub fn validate_chain(&self) -> Result<(), ForgeError> {
224        let chain = &self.default_chain;
225        if !self.chain_allowed(chain) {
226            return Err(ForgeError::BadRequest(format!(
227                "forge: config.chain \"{chain}\" is Bitcoin MAINNET: refusing to derive mainnet \
228anchor addresses. The forge holds the trail key server-side (testnet posture) and never \
229verifies or broadcasts; set allow_mainnet only if you accept that custody"
230            )));
231        }
232        if chain_hrp(chain).is_none() {
233            return Err(ForgeError::BadRequest(format!(
234                "forge: unknown config.chain \"{chain}\" (supported: {})",
235                KNOWN_CHAINS.join(", ")
236            )));
237        }
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn defaults_are_testnet_and_bounded() {
248        let c = ForgeConfig::default();
249        assert_eq!(c.prefix, "/forge");
250        assert_eq!(c.default_chain, "tbtc4");
251        assert!(!c.allow_mainnet);
252        assert_eq!(c.max_body_bytes, 64 * 1024);
253        assert_eq!(c.thread_cap, 500);
254        assert_eq!(c.fetch_concurrency, 8);
255    }
256
257    #[test]
258    fn mainnet_guard_fails_closed() {
259        let c = ForgeConfig::default();
260        for m in ["btc", "BTC", "Bitcoin", "mainnet", "main", "xbt", "XBT"] {
261            assert!(!c.chain_allowed(m), "{m} must be refused by default");
262        }
263        assert!(c.chain_allowed("tbtc4"));
264        assert!(c.chain_allowed("testnet"));
265        assert!(c.chain_allowed("signet"));
266    }
267
268    #[test]
269    fn mainnet_guard_opens_when_allowed() {
270        let c = ForgeConfig {
271            allow_mainnet: true,
272            ..Default::default()
273        };
274        assert!(c.chain_allowed("btc"));
275        assert!(c.chain_allowed("mainnet"));
276    }
277
278    #[test]
279    fn validate_chain_refuses_mainnet_and_unknown_like_jss_activate() {
280        // Transcribed from forge/test.js 'a mainnet chain without allowMainnet
281        // refuses to activate, loudly' (plus xbt, which JSS also treats as mainnet).
282        for chain in ["btc", "mainnet", "bitcoin", "xbt"] {
283            let c = ForgeConfig {
284                default_chain: chain.into(),
285                ..Default::default()
286            };
287            let e = c.validate_chain().unwrap_err().to_string();
288            assert!(e.contains("MAINNET"), "{e}");
289            assert!(e.contains("allow_mainnet"), "{e}");
290        }
291        let unknown = ForgeConfig {
292            default_chain: "dogecoin".into(),
293            ..Default::default()
294        };
295        assert!(unknown
296            .validate_chain()
297            .unwrap_err()
298            .to_string()
299            .contains("unknown config.chain"));
300        // with the override the gate opens
301        let open = ForgeConfig {
302            default_chain: "btc".into(),
303            allow_mainnet: true,
304            ..Default::default()
305        };
306        assert!(open.validate_chain().is_ok());
307        // every testnet token is fine
308        for chain in ["tbtc4", "tbtc3", "signet", "regtest", "txbt4"] {
309            let c = ForgeConfig {
310                default_chain: chain.into(),
311                ..Default::default()
312            };
313            assert!(c.validate_chain().is_ok(), "{chain}");
314        }
315    }
316
317    #[test]
318    fn chain_hrp_table_matches_jss() {
319        assert_eq!(chain_hrp("btc"), Some("bc"));
320        assert_eq!(chain_hrp("xbt"), Some("bc"));
321        assert_eq!(chain_hrp("txbt4"), Some("tb"));
322        assert_eq!(chain_hrp("regtest"), Some("bcrt"));
323        assert_eq!(chain_hrp("testnet"), None);
324    }
325
326    #[test]
327    fn new_anchoring_keys_default_off() {
328        let c = ForgeConfig::default();
329        assert!(!c.anchoring_ui);
330        assert!(!c.sparse_marks);
331    }
332
333    #[test]
334    fn prefix_normalisation() {
335        let mk = |p: &str| ForgeConfig {
336            prefix: p.to_string(),
337            ..Default::default()
338        };
339        assert_eq!(mk("/forge").normalized_prefix(), "/forge");
340        assert_eq!(mk("forge").normalized_prefix(), "/forge");
341        assert_eq!(mk("/forge/").normalized_prefix(), "/forge");
342        assert_eq!(mk("/git/forge/").normalized_prefix(), "/git/forge");
343        assert_eq!(mk("/").normalized_prefix(), "");
344        assert_eq!(mk("").normalized_prefix(), "");
345    }
346}