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.
This commit is contained in:
jwinterm
2026-07-19 03:34:39 -04:00
parent 542e70b8e0
commit 13b2557b9b
2 changed files with 360 additions and 1 deletions
+73
View File
@@ -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());
+287 -1
View File
@@ -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<RetentionOverlay>,
pub restore: Option<RestoreOverlay>,
pub console: Option<ConsoleOverlay>,
pub registration: Option<RegistrationOverlay>,
pub pow: Option<PowOverlay>,
pub features: Option<FeaturesOverlay>,
pub relay: Option<RelayOverlay>,
pub premium: Option<PremiumOverlay>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -68,6 +73,84 @@ pub struct ConsoleOverlay {
pub restart_apply_mode: Option<String>,
}
/// 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<bool>,
/// `all` (every enabled gate) | `any` (one-of). PoW is orthogonal.
pub gate_mode: Option<String>,
pub require_payment: Option<bool>,
/// Registration price as a decimal string (no float math).
pub payment_amount: Option<String>,
pub payment_currency: Option<String>,
pub payment_confirmations: Option<u32>,
pub payment_expires_minutes: Option<u32>,
}
/// 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<bool>,
pub required: Option<bool>,
pub cost: Option<u64>,
}
/// 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<bool>,
pub prices_provider: Option<String>,
pub prices_interval_secs: Option<u64>,
pub prices_currency: Option<String>,
pub tips: Option<bool>,
pub nostr_identity: Option<bool>,
pub grin_relay: Option<bool>,
pub chain_btc: Option<bool>,
pub chain_ltc: Option<bool>,
pub chain_xmr: Option<bool>,
pub chain_wow: Option<bool>,
pub chain_grin: Option<bool>,
}
/// 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<bool>,
/// `bundled` | `external`.
pub mode: Option<String>,
/// Public `ws(s)://` URL clients connect to + we advertise.
pub advertised_url: Option<String>,
/// `inbox-outbox` | `author-allowlist` | `open` | `premium-post`.
pub write_policy: Option<String>,
pub inbound_pow_bits: Option<u8>,
pub max_event_bytes: Option<usize>,
pub retention_days: Option<u32>,
}
/// 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<bool>,
pub currency: Option<String>,
}
/// 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()),
}),
}
}
}