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 <noreply@anthropic.com>
This commit is contained in:
@@ -136,7 +136,15 @@ async def register(
|
|||||||
# Validate platform invite code (skip if account invite was provided)
|
# Validate platform invite code (skip if account invite was provided)
|
||||||
invite_code_record = None
|
invite_code_record = None
|
||||||
if not account_invite_record:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Invite code is required"
|
detail="Invite code is required"
|
||||||
|
|||||||
40
backend/app/api/endpoints/config.py
Normal file
40
backend/app/api/endpoints/config.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -29,6 +29,7 @@ from app.api.endpoints import (
|
|||||||
sales_leads,
|
sales_leads,
|
||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
|
config as config_endpoints,
|
||||||
copilot,
|
copilot,
|
||||||
device_types,
|
device_types,
|
||||||
draft_templates,
|
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(webhooks.router) # Stripe webhook receiver
|
||||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
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(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
|
# Admin endpoints — super_admin only
|
||||||
|
|||||||
18
backend/app/schemas/config.py
Normal file
18
backend/app/schemas/config.py
Normal file
@@ -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]
|
||||||
100
backend/tests/test_config_public.py
Normal file
100
backend/tests/test_config_public.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user