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:
jwinterm
2026-06-30 17:14:53 -04:00
parent 1e08119c5e
commit 610819bb5d
13 changed files with 371 additions and 1 deletions
+17
View File
@@ -129,6 +129,7 @@ pub struct Config {
pub landing: LandingConfig,
pub retention: RetentionConfig,
pub restore: RestoreConfig,
pub registration: RegistrationConfig,
}
#[derive(Clone)]
@@ -392,6 +393,16 @@ impl RestoreConfig {
}
}
/// 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
/// them (run your own backend). PoW lives in [`PowConfig`]; this holds the rest.
#[derive(Clone, Copy)]
pub struct RegistrationConfig {
/// Require a valid operator-minted invite code to register a new wallet.
pub require_invite: bool,
}
impl Config {
/// Load configuration from the environment and validate it (fail-closed).
pub fn from_env() -> Result<Self, AppError> {
@@ -560,6 +571,9 @@ impl Config {
max_depth_days: env_parse("WALLET_MAX_RESTORE_DEPTH_DAYS", 365u32)?,
}
},
registration: RegistrationConfig {
require_invite: env_bool("REGISTRATION_REQUIRE_INVITE", false),
},
};
cfg.validate()?;
@@ -857,6 +871,9 @@ mod tests {
policy: RestorePolicy::Bounded,
max_depth_days: 365,
},
registration: RegistrationConfig {
require_invite: false,
},
}
}