feat(billing): add INTERNAL_TESTER_EMAILS allowlist for self-serve soft cutover
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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user