Skip to main content

max / makenotwork

Harden invites and TOTP replay; add enum-drift guard Invites: store SHA-256 hashes instead of plaintext (migration 159 widens the column and hashes existing codes in place) and redeem via a guarded atomic UPDATE (WHERE redeemed_by_id IS NULL) so a single-use invite can never onboard two racing redeemers. Shared crypto::sha256_hex primitive. TOTP: set_totp_last_used_step now advances the step atomically (WHERE $2 > COALESCE(step,0)) and returns whether it won; both the confirm and login-2FA paths gate acceptance on that, closing the check-then-set replay race. Observability: add #[tracing::instrument] to the 14 cli_features internal handlers that were missing it. Enum-drift guard: impl_str_enum! now emits VARIANTS; a new integration test asserts each enum's variant set equals its column's Postgres CHECK list. This caught a real drift -- transactions.status permits 'failed' but the Rust enum lacked the variant, so a stray row would fail-closed-poison reads; added the Failed variant to decode it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 17:04 UTC
Commit: 4835beb6d1f0b2b68262bfe74f16d170f90faa8e
Parent: 7045240
14 files changed, +306 insertions, -52 deletions
@@ -0,0 +1,16 @@
1 + -- Store invite codes hashed, never in plaintext (audit 2026-07-01): the lone
2 + -- single-use credential that was readable at rest. A DB read must not yield a
3 + -- directly-usable invite, matching the reset/login/git-PAT posture.
4 + --
5 + -- Widen the column first (a SHA-256 hex digest is 64 chars, the old cap was 12),
6 + -- then hash existing plaintext codes in place so outstanding invites keep
7 + -- working. `encode(sha256(...))` yields lowercase hex, matching the Rust
8 + -- `crypto::invite_code_hash` output.
9 +
10 + ALTER TABLE invite_codes ALTER COLUMN code TYPE TEXT;
11 +
12 + UPDATE invite_codes
13 + SET code = encode(sha256(code::bytea), 'hex')
14 + -- A 64-char lowercase-hex value is already a hash; only rewrite plaintext rows
15 + -- so the migration is idempotent if partially applied.
16 + WHERE code !~ '^[0-9a-f]{64}$';
@@ -149,12 +149,26 @@ pub fn generate_git_token() -> (String, String) {
149 149 (plaintext, hash)
150 150 }
151 151
152 + /// Lowercase SHA-256 hex digest of a string. The shared primitive behind the
153 + /// "store a hash, never the plaintext" credential pattern so a DB read can't
154 + /// recover a usable secret.
155 + pub fn sha256_hex(input: &str) -> String {
156 + use sha2::{Digest, Sha256};
157 + Sha256::digest(input.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
158 + }
159 +
152 160 /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
153 161 /// token and when verifying one on a request, so the stored hash is never the
154 162 /// plaintext and a DB read can't recover a usable credential.
155 163 pub fn git_token_hash(token: &str) -> String {
156 - use sha2::{Digest, Sha256};
157 - Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
164 + sha256_hex(token)
165 + }
166 +
167 + /// SHA-256 hex digest of an invite code. Stored in place of the plaintext so a
168 + /// DB read yields no directly-usable invite (matches the reset/login/git-PAT
169 + /// posture). Callers normalize the code (uppercase, dashes stripped) first.
170 + pub fn invite_code_hash(code: &str) -> String {
171 + sha256_hex(code)
158 172 }
159 173
160 174 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
@@ -80,6 +80,16 @@ macro_rules! impl_str_enum {
80 80 s == other
81 81 }
82 82 }
83 +
84 + impl $enum_name {
85 + /// Every wire/DB string this enum maps to. The single source of truth
86 + /// for the variant set; the enum-drift test asserts it equals the
87 + /// Postgres `CHECK (... IN (...))` list for each backing column, so a
88 + /// variant added here without widening the DB constraint (or vice
89 + /// versa) fails at test time instead of at the first read of a
90 + /// poisoned row.
91 + pub const VARIANTS: &'static [&'static str] = &[$($str),+];
92 + }
83 93 };
84 94 }
85 95
@@ -157,6 +167,12 @@ pub enum TransactionStatus {
157 167 /// against double-submit on shared-cart PaymentIntents (Pay-S1, Run 9).
158 168 Refunding,
159 169 Refunded,
170 + /// Present in the DB `CHECK` since the initial schema but never written by
171 + /// the app (stale pending transactions are deleted, not failed). Kept as a
172 + /// variant so the enum can decode any legacy/manual `'failed'` row instead of
173 + /// fail-closed-poisoning the whole query — and so the enum-drift test's
174 + /// variant set matches the column constraint.
175 + Failed,
160 176 }
161 177
162 178 impl_str_enum!(TransactionStatus {
@@ -164,6 +180,7 @@ impl_str_enum!(TransactionStatus {
164 180 Completed => "completed",
165 181 Refunding => "refunding",
166 182 Refunded => "refunded",
183 + Failed => "failed",
167 184 });
168 185
169 186 // ── Follows ──
@@ -42,6 +42,8 @@ pub async fn create_invite_code(
42 42 creator_id: UserId,
43 43 code: &str,
44 44 ) -> Result<DbInviteCode> {
45 + // Store the hash, never the plaintext: a DB read must not yield a usable
46 + // invite. The caller keeps the raw `code` to show/share with the creator.
45 47 let invite = sqlx::query_as::<_, DbInviteCode>(
46 48 r#"
47 49 INSERT INTO invite_codes (creator_id, code)
@@ -50,7 +52,7 @@ pub async fn create_invite_code(
50 52 "#,
51 53 )
52 54 .bind(creator_id)
53 - .bind(code)
55 + .bind(crate::crypto::invite_code_hash(code))
54 56 .fetch_one(pool)
55 57 .await?;
56 58
@@ -83,35 +85,44 @@ pub async fn get_invites_by_creator(pool: &PgPool, creator_id: UserId) -> Result
83 85 Ok(invites)
84 86 }
85 87
86 - /// Look up an unredeemed invite code by its raw code string.
88 + /// Look up an unredeemed invite code by its raw code string. The raw code is
89 + /// hashed before lookup (codes are stored hashed); the caller normalizes it
90 + /// (uppercase, dashes stripped) first.
87 91 #[tracing::instrument(skip_all)]
88 92 pub async fn get_valid_invite_code(pool: &PgPool, code: &str) -> Result<Option<DbInviteCode>> {
89 93 let invite = sqlx::query_as::<_, DbInviteCode>(
90 94 "SELECT * FROM invite_codes WHERE code = $1 AND redeemed_by_id IS NULL",
91 95 )
92 - .bind(code)
96 + .bind(crate::crypto::invite_code_hash(code))
93 97 .fetch_optional(pool)
94 98 .await?;
95 99
96 100 Ok(invite)
97 101 }
98 102
99 - /// Mark an invite code as redeemed by a specific user.
103 + /// Atomically claim an invite code for a user. Returns `true` if this call won
104 + /// the claim, `false` if the code was already redeemed (lost race / reused).
105 + ///
106 + /// The `AND redeemed_by_id IS NULL` guard makes the read-then-write in the
107 + /// caller race-free: two concurrent redeemers both pass `get_valid_invite_code`
108 + /// but only one satisfies the guarded UPDATE, so a single-use invite can never
109 + /// onboard two users.
100 110 #[tracing::instrument(skip_all)]
101 111 pub async fn redeem_invite_code(
102 112 pool: &PgPool,
103 113 code_id: InviteCodeId,
104 114 user_id: UserId,
105 - ) -> Result<()> {
106 - sqlx::query(
107 - "UPDATE invite_codes SET redeemed_by_id = $2, redeemed_at = NOW() WHERE id = $1",
115 + ) -> Result<bool> {
116 + let claimed = sqlx::query_scalar::<_, InviteCodeId>(
117 + "UPDATE invite_codes SET redeemed_by_id = $2, redeemed_at = NOW() \
118 + WHERE id = $1 AND redeemed_by_id IS NULL RETURNING id",
108 119 )
109 120 .bind(code_id)
110 121 .bind(user_id)
111 - .execute(pool)
122 + .fetch_optional(pool)
112 123 .await?;
113 124
114 - Ok(())
125 + Ok(claimed.is_some())
115 126 }
116 127
117 128 #[cfg(test)]
@@ -41,7 +41,7 @@ pub(crate) mod scanning;
41 41 pub(crate) mod scan_jobs;
42 42 pub(crate) mod scan_admin_actions;
43 43 pub(crate) mod content_insertions;
44 - pub(crate) mod invites;
44 + pub mod invites; // pub so the integration test crate can exercise redemption races directly
45 45 pub(crate) mod analytics;
46 46 pub(crate) mod email_suppressions;
47 47 pub mod git_repos;
@@ -82,28 +82,27 @@ pub async fn disable_totp(pool: &PgPool, user_id: UserId) -> Result<()> {
82 82 Ok(())
83 83 }
84 84
85 - /// Get the last accepted TOTP time step for a user (for replay prevention).
86 - #[tracing::instrument(skip_all)]
87 - pub async fn get_totp_last_used_step(pool: &PgPool, user_id: UserId) -> Result<Option<i64>> {
88 - let step: Option<i64> =
89 - sqlx::query_scalar("SELECT totp_last_used_step FROM users WHERE id = $1")
90 - .bind(user_id)
91 - .fetch_one(pool)
92 - .await?;
93 -
94 - Ok(step)
95 - }
96 -
97 - /// Update the last accepted TOTP time step (set after successful verification).
85 + /// Atomically advance the last accepted TOTP time step. Returns `true` if this
86 + /// call recorded the step (it was strictly newer than the stored one), `false`
87 + /// if a concurrent verification already consumed this step or a later one.
88 + ///
89 + /// The `AND $2 > totp_last_used_step` guard makes the read-then-write in the
90 + /// caller race-free: two concurrent submissions of the same code both read the
91 + /// old step and both match, but only the winner's UPDATE affects a row — so a
92 + /// code cannot be accepted twice within its 30s window. Callers must gate
93 + /// acceptance on the returned bool, not on their own prior step read.
98 94 #[tracing::instrument(skip_all)]
99 - pub async fn set_totp_last_used_step(pool: &PgPool, user_id: UserId, step: i64) -> Result<()> {
100 - sqlx::query("UPDATE users SET totp_last_used_step = $2 WHERE id = $1")
101 - .bind(user_id)
102 - .bind(step)
103 - .execute(pool)
104 - .await?;
95 + pub async fn set_totp_last_used_step(pool: &PgPool, user_id: UserId, step: i64) -> Result<bool> {
96 + let updated = sqlx::query_scalar::<_, UserId>(
97 + "UPDATE users SET totp_last_used_step = $2 \
98 + WHERE id = $1 AND $2 > COALESCE(totp_last_used_step, 0) RETURNING id",
99 + )
100 + .bind(user_id)
101 + .bind(step)
102 + .fetch_optional(pool)
103 + .await?;
105 104
106 - Ok(())
105 + Ok(updated.is_some())
107 106 }
108 107
109 108 /// Check if a user has TOTP 2FA enabled.
@@ -36,6 +36,7 @@ struct TagView {
36 36 }
37 37
38 38 /// GET /api/internal/creator/items/{id}/tags?user_id=...
39 + #[tracing::instrument(skip_all, name = "internal::list_item_tags")]
39 40 pub(super) async fn list_item_tags(
40 41 State(state): State<AppState>,
41 42 _auth: ServiceAuth,
@@ -67,6 +68,7 @@ pub(super) async fn list_item_tags(
67 68 }
68 69
69 70 /// POST /api/internal/creator/items/tags
71 + #[tracing::instrument(skip_all, name = "internal::add_item_tag")]
70 72 pub(super) async fn add_item_tag(
71 73 State(state): State<AppState>,
72 74 _auth: ServiceAuth,
@@ -96,6 +98,7 @@ pub(super) async fn add_item_tag(
96 98 }
97 99
98 100 /// POST /api/internal/creator/items/tags/remove
101 + #[tracing::instrument(skip_all, name = "internal::remove_item_tag")]
99 102 pub(super) async fn remove_item_tag(
100 103 State(state): State<AppState>,
101 104 _auth: ServiceAuth,
@@ -126,6 +129,7 @@ pub(super) struct TagSearchQuery {
126 129 }
127 130
128 131 /// GET /api/internal/tags/search?q=...
132 + #[tracing::instrument(skip_all, name = "internal::search_tags")]
129 133 pub(super) async fn search_tags(
130 134 State(state): State<AppState>,
131 135 _auth: ServiceAuth,
@@ -154,6 +158,7 @@ pub(super) struct BroadcastRequest {
154 158 }
155 159
156 160 /// POST /api/internal/creator/broadcast
161 + #[tracing::instrument(skip_all, name = "internal::send_broadcast")]
157 162 pub(super) async fn send_broadcast(
158 163 State(state): State<AppState>,
159 164 _auth: ServiceAuth,
@@ -265,6 +270,7 @@ struct TierView {
265 270 }
266 271
267 272 /// GET /api/internal/creator/projects/{id}/tiers?user_id=...
273 + #[tracing::instrument(skip_all, name = "internal::list_tiers")]
268 274 pub(super) async fn list_tiers(
269 275 State(state): State<AppState>,
270 276 _auth: ServiceAuth,
@@ -315,6 +321,7 @@ struct CollectionView {
315 321 }
316 322
317 323 /// GET /api/internal/creator/collections?user_id=...
324 + #[tracing::instrument(skip_all, name = "internal::list_collections")]
318 325 pub(super) async fn list_collections(
319 326 State(state): State<AppState>,
320 327 _auth: ServiceAuth,
@@ -337,6 +344,7 @@ pub(super) async fn list_collections(
337 344 }
338 345
339 346 /// POST /api/internal/creator/collections
347 + #[tracing::instrument(skip_all, name = "internal::create_collection")]
340 348 pub(super) async fn create_collection(
341 349 State(state): State<AppState>,
342 350 _auth: ServiceAuth,
@@ -362,6 +370,7 @@ pub(super) async fn create_collection(
362 370 }
363 371
364 372 /// DELETE /api/internal/creator/collections/{id}?user_id=...
373 + #[tracing::instrument(skip_all, name = "internal::delete_collection")]
365 374 pub(super) async fn delete_collection(
366 375 State(state): State<AppState>,
367 376 _auth: ServiceAuth,
@@ -389,6 +398,7 @@ pub(super) struct AddDomainRequest {
389 398 }
390 399
391 400 /// GET /api/internal/creator/domain?user_id=...
401 + #[tracing::instrument(skip_all, name = "internal::get_domain")]
392 402 pub(super) async fn get_domain(
393 403 State(state): State<AppState>,
394 404 _auth: ServiceAuth,
@@ -407,6 +417,7 @@ pub(super) async fn get_domain(
407 417 }
408 418
409 419 /// POST /api/internal/creator/domain
420 + #[tracing::instrument(skip_all, name = "internal::add_domain")]
410 421 pub(super) async fn add_domain(
411 422 State(state): State<AppState>,
412 423 _auth: ServiceAuth,
@@ -433,6 +444,7 @@ pub(super) async fn add_domain(
433 444 }
434 445
435 446 /// POST /api/internal/creator/domain/verify?user_id=...
447 + #[tracing::instrument(skip_all, name = "internal::verify_domain")]
436 448 pub(super) async fn verify_domain(
437 449 State(state): State<AppState>,
438 450 _auth: ServiceAuth,
@@ -485,6 +497,7 @@ pub(super) async fn verify_domain(
485 497 }
486 498
487 499 /// DELETE /api/internal/creator/domain?user_id=...
500 + #[tracing::instrument(skip_all, name = "internal::remove_domain")]
488 501 pub(super) async fn remove_domain(
489 502 State(state): State<AppState>,
490 503 _auth: ServiceAuth,
@@ -553,6 +566,7 @@ struct CreateProjectResponse {
553 566 }
554 567
555 568 /// POST /api/internal/creator/projects
569 + #[tracing::instrument(skip_all, name = "internal::create_project")]
556 570 pub(super) async fn create_project(
557 571 State(state): State<AppState>,
558 572 _auth: ServiceAuth,
@@ -101,20 +101,25 @@ pub(super) async fn confirm(
101 101 // accepted step, and that step is then recorded. `setup` cleared the step to
102 102 // NULL (defaults to 0), so a genuine first code (step ~= now/30) always wins.
103 103 let now = chrono::Utc::now().timestamp() as u64;
104 - let matched_step = find_matching_step(&totp, &form.code, now);
105 - let last_step = db::totp::get_totp_last_used_step(&state.db, user.id)
106 - .await?
107 - .unwrap_or(0);
108 - let Some(step) = matched_step.filter(|&step| step > last_step) else {
109 - return Ok((
104 + let invalid = || {
105 + (
110 106 [("HX-Retarget", "#totp-confirm-status"), ("HX-Reswap", "innerHTML")],
111 107 Html("<span class=\"save-error\">Invalid code. Please try again.</span>"),
112 108 )
113 - .into_response());
109 + .into_response()
114 110 };
115 111
116 - // Record the matched step to prevent replay on the very first login.
117 - db::totp::set_totp_last_used_step(&state.db, user.id, step).await?;
112 + let Some(step) = find_matching_step(&totp, &form.code, now) else {
113 + return Ok(invalid());
114 + };
115 +
116 + // Record the matched step atomically; the guarded write is the authoritative
117 + // replay gate. A concurrent submission of the same code that already advanced
118 + // the step loses here and is rejected — the step monotonicity is enforced by
119 + // the DB, not by a separate read-then-write.
120 + if !db::totp::set_totp_last_used_step(&state.db, user.id, step).await? {
121 + return Ok(invalid());
122 + }
118 123
119 124 db::totp::enable_totp(&state.db, user.id).await?;
120 125
@@ -207,8 +207,11 @@ pub async fn step_account_create(
207 207 let code = code_raw.replace('-', "").trim().to_uppercase();
208 208 if !code.is_empty()
209 209 && let Some(invite) = db::invites::get_valid_invite_code(&state.db, &code).await?
210 + // Atomic claim: if a concurrent signup redeemed the same code first,
211 + // this returns false and we skip the invite side-effects (the signup
212 + // itself already succeeded).
213 + && db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?
210 214 {
211 - db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?;
212 215 db::waitlist::create_invited_waitlist_entry(&state.db, user.id, invite.creator_id)
213 216 .await?;
214 217
@@ -197,12 +197,12 @@ pub(super) async fn verify_two_factor(
197 197 let now = chrono::Utc::now().timestamp() as u64;
198 198 let matched_step = find_matching_step(&totp, &code, now);
199 199 if let Some(step) = matched_step {
200 - let last_step = db::totp::get_totp_last_used_step(&state.db, user_id).await?.unwrap_or(0);
201 - if step > last_step {
202 - db::totp::set_totp_last_used_step(&state.db, user_id, step).await?;
203 - verified = true;
204 - }
205 - // If step <= last_step, the code was already used — fall through to backup codes
200 + // The guarded write is the authoritative replay gate: it
201 + // advances the step only if strictly newer, atomically. A
202 + // concurrent submission of the same code that already advanced
203 + // the step returns false here and falls through to backup
204 + // codes instead of being accepted twice.
205 + verified = db::totp::set_totp_last_used_step(&state.db, user_id, step).await?;
206 206 }
207 207 }
208 208 Err(e) => {
@@ -0,0 +1,99 @@
1 + //! Guards against tri-source enum drift: every domain enum's variant set must
2 + //! equal the Postgres `CHECK (... IN (...))` list on its backing column. The
3 + //! enums decode fail-closed (an unknown string errors the whole row read), so a
4 + //! value the DB permits but the Rust enum can't decode would poison any query
5 + //! that touches such a row. This test makes that drift fail in CI instead of at
6 + //! the first read of a poisoned row.
7 + //!
8 + //! `EnumType::VARIANTS` is the single source of truth (generated by
9 + //! `impl_str_enum!`); the DB side is read from the live, fully-migrated schema
10 + //! so later `ALTER ... CHECK` migrations are accounted for.
11 +
12 + use crate::harness::TestHarness;
13 + use makenotwork::db;
14 + use std::collections::BTreeSet;
15 +
16 + /// Extract the single-quoted string literals from a `pg_get_constraintdef`
17 + /// rendering (Postgres renders `col IN ('a','b')` as
18 + /// `CHECK ((col)::text = ANY (ARRAY['a'::text, 'b'::text]))`; the allowed values
19 + /// are exactly the single-quoted tokens).
20 + fn quoted_values(constraint_def: &str) -> BTreeSet<String> {
21 + let mut out = BTreeSet::new();
22 + let mut chars = constraint_def.char_indices().peekable();
23 + while let Some((_, c)) = chars.next() {
24 + if c == '\'' {
25 + let mut val = String::new();
26 + for (_, c2) in chars.by_ref() {
27 + if c2 == '\'' {
28 + break;
29 + }
30 + val.push(c2);
31 + }
32 + out.insert(val);
33 + }
34 + }
35 + out
36 + }
37 +
38 + /// Read the union of allowed values across every single-column CHECK constraint
39 + /// on exactly `table.column`. Constraining to `conkey = [column]` excludes
40 + /// cross-column CHECKs (which would leak an unrelated column's literals).
41 + async fn db_check_values(pool: &sqlx::PgPool, table: &str, column: &str) -> BTreeSet<String> {
42 + let defs: Vec<String> = sqlx::query_scalar(
43 + "SELECT pg_get_constraintdef(c.oid) \
44 + FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid \
45 + WHERE t.relname = $1 AND c.contype = 'c' \
46 + AND c.conkey = ARRAY[ \
47 + (SELECT a.attnum FROM pg_attribute a WHERE a.attrelid = t.oid AND a.attname = $2) \
48 + ]::smallint[]",
49 + )
50 + .bind(table)
51 + .bind(column)
52 + .fetch_all(pool)
53 + .await
54 + .unwrap();
55 +
56 + assert!(
57 + !defs.is_empty(),
58 + "no CHECK constraint found on {table}.{column} — update the enum-drift registry"
59 + );
60 + defs.iter().flat_map(|d| quoted_values(d)).collect()
61 + }
62 +
63 + fn variant_set(variants: &[&str]) -> BTreeSet<String> {
64 + variants.iter().map(|s| s.to_string()).collect()
65 + }
66 +
67 + #[tokio::test]
68 + async fn enum_variants_match_db_check_constraints() {
69 + let h = TestHarness::new().await;
70 +
71 + // (label, table, column, Rust variant set). Each pair's DB CHECK list must
72 + // equal the enum's VARIANTS. Add a row when a new enum backs a CHECK column.
73 + // Only enum-backed columns that actually carry an `IN (...)` CHECK. Columns
74 + // that store a broad external status set (e.g. subscriptions.status mirrors
75 + // Stripe) intentionally have no CHECK and are not listed. Grow this as new
76 + // CHECK-constrained enum columns land.
77 + let cases: Vec<(&str, &str, &str, BTreeSet<String>)> = vec![
78 + ("TransactionStatus", "transactions", "status", variant_set(db::TransactionStatus::VARIANTS)),
79 + ("DiscountType", "promo_codes", "discount_type", variant_set(db::DiscountType::VARIANTS)),
80 + ("CodePurpose", "promo_codes", "code_purpose", variant_set(db::CodePurpose::VARIANTS)),
81 + ("FollowTargetType", "follows", "target_type", variant_set(db::FollowTargetType::VARIANTS)),
82 + ];
83 +
84 + let mut failures = Vec::new();
85 + for (label, table, column, variants) in &cases {
86 + let db_values = db_check_values(&h.db, table, column).await;
87 + if &db_values != variants {
88 + failures.push(format!(
89 + "{label} ({table}.{column}):\n rust VARIANTS = {variants:?}\n db CHECK = {db_values:?}"
90 + ));
91 + }
92 + }
93 +
94 + assert!(
95 + failures.is_empty(),
96 + "enum <-> DB CHECK drift detected:\n{}",
97 + failures.join("\n")
98 + );
99 + }
@@ -71,6 +71,78 @@ async fn invite_redeem_on_signup() {
71 71 assert!(redeemed.is_some(), "Invite code should have been redeemed");
72 72 }
73 73
74 + /// Invite codes are stored hashed, never in plaintext: a DB read of the `code`
75 + /// column must yield a SHA-256 hex digest, not the shareable code.
76 + #[tokio::test]
77 + async fn invite_code_stored_hashed() {
78 + let mut h = TestHarness::new().await;
79 + let creator_id = h.signup("invhash", "invhash@test.com", "password123").await;
80 + h.grant_creator(creator_id).await;
81 +
82 + let raw = "TESTCODE1234";
83 + makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw)
84 + .await
85 + .unwrap();
86 +
87 + let stored: String =
88 + sqlx::query_scalar("SELECT code FROM invite_codes WHERE creator_id = $1")
89 + .bind(creator_id)
90 + .fetch_one(&h.db)
91 + .await
92 + .unwrap();
93 +
94 + assert_ne!(stored, raw, "plaintext invite code must not be stored");
95 + assert_eq!(stored, makenotwork::crypto::invite_code_hash(raw), "stored value must be the code hash");
96 + assert_eq!(stored.len(), 64, "SHA-256 hex digest is 64 chars");
97 +
98 + // The hashed lookup still resolves the raw code.
99 + let found = makenotwork::db::invites::get_valid_invite_code(&h.db, raw)
100 + .await
101 + .unwrap();
102 + assert!(found.is_some(), "hashed lookup should resolve the raw code");
103 + }
104 +
105 + /// A single-use invite can never onboard two users: concurrent redemptions of
106 + /// the same code — both of which pass the validity read — resolve to exactly one
107 + /// winner via the guarded atomic UPDATE.
108 + #[tokio::test]
109 + async fn invite_redeemed_once_under_race() {
110 + let mut h = TestHarness::new().await;
111 + let creator_id = h.signup("invrace", "invrace@test.com", "password123").await;
112 + h.grant_creator(creator_id).await;
113 +
114 + let raw = "RACECODE5678";
115 + makenotwork::db::invites::create_invite_code(&h.db, creator_id, raw)
116 + .await
117 + .unwrap();
118 + let invite = makenotwork::db::invites::get_valid_invite_code(&h.db, raw)
119 + .await
120 + .unwrap()
121 + .expect("invite should be valid");
122 +
123 + let user_a = h.signup("racea", "racea@test.com", "password123").await;
124 + let user_b = h.signup("raceb", "raceb@test.com", "password123").await;
125 +
126 + // Both readers saw the invite as unredeemed; only one write may win.
127 + let first = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_a)
128 + .await
129 + .unwrap();
130 + let second = makenotwork::db::invites::redeem_invite_code(&h.db, invite.id, user_b)
131 + .await
132 + .unwrap();
133 +
134 + assert!(first, "first redemption should win the claim");
135 + assert!(!second, "second redemption of the same code must lose");
136 +
137 + let redeemer: uuid::Uuid =
138 + sqlx::query_scalar("SELECT redeemed_by_id FROM invite_codes WHERE id = $1")
139 + .bind(invite.id)
140 + .fetch_one(&h.db)
141 + .await
142 + .unwrap();
143 + assert_eq!(redeemer, *user_a.as_uuid(), "the winner must be the only redeemer");
144 + }
145 +
74 146 #[tokio::test]
75 147 async fn invite_limit_enforced() {
76 148 let mut h = TestHarness::new().await;
@@ -5,6 +5,7 @@ mod custom_pages;
5 5 mod sso;
6 6 mod discover;
7 7 mod embeds;
8 + mod enum_drift;
8 9 mod streaming;
9 10 mod creator;
10 11 mod purchase;
@@ -814,11 +814,13 @@ async fn join_wizard_with_invite_code() {
814 814 // Create a creator who can issue invites
815 815 let creator_id = h.create_creator("jinviter").await;
816 816
817 - // Create an invite code directly in DB
817 + // Create an invite code directly in DB. Codes are stored hashed, so insert
818 + // the hash of the raw code the wizard will submit.
818 819 sqlx::query(
819 - "INSERT INTO invite_codes (creator_id, code) VALUES ($1, 'TESTCODE')",
820 + "INSERT INTO invite_codes (creator_id, code) VALUES ($1, $2)",
820 821 )
821 822 .bind(creator_id)
823 + .bind(makenotwork::crypto::invite_code_hash("TESTCODE"))
822 824 .execute(&h.db)
823 825 .await
824 826 .unwrap();
@@ -842,10 +844,11 @@ async fn join_wizard_with_invite_code() {
842 844 .await;
843 845 assert!(resp.status.is_success(), "Signup with invite failed: {}", resp.text);
844 846
845 - // Verify invite redeemed
847 + // Verify invite redeemed (query by creator; the code column holds a hash now)
846 848 let redeemed: bool = sqlx::query_scalar(
847 - "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE code = 'TESTCODE'",
849 + "SELECT redeemed_by_id IS NOT NULL FROM invite_codes WHERE creator_id = $1",
848 850 )
851 + .bind(creator_id)
849 852 .fetch_one(&h.db)
850 853 .await
851 854 .unwrap();