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}