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() {