from __future__ import annotations from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.types import ASGIApp from app.core.db.common import CommonSessionLocal from app.modules.domain_management.services import normalize_request_host, resolve_domain_context class DomainResolverMiddleware(BaseHTTPMiddleware): """Resolve request host to platform / tenant / consultant context. Phase 7T.2 is intentionally read-only: - It does not redirect users. - It does not change database records. - It does not override logged-in user permissions. - It only exposes a trusted runtime context on request.state. Later phases use this context for branding, marketplace mode, tenant subdomains, consultant domains, and custom domain verification. """ def __init__(self, app: ASGIApp) -> None: super().__init__(app) async def dispatch(self, request: Request, call_next): host_header = request.headers.get("x-forwarded-host") or request.headers.get("host") host = normalize_request_host(host_header) # Safe defaults; every template/route can read these without checking existence. request.state.request_host = host request.state.domain_resolved = False request.state.domain_mapping_id = None request.state.domain_name = host request.state.domain_type = None request.state.domain_tenant_id = None request.state.domain_tenant_code = None request.state.domain_branch_id = None request.state.domain_branch_code = None request.state.domain_consultant_id = None request.state.domain_parent_tenant_id = None request.state.domain_is_verified = False request.state.domain_status = None request.state.domain_context = { "is_resolved": False, "host": host, "mapping_id": None, "domain_name": host, "domain_type": None, "tenant_id": None, "tenant_code": None, "branch_id": None, "branch_code": None, "consultant_id": None, "parent_tenant_id": None, "is_verified": False, "status": None, } # Static files and empty/invalid host can proceed without DB lookup. if host and not request.url.path.startswith("/static/"): db = CommonSessionLocal() try: resolved = resolve_domain_context(db, host) if resolved.is_resolved: request.state.domain_resolved = True request.state.domain_mapping_id = resolved.mapping_id request.state.domain_name = resolved.domain_name request.state.domain_type = resolved.domain_type request.state.domain_tenant_id = resolved.tenant_id request.state.domain_tenant_code = resolved.tenant_code request.state.domain_branch_id = resolved.branch_id request.state.domain_branch_code = resolved.branch_code request.state.domain_consultant_id = resolved.consultant_id request.state.domain_parent_tenant_id = resolved.parent_tenant_id request.state.domain_is_verified = resolved.is_verified request.state.domain_status = resolved.status request.state.domain_context = { "is_resolved": True, "host": resolved.host, "mapping_id": resolved.mapping_id, "domain_name": resolved.domain_name, "domain_type": resolved.domain_type, "tenant_id": resolved.tenant_id, "tenant_code": resolved.tenant_code, "branch_id": resolved.branch_id, "branch_code": resolved.branch_code, "consultant_id": resolved.consultant_id, "parent_tenant_id": resolved.parent_tenant_id, "is_verified": resolved.is_verified, "status": resolved.status, } except Exception: # Domain resolution must never take the ERP down. If the domain table is # missing during deployment or DB is temporarily unavailable, continue # with the normal default context. pass finally: db.close() response = await call_next(request) if getattr(request.state, "domain_resolved", False): response.headers["X-AuditFirm-Domain-Resolved"] = "1" response.headers["X-AuditFirm-Domain-Type"] = str(getattr(request.state, "domain_type", "") or "") return response