diff --git a/ROADMAP.md b/ROADMAP.md index 72c2aa3..fe43718 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,9 +237,18 @@ pieces exist; this phase closes the gaps that block real usage. ever conflicts, Docker's bind error lands in `deployments.error` and shows in the failed-deploy log tail — not silent. Revisit if a service-level host-port config is ever added. -- [ ] Auth hardening: e2e verify bootstrap gate (first user registers, second - is refused, manual creation works), OAuth happy paths for GitHub + - Google, session expiry redirect. +- [x] Auth hardening: e2e-verified against a live stack (scratch Postgres + + Redis + real Better Auth sidecar). Fixed a real gate hole: sign-ups write + `auth_users` but the gate counted `users`, which only mirrors on the + first authenticated call — a second registration slipped through the + pre-mirror window. `countLocalUsers` now unions both tables (verified: + `users=0, auth_users=1` already returns `mode: login` and 403s on both + public register paths; internal-token manual creation works, wrong token + refused). Session expiry redirect confirmed in `App.tsx` + (401 → null → `/auth/sign-in?redirect=…`). OAuth happy paths remain + unverifiable without real GitHub/Google credentials — providers are + env-gated and `disableImplicitSignUp` means OAuth cannot bypass the + gate by creating accounts. **Gate**: a fresh instance can register → create project → connect GitHub repo → push → see build → see deploy → view logs → roll back, with zero manual API diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 4d9f5f9..8ee42d2 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -264,8 +264,7 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - var count int - err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + count, err := countLocalUsers(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return @@ -432,6 +431,25 @@ func countLocalUsers(db *database.DB) (int, error) { if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { return 0, err } + // Better Auth writes to auth_users; a row there only mirrors into users on + // the first authenticated call. Count it too or a second public sign-up + // slips through the bootstrap window. UNION by email avoids double-counting + // mirrored users. + var authTable *string + if err := db.QueryRow("SELECT to_regclass('auth_users')").Scan(&authTable); err != nil { + return 0, err + } + if authTable != nil && *authTable != "" { + if err := db.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT email FROM users + UNION + SELECT email FROM auth_users + ) u + `).Scan(&count); err != nil { + return 0, err + } + } return count, nil }