diff --git a/docs/SITE_BUILDER_DESIGN.md b/docs/SITE_BUILDER_DESIGN.md
new file mode 100644
index 0000000..7ddec21
--- /dev/null
+++ b/docs/SITE_BUILDER_DESIGN.md
@@ -0,0 +1,576 @@
+# Puck Site Builder - Design Document
+
+## Overview
+
+This document describes the architecture, data model, migration strategy, and security decisions for the SmoothSchedule Puck-based site builder.
+
+## Goals
+
+1. **Production-quality site builder** - Enable tenants to build unique pages using nested layout primitives, theme tokens, and booking-native blocks
+2. **Backward compatibility** - Existing pages must continue to render
+3. **Multi-tenant safety** - Full tenant isolation for all page data
+4. **Security** - No arbitrary script injection; sanitized embeds only
+5. **Feature gating** - Hide/disable blocks based on plan without breaking existing content
+
+## Data Model
+
+### Current Schema (Existing)
+
+```
+Site
+├── tenant (OneToOne → Tenant)
+├── primary_domain
+├── is_enabled
+├── template_key
+└── pages[] (Page)
+
+Page
+├── site (FK → Site)
+├── slug
+├── path
+├── title
+├── is_home
+├── is_published
+├── order
+├── puck_data (JSONField - Puck Data payload)
+└── version (int - for migrations)
+```
+
+### New Schema Additions
+
+#### SiteConfig (New Model)
+
+Stores global theme tokens and chrome settings. One per Site, not duplicated per page.
+
+```python
+class SiteConfig(models.Model):
+ site = models.OneToOneField(Site, on_delete=models.CASCADE, related_name='config')
+
+ # Theme Tokens
+ theme = models.JSONField(default=dict)
+ # Structure:
+ # {
+ # "colors": {
+ # "primary": "#3b82f6",
+ # "secondary": "#64748b",
+ # "accent": "#f59e0b",
+ # "background": "#ffffff",
+ # "surface": "#f8fafc",
+ # "text": "#1e293b",
+ # "textMuted": "#64748b"
+ # },
+ # "typography": {
+ # "fontFamily": "Inter, system-ui, sans-serif",
+ # "headingFontFamily": null, # null = use fontFamily
+ # "baseFontSize": "16px",
+ # "scale": 1.25 # type scale ratio
+ # },
+ # "buttons": {
+ # "borderRadius": "8px",
+ # "primaryStyle": "solid", # solid | outline | ghost
+ # "secondaryStyle": "outline"
+ # },
+ # "sections": {
+ # "containerMaxWidth": "1280px",
+ # "defaultPaddingY": "80px"
+ # }
+ # }
+
+ # Global Chrome
+ header = models.JSONField(default=dict)
+ # Structure:
+ # {
+ # "enabled": true,
+ # "logo": { "src": "", "alt": "", "width": 120 },
+ # "navigation": [
+ # { "label": "Home", "href": "/" },
+ # { "label": "Services", "href": "/services" },
+ # { "label": "Book Now", "href": "/book", "style": "button" }
+ # ],
+ # "sticky": true,
+ # "style": "default" # default | transparent | minimal
+ # }
+
+ footer = models.JSONField(default=dict)
+ # Structure:
+ # {
+ # "enabled": true,
+ # "columns": [
+ # {
+ # "title": "Company",
+ # "links": [{ "label": "About", "href": "/about" }]
+ # }
+ # ],
+ # "copyright": "© 2024 {business_name}. All rights reserved.",
+ # "socialLinks": [
+ # { "platform": "facebook", "url": "" },
+ # { "platform": "instagram", "url": "" }
+ # ]
+ # }
+
+ version = models.PositiveIntegerField(default=1)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+```
+
+#### Page Model Enhancements
+
+Add SEO and navigation fields to existing Page model:
+
+```python
+# Add to existing Page model:
+meta_title = models.CharField(max_length=255, blank=True)
+meta_description = models.TextField(blank=True)
+og_image = models.URLField(blank=True)
+canonical_url = models.URLField(blank=True)
+noindex = models.BooleanField(default=False)
+include_in_nav = models.BooleanField(default=True)
+hide_chrome = models.BooleanField(default=False) # Landing page mode
+```
+
+### Puck Data Schema
+
+The `puck_data` JSONField stores the Puck editor payload:
+
+```json
+{
+ "content": [
+ {
+ "type": "Section",
+ "props": {
+ "id": "section-abc123",
+ "background": { "type": "color", "value": "#f8fafc" },
+ "padding": "large",
+ "containerWidth": "default",
+ "anchorId": "hero"
+ }
+ }
+ ],
+ "root": {},
+ "zones": {
+ "section-abc123:content": [
+ {
+ "type": "Heading",
+ "props": { "text": "Welcome", "level": "h1" }
+ }
+ ]
+ }
+}
+```
+
+### Version Strategy
+
+- `Page.version` tracks payload schema version
+- `SiteConfig.version` tracks theme/chrome schema version
+- Migrations are handled on read (lazy migration)
+- On save, always write latest version
+
+## API Endpoints
+
+### Existing (No Changes)
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `GET /api/sites/me/` | GET | Get current site |
+| `GET /api/sites/me/pages/` | GET | List pages |
+| `POST /api/sites/me/pages/` | POST | Create page |
+| `PATCH /api/sites/me/pages/{id}/` | PATCH | Update page |
+| `DELETE /api/sites/me/pages/{id}/` | DELETE | Delete page |
+| `GET /api/public/page/` | GET | Get home page (public) |
+
+### New Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `GET /api/sites/me/config/` | GET | Get site config (theme, chrome) |
+| `PATCH /api/sites/me/config/` | PATCH | Update site config |
+| `GET /api/public/page/{slug}/` | GET | Get page by slug (public) |
+
+## Component Library
+
+### Categories
+
+1. **Layout** - Section, Columns, Card, Spacer, Divider
+2. **Content** - Heading, RichText, Image, Button, IconList, Testimonial, FAQ
+3. **Booking** - BookingWidget, ServiceCatalog
+4. **Contact** - ContactForm, BusinessHours, Map
+
+### Component Specification
+
+#### Section (Layout)
+
+The fundamental building block for page sections.
+
+```typescript
+{
+ type: "Section",
+ label: "Section",
+ fields: {
+ background: {
+ type: "custom", // Color picker, image upload, or gradient
+ options: ["none", "color", "image", "gradient"]
+ },
+ overlay: {
+ type: "custom", // Overlay color + opacity
+ },
+ padding: {
+ type: "select",
+ options: ["none", "small", "medium", "large", "xlarge"]
+ },
+ containerWidth: {
+ type: "select",
+ options: ["narrow", "default", "wide", "full"]
+ },
+ anchorId: { type: "text" },
+ hideOnMobile: { type: "checkbox" },
+ hideOnTablet: { type: "checkbox" },
+ hideOnDesktop: { type: "checkbox" }
+ },
+ render: ({ puck }) => (
+
+ )
+}
+```
+
+#### Columns (Layout)
+
+Flexible column layout with nested drop zones.
+
+```typescript
+{
+ type: "Columns",
+ fields: {
+ columns: {
+ type: "select",
+ options: ["2", "3", "4", "2-1", "1-2"] // ratios
+ },
+ gap: {
+ type: "select",
+ options: ["none", "small", "medium", "large"]
+ },
+ verticalAlign: {
+ type: "select",
+ options: ["top", "center", "bottom", "stretch"]
+ },
+ stackOnMobile: { type: "checkbox", default: true }
+ },
+ render: ({ columns, puck }) => (
+
+ {Array.from({ length: columnCount }).map((_, i) => (
+
+ ))}
+
+ )
+}
+```
+
+#### BookingWidget (Booking)
+
+Embedded booking interface - SmoothSchedule's differentiator.
+
+```typescript
+{
+ type: "BookingWidget",
+ fields: {
+ serviceMode: {
+ type: "select",
+ options: [
+ { label: "All Services", value: "all" },
+ { label: "By Category", value: "category" },
+ { label: "Specific Services", value: "specific" }
+ ]
+ },
+ categoryId: { type: "text" }, // When mode = category
+ serviceIds: { type: "array" }, // When mode = specific
+ showDuration: { type: "checkbox", default: true },
+ showPrice: { type: "checkbox", default: true },
+ showDeposits: { type: "checkbox", default: true },
+ requireLogin: { type: "checkbox", default: false },
+ ctaAfterBooking: { type: "text" }
+ }
+}
+```
+
+## Security Measures
+
+### 1. XSS Prevention
+
+All text content is rendered through React, which auto-escapes HTML by default.
+
+For rich text (RichText component):
+- Store content as structured JSON (Slate/Tiptap document format), not raw HTML
+- Render using a safe renderer that only supports whitelisted elements (p, strong, em, a, ul, ol, li)
+- Never render raw HTML strings directly into the DOM
+- All user-provided content goes through React's safe text rendering
+
+### 2. Embed/Script Injection
+
+No arbitrary embeds allowed. Map component only supports:
+- Google Maps embed URLs (maps.google.com/*)
+- OpenStreetMap iframes
+
+Implementation:
+```typescript
+const ALLOWED_EMBED_DOMAINS = [
+ 'www.google.com/maps/embed',
+ 'maps.google.com',
+ 'www.openstreetmap.org'
+];
+
+function isAllowedEmbed(url: string): boolean {
+ return ALLOWED_EMBED_DOMAINS.some(domain =>
+ url.startsWith(`https://${domain}`)
+ );
+}
+```
+
+### 3. Backend Validation
+
+```python
+# In PageSerializer.validate_puck_data()
+def validate_puck_data(self, value):
+ # 1. Size limit
+ if len(json.dumps(value)) > 5_000_000: # 5MB limit
+ raise ValidationError("Page data too large")
+
+ # 2. Validate structure
+ if not isinstance(value.get('content'), list):
+ raise ValidationError("Invalid puck_data structure")
+
+ # 3. Scan for disallowed content
+ serialized = json.dumps(value).lower()
+ disallowed = [''
+ }
+ }
+ ],
+ 'root': {}
+ }
+ }
+
+ serializer = PageSerializer(data=malicious_data)
+ is_valid = serializer.is_valid()
+
+ # Should fail validation for script tags
+ assert is_valid is False
+
+ def test_rejects_javascript_urls(self):
+ """Should reject puck_data containing javascript: URLs."""
+ from smoothschedule.platform.tenant_sites.serializers import PageSerializer
+
+ malicious_data = {
+ 'title': 'Test Page',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'Hero',
+ 'props': {
+ 'title': 'Click me',
+ 'ctaLink': 'javascript:alert("xss")'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ }
+
+ serializer = PageSerializer(data=malicious_data)
+ is_valid = serializer.is_valid()
+
+ # Should fail validation for javascript: URLs
+ assert is_valid is False
+
+ def test_rejects_onerror_attributes(self):
+ """Should reject puck_data containing onerror handlers."""
+ from smoothschedule.platform.tenant_sites.serializers import PageSerializer
+
+ malicious_data = {
+ 'title': 'Test Page',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'Image',
+ 'props': {
+ 'src': 'x',
+ 'alt': 'onerror=alert("xss")'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ }
+
+ serializer = PageSerializer(data=malicious_data)
+ is_valid = serializer.is_valid()
+
+ # Should fail validation for onerror handlers
+ assert is_valid is False
+
+ def test_rejects_onload_attributes(self):
+ """Should reject puck_data containing onload handlers."""
+ from smoothschedule.platform.tenant_sites.serializers import PageSerializer
+
+ malicious_data = {
+ 'title': 'Test Page',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'Image',
+ 'props': {
+ 'src': 'x',
+ 'onload': 'alert("xss")'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ }
+
+ serializer = PageSerializer(data=malicious_data)
+ is_valid = serializer.is_valid()
+
+ # Should fail validation for onload handlers
+ assert is_valid is False
+
+ def test_accepts_safe_content(self):
+ """Should accept safe content without XSS patterns."""
+ from smoothschedule.platform.tenant_sites.serializers import PageSerializer
+
+ safe_data = {
+ 'title': 'Test Page',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'Hero',
+ 'props': {
+ 'id': 'hero-1',
+ 'title': 'Welcome to our site',
+ 'subtitle': 'We offer great services',
+ 'align': 'center',
+ 'ctaText': 'Book Now',
+ 'ctaLink': '/book'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ }
+
+ serializer = PageSerializer(data=safe_data)
+ is_valid = serializer.is_valid()
+
+ # Should pass validation
+ if not is_valid:
+ # If it fails, it should not be due to XSS detection
+ error_str = str(serializer.errors.get('puck_data', ''))
+ assert 'disallowed' not in error_str.lower()
+
+
+class TestPuckDataEmbedAllowlist:
+ """Test embed URL allowlist validation."""
+
+ def test_accepts_google_maps_embed(self):
+ """Should accept Google Maps embed URLs."""
+ from smoothschedule.platform.tenant_sites.validators import validate_embed_url
+
+ valid_url = 'https://www.google.com/maps/embed?pb=...'
+
+ # Should not raise
+ result = validate_embed_url(valid_url)
+ assert result is True
+
+ def test_accepts_openstreetmap_embed(self):
+ """Should accept OpenStreetMap embed URLs."""
+ from smoothschedule.platform.tenant_sites.validators import validate_embed_url
+
+ valid_url = 'https://www.openstreetmap.org/export/embed.html?...'
+
+ # Should not raise
+ result = validate_embed_url(valid_url)
+ assert result is True
+
+ def test_rejects_arbitrary_embed(self):
+ """Should reject non-allowlisted embed URLs."""
+ from smoothschedule.platform.tenant_sites.validators import validate_embed_url
+
+ invalid_url = 'https://evil-site.com/embed'
+
+ # Should return False or raise
+ result = validate_embed_url(invalid_url)
+ assert result is False
+
+ def test_rejects_data_uri(self):
+ """Should reject data: URIs in embeds."""
+ from smoothschedule.platform.tenant_sites.validators import validate_embed_url
+
+ data_uri = 'data:text/html,'
+
+ result = validate_embed_url(data_uri)
+ assert result is False
+
+
+class TestPuckDataValidatorFunction:
+ """Test the validate_puck_data validator function."""
+
+ def test_validator_exists(self):
+ """Should have validate_puck_data function."""
+ from smoothschedule.platform.tenant_sites.validators import validate_puck_data
+
+ assert callable(validate_puck_data)
+
+ def test_validator_returns_cleaned_data(self):
+ """Should return the puck_data if valid."""
+ from smoothschedule.platform.tenant_sites.validators import validate_puck_data
+
+ valid_data = {
+ 'content': [{'type': 'Hero', 'props': {'title': 'Test'}}],
+ 'root': {}
+ }
+
+ result = validate_puck_data(valid_data)
+
+ assert result == valid_data
+
+ def test_validator_raises_for_invalid(self):
+ """Should raise ValidationError for invalid data."""
+ from smoothschedule.platform.tenant_sites.validators import validate_puck_data
+ from rest_framework.exceptions import ValidationError
+
+ invalid_data = {
+ 'content': [{'type': 'Hero', 'props': {'title': ''}}],
+ 'root': {}
+ }
+
+ with pytest.raises(ValidationError):
+ validate_puck_data(invalid_data)
diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_site_config.py b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_site_config.py
new file mode 100644
index 0000000..7802ed3
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_site_config.py
@@ -0,0 +1,192 @@
+"""
+Unit tests for SiteConfig model and related functionality.
+
+Tests the theme tokens, header/footer chrome, and versioning.
+"""
+from unittest.mock import Mock, patch
+import pytest
+
+
+class TestSiteConfigModel:
+ """Test SiteConfig model fields and methods."""
+
+ def test_model_exists(self):
+ """Should have SiteConfig model."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ assert SiteConfig is not None
+
+ def test_str_method(self):
+ """Should return 'Config for {site}'."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ mock_site = Mock()
+ mock_site.__str__ = Mock(return_value='Site for Test Business')
+ config._state.fields_cache['site'] = mock_site
+
+ result = str(config)
+
+ assert 'Config' in result
+
+ def test_model_has_required_fields(self):
+ """Should have site, theme, header, footer, version fields."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ field_names = [f.name for f in SiteConfig._meta.get_fields()]
+ assert 'site' in field_names
+ assert 'theme' in field_names
+ assert 'header' in field_names
+ assert 'footer' in field_names
+ assert 'version' in field_names
+ assert 'created_at' in field_names
+ assert 'updated_at' in field_names
+
+ def test_site_one_to_one_relationship(self):
+ """Should have OneToOne relationship with Site."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ site_field = SiteConfig._meta.get_field('site')
+ assert site_field.one_to_one is True
+
+ def test_theme_default_value(self):
+ """Should default theme to empty dict."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ theme_field = SiteConfig._meta.get_field('theme')
+ assert theme_field.default == dict
+
+ def test_header_default_value(self):
+ """Should default header to empty dict."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ header_field = SiteConfig._meta.get_field('header')
+ assert header_field.default == dict
+
+ def test_footer_default_value(self):
+ """Should default footer to empty dict."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ footer_field = SiteConfig._meta.get_field('footer')
+ assert footer_field.default == dict
+
+ def test_version_default_value(self):
+ """Should default version to 1."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ version_field = SiteConfig._meta.get_field('version')
+ assert version_field.default == 1
+
+
+class TestSiteConfigThemeStructure:
+ """Test theme token structure validation."""
+
+ def test_get_default_theme_returns_complete_structure(self):
+ """Should return theme with colors, typography, buttons, sections."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ default_theme = config.get_default_theme()
+
+ assert 'colors' in default_theme
+ assert 'typography' in default_theme
+ assert 'buttons' in default_theme
+ assert 'sections' in default_theme
+
+ def test_get_default_theme_colors(self):
+ """Should have primary, secondary, accent colors."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ default_theme = config.get_default_theme()
+ colors = default_theme['colors']
+
+ assert 'primary' in colors
+ assert 'secondary' in colors
+ assert 'accent' in colors
+ assert 'background' in colors
+ assert 'text' in colors
+
+ def test_get_merged_theme_uses_defaults_for_missing(self):
+ """Should merge custom theme with defaults."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ config.theme = {'colors': {'primary': '#ff0000'}}
+
+ merged = config.get_merged_theme()
+
+ # Custom value preserved
+ assert merged['colors']['primary'] == '#ff0000'
+ # Default values filled in
+ assert 'secondary' in merged['colors']
+ assert 'typography' in merged
+
+
+class TestSiteConfigHeaderFooter:
+ """Test header/footer chrome configuration."""
+
+ def test_get_default_header_structure(self):
+ """Should return header with enabled, logo, navigation."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ default_header = config.get_default_header()
+
+ assert 'enabled' in default_header
+ assert 'logo' in default_header
+ assert 'navigation' in default_header
+ assert 'sticky' in default_header
+
+ def test_get_default_footer_structure(self):
+ """Should return footer with enabled, columns, copyright."""
+ from smoothschedule.platform.tenant_sites.models import SiteConfig
+
+ config = SiteConfig()
+ default_footer = config.get_default_footer()
+
+ assert 'enabled' in default_footer
+ assert 'columns' in default_footer
+ assert 'copyright' in default_footer
+ assert 'socialLinks' in default_footer
+
+
+class TestSiteConfigSignal:
+ """Test SiteConfig auto-creation signal."""
+
+ def test_creates_config_on_new_site(self):
+ """Should auto-create SiteConfig when Site is created."""
+ from smoothschedule.platform.tenant_sites.models import (
+ create_config_for_site, Site, SiteConfig
+ )
+
+ mock_site = Mock(spec=Site)
+ mock_site.id = 1
+
+ with patch.object(SiteConfig.objects, 'get_or_create') as mock_create:
+ mock_create.return_value = (Mock(), True)
+
+ create_config_for_site(
+ sender=Site,
+ instance=mock_site,
+ created=True
+ )
+
+ mock_create.assert_called_once_with(site=mock_site)
+
+ def test_does_not_create_config_on_site_update(self):
+ """Should not create SiteConfig when Site is updated."""
+ from smoothschedule.platform.tenant_sites.models import (
+ create_config_for_site, Site, SiteConfig
+ )
+
+ mock_site = Mock(spec=Site)
+
+ with patch.object(SiteConfig.objects, 'get_or_create') as mock_create:
+ create_config_for_site(
+ sender=Site,
+ instance=mock_site,
+ created=False
+ )
+
+ mock_create.assert_not_called()
diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_tenant_isolation.py b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_tenant_isolation.py
new file mode 100644
index 0000000..7672b0a
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/tenant_sites/tests/test_tenant_isolation.py
@@ -0,0 +1,329 @@
+"""
+Unit tests for tenant isolation in tenant_sites.
+
+Tests that tenants cannot read/write other tenants' pages or site configs.
+"""
+from unittest.mock import Mock, patch, MagicMock
+import pytest
+from rest_framework.test import APIRequestFactory
+
+
+class TestPageViewSetTenantIsolation:
+ """Test tenant isolation in PageViewSet."""
+
+ def test_get_queryset_filters_by_tenant(self):
+ """Should filter pages by request.tenant."""
+ from smoothschedule.platform.tenant_sites.views import PageViewSet
+ from smoothschedule.platform.tenant_sites.models import Page
+
+ # Create mock request with tenant
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ # Create viewset
+ viewset = PageViewSet()
+ viewset.request = mock_request
+ viewset.format_kwarg = None
+
+ # Mock the Page.objects queryset
+ mock_queryset = Mock()
+ mock_queryset.filter.return_value = mock_queryset
+
+ with patch.object(Page, 'objects', mock_queryset):
+ viewset.get_queryset()
+
+ # Verify filter was called with tenant
+ mock_queryset.filter.assert_called_once()
+ call_kwargs = mock_queryset.filter.call_args[1]
+ assert 'site__tenant' in call_kwargs
+
+ def test_cannot_access_other_tenant_page(self):
+ """Should not return pages from other tenants."""
+ from smoothschedule.platform.tenant_sites.views import PageViewSet
+ from smoothschedule.platform.tenant_sites.models import Page
+
+ # Create mock request for tenant 1
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = PageViewSet()
+ viewset.request = mock_request
+ viewset.format_kwarg = None
+
+ # Mock queryset that would filter correctly
+ mock_queryset = MagicMock()
+ mock_queryset.filter.return_value = mock_queryset
+
+ with patch.object(Page, 'objects', mock_queryset):
+ result = viewset.get_queryset()
+
+ # Verify the tenant filter is applied
+ filter_call = mock_queryset.filter.call_args
+ assert filter_call is not None
+ assert filter_call[1].get('site__tenant') == mock_tenant
+
+
+class TestSiteViewSetTenantIsolation:
+ """Test tenant isolation in SiteViewSet."""
+
+ def test_get_object_filters_by_tenant(self):
+ """Should get site by request.tenant."""
+ from smoothschedule.platform.tenant_sites.views import SiteViewSet
+ from smoothschedule.platform.tenant_sites.models import Site
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = SiteViewSet()
+ viewset.request = mock_request
+
+ with patch('smoothschedule.platform.tenant_sites.views.get_object_or_404') as mock_get:
+ mock_site = Mock()
+ mock_get.return_value = mock_site
+
+ result = viewset.get_object()
+
+ # Verify get_object_or_404 was called with correct tenant filter
+ mock_get.assert_called_once_with(Site, tenant=mock_tenant)
+
+ def test_me_action_returns_only_current_tenant_site(self):
+ """me action should return only the current tenant's site."""
+ from smoothschedule.platform.tenant_sites.views import SiteViewSet
+ from smoothschedule.platform.tenant_sites.models import Site
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = SiteViewSet()
+ viewset.request = mock_request
+ viewset.format_kwarg = None
+
+ mock_site = Mock()
+ mock_site.id = 1
+
+ with patch.object(Site.objects, 'get') as mock_get:
+ mock_get.return_value = mock_site
+
+ # Need to mock get_serializer
+ with patch.object(viewset, 'get_serializer') as mock_serializer:
+ mock_serializer.return_value.data = {'id': 1}
+
+ response = viewset.me(mock_request)
+
+ # Verify Site.objects.get was called with tenant filter
+ mock_get.assert_called_once_with(tenant=mock_tenant)
+
+
+class TestSiteConfigViewSetTenantIsolation:
+ """Test tenant isolation in SiteConfigViewSet."""
+
+ def test_get_config_filters_by_tenant(self):
+ """Should get config for current tenant's site only."""
+ from smoothschedule.platform.tenant_sites.views import SiteConfigViewSet
+ from smoothschedule.platform.tenant_sites.models import SiteConfig, Site
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = SiteConfigViewSet()
+ viewset.request = mock_request
+
+ with patch('smoothschedule.platform.tenant_sites.views.get_object_or_404') as mock_get:
+ mock_config = Mock()
+ mock_get.return_value = mock_config
+
+ result = viewset.get_object()
+
+ # Verify it filters by site__tenant
+ mock_get.assert_called_once()
+ call_args = mock_get.call_args
+ assert call_args[1].get('site__tenant') == mock_tenant
+
+
+class TestDomainViewSetTenantIsolation:
+ """Test tenant isolation in DomainViewSet."""
+
+ def test_get_queryset_filters_by_tenant(self):
+ """Should filter domains by request.tenant."""
+ from smoothschedule.platform.tenant_sites.views import DomainViewSet
+ from smoothschedule.platform.tenant_sites.models import Domain
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = DomainViewSet()
+ viewset.request = mock_request
+ viewset.format_kwarg = None
+
+ mock_queryset = Mock()
+ mock_queryset.filter.return_value = mock_queryset
+
+ with patch.object(Domain, 'objects', mock_queryset):
+ viewset.get_queryset()
+
+ # Verify filter includes tenant
+ mock_queryset.filter.assert_called_once()
+ call_kwargs = mock_queryset.filter.call_args[1]
+ assert 'site__tenant' in call_kwargs
+
+
+class TestPublicPageViewTenantIsolation:
+ """Test tenant isolation in PublicPageView."""
+
+ def test_returns_only_current_tenant_page(self):
+ """Should return page from request.tenant only."""
+ from smoothschedule.platform.tenant_sites.views import PublicPageView
+ from smoothschedule.platform.tenant_sites.models import Site, Page
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_tenant.schema_name = 'tenant1'
+ mock_request.tenant = mock_tenant
+
+ view = PublicPageView()
+
+ mock_site = Mock()
+ mock_site.is_enabled = True
+
+ mock_page = Mock()
+ mock_page.title = 'Home'
+ mock_page.puck_data = {'content': [], 'root': {}}
+
+ with patch.object(Site.objects, 'get') as mock_site_get:
+ mock_site_get.return_value = mock_site
+
+ with patch.object(Page.objects, 'get') as mock_page_get:
+ mock_page_get.return_value = mock_page
+
+ response = view.get(mock_request)
+
+ # Verify Site.objects.get was called with current tenant
+ mock_site_get.assert_called_once_with(tenant=mock_tenant)
+
+
+class TestCrossTenanantDataAccess:
+ """Test that cross-tenant data access is blocked."""
+
+ def test_page_create_assigns_correct_site(self):
+ """Creating a page should assign the current tenant's site."""
+ from smoothschedule.platform.tenant_sites.views import SiteViewSet
+ from smoothschedule.platform.tenant_sites.models import Site, Page
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+ mock_request.method = 'POST'
+
+ viewset = SiteViewSet()
+ viewset.request = mock_request
+
+ mock_site = Mock()
+ mock_site.id = 1
+
+ # Verify that the site is fetched by tenant
+ with patch.object(Site.objects, 'get') as mock_get:
+ mock_get.return_value = mock_site
+
+ # Call would fetch site by tenant
+ mock_get.assert_not_called() # Not called yet
+
+ # When me_pages is called, it fetches the site
+ # This verifies the pattern: Site.objects.get(tenant=tenant)
+ pass # The key assertion is that the view filters by tenant
+
+ def test_page_update_validates_ownership(self):
+ """Should only allow updating pages belonging to current tenant."""
+ from smoothschedule.platform.tenant_sites.views import PageViewSet
+ from smoothschedule.platform.tenant_sites.models import Page
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.id = 1
+ mock_request.tenant = mock_tenant
+
+ viewset = PageViewSet()
+ viewset.request = mock_request
+ viewset.kwargs = {'pk': 999} # Some page ID
+
+ # The queryset should filter by tenant, so attempting to get
+ # a page from another tenant would raise 404
+ mock_queryset = MagicMock()
+ mock_queryset.filter.return_value.get.side_effect = Page.DoesNotExist()
+
+ with patch.object(Page, 'objects', mock_queryset):
+ with pytest.raises(Page.DoesNotExist):
+ viewset.get_queryset().get(pk=999)
+
+
+class TestPublicSchemaHandling:
+ """Test handling of public schema (platform) requests."""
+
+ def test_public_page_view_uses_subdomain_header(self):
+ """Should use x-business-subdomain header when on public schema."""
+ from smoothschedule.platform.tenant_sites.views import PublicPageView
+ from smoothschedule.platform.tenant_sites.models import Site, Page
+ from smoothschedule.identity.core.models import Tenant
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.schema_name = 'public'
+ mock_request.tenant = mock_tenant
+ mock_request.headers = {'x-business-subdomain': 'business1'}
+
+ view = PublicPageView()
+
+ mock_actual_tenant = Mock()
+ mock_site = Mock()
+ mock_site.is_enabled = True
+ mock_page = Mock()
+ mock_page.title = 'Home'
+ mock_page.puck_data = {'content': [], 'root': {}}
+
+ with patch.object(Tenant.objects, 'get') as mock_tenant_get:
+ mock_tenant_get.return_value = mock_actual_tenant
+
+ with patch.object(Site.objects, 'get') as mock_site_get:
+ mock_site_get.return_value = mock_site
+
+ with patch.object(Page.objects, 'get') as mock_page_get:
+ mock_page_get.return_value = mock_page
+
+ response = view.get(mock_request)
+
+ # Verify Tenant lookup used subdomain header
+ mock_tenant_get.assert_called_once_with(schema_name='business1')
+
+ def test_public_page_view_rejects_missing_subdomain(self):
+ """Should return error when public schema without subdomain header."""
+ from smoothschedule.platform.tenant_sites.views import PublicPageView
+ from smoothschedule.platform.tenant_sites.models import Site, Page
+
+ mock_request = Mock()
+ mock_tenant = Mock()
+ mock_tenant.schema_name = 'public'
+ mock_request.tenant = mock_tenant
+ mock_request.headers = {} # No subdomain header
+
+ view = PublicPageView()
+
+ # When no subdomain header, should still try to load from 'public' tenant
+ # Mock to simulate no site/page found
+ with patch.object(Site.objects, 'get', side_effect=Site.DoesNotExist()):
+ response = view.get(mock_request)
+
+ # Should return 404 when site not found
+ assert response.status_code == 404
diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/urls.py b/smoothschedule/smoothschedule/platform/tenant_sites/urls.py
index 686b63d..22f1c7d 100644
--- a/smoothschedule/smoothschedule/platform/tenant_sites/urls.py
+++ b/smoothschedule/smoothschedule/platform/tenant_sites/urls.py
@@ -1,20 +1,23 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
- SiteViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet,
+ SiteViewSet, SiteConfigViewSet, PageViewSet, DomainViewSet, PublicPageView, PublicServiceViewSet,
PublicAvailabilityView, PublicBusinessHoursView, PublicBookingView,
- PublicPaymentIntentView, PublicBusinessInfoView
+ PublicPaymentIntentView, PublicBusinessInfoView, PublicSiteConfigView
)
router = DefaultRouter()
router.register(r'sites', SiteViewSet, basename='site')
+router.register(r'site-config', SiteConfigViewSet, basename='site-config')
router.register(r'pages', PageViewSet, basename='page')
router.register(r'domains', DomainViewSet, basename='domain')
router.register(r'public/services', PublicServiceViewSet, basename='public-service')
urlpatterns = [
path('', include(router.urls)),
+ path('sites/me/config/', SiteConfigViewSet.as_view({'get': 'me', 'patch': 'me'}), name='site-config-me'),
path('public/page/', PublicPageView.as_view(), name='public-page'),
+ path('public/site-config/', PublicSiteConfigView.as_view(), name='public-site-config'),
path('public/business/', PublicBusinessInfoView.as_view(), name='public-business'),
path('public/availability/', PublicAvailabilityView.as_view(), name='public-availability'),
path('public/business-hours/', PublicBusinessHoursView.as_view(), name='public-business-hours'),
diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/validators.py b/smoothschedule/smoothschedule/platform/tenant_sites/validators.py
new file mode 100644
index 0000000..f1594ad
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/tenant_sites/validators.py
@@ -0,0 +1,203 @@
+"""
+Validators for puck_data and embed URLs.
+
+Provides security validation for site builder content.
+"""
+import json
+from rest_framework.exceptions import ValidationError
+
+
+# Maximum size for puck_data (5MB)
+MAX_PUCK_DATA_SIZE = 5 * 1024 * 1024
+
+# Disallowed patterns for XSS prevention
+DISALLOWED_PATTERNS = [
+ '