diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs index d8cf8bb..cd80ce3 100644 --- a/crates/outer/src/auth/mod.rs +++ b/crates/outer/src/auth/mod.rs @@ -493,7 +493,10 @@ pub async fn verify_code_core( }); } } - if !is_admin(cfg, &email) { + // Only people who joined with an invite get codes to pass on + // (owner, 2026-10-24): not admins, not allowlisted emails, and + // not app collaborators, who are admitted through the allowlist. + if carried_invite.is_some() && !is_admin(cfg, &email) { invites::mint_grants(&mut tx, &id, now) .await .map_err(|err| AuthError::new("db", format!("{err}")))?; diff --git a/crates/outer/src/auth/tests.rs b/crates/outer/src/auth/tests.rs index a1fced5..94ab5f6 100644 --- a/crates/outer/src/auth/tests.rs +++ b/crates/outer/src/auth/tests.rs @@ -1424,9 +1424,11 @@ async fn closed_beta_waitlists_by_default() { .unwrap(); } -/// A fresh account mints exactly 3 grant codes; an admin account mints none. +/// Codes go only to people who joined with an invite: an account admitted +/// any other way (open sign-up, the allowlist — how app collaborators get +/// in) and an admin mint none. The invite round trip below pins the 3. #[tokio::test] -async fn fresh_account_gets_three_grants_admin_gets_none() { +async fn only_invite_joiners_get_grants() { let pool = test_pool().await; let (config, codes) = test_config(); @@ -1448,7 +1450,7 @@ async fn fresh_account_gets_three_grants_admin_gets_none() { .fetch_one(&pool) .await .unwrap(); - assert_eq!(ana_grants, 3); + assert_eq!(ana_grants, 0, "no invite, no codes to pass on"); let (owner_grants,): (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM invite_code WHERE createdBy = \ (SELECT id FROM user WHERE email = 'owner@example.com')", diff --git a/crates/outer/src/deploy/pipeline.rs b/crates/outer/src/deploy/pipeline.rs index e88227a..1e2c102 100644 --- a/crates/outer/src/deploy/pipeline.rs +++ b/crates/outer/src/deploy/pipeline.rs @@ -3063,14 +3063,18 @@ mod tests { async fn test_pool() -> (tempfile::TempDir, SqlitePool) { let dir = tempfile::tempdir().unwrap(); - let pool = crate::db::open(&dir.path().join("outer.sqlite")) - .await - .unwrap(); + let pool = test_pool_in(dir.path()).await; + (dir, pool) + } + /// A pool whose database lives in `dir`; the caller keeps `dir` alive + /// for as long as the pool may open new connections. + async fn test_pool_in(dir: &Path) -> SqlitePool { + let pool = crate::db::open(&dir.join("outer.sqlite")).await.unwrap(); sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('owner1', 'o@example.com', 0, 0, 0)") .execute(&pool) .await .unwrap(); - (dir, pool) + pool } fn jev_client(base_url: &str) -> JevClient { let mut config = JevConfig::new( @@ -3969,7 +3973,10 @@ exit 0 ) { let data = tempfile::tempdir().unwrap(); let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 1); - let (_ddir, pool) = test_pool().await; + // The database lives in `data`, which the caller keeps: a TempDir + // dropped here would delete it while the pool can still open new + // connections ("unable to open database file", flaky in CI). + let pool = test_pool_in(data.path()).await; let app = test_app(10301); insert_test_app(&pool, &app).await; let key = crate::secrets::SecretsKey::test_key(); diff --git a/web/docs/sign-in.mdx b/web/docs/sign-in.mdx index 20844f3..9a9f9f4 100644 --- a/web/docs/sign-in.mdx +++ b/web/docs/sign-in.mdx @@ -30,8 +30,9 @@ After sign-in, `sign_in` and `verify_code` disappear from the tool list. ## Invite codes -Every new account gets 3 invite codes to share (`OUTER-XXXX-XXXX`, shown on -the console's Invites page). A code admits one new account and is spent when +Everyone who joins with an invite code gets 3 codes of their own to share +(`OUTER-XXXX-XXXX`, shown on the console's Invites page). App collaborators +and allowlisted emails get in without a code and get none. A code admits one new account and is spent when that account signs in — allowlisted and existing emails never burn one. Admins generate codes in bulk from the console's Admin page instead of getting automatic ones. diff --git a/web/pages/console/_islands/console.tsx b/web/pages/console/_islands/console.tsx index c49d6ba..8060a3b 100644 --- a/web/pages/console/_islands/console.tsx +++ b/web/pages/console/_islands/console.tsx @@ -784,7 +784,7 @@ export function Console() { -
+