mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-23 18:28:43 +02:00
feat(registration): composable gate framework + invite codes (gate #1)
First of the operator-configurable registration gates (PoW-priced restore and pay-to-register follow). A NEW wallet must present a valid invite code when REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it, and the active gates are advertised via /capabilities so the wallet shapes onboarding. - migration + db: invite_codes table storing only sha256(code) (128-bit codes, no pepper needed). Redemption is an atomic single-statement claim (UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent submission (L1 test fires 8 concurrent claims; exactly one wins). - core::invite: generate (base58, 128-bit) + sha256 hash. - enforce_invite in extension_register, mirroring enforce_pow (returning bypass; ONE literal error for not-found/used/expired — never an oracle). - RegistrationConfig.require_invite + RegistrationCapability in /capabilities. - smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes). - L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
This commit is contained in:
@@ -303,6 +303,11 @@ pub struct ExtensionRegisterRequest {
|
||||
#[serde(default)]
|
||||
#[schema(value_type = Object)]
|
||||
pub altcha_solution: Option<altcha::Payload>,
|
||||
/// Operator-minted invite code. Required when the instance enables the invite
|
||||
/// registration gate (see `/capabilities` → `registration.invite_required`);
|
||||
/// ignored otherwise and for returning wallets.
|
||||
#[serde(default)]
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Register a new extension wallet or authenticate an existing one.
|
||||
@@ -460,6 +465,7 @@ 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?;
|
||||
|
||||
// If we reached here after an unproven fingerprint match, do NOT attach that
|
||||
// fingerprint to the new row — it belongs to another user, and the UNIQUE
|
||||
@@ -579,6 +585,48 @@ fn enforce_pow(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// instance with the gate off accepts everyone. The claim is atomic and
|
||||
/// single-use (db layer), so a code is redeemable by at most one registration
|
||||
/// even under concurrent submission. Self-hosting bypasses this entirely
|
||||
/// (operators simply leave the gate off).
|
||||
async fn enforce_invite(
|
||||
state: &AppState,
|
||||
returning: bool,
|
||||
invite_code: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
if returning || !state.config.registration.require_invite {
|
||||
return Ok(());
|
||||
}
|
||||
let code = invite_code
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| {
|
||||
warn!(
|
||||
gate = "invite",
|
||||
"invite required for new wallet but none supplied"
|
||||
);
|
||||
AppError::ValidationError(
|
||||
"An invite code is required to register on this instance.".into(),
|
||||
)
|
||||
})?;
|
||||
if !state
|
||||
.db
|
||||
.claim_invite_code(&crate::core::invite::hash_invite_code(code))
|
||||
.await?
|
||||
{
|
||||
// One literal for "no such code", "already used", and "expired" — never
|
||||
// an oracle that distinguishes them.
|
||||
return Err(AppError::ValidationError(
|
||||
"Invalid or already-used invite code.".into(),
|
||||
));
|
||||
}
|
||||
info!(gate = "invite", "invite code accepted (new user)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA-256 hex of a public key string — the wallet's stable identity handle.
|
||||
fn hash_public_key(public_key: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -60,6 +60,17 @@ pub struct RestoreCapability {
|
||||
pub max_depth_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// Registration gates this instance enforces for a NEW wallet (returning wallets
|
||||
/// and self-hosting bypass them). The wallet uses these to shape onboarding —
|
||||
/// prompt for an invite code, solve PoW, etc.
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct RegistrationCapability {
|
||||
/// A valid operator-minted invite code is required to register.
|
||||
pub invite_required: bool,
|
||||
/// A proof-of-work solution is required to register.
|
||||
pub pow_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct CapabilitiesResponse {
|
||||
/// Backend version (Cargo package version).
|
||||
@@ -70,6 +81,8 @@ pub struct CapabilitiesResponse {
|
||||
pub features: FeatureCapabilities,
|
||||
/// Wallet restore (import) policy for this instance.
|
||||
pub restore: RestoreCapability,
|
||||
/// Registration gates for a new wallet on this instance.
|
||||
pub registration: RegistrationCapability,
|
||||
}
|
||||
|
||||
/// Whether an enabled chain can actually be served (its infra secret/URL is
|
||||
@@ -135,6 +148,10 @@ pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
registration: RegistrationCapability {
|
||||
invite_required: config.registration.require_invite,
|
||||
pow_required: config.pow.enabled && config.pow.required,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ use utoipa::OpenApi;
|
||||
crate::api::capabilities::ChainCapability,
|
||||
crate::api::capabilities::FeatureCapabilities,
|
||||
crate::api::capabilities::RestoreCapability,
|
||||
crate::api::capabilities::RegistrationCapability,
|
||||
crate::api::prices::PricesResponse,
|
||||
crate::api::auth::AuthResponse,
|
||||
crate::api::auth::UserInfo,
|
||||
|
||||
Reference in New Issue
Block a user