From 13b2557b9b920e145786eaf417c3f592010a02cf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 19 Jul 2026 03:34:39 -0400 Subject: [PATCH] feat(config): editable registration, pow, features, relay, premium sections Extends the operator settings overlay with five sections, all non-secret by construction (api_key, ALTCHA hmac_key, peppers, and processor wiring stay in env): - registration: require_invite, gate_mode, require_payment + payment amount/currency/confirmations/expires (runtime-safe: read per-request) - pow: enabled, required, cost (runtime-safe) - features: prices + provider/interval/currency, tips, nostr_identity, grin_relay, and per-chain enablement (restart-required: boot workers) - relay: enabled, mode, advertised_url, write_policy, inbound_pow_bits, max_event_bytes, retention (restart-required; bind + allowlist excluded) - premium: enabled, currency (restart-required; priced plans stay in env) Every PUT re-runs Config::validate, so enabling a gate with incomplete wiring (e.g. pow without a key, pay-to-register without a processor) is rejected at save, never booted disarmed. Tests: identity round-trip (proves read-field == write-field for every entry), a hard assert that no secret can appear in the editable overlay, gate_mode typo rejection, and pow-without-key fail-closed. --- src/config.rs | 73 +++++++++++ src/config_overlay.rs | 288 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 360 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 03fb4d2..aa6bd82 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1555,6 +1555,79 @@ mod tests { } } + // ── operator settings overlay (config_overlay.rs) ─────────────────────── + // These live here (not in config_overlay's own test mod) because they need the + // fully-populated `valid()` Config to exercise apply_overlay/editable_overlay. + + #[test] + fn editable_overlay_round_trips_as_identity() { + // Applying the effective overlay back onto the config must be a no-op: it + // proves every field editable_overlay() READS is the same field + // apply_overlay() WRITES. A single mismatched wire diverges the two views. + use crate::config_overlay::SettingsOverlay; + let base = valid(); + let effective = base.editable_overlay(); + let ov: SettingsOverlay = + serde_json::from_value(serde_json::to_value(&effective).unwrap()).unwrap(); + let applied = base.apply_overlay(&ov).expect("identity overlay applies"); + assert_eq!( + serde_json::to_value(applied.editable_overlay()).unwrap(), + serde_json::to_value(&effective).unwrap(), + ); + } + + #[test] + fn editable_overlay_never_exposes_a_secret() { + // The overlay is the read+write surface of the admin plane. No secret may + // ever appear in it — secrets are excluded BY CONSTRUCTION (no overlay + // field maps to one), and this asserts that structurally. + let mut c = valid(); + c.registration.payment.api_key = "LEAKMARK_PAYMENT_APIKEY".into(); + c.pow.hmac_key = "LEAKMARK_ALTCHA_HMAC".into(); + c.admin.jwt_secret = "LEAKMARK_ADMIN_JWT".into(); + c.admin.key_integrity_secret = "LEAKMARK_ADMIN_MAC".into(); + c.auth.jwt_secret = "LEAKMARK_AUTH_JWT".into(); + c.secrets.seed_fingerprint_pepper = "LEAKMARK_SEED_PEPPER".into(); + let json = serde_json::to_string(&c.editable_overlay()).unwrap(); + assert!( + !json.contains("LEAKMARK"), + "a secret leaked into the editable overlay: {json}" + ); + } + + #[test] + fn overlay_gate_mode_rejects_a_typo() { + use crate::config_overlay::SettingsOverlay; + let ov: SettingsOverlay = + serde_json::from_str(r#"{"registration":{"gate_mode":"nonsense"}}"#).unwrap(); + assert!(valid().apply_overlay(&ov).is_err()); + } + + #[test] + fn overlay_toggles_registration_gates() { + use crate::config_overlay::SettingsOverlay; + let ov: SettingsOverlay = serde_json::from_str( + r#"{"registration":{"require_invite":true,"gate_mode":"any"}}"#, + ) + .unwrap(); + let applied = valid().apply_overlay(&ov).expect("valid registration overlay applies"); + assert!(applied.registration.require_invite); + assert_eq!(applied.registration.gate_mode, GateMode::Any); + } + + #[test] + fn overlay_enabling_pow_without_hmac_key_is_rejected() { + // Fail-closed: turning the PoW gate on via the console with no HMAC key + // (a secret that stays in env) must be rejected at PUT, not boot a disarmed + // gate that /capabilities reports as active. + use crate::config_overlay::SettingsOverlay; + let mut base = valid(); + base.pow.hmac_key = String::new(); + let ov: SettingsOverlay = + serde_json::from_str(r#"{"pow":{"enabled":true,"required":true}}"#).unwrap(); + assert!(base.apply_overlay(&ov).is_err()); + } + #[test] fn valid_config_passes() { assert!(valid().validate().is_ok()); diff --git a/src/config_overlay.rs b/src/config_overlay.rs index 62c44f1..f44d598 100644 --- a/src/config_overlay.rs +++ b/src/config_overlay.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; -use crate::config::{Config, RestartApplyMode, RestorePolicy}; +use crate::config::{Config, GateMode, RestartApplyMode, RestorePolicy}; use crate::error::AppError; /// Sparse patch over the env-derived [`Config`]. Each section is independent; a `None` @@ -22,6 +22,11 @@ pub struct SettingsOverlay { pub retention: Option, pub restore: Option, pub console: Option, + pub registration: Option, + pub pow: Option, + pub features: Option, + pub relay: Option, + pub premium: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -68,6 +73,84 @@ pub struct ConsoleOverlay { pub restart_apply_mode: Option, } +/// Registration gating POLICY knobs (read per-request → runtime-safe). The +/// processor WIRING (provider, provider_url, store_id) and the SECRET api_key stay +/// in env by design: they pair with the secret and are set-once infra, so they are +/// deliberately absent here. Toggling `require_payment` on with incomplete wiring +/// is rejected by `Config::validate` at PUT time (fail-closed). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RegistrationOverlay { + pub require_invite: Option, + /// `all` (every enabled gate) | `any` (one-of). PoW is orthogonal. + pub gate_mode: Option, + pub require_payment: Option, + /// Registration price as a decimal string (no float math). + pub payment_amount: Option, + pub payment_currency: Option, + pub payment_confirmations: Option, + pub payment_expires_minutes: Option, +} + +/// Proof-of-work signup gate (read per-request → runtime-safe). The HMAC key is a +/// SECRET and stays in env; enabling the gate without it is rejected by validate. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PowOverlay { + pub enabled: Option, + pub required: Option, + pub cost: Option, +} + +/// Feature flags + per-chain enablement. Each gates a boot-built client or worker +/// (price poller, chain adapters), so these are RESTART-REQUIRED. Enabling a chain +/// with no infra configured is rejected in production by validate (fail-closed). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FeaturesOverlay { + pub prices: Option, + pub prices_provider: Option, + pub prices_interval_secs: Option, + pub prices_currency: Option, + pub tips: Option, + pub nostr_identity: Option, + pub grin_relay: Option, + pub chain_btc: Option, + pub chain_ltc: Option, + pub chain_xmr: Option, + pub chain_wow: Option, + pub chain_grin: Option, +} + +/// Nostr relay NON-BIND, non-secret scalars. The admission-service BIND address +/// (a registration oracle) and the write-allowlist (a list) are deliberately +/// absent — bind is security-load-bearing and stays in env. RESTART-REQUIRED (the +/// relay/admission service is boot-bound and the URL is advertised at boot). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RelayOverlay { + pub enabled: Option, + /// `bundled` | `external`. + pub mode: Option, + /// Public `ws(s)://` URL clients connect to + we advertise. + pub advertised_url: Option, + /// `inbox-outbox` | `author-allowlist` | `open` | `premium-post`. + pub write_policy: Option, + pub inbound_pow_bits: Option, + pub max_event_bytes: Option, + pub retention_days: Option, +} + +/// Premium subscription master switch + currency. The priced PLANS are a list and +/// stay env-configured until a structured list editor exists. RESTART-REQUIRED +/// (premium is enforced by the boot-bound relay admission service). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PremiumOverlay { + pub enabled: Option, + pub currency: Option, +} + /// Whether a changed field applies live or needs a restart. Drives BOTH the hot-swap /// decision and the `GET /admin/config` annotation — the single source of truth. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -91,6 +174,17 @@ pub fn runtime_class(section: &str, field: &str) -> RuntimeClass { ("retention", _) => RuntimeSafe, ("restore", _) => RuntimeSafe, ("console", _) => RuntimeSafe, + // Registration gates + PoW are read via `state.cfg()` on the registration + // path (auth.rs / capabilities.rs), so an overlay change lands on the next + // request — hot-swappable. + ("registration", _) => RuntimeSafe, + ("pow", _) => RuntimeSafe, + // Feature flags / chain enablement / relay / premium all gate boot-built + // clients, workers, or the boot-bound admission service — a live flip would + // desync the running process, so they apply on a graceful restart. + ("features", _) => RestartRequired, + ("relay", _) => RestartRequired, + ("premium", _) => RestartRequired, _ => RestartRequired, } } @@ -192,6 +286,126 @@ impl Config { } } + if let Some(rg) = &ov.registration { + if let Some(v) = rg.require_invite { + c.registration.require_invite = v; + } + if let Some(m) = &rg.gate_mode { + // GateMode maps to a Rust enum, and env parsing is lenient (unknown + // -> All); a console PUT should reject a typo instead of silently + // weakening the gate, so match strictly here. + c.registration.gate_mode = match m.to_lowercase().as_str() { + "all" => GateMode::All, + "any" => GateMode::Any, + other => { + return Err(AppError::ValidationError(format!( + "invalid registration.gate_mode {other:?} (want all|any)" + ))) + } + }; + } + if let Some(v) = rg.require_payment { + c.registration.payment.require_payment = v; + } + if let Some(v) = &rg.payment_amount { + c.registration.payment.amount = v.clone(); + } + if let Some(v) = &rg.payment_currency { + // Match env normalization (`.to_uppercase()`). + c.registration.payment.currency = v.to_uppercase(); + } + if let Some(v) = rg.payment_confirmations { + c.registration.payment.confirmations = v; + } + if let Some(v) = rg.payment_expires_minutes { + c.registration.payment.expires_minutes = v; + } + } + + if let Some(p) = &ov.pow { + if let Some(v) = p.enabled { + c.pow.enabled = v; + } + if let Some(v) = p.required { + c.pow.required = v; + } + if let Some(v) = p.cost { + c.pow.cost = v; + } + } + + if let Some(f) = &ov.features { + if let Some(v) = f.prices { + c.features.prices = v; + } + if let Some(v) = &f.prices_provider { + c.features.prices_provider = v.to_lowercase(); + } + if let Some(v) = f.prices_interval_secs { + c.features.prices_interval_secs = v; + } + if let Some(v) = &f.prices_currency { + c.features.prices_currency = v.to_lowercase(); + } + if let Some(v) = f.tips { + c.features.tips = v; + } + if let Some(v) = f.nostr_identity { + c.features.nostr_identity = v; + } + if let Some(v) = f.grin_relay { + c.features.grin_relay = v; + } + if let Some(v) = f.chain_btc { + c.features.chains.btc = v; + } + if let Some(v) = f.chain_ltc { + c.features.chains.ltc = v; + } + if let Some(v) = f.chain_xmr { + c.features.chains.xmr = v; + } + if let Some(v) = f.chain_wow { + c.features.chains.wow = v; + } + if let Some(v) = f.chain_grin { + c.features.chains.grin = v; + } + } + + if let Some(r) = &ov.relay { + if let Some(v) = r.enabled { + c.messaging.relay.enabled = v; + } + if let Some(v) = &r.mode { + c.messaging.relay.mode = v.to_lowercase(); + } + if let Some(v) = &r.advertised_url { + c.messaging.relay.advertised_url = v.clone(); + } + if let Some(v) = &r.write_policy { + c.messaging.relay.write_policy = v.to_lowercase(); + } + if let Some(v) = r.inbound_pow_bits { + c.messaging.relay.inbound_pow_bits = v; + } + if let Some(v) = r.max_event_bytes { + c.messaging.relay.max_event_bytes = v; + } + if let Some(v) = r.retention_days { + c.messaging.relay.retention_days = v; + } + } + + if let Some(pr) = &ov.premium { + if let Some(v) = pr.enabled { + c.premium.enabled = v; + } + if let Some(v) = &pr.currency { + c.premium.currency = v.to_uppercase(); + } + } + c.validate()?; Ok(c) } @@ -220,6 +434,37 @@ pub const EDITABLE_FIELDS: &[(&str, &str)] = &[ ("restore", "pow_days_per_bit"), ("restore", "pow_max_bits"), ("console", "restart_apply_mode"), + ("registration", "require_invite"), + ("registration", "gate_mode"), + ("registration", "require_payment"), + ("registration", "payment_amount"), + ("registration", "payment_currency"), + ("registration", "payment_confirmations"), + ("registration", "payment_expires_minutes"), + ("pow", "enabled"), + ("pow", "required"), + ("pow", "cost"), + ("features", "prices"), + ("features", "prices_provider"), + ("features", "prices_interval_secs"), + ("features", "prices_currency"), + ("features", "tips"), + ("features", "nostr_identity"), + ("features", "grin_relay"), + ("features", "chain_btc"), + ("features", "chain_ltc"), + ("features", "chain_xmr"), + ("features", "chain_wow"), + ("features", "chain_grin"), + ("relay", "enabled"), + ("relay", "mode"), + ("relay", "advertised_url"), + ("relay", "write_policy"), + ("relay", "inbound_pow_bits"), + ("relay", "max_event_bytes"), + ("relay", "retention_days"), + ("premium", "enabled"), + ("premium", "currency"), ]; impl Config { @@ -257,6 +502,47 @@ impl Config { console: Some(ConsoleOverlay { restart_apply_mode: Some(self.console.restart_apply_mode.as_str().to_string()), }), + registration: Some(RegistrationOverlay { + require_invite: Some(self.registration.require_invite), + gate_mode: Some(self.registration.gate_mode.as_str().to_string()), + require_payment: Some(self.registration.payment.require_payment), + payment_amount: Some(self.registration.payment.amount.clone()), + payment_currency: Some(self.registration.payment.currency.clone()), + payment_confirmations: Some(self.registration.payment.confirmations), + payment_expires_minutes: Some(self.registration.payment.expires_minutes), + }), + pow: Some(PowOverlay { + enabled: Some(self.pow.enabled), + required: Some(self.pow.required), + cost: Some(self.pow.cost), + }), + features: Some(FeaturesOverlay { + prices: Some(self.features.prices), + prices_provider: Some(self.features.prices_provider.clone()), + prices_interval_secs: Some(self.features.prices_interval_secs), + prices_currency: Some(self.features.prices_currency.clone()), + tips: Some(self.features.tips), + nostr_identity: Some(self.features.nostr_identity), + grin_relay: Some(self.features.grin_relay), + chain_btc: Some(self.features.chains.btc), + chain_ltc: Some(self.features.chains.ltc), + chain_xmr: Some(self.features.chains.xmr), + chain_wow: Some(self.features.chains.wow), + chain_grin: Some(self.features.chains.grin), + }), + relay: Some(RelayOverlay { + enabled: Some(self.messaging.relay.enabled), + mode: Some(self.messaging.relay.mode.clone()), + advertised_url: Some(self.messaging.relay.advertised_url.clone()), + write_policy: Some(self.messaging.relay.write_policy.clone()), + inbound_pow_bits: Some(self.messaging.relay.inbound_pow_bits), + max_event_bytes: Some(self.messaging.relay.max_event_bytes), + retention_days: Some(self.messaging.relay.retention_days), + }), + premium: Some(PremiumOverlay { + enabled: Some(self.premium.enabled), + currency: Some(self.premium.currency.clone()), + }), } } }