From 80baf89b003eef4a34f8fc99a611fda9a787431e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 20:38:50 -0400 Subject: [PATCH] feat(config): add SELF_SERVE_ENABLED flag + GET /config/public Phase 2 Task 31. Single flag now controls whether the public-facing self-serve flow is exposed. - New public endpoint GET /api/v1/config/public returns {self_serve_enabled, oauth_providers}. oauth_providers includes "google" if GOOGLE_CLIENT_ID is set and "microsoft" if MS_CLIENT_ID is set. No auth required; consumed once by the frontend at load. - POST /auth/register: when SELF_SERVE_ENABLED=true the platform invite-code requirement is bypassed even with REQUIRE_INVITE_CODE=true. invite_code stays in the schema for backward compat and still applies when supplied. With the flag off, the gate behaves exactly as before. - Adds backend/app/schemas/config.py with PublicConfigResponse and registers the new router in the public/unauthenticated section. - Adds 3 integration tests in tests/test_config_public.py covering the flag round-trip, the regression case (flag off keeps the 400), and the new behavior (flag on bypasses the gate, creates user + Pro trial). Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/auth.py | 10 ++- backend/app/api/endpoints/config.py | 40 +++++++++++ backend/app/api/router.py | 2 + backend/app/schemas/config.py | 18 +++++ backend/tests/test_config_public.py | 100 ++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/config.py create mode 100644 backend/app/schemas/config.py create mode 100644 backend/tests/test_config_public.py diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 44507328..7f5e017f 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -136,7 +136,15 @@ async def register( # Validate platform invite code (skip if account invite was provided) invite_code_record = None if not account_invite_record: - if settings.REQUIRE_INVITE_CODE and not user_data.invite_code: + # When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed + # entirely — public self-serve signup is the whole point. The + # invite_code field stays in the schema for backward compatibility + # and so paid/trial-bearing codes still apply when supplied. + if ( + settings.REQUIRE_INVITE_CODE + and not settings.SELF_SERVE_ENABLED + and not user_data.invite_code + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required" diff --git a/backend/app/api/endpoints/config.py b/backend/app/api/endpoints/config.py new file mode 100644 index 00000000..a621e738 --- /dev/null +++ b/backend/app/api/endpoints/config.py @@ -0,0 +1,40 @@ +"""Public runtime configuration endpoint. + +GET /api/v1/config/public + Returns the small set of runtime flags the frontend needs at app load + to decide whether to render the self-serve signup flow and which OAuth + buttons to show. No authentication required. + +The response model lives in `app.schemas.config` so it can be reused by +frontend codegen and other call sites if needed. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.config import settings +from app.schemas.config import PublicConfigResponse + +router = APIRouter(prefix="/config", tags=["config"]) + + +@router.get("/public", response_model=PublicConfigResponse) +async def get_public_config() -> 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. + """ + providers: list[str] = [] + if settings.GOOGLE_CLIENT_ID: + providers.append("google") + if settings.MS_CLIENT_ID: + providers.append("microsoft") + + return PublicConfigResponse( + self_serve_enabled=settings.SELF_SERVE_ENABLED, + oauth_providers=providers, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 5ef8122c..155fa304 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -29,6 +29,7 @@ from app.api.endpoints import ( sales_leads, branding, categories, + config as config_endpoints, copilot, device_types, draft_templates, @@ -93,6 +94,7 @@ api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-li api_router.include_router(webhooks.router) # Stripe webhook receiver api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited) api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited) +api_router.include_router(config_endpoints.router) # Public runtime feature flags # --------------------------------------------------------------------------- # Admin endpoints — super_admin only diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py new file mode 100644 index 00000000..c9937d8a --- /dev/null +++ b/backend/app/schemas/config.py @@ -0,0 +1,18 @@ +"""Pydantic schemas for public runtime configuration.""" + +from __future__ import annotations + +from typing import List + +from pydantic import BaseModel + + +class PublicConfigResponse(BaseModel): + """Runtime feature flags + OAuth provider list exposed to anonymous clients. + + Read once by the frontend at app load to decide whether to render the + self-serve signup flow and which OAuth buttons to show. + """ + + self_serve_enabled: bool + oauth_providers: List[str] diff --git a/backend/tests/test_config_public.py b/backend/tests/test_config_public.py new file mode 100644 index 00000000..c68738a3 --- /dev/null +++ b/backend/tests/test_config_public.py @@ -0,0 +1,100 @@ +"""Integration tests for the public runtime config endpoint. + +Covers GET /api/v1/config/public and the SELF_SERVE_ENABLED interaction +with the existing /auth/register invite-code gate. +""" + +from __future__ import annotations + +import pytest +from httpx import AsyncClient + +from app.core.config import settings + + +class TestConfigPublic: + """GET /api/v1/config/public — anonymous, no auth.""" + + @pytest.mark.asyncio + async def test_get_config_public_returns_self_serve_flag( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Endpoint reflects the current SELF_SERVE_ENABLED setting and the + configured OAuth providers, with no auth required.""" + # Default-off: SELF_SERVE_ENABLED is False unless explicitly set. + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", None) + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + body = response.json() + assert body == {"self_serve_enabled": False, "oauth_providers": []} + + # Flip it on, with both OAuth providers configured. + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True) + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "google-test-id") + monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id") + + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + body = response.json() + assert body["self_serve_enabled"] is True + assert body["oauth_providers"] == ["google", "microsoft"] + + # Only Microsoft configured. + monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None) + monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id") + response = await client.get("/api/v1/config/public") + assert response.status_code == 200 + assert response.json()["oauth_providers"] == ["microsoft"] + + +class TestRegisterInviteCodeGate: + """Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED.""" + + @pytest.mark.asyncio + async def test_register_invite_code_required_when_self_serve_disabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Pre-self-serve behavior: REQUIRE_INVITE_CODE=True without an + invite code (and no account-invite) must still 400.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "no-invite@example.com", + "password": "SecurePass123!", + "name": "No Invite", + }, + ) + + assert response.status_code == 400 + assert "invite code is required" in response.json()["detail"].lower() + + @pytest.mark.asyncio + async def test_register_invite_code_optional_when_self_serve_enabled( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ): + """Self-serve on: registration succeeds with no invite code even + when REQUIRE_INVITE_CODE is True. The user, personal account, and + a Pro trial subscription are all created.""" + monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True) + monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True) + + response = await client.post( + "/api/v1/auth/register", + json={ + "email": "self-serve@example.com", + "password": "SecurePass123!", + "name": "Self Serve", + }, + ) + + assert response.status_code == 201, response.text + body = response.json() + assert body["email"] == "self-serve@example.com" + assert body["account_role"] == "owner" + assert "account_id" in body