mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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
@@ -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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user