diff --git a/.env.example b/.env.example index e774bf4..a72b052 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,14 @@ PAYMENT_CURRENCY=XMR PAYMENT_CONFIRMATIONS=1 PAYMENT_EXPIRES_MINUTES=60 +# How the enabled invite/payment gates COMBINE (PoW is orthogonal and always +# applies on top). 'all' (default): a new wallet must satisfy EVERY enabled gate +# (invite AND payment when both are on). 'any': the enabled gates are +# ALTERNATIVES and the wallet satisfies exactly ONE (e.g. "an invite code OR pay +# to register"); the client shows a "pick a method" chooser. With a single gate +# enabled the two modes are identical. +REGISTRATION_GATE_MODE=all + # ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ───── BTC_NETWORK=mainnet BTC_ELECTRUM_URL= diff --git a/openapi.json b/openapi.json index dbfbbcc..6b0c04a 100644 --- a/openapi.json +++ b/openapi.json @@ -3414,7 +3414,8 @@ "required": [ "invite_required", "pow_required", - "payment_required" + "payment_required", + "registration_mode" ], "properties": { "invite_required": { @@ -3441,6 +3442,10 @@ "pow_required": { "type": "boolean", "description": "A proof-of-work solution is required to register." + }, + "registration_mode": { + "type": "string", + "description": "How the enabled gates combine: `\"all\"` (satisfy every gate; the default)\nor `\"any\"` (the gates are alternatives; satisfy one). PoW is orthogonal\nand applies regardless. The wallet routes onboarding on this: multiple\ngates + `\"any\"` => \"pick a method\" buttons; `\"all\"` => satisfy each." } } }, diff --git a/src/api/auth.rs b/src/api/auth.rs index 50044a1..52028e0 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -483,11 +483,13 @@ pub async fn extension_register( returning_by_pubkey, req.altcha_solution.as_ref(), )?; - enforce_invite(&state, returning_by_pubkey, req.invite_code.as_deref()).await?; - enforce_payment( + // Invite + payment gates, composed per the operator's gate mode (`all` = + // every enabled gate; `any` = one-of). PoW above is orthogonal to both. + enforce_method_gates( &state, returning_by_pubkey, &pubkey_hash, + req.invite_code.as_deref(), req.payment_invoice_id.as_deref(), ) .await?; @@ -610,6 +612,117 @@ fn enforce_pow( Ok(()) } +/// Compose the invite + payment gates per the operator's gate mode. PoW is +/// enforced separately (orthogonal) and always applies on top. Returning wallets +/// and self-hosting (both gates off) pass trivially. +/// +/// - `All` (default): every ENABLED gate must pass, each consuming its own token +/// — the historical conjunctive behavior (invite AND payment when both on). +/// - `Any`: the enabled gates are ALTERNATIVES. With a single gate on it is just +/// that gate. With BOTH on, the client presents exactly ONE method's credential +/// and only that gate runs (and only it consumes its single-use token). +/// Presenting BOTH is rejected outright — a one-of choice must never burn two +/// tokens (an invite AND a paid invoice) in a single request. +/// +/// Dispatching to a single gate's own `enforce_*` preserves its exact errors, +/// including the payment "not yet confirmed" literal the client polls on. +async fn enforce_method_gates( + state: &AppState, + returning: bool, + pubkey_hash: &str, + invite_code: Option<&str>, + payment_invoice_id: Option<&str>, +) -> Result<(), AppError> { + if returning { + return Ok(()); + } + let has_invite = invite_code.map(str::trim).is_some_and(|c| !c.is_empty()); + let has_payment = payment_invoice_id + .map(str::trim) + .is_some_and(|s| !s.is_empty()); + + match plan_gates( + state.config.registration.gate_mode, + state.config.registration.require_invite, + state.config.registration.payment.require_payment, + has_invite, + has_payment, + ) { + GatePlan::Open => Ok(()), + // Conjunction: run both (each self-disables when its own flag is off). + GatePlan::All => { + enforce_invite(state, returning, invite_code).await?; + enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await?; + Ok(()) + } + // Exactly ONE gate runs (and only it consumes its single-use token), + // preserving that gate's own error literals. + GatePlan::Invite => enforce_invite(state, returning, invite_code).await, + GatePlan::Payment => { + enforce_payment(state, returning, pubkey_hash, payment_invoice_id).await + } + GatePlan::RejectBothPresented => Err(AppError::ValidationError( + "Present exactly one registration method, not both.".into(), + )), + GatePlan::RequireOne => Err(AppError::ValidationError( + "Registration on this instance requires an invite code or a settled payment. \ + Choose one and retry." + .into(), + )), + } +} + +/// What to do about the invite + payment gates for a NEW wallet. The pure +/// decision (no db / async), extracted so the fund-critical one-of dispatch is +/// unit-testable. PoW is handled separately (orthogonal). +#[derive(Debug, PartialEq, Eq)] +enum GatePlan { + /// No enabled gates — accept (PoW aside). + Open, + /// Conjunction: every enabled gate must pass (`all` mode). + All, + /// Run only the invite gate. + Invite, + /// Run only the payment gate. + Payment, + /// `any` mode, both gates enabled, and the client presented BOTH credentials + /// — reject rather than risk burning two single-use tokens. + RejectBothPresented, + /// `any` mode, both gates enabled, but no usable credential presented. + RequireOne, +} + +fn plan_gates( + mode: crate::config::GateMode, + require_invite: bool, + require_payment: bool, + has_invite: bool, + has_payment: bool, +) -> GatePlan { + if !require_invite && !require_payment { + return GatePlan::Open; + } + match mode { + crate::config::GateMode::All => GatePlan::All, + crate::config::GateMode::Any => { + // A single enabled gate under `any` is just that gate. + if require_invite && !require_payment { + return GatePlan::Invite; + } + if require_payment && !require_invite { + return GatePlan::Payment; + } + // Both enabled => one-of, dispatched by the presented credential. + match (has_invite, has_payment) { + (true, true) => GatePlan::RejectBothPresented, + (true, false) => GatePlan::Invite, + (false, true) => GatePlan::Payment, + (false, false) => GatePlan::RequireOne, + } + } + } +} + /// Invite-code registration gate. A genuinely new wallet must present a valid, /// unused operator-minted invite code when the instance requires one; a /// returning wallet (known pubkey) bypasses it, exactly like PoW, and an @@ -1457,6 +1570,58 @@ mod tests { assert_ne!(a, hash_public_key("deadbee0")); } + // ── registration gate composition (plan_gates) ───────────────────────── + // The pure one-of / conjunction dispatch. Money-safety hinges on `any` + // mode never planning to run (and thus consume) more than one token. + mod gate_plan { + use super::super::{plan_gates, GatePlan}; + use crate::config::GateMode::{All, Any}; + + #[test] + fn no_gates_enabled_is_open_in_either_mode() { + assert_eq!(plan_gates(All, false, false, false, false), GatePlan::Open); + assert_eq!(plan_gates(Any, false, false, true, true), GatePlan::Open); + } + + #[test] + fn all_mode_is_conjunction_regardless_of_presented_creds() { + // Both enabled -> All (run both). Single enabled -> still All (the + // off gate self-disables inside enforce_*). + assert_eq!(plan_gates(All, true, true, false, false), GatePlan::All); + assert_eq!(plan_gates(All, true, false, false, false), GatePlan::All); + assert_eq!(plan_gates(All, false, true, true, true), GatePlan::All); + } + + #[test] + fn any_mode_single_gate_is_just_that_gate() { + assert_eq!(plan_gates(Any, true, false, false, false), GatePlan::Invite); + assert_eq!(plan_gates(Any, false, true, false, false), GatePlan::Payment); + } + + #[test] + fn any_mode_both_enabled_dispatches_on_presented_credential() { + assert_eq!(plan_gates(Any, true, true, true, false), GatePlan::Invite); + assert_eq!(plan_gates(Any, true, true, false, true), GatePlan::Payment); + } + + #[test] + fn any_mode_rejects_both_credentials_to_avoid_double_consume() { + // The load-bearing money-safety case: never plan to burn two tokens. + assert_eq!( + plan_gates(Any, true, true, true, true), + GatePlan::RejectBothPresented + ); + } + + #[test] + fn any_mode_both_enabled_no_credential_requires_one() { + assert_eq!( + plan_gates(Any, true, true, false, false), + GatePlan::RequireOne + ); + } + } + /// Wire-shape regression: the extension request must accept the wrapped /// `altcha::Payload` envelope and reject a bare `Solution`. #[test] diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index 49fd15d..41edd5c 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -143,6 +143,11 @@ pub struct RegistrationCapability { /// so the wallet can show "registration costs X" before minting an invoice. pub payment_amount: Option, pub payment_currency: Option, + /// How the enabled gates combine: `"all"` (satisfy every gate; the default) + /// or `"any"` (the gates are alternatives; satisfy one). PoW is orthogonal + /// and applies regardless. The wallet routes onboarding on this: multiple + /// gates + `"any"` => "pick a method" buttons; `"all"` => satisfy each. + pub registration_mode: String, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -253,6 +258,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse { .payment .require_payment .then(|| config.registration.payment.currency.clone()), + registration_mode: config.registration.gate_mode.as_str().to_string(), }, messaging: relay_advertised(config).then(|| { let r = &config.messaging.relay; diff --git a/src/config.rs b/src/config.rs index 50c2b19..ff042fb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -481,6 +481,41 @@ impl RestoreConfig { } } +/// How the composable registration gates (invite / payment) combine. PoW is +/// orthogonal and always applies on top, regardless of mode. +/// +/// - `All` (default): a new wallet must satisfy EVERY enabled gate (conjunction) +/// — the historical behavior. +/// - `Any`: the enabled gates are ALTERNATIVES; the wallet satisfies exactly ONE +/// (the client presents that one method's credential). Lets an operator offer +/// e.g. "invite code OR pay to register." +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum GateMode { + #[default] + All, + Any, +} + +impl GateMode { + /// Wire string advertised via `/capabilities` and parsed from env. + pub fn as_str(self) -> &'static str { + match self { + GateMode::All => "all", + GateMode::Any => "any", + } + } + + /// Parse the operator's `REGISTRATION_GATE_MODE`. Anything other than a + /// case-insensitive `any` is the safe default (`all` — conjunction). + pub fn from_env_str(s: &str) -> Self { + if s.eq_ignore_ascii_case("any") { + GateMode::Any + } else { + GateMode::All + } + } +} + /// Registration gates beyond PoW — composable, operator-configured, advertised /// via `/capabilities`. Each is a gate the wallet must satisfy to create a NEW /// identity; returning wallets bypass them, and self-hosting bypasses all of @@ -492,6 +527,9 @@ pub struct RegistrationConfig { /// Pay-to-register gate (settle an invoice on an external, non-custodial /// processor before a new wallet is granted). pub payment: PaymentConfig, + /// How enabled gates combine (`All` = every gate; `Any` = one-of). PoW is + /// orthogonal and applies regardless. + pub gate_mode: GateMode, } /// Pay-to-register gate. When `require_payment` is on, a NEW wallet must present @@ -807,6 +845,7 @@ impl Config { confirmations: env_parse("PAYMENT_CONFIRMATIONS", 1u32)?, expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?, }, + gate_mode: GateMode::from_env_str(&env_or("REGISTRATION_GATE_MODE", "all")), }, messaging: MessagingConfig { relay: RelayConfig { @@ -1367,6 +1406,7 @@ mod tests { confirmations: 1, expires_minutes: 60, }, + gate_mode: GateMode::All, }, messaging: MessagingConfig { relay: RelayConfig {