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
+89
View File
@@ -0,0 +1,89 @@
//! L1: invite-code registration gate.
//!
//! The security-critical invariant is **atomic single-use**: a code redeems at
//! most ONE registration, even under concurrent submission (otherwise one invite
//! grants unbounded Sybil registrations). The redemption is a single-statement
//! `UPDATE ... WHERE used_at IS NULL ... RETURNING`, so the database enforces it;
//! these tests lock that in (incl. a concurrency race).
mod common;
use std::sync::Arc;
use smirk_backend_core::core::invite::{generate_invite_code, hash_invite_code};
#[tokio::test]
async fn invite_code_is_single_use() {
let app = require_app!();
let hash = hash_invite_code(&generate_invite_code());
app.state
.db
.insert_invite_code(&hash, Some("test-batch"))
.await
.unwrap();
assert!(
app.state.db.claim_invite_code(&hash).await.unwrap(),
"first redemption must succeed"
);
assert!(
!app.state.db.claim_invite_code(&hash).await.unwrap(),
"a reused code must be rejected"
);
}
#[tokio::test]
async fn unknown_invite_code_is_rejected() {
let app = require_app!();
let unknown = hash_invite_code(&generate_invite_code());
assert!(
!app.state.db.claim_invite_code(&unknown).await.unwrap(),
"a never-minted code must be rejected"
);
}
#[tokio::test]
async fn concurrent_claims_redeem_at_most_once() {
let app = require_app!();
let hash = hash_invite_code(&generate_invite_code());
app.state.db.insert_invite_code(&hash, None).await.unwrap();
// Fire many concurrent redemptions of the SAME code; exactly one may win.
let mut handles = Vec::new();
for _ in 0..8 {
let state = Arc::clone(&app.state);
let h = hash.clone();
handles.push(tokio::spawn(async move {
state.db.claim_invite_code(&h).await.unwrap()
}));
}
let mut wins = 0;
for handle in handles {
if handle.await.unwrap() {
wins += 1;
}
}
assert_eq!(
wins, 1,
"exactly one concurrent claim may redeem a single-use code"
);
}
#[tokio::test]
async fn unused_count_is_queryable_and_nonnegative() {
// The unused count is a global doctor metric, so under parallel tests we
// can't assert exact deltas (siblings mint/claim concurrently). We assert it
// is queryable and reflects at least our own still-unused code; the "claimed
// codes are excluded" property is the same `used_at IS NULL` predicate the
// single-use test already locks in.
let app = require_app!();
app.state
.db
.insert_invite_code(&hash_invite_code(&generate_invite_code()), None)
.await
.unwrap();
assert!(
app.state.db.unused_invite_code_count().await.unwrap() >= 1,
"count must include the just-minted unused code"
);
}