tools/Start-IdentityAtlasDevServer.ps1

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string] $Root,

    [ValidateRange(1024, 65535)]
    [int] $Port = 8765,

    [ValidateRange(1, 30)]
    [int] $RequestTimeoutSeconds = 5
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$resolvedRoot = [System.IO.Path]::GetFullPath($Root)
if (-not (Test-Path -LiteralPath $resolvedRoot -PathType Container)) {
    throw "The web root '$resolvedRoot' does not exist."
}

$indexPath = Join-Path $resolvedRoot 'index.html'
$manifestPath = Join-Path $resolvedRoot 'data\manifest.js'
$packagePath = Join-Path $resolvedRoot '.identity-atlas-report.json'
foreach ($requiredPath in @($indexPath, $manifestPath, $packagePath)) {
    if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
        throw "The web root is not a complete Identity Atlas report. Required file '$requiredPath' is missing."
    }
}

try {
    $packageMetadata = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json
}
catch {
    throw "The Identity Atlas report package marker is invalid: $($_.Exception.Message)"
}
if ($packageMetadata.format -ne 'IdentityAtlasReport') {
    throw 'The report package marker does not identify an Identity Atlas report.'
}

$manifestContent = Get-Content -LiteralPath $manifestPath -Raw
$manifestMatch = [regex]::Match(
    $manifestContent,
    '^window\.IdentityAtlasData\.registerManifest\((?<json>.*)\);\s*$',
    [System.Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $manifestMatch.Success) {
    throw 'The report manifest wrapper is invalid.'
}
try {
    $manifest = $manifestMatch.Groups['json'].Value | ConvertFrom-Json
}
catch {
    throw "The report manifest JSON is invalid: $($_.Exception.Message)"
}

if ($manifest.dataOrigin -notin @('LiveTenant', 'SampleFixture')) {
    throw 'The report manifest does not contain a recognised data origin.'
}
if ($packageMetadata.dataOrigin -ne $manifest.dataOrigin) {
    throw 'The report package marker and manifest data origins do not match.'
}
if ($manifest.dataOrigin -eq 'SampleFixture') {
    throw 'Refusing to serve sample fixture data. Identity Atlas reports must be collected from a live tenant.'
}

$contentTypes = @{
    '.html' = 'text/html; charset=utf-8'
    '.css' = 'text/css; charset=utf-8'
    '.js' = 'text/javascript; charset=utf-8'
    '.json' = 'application/json; charset=utf-8'
    '.svg' = 'image/svg+xml'
    '.png' = 'image/png'
    '.md' = 'text/markdown; charset=utf-8'
}

$rootPrefix = $resolvedRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port)
$listener.Start()
Write-Information "Serving the $($manifest.dataOrigin) Identity Atlas report at http://127.0.0.1:$Port/ from '$resolvedRoot'." -InformationAction Continue

