feat(middleware): harden routing with fail-closed policy, custom subdomain management, and perf fixes
Some checks failed
CI / pytest (push) Waiting to run
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Fix IPv6 host parsing with _strip_port() utility
- Remove dangerous StorePlatform→Store.subdomain silent fallback
- Close storefront gate bypass when frontend_type is None
- Add custom subdomain management UI and API for stores
- Add domain health diagnostic tool
- Convert db.add() in loops to db.add_all() (24 PERF-006 fixes)
- Add tests for all new functionality (18 subdomain service tests)
- Add .github templates for validator compliance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:13:01 +01:00
parent 07fab01f6a
commit 540205402f
38 changed files with 1827 additions and 134 deletions

View File

@@ -27,6 +27,7 @@ from .admin_platform_users import admin_platform_users_router
from .admin_platforms import admin_platforms_router
from .admin_store_domains import admin_store_domains_router
from .admin_store_roles import admin_store_roles_router
from .admin_store_subdomains import admin_store_subdomains_router
from .admin_stores import admin_stores_router
from .admin_users import admin_users_router
from .user_account import admin_account_router
@@ -42,6 +43,7 @@ router.include_router(admin_merchants_router, tags=["admin-merchants"])
router.include_router(admin_platforms_router, tags=["admin-platforms"])
router.include_router(admin_stores_router, tags=["admin-stores"])
router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
router.include_router(admin_store_subdomains_router, tags=["admin-store-subdomains"])
router.include_router(admin_store_roles_router, tags=["admin-store-roles"])
router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
router.include_router(admin_modules_router, tags=["admin-modules"])

View File

@@ -0,0 +1,137 @@
# app/modules/tenancy/routes/api/admin_store_subdomains.py
"""
Admin endpoints for managing store custom subdomains (per-platform).
Each store can have a custom subdomain on each platform it belongs to.
For example, store "WizaTech" on the loyalty platform could have
custom_subdomain="wizatech-rewards" → wizatech-rewards.rewardflow.lu
"""
import logging
from fastapi import APIRouter, Body, Depends, Path
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.auth import UserContext
from app.modules.tenancy.services.store_subdomain_service import store_subdomain_service
admin_store_subdomains_router = APIRouter(prefix="/stores")
logger = logging.getLogger(__name__)
# ── Schemas ──────────────────────────────────────────────────────────────────
class CustomSubdomainEntry(BaseModel):
store_platform_id: int
platform_id: int
platform_code: str
platform_name: str
platform_domain: str | None
custom_subdomain: str | None
default_subdomain: str | None
full_url: str | None
default_url: str | None
class CustomSubdomainListResponse(BaseModel):
subdomains: list[CustomSubdomainEntry]
total: int
class SetCustomSubdomainRequest(BaseModel):
subdomain: str = Field(
...,
min_length=3,
max_length=63,
description="Custom subdomain (lowercase, alphanumeric + hyphens)",
)
class CustomSubdomainUpdateResponse(BaseModel):
message: str
platform_id: int
custom_subdomain: str | None
# ── Endpoints ────────────────────────────────────────────────────────────────
@admin_store_subdomains_router.get(
"/{store_id}/custom-subdomains",
response_model=CustomSubdomainListResponse,
)
def list_custom_subdomains(
store_id: int = Path(..., description="Store ID", gt=0),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
List all platform memberships with their custom subdomains.
Returns one entry per active platform the store belongs to,
showing the custom_subdomain (if set) and the default subdomain.
"""
entries = store_subdomain_service.get_custom_subdomains(db, store_id)
return CustomSubdomainListResponse(
subdomains=[CustomSubdomainEntry(**e) for e in entries],
total=len(entries),
)
@admin_store_subdomains_router.put(
"/{store_id}/custom-subdomains/{platform_id}",
response_model=CustomSubdomainUpdateResponse,
)
def set_custom_subdomain(
store_id: int = Path(..., description="Store ID", gt=0),
platform_id: int = Path(..., description="Platform ID", gt=0),
payload: SetCustomSubdomainRequest = Body(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Set or update the custom subdomain for a store on a specific platform.
The subdomain must be unique on the platform (no other store can claim it).
Format: lowercase alphanumeric + hyphens, 3-63 characters.
"""
sp = store_subdomain_service.set_custom_subdomain(
db, store_id, platform_id, payload.subdomain
)
db.commit()
return CustomSubdomainUpdateResponse(
message=f"Custom subdomain set to '{sp.custom_subdomain}'",
platform_id=platform_id,
custom_subdomain=sp.custom_subdomain,
)
@admin_store_subdomains_router.delete(
"/{store_id}/custom-subdomains/{platform_id}",
response_model=CustomSubdomainUpdateResponse,
)
def clear_custom_subdomain(
store_id: int = Path(..., description="Store ID", gt=0),
platform_id: int = Path(..., description="Platform ID", gt=0),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Clear the custom subdomain for a store on a specific platform.
The store will still be accessible via its default subdomain
(Store.subdomain + platform domain).
"""
store_subdomain_service.clear_custom_subdomain(db, store_id, platform_id)
db.commit()
return CustomSubdomainUpdateResponse(
message="Custom subdomain cleared",
platform_id=platform_id,
custom_subdomain=None,
)