feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 1m57s
CI / frontend (pull_request) Failing after 2m35s
CI / backend (pull_request) Successful in 9m46s

Phase O Task 46 needs internal validation of the full self-serve flow
against the prod backend before flipping SELF_SERVE_ENABLED public. This
adds the per-email allowlist that bypasses the global flag for specific
authenticated users.

- INTERNAL_TESTER_EMAILS: comma-separated list, parsed by a Pydantic
  field_validator into a normalized lowercase list. Settings.is_internal_tester
  and Settings.is_self_serve_active_for centralize the allowlist + global-flag
  check; both endpoints below call the latter.
- New get_current_user_optional dep — best-effort auth that returns None
  on missing/invalid token instead of 401. Used by /config/public so the
  same endpoint serves anonymous public callers and authenticated allowlist
  members.
- /config/public now accepts optional auth and returns self_serve_enabled=True
  for authenticated allowlist members even when the global flag is off.
  Anonymous callers always see the global flag.
- /auth/register replaces the SELF_SERVE_ENABLED check with the helper so a
  registering email on the allowlist can join without an invite code.
  Non-allowlist emails still 400 when self-serve is off.
- docker-compose.dev.yml passes SELF_SERVE_ENABLED + INTERNAL_TESTER_EMAILS
  through; backend/.env.example documents both.

Tests cover: allowlisted authenticated user sees true, non-allowlisted
authenticated user sees the global flag, anonymous calls ignore the
allowlist, allowlisted email registers without invite code, non-allowlisted
email still blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:57:25 -04:00
parent a628b2410d
commit 8494366ec6
7 changed files with 200 additions and 6 deletions

View File

@@ -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(

View File

@@ -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,
)