try {
    while ($true) {
        $client = $listener.AcceptTcpClient()
        try {
            $timeoutMilliseconds = $RequestTimeoutSeconds * 1000
            $client.ReceiveTimeout = $timeoutMilliseconds
            $client.SendTimeout = $timeoutMilliseconds
            $stream = $client.GetStream()
            $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::ASCII, $false, 4096, $true)
            $requestLine = $reader.ReadLine()
            $headerCount = 0
            $headerLength = 0
            while ($true) {
                $headerLine = $reader.ReadLine()
                if ([string]::IsNullOrEmpty($headerLine)) {
                    break
                }
                $headerCount++
                $headerLength += $headerLine.Length
                if ($headerCount -gt 100 -or $headerLength -gt 16384) {
                    break
                }
            }

            $responseDetails = [ordered] @{
                Status = '400 Bad Request'
                Body = [System.Text.Encoding]::UTF8.GetBytes('Bad request')
                ContentType = 'text/plain; charset=utf-8'
                SendBody = $true
            }

            if (-not $requestLine) {
                $responseDetails.Status = '400 Bad Request'
            }
            elseif ($requestLine.Length -gt 4096) {
                $responseDetails.Status = '414 URI Too Long'
                $responseDetails.Body = [System.Text.Encoding]::UTF8.GetBytes('URI too long')
            }
            elseif ($headerCount -gt 100 -or $headerLength -gt 16384) {
                $responseDetails.Status = '431 Request Header Fields Too Large'
                $responseDetails.Body = [System.Text.Encoding]::UTF8.GetBytes('Request headers too large')
            }
            else {
                $parts = $requestLine.Split(' ')
                $method = if ($parts.Count -ge 1) { $parts[0] } else { '' }
                if ($parts.Count -lt 3 -or $method -notin @('GET', 'HEAD') -or $parts[2] -notmatch '^HTTP/1\.[01]$') {
                    $responseDetails.Status = if ($method -and $method -notin @('GET', 'HEAD')) {
                        '405 Method Not Allowed'
                    }
                    else {
                        '400 Bad Request'
                    }
                    $responseText = if ($responseDetails.Status -like '405*') {
                        'Method not allowed'
                    }
                    else {
                        'Bad request'
                    }
                    $responseDetails.Body = [System.Text.Encoding]::UTF8.GetBytes($responseText)
                }
                else {
                    $responseDetails.SendBody = $method -ne 'HEAD'
                    try {
                        $requestPath = [System.Uri]::UnescapeDataString($parts[1].Split('?')[0]).TrimStart('/', '\')
                    }
                    catch {
                        $requestPath = $null
                    }

                    if ($null -eq $requestPath -or $requestPath.Contains([char] 0)) {
                        $responseDetails.Status = '400 Bad Request'
                    }
                    else {
                        if (-not $requestPath) {
                            $requestPath = 'index.html'
                        }

                        $relativePath = $requestPath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
                        $candidate = [System.IO.Path]::GetFullPath((Join-Path $resolvedRoot $relativePath))
                        if (-not $candidate.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
                            $responseDetails.Status = '403 Forbidden'
                            $responseDetails.Body = [System.Text.Encoding]::UTF8.GetBytes('Forbidden')
                        }
                        elseif (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
                            $responseDetails.Status = '404 Not Found'
                            $responseDetails.Body = [System.Text.Encoding]::UTF8.GetBytes('Not found')
                        }
                        else {
                            $responseDetails.Status = '200 OK'
                            $responseDetails.Body = [System.IO.File]::ReadAllBytes($candidate)
                            $extension = [System.IO.Path]::GetExtension($candidate).ToLowerInvariant()
                            $responseDetails.ContentType = if ($contentTypes.ContainsKey($extension)) {
                                $contentTypes[$extension]
                            }
                            else {
                                'application/octet-stream'
                            }
                        }
                    }
                }
            }

            $securityHeaders = @(
                "HTTP/1.1 $($responseDetails.Status)"
                "Content-Type: $($responseDetails.ContentType)"
                "Content-Length: $($responseDetails.Body.Length)"
                'Cache-Control: no-store, max-age=0'
                'Pragma: no-cache'
                'X-Content-Type-Options: nosniff'
                'X-Frame-Options: DENY'
                'Referrer-Policy: no-referrer'
                'Cross-Origin-Opener-Policy: same-origin'
                'Cross-Origin-Resource-Policy: same-origin'
                'Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()'
                "Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; worker-src blob:; connect-src 'none'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
                'Connection: close'
                ''
                ''
            )
            $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($securityHeaders -join "`r`n")
            $stream.Write($headerBytes, 0, $headerBytes.Length)
            if ($responseDetails.SendBody) {
                $stream.Write($responseDetails.Body, 0, $responseDetails.Body.Length)
            }
            $stream.Flush()
        }
        catch [System.IO.IOException] {
            Write-Verbose "The client disconnected before the response completed: $($_.Exception.Message)"
        }
        finally {
            $client.Dispose()
        }
    }
}
finally {
    $listener.Stop()
}