diff --git a/backend/.env.example b/backend/.env.example index 28880cdb..05d46396 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,4 +29,14 @@ CW_CLIENT_ID= # When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit. STRIPE_SECRET_KEY=sk_test_ STRIPE_PUBLISHABLE_KEY=pk_test_ -STRIPE_WEBHOOK_SECRET=whsec_ \ No newline at end of file +STRIPE_WEBHOOK_SECRET=whsec_ + +# Self-serve cutover +# SELF_SERVE_ENABLED is the master switch for the public self-serve signup +# flow (pricing page, invite-code-optional registration). Default is false +# until Phase O cutover. +# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the +# global flag for specific users — used for prod test-mode validation +# before the public flip. Empty by default. +SELF_SERVE_ENABLED=false +INTERNAL_TESTER_EMAILS= \ No newline at end of file diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 32ada630..67717d45 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -64,6 +64,40 @@ async def get_current_user( return user +async def get_current_user_optional( + request: Request, + db: Annotated[AsyncSession, Depends(get_admin_db)], +) -> Optional[User]: + """Best-effort current user for endpoints that work both anonymous and authed. + + Returns None on missing/invalid/expired token instead of raising. Used by + surfaces like /config/public that anonymous clients can hit but where an + authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS + allowlist override). + """ + auth_header = request.headers.get("Authorization") or request.headers.get("authorization") + if not auth_header or not auth_header.lower().startswith("bearer "): + return None + token = auth_header.split(None, 1)[1].strip() + if not token: + return None + + payload = decode_token(token) + if payload is None or payload.get("type") != "access": + return None + + user_id = payload.get("sub") + if user_id is None: + return None + try: + user_uuid = UUID(user_id) + except ValueError: + return None + + result = await db.execute(select(User).where(User.id == user_uuid)) + return result.scalar_one_or_none() + + async def get_refresh_token_payload( token: Annotated[str, Depends(oauth2_scheme)] ) -> dict: diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 7b8a1ae1..62cce07c 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -150,7 +150,7 @@ async def register( # and so paid/trial-bearing codes still apply when supplied. if ( settings.REQUIRE_INVITE_CODE - and not settings.SELF_SERVE_ENABLED + and not settings.is_self_serve_active_for(user_data.email) and not user_data.invite_code ): raise HTTPException( diff --git a/backend/app/api/endpoints/config.py b/backend/app/api/endpoints/config.py index a621e738..3ae5156a 100644 --- a/backend/app/api/endpoints/config.py +++ b/backend/app/api/endpoints/config.py @@ -11,22 +11,31 @@ frontend codegen and other call sites if needed. from __future__ import annotations -from fastapi import APIRouter +from typing import Annotated, Optional +from fastapi import APIRouter, Depends + +from app.api.deps import get_current_user_optional from app.core.config import settings +from app.models.user import User from app.schemas.config import PublicConfigResponse router = APIRouter(prefix="/config", tags=["config"]) @router.get("/public", response_model=PublicConfigResponse) -async def get_public_config() -> PublicConfigResponse: +async def get_public_config( + current_user: Annotated[Optional[User], Depends(get_current_user_optional)], +) -> PublicConfigResponse: """Return public-safe runtime config. `oauth_providers` reflects which OAuth client IDs are configured server side; the frontend uses it to render only buttons that will actually succeed. `self_serve_enabled` is the master switch for the new public - self-serve signup flow. + self-serve signup flow; an authenticated caller whose email is on the + INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag + is off, so internal validation in prod test mode can exercise the full + surface before the public flip. """ providers: list[str] = [] if settings.GOOGLE_CLIENT_ID: @@ -34,7 +43,8 @@ async def get_public_config() -> PublicConfigResponse: if settings.MS_CLIENT_ID: providers.append("microsoft") + user_email = current_user.email if current_user else None return PublicConfigResponse( - self_serve_enabled=settings.SELF_SERVE_ENABLED, + self_serve_enabled=settings.is_self_serve_active_for(user_email), oauth_providers=providers, ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 815c95db..9c5bd838 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -97,6 +97,40 @@ class Settings(BaseSettings): STRIPE_WEBHOOK_SECRET: Optional[str] = None SELF_SERVE_ENABLED: bool = False + # Internal tester allowlist for soft cutover. Comma-separated emails; + # when SELF_SERVE_ENABLED is False, listed users still see the self-serve + # surfaces (pricing page, invite-code-optional registration, etc.) so the + # full flow can be exercised in prod test mode before public flip. + INTERNAL_TESTER_EMAILS: list[str] = [] + + @field_validator("INTERNAL_TESTER_EMAILS", mode="before") + @classmethod + def split_internal_tester_emails(cls, v) -> list[str]: + """Parse a comma-separated string into a normalized lowercase list.""" + if v is None or v == "": + return [] + if isinstance(v, list): + return [e.strip().lower() for e in v if e and e.strip()] + if isinstance(v, str): + return [e.strip().lower() for e in v.split(",") if e.strip()] + return [] + + def is_internal_tester(self, email: Optional[str]) -> bool: + """Case-insensitive allowlist check. None/empty email is never a tester.""" + if not email: + return False + return email.lower() in self.INTERNAL_TESTER_EMAILS + + def is_self_serve_active_for(self, email: Optional[str]) -> bool: + """True if self-serve surfaces should render for this user. + + Either the global flag is on, or the user is on the internal-tester + allowlist. Anonymous calls (email is None) only see the global flag. + """ + if self.SELF_SERVE_ENABLED: + return True + return self.is_internal_tester(email) + @property def stripe_enabled(self) -> bool: """Check if Stripe is configured.""" diff --git a/backend/tests/test_config_public.py b/backend/tests/test_config_public.py index c68738a3..e06e886f 100644 --- a/backend/tests/test_config_public.py +++ b/backend/tests/test_config_public.py @@ -49,6 +49,58 @@ class TestConfigPublic: assert response.status_code == 200 assert response.json()["oauth_providers"] == ["microsoft"] + @pytest.mark.asyncio + async def test_get_config_public_returns_true_for_internal_tester( + self, + client: AsyncClient, + auth_headers: dict, + test_user: dict, + monkeypatch: pytest.MonkeyPatch, + ): + """Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees + self_serve_enabled=True even when the global flag is off.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()]) + + response = await client.get("/api/v1/config/public", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is True + + @pytest.mark.asyncio + async def test_get_config_public_returns_false_for_non_tester_when_global_off( + self, + client: AsyncClient, + auth_headers: dict, + monkeypatch: pytest.MonkeyPatch, + ): + """Authenticated user NOT on the allowlist sees the global flag — + prevents accidental opt-in via stale credentials or empty allowlist.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"]) + + response = await client.get("/api/v1/config/public", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is False + + @pytest.mark.asyncio + async def test_get_config_public_anonymous_ignores_allowlist( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Anonymous callers always see the global flag — the allowlist is + keyed on authenticated identity, not request content.""" + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"]) + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + assert response.json()["self_serve_enabled"] is False + class TestRegisterInviteCodeGate: """Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED.""" @@ -98,3 +150,55 @@ class TestRegisterInviteCodeGate: assert body["email"] == "self-serve@example.com" assert body["account_role"] == "owner" assert "account_id" in body + + @pytest.mark.asyncio + async def test_register_invite_code_optional_for_internal_tester( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """SELF_SERVE_ENABLED is False but the registering email is on + INTERNAL_TESTER_EMAILS — registration should succeed without an + invite code, matching the per-email soft-cutover behavior.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr( + settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"] + ) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "tester@example.com", + "password": "SecurePass123!", + "name": "Internal Tester", + }, + ) + + assert response.status_code == 201, response.text + body = response.json() + assert body["email"] == "tester@example.com" + assert body["account_role"] == "owner" + + @pytest.mark.asyncio + async def test_register_blocked_for_non_tester_when_self_serve_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Registering with an email NOT on the allowlist still 400s when + self-serve is off and no invite code is provided. Prevents the + allowlist from leaking to public users.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr( + settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"] + ) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "outsider@example.com", + "password": "SecurePass123!", + "name": "Outsider", + }, + ) + + assert response.status_code == 400 + assert "invite code is required" in response.json()["detail"].lower() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 97b88b37..337635e5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -48,6 +48,8 @@ services: - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - SELF_SERVE_ENABLED=${SELF_SERVE_ENABLED:-false} + - INTERNAL_TESTER_EMAILS=${INTERNAL_TESTER_EMAILS:-} - ENABLE_MCP_MICROSOFT_LEARN=true - FRONTEND_URL=http://docker-01:5173 - CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]