mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(registration): add gate mode (all|any) for alternative registration methods
The invite + payment gates were strictly conjunctive: a new wallet had to satisfy
EVERY enabled gate. Add a REGISTRATION_GATE_MODE knob so an operator can offer the
enabled gates as ALTERNATIVES instead (e.g. "an invite code OR pay to register").
PoW stays orthogonal and always applies on top, regardless of mode.
- config: GateMode { All (default) | Any } on RegistrationConfig, from
REGISTRATION_GATE_MODE (default "all", back-compatible).
- capabilities: advertise registration_mode ("all"|"any") so the wallet can route
onboarding (multiple gates + "any" => "pick a method"; "all" => satisfy each).
- enforcement: enforce_method_gates composes invite+payment per mode. Extracted a
pure plan_gates() decision so the fund-critical dispatch is unit-tested. In
"any" with both gates on, the client presents exactly ONE credential and only
that gate runs (and only it consumes its single-use token); presenting BOTH is
rejected so a one-of choice can never burn two tokens (an invite AND a paid
invoice). Single-gate configs behave identically in both modes.
6 KATs on plan_gates (open / conjunction / single-gate / dispatch / reject-both /
require-one). openapi.json regenerated. 211 lib tests pass.
This commit is contained in:
@@ -66,6 +66,14 @@ PAYMENT_CURRENCY=XMR
|
|||||||
PAYMENT_CONFIRMATIONS=1
|
PAYMENT_CONFIRMATIONS=1
|
||||||
PAYMENT_EXPIRES_MINUTES=60
|
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) ─────
|
# ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ─────
|
||||||
BTC_NETWORK=mainnet
|
BTC_NETWORK=mainnet
|
||||||
BTC_ELECTRUM_URL=
|
BTC_ELECTRUM_URL=
|
||||||
|
|||||||
+6
-1
@@ -3414,7 +3414,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"invite_required",
|
"invite_required",
|
||||||
"pow_required",
|
"pow_required",
|
||||||
"payment_required"
|
"payment_required",
|
||||||
|
"registration_mode"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"invite_required": {
|
"invite_required": {
|
||||||
@@ -3441,6 +3442,10 @@
|
|||||||
"pow_required": {
|
"pow_required": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "A proof-of-work solution is required to register."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+167
-2
@@ -483,11 +483,13 @@ pub async fn extension_register(
|
|||||||
returning_by_pubkey,
|
returning_by_pubkey,
|
||||||
req.altcha_solution.as_ref(),
|
req.altcha_solution.as_ref(),
|
||||||
)?;
|
)?;
|
||||||
enforce_invite(&state, returning_by_pubkey, req.invite_code.as_deref()).await?;
|
// Invite + payment gates, composed per the operator's gate mode (`all` =
|
||||||
enforce_payment(
|
// every enabled gate; `any` = one-of). PoW above is orthogonal to both.
|
||||||
|
enforce_method_gates(
|
||||||
&state,
|
&state,
|
||||||
returning_by_pubkey,
|
returning_by_pubkey,
|
||||||
&pubkey_hash,
|
&pubkey_hash,
|
||||||
|
req.invite_code.as_deref(),
|
||||||
req.payment_invoice_id.as_deref(),
|
req.payment_invoice_id.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -610,6 +612,117 @@ fn enforce_pow(
|
|||||||
Ok(())
|
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,
|
/// Invite-code registration gate. A genuinely new wallet must present a valid,
|
||||||
/// unused operator-minted invite code when the instance requires one; a
|
/// unused operator-minted invite code when the instance requires one; a
|
||||||
/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an
|
/// returning wallet (known pubkey) bypasses it, exactly like PoW, and an
|
||||||
@@ -1457,6 +1570,58 @@ mod tests {
|
|||||||
assert_ne!(a, hash_public_key("deadbee0"));
|
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
|
/// Wire-shape regression: the extension request must accept the wrapped
|
||||||
/// `altcha::Payload` envelope and reject a bare `Solution`.
|
/// `altcha::Payload` envelope and reject a bare `Solution`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ pub struct RegistrationCapability {
|
|||||||
/// so the wallet can show "registration costs X" before minting an invoice.
|
/// so the wallet can show "registration costs X" before minting an invoice.
|
||||||
pub payment_amount: Option<String>,
|
pub payment_amount: Option<String>,
|
||||||
pub payment_currency: Option<String>,
|
pub payment_currency: Option<String>,
|
||||||
|
/// 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)]
|
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||||
@@ -253,6 +258,7 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
|||||||
.payment
|
.payment
|
||||||
.require_payment
|
.require_payment
|
||||||
.then(|| config.registration.payment.currency.clone()),
|
.then(|| config.registration.payment.currency.clone()),
|
||||||
|
registration_mode: config.registration.gate_mode.as_str().to_string(),
|
||||||
},
|
},
|
||||||
messaging: relay_advertised(config).then(|| {
|
messaging: relay_advertised(config).then(|| {
|
||||||
let r = &config.messaging.relay;
|
let r = &config.messaging.relay;
|
||||||
|
|||||||
@@ -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
|
/// Registration gates beyond PoW — composable, operator-configured, advertised
|
||||||
/// via `/capabilities`. Each is a gate the wallet must satisfy to create a NEW
|
/// 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
|
/// 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
|
/// Pay-to-register gate (settle an invoice on an external, non-custodial
|
||||||
/// processor before a new wallet is granted).
|
/// processor before a new wallet is granted).
|
||||||
pub payment: PaymentConfig,
|
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
|
/// 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)?,
|
confirmations: env_parse("PAYMENT_CONFIRMATIONS", 1u32)?,
|
||||||
expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?,
|
expires_minutes: env_parse("PAYMENT_EXPIRES_MINUTES", 60u32)?,
|
||||||
},
|
},
|
||||||
|
gate_mode: GateMode::from_env_str(&env_or("REGISTRATION_GATE_MODE", "all")),
|
||||||
},
|
},
|
||||||
messaging: MessagingConfig {
|
messaging: MessagingConfig {
|
||||||
relay: RelayConfig {
|
relay: RelayConfig {
|
||||||
@@ -1367,6 +1406,7 @@ mod tests {
|
|||||||
confirmations: 1,
|
confirmations: 1,
|
||||||
expires_minutes: 60,
|
expires_minutes: 60,
|
||||||
},
|
},
|
||||||
|
gate_mode: GateMode::All,
|
||||||
},
|
},
|
||||||
messaging: MessagingConfig {
|
messaging: MessagingConfig {
|
||||||
relay: RelayConfig {
|
relay: RelayConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user