msgraphProxy.psm1

$script:ModuleRoot = $PSScriptRoot

function Clear-MsGraphProxySystemProxy {
    <#
    .SYNOPSIS
        Clears a Windows system-proxy registration left behind by Dev Proxy.
     
    .DESCRIPTION
        Dev Proxy registers itself as the Windows system HTTP/HTTPS proxy while
        running, and only unregisters that as part of its own graceful shutdown.
        If Dev Proxy has to be force-killed instead, that unregistration never
        runs, and Windows is left pointed at a dead proxy port, breaking every
        proxy-aware application on the machine. This is a last-resort safety net
        that mirrors what Dev Proxy's own graceful shutdown does, called by
        Stop-MsGraphProxy only when the graceful stop failed.
     
    .EXAMPLE
        PS C:\> Clear-MsGraphProxySystemProxy
     
        Disables the Windows system proxy if Dev Proxy left it enabled.
    #>

    [CmdletBinding()]
    param ()

    if (-not $IsWindows) {
        return
    }

    $key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings'
    $current = Get-ItemProperty -Path $key -Name ProxyEnable -ErrorAction SilentlyContinue
    if (-not $current -or $current.ProxyEnable -eq 0) {
        return
    }

    Set-ItemProperty -Path $key -Name ProxyEnable -Value 0

    if (-not ('MsGraphProxyModule.WinInet' -as [type])) {
        Add-Type -Namespace MsGraphProxyModule -Name WinInet -MemberDefinition @'
[DllImport("wininet.dll", SetLastError = true)]
public static extern bool InternetSetOption(IntPtr hInternet, int dwOption, IntPtr lpBuffer, int dwBufferLength);
'@

    }

    [MsGraphProxyModule.WinInet]::InternetSetOption([IntPtr]::Zero, 39, [IntPtr]::Zero, 0) | Out-Null
    [MsGraphProxyModule.WinInet]::InternetSetOption([IntPtr]::Zero, 37, [IntPtr]::Zero, 0) | Out-Null

    Write-Warning 'Cleared a stale Windows system-proxy registration left behind by Dev Proxy.'
}


function Get-MsGraphProxyEntraIDLicensePreset {
    <#
    .SYNOPSIS
        Builds a graphSchemaMockPlugin subscribedSkus entry for a named Entra ID
        license tier.
 
    .DESCRIPTION
        Maester's Get-MtLicenseInformation (and anything else that inspects
        subscribedSkus the same way) detects the tenant's Entra ID license by
        checking for specific servicePlanId GUIDs in priority order: P2, then
        Governance, then P1, falling back to Free if none match. This returns a
        subscribedSkus entry carrying the right GUID for the requested tier, for
        Start-MsGraphProxy's -EntraIDLicense to feed into
        New-MsGraphProxyCIConfigFile -SubscribedSkus.
 
    .PARAMETER License
        The Entra ID license tier to build a preset for.
 
    .EXAMPLE
        PS C:\> Get-MsGraphProxyEntraIDLicensePreset -License P2
 
        Returns a subscribedSkus entry for Entra ID P2.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Free', 'P1', 'P2', 'Governance')]
        [string]
        $License
    )

    # Real Microsoft Entra ID service plan GUIDs - see
    # https://learn.microsoft.com/entra/identity/users/licensing-service-plan-reference
    switch ($License) {
        'P2' {
            [pscustomobject]@{
                skuPartNumber    = 'AAD_PREMIUM_P2'
                capabilityStatus = 'Enabled'
                servicePlans     = @(
                    [pscustomobject]@{
                        servicePlanId   = 'eec0eb4f-6444-4f95-aba0-50c24d67f998'
                        servicePlanName = 'AAD_PREMIUM_P2'
                    }
                )
            }
        }
        'Governance' {
            [pscustomobject]@{
                skuPartNumber    = 'AAD_PREMIUM_GOVERNANCE'
                capabilityStatus = 'Enabled'
                servicePlans     = @(
                    [pscustomobject]@{
                        servicePlanId   = 'e866a266-3cff-43a3-acca-0c90a7e00c8b'
                        servicePlanName = 'Entra_Identity_Governance'
                    }
                )
            }
        }
        'P1' {
            [pscustomobject]@{
                skuPartNumber    = 'AAD_PREMIUM'
                capabilityStatus = 'Enabled'
                servicePlans     = @(
                    [pscustomobject]@{
                        servicePlanId   = '41781fb2-bc02-4b7c-bd55-b576c07bb09d'
                        servicePlanName = 'AAD_PREMIUM'
                    }
                )
            }
        }
        'Free' {
            [pscustomobject]@{
                skuPartNumber    = 'EXCHANGESTANDARD'
                capabilityStatus = 'Enabled'
                servicePlans     = @(
                    [pscustomobject]@{
                        servicePlanId   = '9aaf7827-d63c-4b61-89c3-182f06f82e5c'
                        servicePlanName = 'EXCHANGE_S_STANDARD'
                    }
                )
            }
        }
    }
}


function Get-MsGraphProxyExePath {
    <#
    .SYNOPSIS
        Resolves the local path to the cached Dev Proxy executable.
     
    .DESCRIPTION
        Looks up the self-contained Dev Proxy build for the current operating
        system inside the module's local binary cache, installed there by
        Install-MsGraphProxy, and returns the full path to its executable.
     
    .EXAMPLE
        PS C:\> Get-MsGraphProxyExePath
     
        Returns the full path to the cached devproxy executable, throwing if it
        hasn't been installed yet.
    #>

    [CmdletBinding()]
    param ()

    $rid = $script:MsGraphProxyRid ?? (Get-MsGraphProxyRid)
    $exeName = if ($IsWindows) { 'devproxy.exe' } else { 'devproxy' }
    $ridRoot = Join-Path -Path $script:MsGraphProxyBinRoot -ChildPath $rid
    $exePath = Join-Path -Path $ridRoot -ChildPath $exeName

    if (-not (Test-Path -Path $exePath)) {
        throw "Dev Proxy isn't installed for $rid. Run Install-MsGraphProxy first."
    }

    $exePath
}


function Get-MsGraphProxyRid {
    <#
    .SYNOPSIS
        Resolves the .NET runtime identifier for the current operating system.
     
    .DESCRIPTION
        Maps the current operating system to the runtime identifier (RID) used to
        name the published, self-contained Dev Proxy binaries this module
        downloads and runs, for example "win-x64" or "linux-x64".
 
        macOS resolves to "osx-arm64" or "osx-x64" depending on processor
        architecture - only osx-arm64 is actually built/published today (it's
        what GitHub Actions' macos-latest runners are), so Install-MsGraphProxy
        on an Intel Mac will fail clearly with "no release asset found" rather
        than this function silently mismapping it.
 
    .EXAMPLE
        PS C:\> Get-MsGraphProxyRid
 
        Returns the RID matching the current operating system, e.g. "win-x64".
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param ()

    if ($IsWindows) {
        return 'win-x64'
    }
    if ($IsLinux) {
        return 'linux-x64'
    }
    if ($IsMacOS) {
        if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) {
            return 'osx-arm64'
        }
        return 'osx-x64'
    }
    throw 'msgraphProxy has no published Dev Proxy build for this operating system.'
}


function New-MsGraphProxyCIConfigFile {
    <#
    .SYNOPSIS
        Derives a modified copy of a devproxyrc.json - certificate auto-install
        disabled for CI, and/or config overrides like a specific Entra ID
        license.
 
    .DESCRIPTION
        Used by Start-MsGraphProxy to produce a working copy of devproxyrc.json
        whenever -CI or -EntraIDLicense is passed, rather than mutating the
        config file bundled with the module itself.
 
        -CI disables certificate auto-install on Windows, which is what lets
        Dev Proxy's proxy port actually bind in a non-interactive session -
        see the code comment where it's applied for why, and why it's only
        ever done under -CI specifically.
 
        -SubscribedSkus overwrites graphSchemaMockPlugin.subscribedSkus, which
        is how Start-MsGraphProxy's -EntraIDLicense picks which license tier
        the mocked tenant reports (see that plugin's own config shape).
 
        The copy is written next to the original config file, not to a temp
        directory: plugin settings like schemaFilePath/mocksFile are relative
        paths resolved against the config file's own directory, so keeping
        the copy alongside the original preserves those references.
 
    .PARAMETER ConfigFile
        Path to the source devproxyrc.json to derive a copy from.
 
    .PARAMETER CI
        Also disable certificate auto-install on Windows - see DESCRIPTION.
 
    .PARAMETER SubscribedSkus
        Replaces graphSchemaMockPlugin.subscribedSkus from the source config.
        Only applied if -SubscribedSkus is passed.
 
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational
        messages will be displayed that explain what would happen if the command
        were to run.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before
        executing any operations that change state.
 
    .EXAMPLE
        PS C:\> New-MsGraphProxyCIConfigFile -ConfigFile 'C:\proxy\devproxyrc.json' -CI
 
        Returns an object with the generated config's path and the proxy port
        it declares (or Dev Proxy's default of 8000 if unset).
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    param (
        [Parameter(Mandatory)]
        [string]
        $ConfigFile,

        [switch]
        $CI,

        [object[]]
        $SubscribedSkus
    )

    $config = Get-Content -Raw -Path $ConfigFile | ConvertFrom-Json -AsHashtable
    if ($CI -and $IsWindows) {
        $config['installCert'] = $false
    }

    if ($PSBoundParameters.ContainsKey('SubscribedSkus')) {
        if (-not $config.ContainsKey('graphSchemaMockPlugin')) {
            $config['graphSchemaMockPlugin'] = @{}
        }
        $config['graphSchemaMockPlugin']['subscribedSkus'] = @($SubscribedSkus)
    }

    $proxyPort = if ($config.ContainsKey('port')) { [int]$config['port'] } else { 8000 }

    $ciConfigFile = Join-Path -Path (Split-Path -Path $ConfigFile -Parent) -ChildPath 'msgraphproxy-ci-devproxyrc.json'
    if ($PSCmdlet.ShouldProcess($ciConfigFile, 'Write derived Dev Proxy config')) {
        $config | ConvertTo-Json -Depth 10 | Set-Content -Path $ciConfigFile
    }

    [pscustomobject]@{
        ConfigFile = $ciConfigFile
        ProxyPort  = $proxyPort
    }
}


function Receive-MsGraphProxyRecording {
    <#
    .SYNOPSIS
        Stops Dev Proxy's active recording and collects the resulting reports.
     
    .DESCRIPTION
        Calls Dev Proxy's control API to stop recording, which synchronously
        triggers its reporting plugins (such as GraphMinimalPermissionsPlugin and
        ExecutionSummaryPlugin) to analyze what was recorded. Because JsonReporter
        is enabled in this module's bundled configuration, those plugins write
        their results as JSON files into Dev Proxy's working directory; this
        function reads them, parses them, deletes them, and returns them as a
        single object keyed by report name.
     
    .PARAMETER ApiPort
        Port of Dev Proxy's control API.
     
    .PARAMETER WorkingDirectory
        The directory Dev Proxy was started in, where report files are written.
     
    .EXAMPLE
        PS C:\> Receive-MsGraphProxyRecording -ApiPort 8897 -WorkingDirectory 'C:\bin\win-x64'
     
        Stops recording and returns any reports Dev Proxy generated.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [int]
        $ApiPort,

        [Parameter(Mandatory)]
        [string]
        $WorkingDirectory
    )

    $reportFilter = 'GraphMinimalPermissions*_JsonReporter.json'
    Get-ChildItem -Path $WorkingDirectory -Filter $reportFilter -ErrorAction SilentlyContinue |
        Remove-Item -Force -ErrorAction SilentlyContinue

    try {
        Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$ApiPort/proxy" `
            -ContentType 'application/json' -Body '{"recording":false}' -TimeoutSec 30 | Out-Null
    } catch {
        Write-Verbose "Stopping the recording via the API failed: $_"
        return $null
    }

    $reportFiles = Get-ChildItem -Path $WorkingDirectory -Filter $reportFilter -ErrorAction SilentlyContinue
    if (-not $reportFiles) {
        return $null
    }

    $reports = [ordered]@{}
    foreach ($reportFile in $reportFiles) {
        $reportName = $reportFile.BaseName -replace '_JsonReporter$', ''
        $reports[$reportName] = Get-Content -Path $reportFile.FullName -Raw | ConvertFrom-Json
        #Remove-Item -Path $reportFile.FullName -Force -ErrorAction SilentlyContinue
    }

    [pscustomobject]$reports
}


function Wait-MsGraphProxyControlApi {
    <#
    .SYNOPSIS
        Waits for Dev Proxy's control API to start responding.
 
    .DESCRIPTION
        Polls the control API (plain HTTP, no TLS involved) until it responds
        or the timeout elapses. Dev Proxy needs a moment after being started to
        bind this endpoint, so callers that need to know it's actually up -
        before fetching its root certificate, for example - poll rather than
        assume a fixed delay is enough.
 
    .PARAMETER ApiPort
        Port of Dev Proxy's control API.
 
    .PARAMETER TimeoutSeconds
        How long to keep polling before giving up.
 
    .EXAMPLE
        PS C:\> Wait-MsGraphProxyControlApi -ApiPort 8897
 
        Returns $true once the control API responds, or $false after 30 seconds.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [int]
        $ApiPort,

        [int]
        $TimeoutSeconds = 30
    )

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        try {
            Invoke-RestMethod -Uri "http://127.0.0.1:$ApiPort/proxy" -TimeoutSec 5 | Out-Null
            return $true
        } catch {
            Write-Verbose "Control API not ready yet: $_"
            Start-Sleep -Seconds 1
        }
    }

    return $false
}


function Wait-MsGraphProxyPort {
    <#
    .SYNOPSIS
        Waits for Dev Proxy's actual proxy port to start accepting connections.
 
    .DESCRIPTION
        Waits and checks that the proxy port has started listening and is ready for connections
 
    .PARAMETER ProxyPort
        The proxy's own listening port (not the control API port).
 
    .PARAMETER TimeoutSeconds
        How long to keep polling before giving up.
 
    .EXAMPLE
        PS C:\> Wait-MsGraphProxyPort -ProxyPort 8000
 
        Returns $true once the proxy port accepts a connection, or $false after 30 seconds.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [int]
        $ProxyPort,

        [int]
        $TimeoutSeconds = 30
    )

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        try {
            $tcpClient = [System.Net.Sockets.TcpClient]::new()
            try {
                $tcpClient.Connect('127.0.0.1', $ProxyPort)
                return $true
            } finally {
                $tcpClient.Close()
            }
        } catch {
            Write-Verbose "Proxy port not ready yet: $_"
            Start-Sleep -Milliseconds 250
        }
    }

    return $false
}


function Get-MsGraphProxyStatus {
    <#
    .SYNOPSIS
        Reports whether the Dev Proxy process started by Start-MsGraphProxy is
        still running.
     
    .DESCRIPTION
        Reads the state file written by Start-MsGraphProxy and checks whether
        the process it recorded is still alive. Returns an object with:
            Running - whether the process is currently alive
            Id - its process ID
            ConfigFile - the devproxyrc.json it was started with
            ExePath - path to the Dev Proxy executable
            ApiPort - its control-API port
            Recording - whether it's currently recording
            StartedAt - when it was started
        If Start-MsGraphProxy was never called (or Stop-MsGraphProxy already
        cleaned up), all of these are $false/$null.
 
    .EXAMPLE
        PS C:\> Get-MsGraphProxyStatus
 
        Returns an object describing whether Dev Proxy is currently running.
     
    .LINK
        https://mynster-it.dk/docs/modules/msgraphProxy/commands/Get-MsGraphProxyStatus
    #>

    [CmdletBinding()]
    param ()

    if (-not (Test-Path -Path $script:MsGraphProxyStateFile)) {
        return [pscustomobject]@{
            Running    = $false
            Id         = $null
            ConfigFile = $null
            ExePath    = $null
            ApiPort    = $null
            Recording  = $false
            StartedAt  = $null
        }
    }

    $state = Get-Content -Path $script:MsGraphProxyStateFile -Raw | ConvertFrom-Json
    $process = if ($state.Id) { Get-Process -Id $state.Id -ErrorAction SilentlyContinue }

    [pscustomobject]@{
        Running    = [bool]$process
        Id         = $state.Id
        ConfigFile = $state.ConfigFile
        ExePath    = $state.ExePath
        ApiPort    = $state.ApiPort
        Recording  = [bool]$state.Recording
        StartedAt  = $state.StartedAt
    }
}


function Install-MsGraphProxy {
    <#
    .SYNOPSIS
        Downloads and installs the self-contained Dev Proxy build this module wraps.
     
    .DESCRIPTION
        Downloads the zipped, self-contained Dev Proxy build (bundled with this
        module's GraphSchemaMockPlugin and EntraTokenMockPlugin extensions)
        from this repository's latest GitHub release, and extracts it into the
        module's local binary cache - no separate DOTNET installation needed on
        this machine.
 
        If a build for the target RID is already cached, this does nothing
        unless -Force is passed. Start-MsGraphProxy calls this automatically
        the first time it needs to, so you normally don't need to call it
        yourself.
 
    .PARAMETER Rid
        The DOTNET runtime identifier to install a build for. Defaults to the RID
        matching the current operating system.
 
    .PARAMETER Force
        Reinstall even if a build for this RID is already cached.
 
    .EXAMPLE
        PS C:\> Install-MsGraphProxy
 
        Downloads and installs the Dev Proxy build matching the current OS.
 
    .EXAMPLE
        PS C:\> Install-MsGraphProxy -Force
 
        Re-downloads and reinstalls the Dev Proxy build, replacing whatever is
        already cached.
 
    .LINK
        https://mynster-it.dk/docs/modules/msgraphProxy/commands/Install-MsGraphProxy
    #>

    [CmdletBinding()]
    param (
        [string]
        $Rid = ($script:MsGraphProxyRid ?? (Get-MsGraphProxyRid)),

        [switch]
        $Force
    )

    $ridRoot = Join-Path -Path $script:MsGraphProxyBinRoot -ChildPath $Rid
    $exeName = if ($IsWindows) { 'devproxy.exe' } else { 'devproxy' }
    $exePath = Join-Path -Path $ridRoot -ChildPath $exeName

    if ((Test-Path -Path $exePath) -and -not $Force) {
        Write-Verbose "Dev Proxy for $Rid is already installed at $ridRoot."
        return $exePath
    }

    $releaseUri = "https://api.github.com/repos/$($script:MsGraphProxyGitHubRepo)/releases/latest"
    $release = Invoke-RestMethod -Uri $releaseUri -Headers @{ 'User-Agent' = 'msgraphProxy' }

    $asset = $release.assets | Where-Object Name -Like "*$Rid*.zip" | Select-Object -First 1
    if (-not $asset) {
        throw "No release asset found for $Rid in release $($release.tag_name). Available assets: $($release.assets.name -join ', ')"
    }

    $zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $asset.name
    Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zipPath -UseBasicParsing

    if (Test-Path -Path $ridRoot) {
        Remove-Item -Path $ridRoot -Recurse -Force
    }
    New-Item -Path $ridRoot -ItemType Directory -Force | Out-Null
    Expand-Archive -Path $zipPath -DestinationPath $ridRoot -Force
    Remove-Item -Path $zipPath -Force

    if ($IsLinux -or $IsMacOS) {
        & chmod +x $exePath
    }

    Write-Verbose "Installed Dev Proxy $($release.tag_name) for $Rid to $ridRoot."
    $exePath
}


function Install-MsGraphProxyCertificate {
    <#
    .SYNOPSIS
        Trusts the running Dev Proxy instance's root CA certificate for the
        current user.
 
    .DESCRIPTION
        Fetches Dev Proxy's root CA certificate from its control API and
        trusts it for the current OS user, so HTTPS clients accept the
        certificates Dev Proxy generates for intercepted requests without any
        client-side accommodation (like skipping certificate validation).
 
        Supported on Windows (via certutil), Linux (via
        update-ca-certificates) and macOS (via the current user's login
        keychain). This is best-effort, not guaranteed: trusting a
        certificate can require an interactive confirmation dialog, which
        will never resolve in a non-interactive session (most commonly hit
        via Start-MsGraphProxy -CI). Rather than hang waiting for it, this
        function waits up to 15 seconds and then returns $false with a
        warning instead of throwing, so callers can decide for themselves
        whether to fall back to skipping certificate validation in their own
        requests. On a genuine interactive desktop session, trust normally
        succeeds and the confirmation dialog (Windows only) can just be
        answered.
 
    .PARAMETER ApiPort
        Port of Dev Proxy's control API.
 
    .EXAMPLE
        PS C:\> Install-MsGraphProxyCertificate
 
        Fetches and trusts the root certificate of the Dev Proxy instance
        using the default control-API port.
 
    .LINK
        https://mynster-it.dk/docs/modules/msgraphProxy/commands/Install-MsGraphProxyCertificate
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [int]
        $ApiPort = $script:MsGraphProxyDefaultApiPort
    )

    if (-not $IsWindows -and -not $IsLinux -and -not $IsMacOS) {
        Write-Warning 'Trusting the Dev Proxy root certificate automatically is only implemented for Windows, Linux and macOS.'
        return $false
    }

    $certPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'msgraphproxy-devproxy-ca.crt'
    try {
        Invoke-WebRequest -Uri "http://127.0.0.1:$ApiPort/proxy/rootCertificate?format=crt" -OutFile $certPath -TimeoutSec 15
    } catch {
        Write-Warning "Couldn't fetch the Dev Proxy root certificate: $_"
        return $false
    }

    try {
        if ($IsWindows) {
            $psi = [System.Diagnostics.ProcessStartInfo]::new('certutil.exe')
            foreach ($arg in @('-addstore', '-f', '-user', 'Root', $certPath)) {
                $psi.ArgumentList.Add($arg)
            }
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError = $true
            $psi.UseShellExecute = $false

            $process = [System.Diagnostics.Process]::Start($psi)
            if (-not $process.WaitForExit(15000)) {
                $process.Kill()
                Write-Warning 'Trusting the Dev Proxy root certificate timed out, likely waiting on an interactive confirmation prompt that nothing could answer. HTTPS clients may need to skip certificate validation instead.'
                return $false
            }

            if ($process.ExitCode -ne 0) {
                $errorOutput = $process.StandardError.ReadToEnd()
                Write-Warning "certutil failed to trust the Dev Proxy root certificate (exit $($process.ExitCode)): $errorOutput"
                return $false
            }
        } elseif ($IsLinux) {
            $isRoot = (& id -u) -eq '0'
            $dest = '/usr/local/share/ca-certificates/msgraphproxy-devproxy.crt'
            if ($isRoot) {
                Copy-Item -Path $certPath -Destination $dest
                update-ca-certificates | Out-Null
            } else {
                sudo cp $certPath $dest
                sudo update-ca-certificates | Out-Null
            }

            if ($LASTEXITCODE -ne 0) {
                Write-Warning "update-ca-certificates failed (exit $LASTEXITCODE) to trust the Dev Proxy root certificate."
                return $false
            }
        } else {
            $keychain = Join-Path -Path $HOME -ChildPath 'Library/Keychains/login.keychain-db'
            $psi = [System.Diagnostics.ProcessStartInfo]::new('security')
            foreach ($arg in @('add-trusted-cert', '-r', 'trustRoot', '-k', $keychain, $certPath)) {
                $psi.ArgumentList.Add($arg)
            }
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError = $true
            $psi.UseShellExecute = $false

            $process = [System.Diagnostics.Process]::Start($psi)
            if (-not $process.WaitForExit(15000)) {
                $process.Kill()
                Write-Warning 'Trusting the Dev Proxy root certificate timed out, likely waiting on a keychain authorization prompt that nothing could answer. HTTPS clients may need to skip certificate validation instead.'
                return $false
            }

            if ($process.ExitCode -ne 0) {
                $errorOutput = $process.StandardError.ReadToEnd()
                Write-Warning "security failed to trust the Dev Proxy root certificate (exit $($process.ExitCode)): $errorOutput"
                return $false
            }
        }
    } finally {
        Remove-Item -Path $certPath -Force -ErrorAction SilentlyContinue
    }

    Write-Verbose 'Dev Proxy root certificate trusted.'
    $true
}


function Start-MsGraphProxy {
    <#
    .SYNOPSIS
        Starts the self-contained Dev Proxy build in its own process.
     
    .DESCRIPTION
        Launches the cached Dev Proxy executable against a devproxyrc.json
        configuration, and tracks the resulting process so Stop-MsGraphProxy
        and Get-MsGraphProxyStatus can find it again later, even from a
        different PowerShell session. If Dev Proxy hasn't been installed yet
        for this OS, it's installed automatically first (see
        Install-MsGraphProxy).
 
        Recording starts automatically with the proxy, so Stop-MsGraphProxy
        can stop it again and return the resulting reports (such as minimal
        Graph permissions) as an object. Pass -NoRecord to opt out.
 
        While running, Dev Proxy intercepts and mocks calls to the hosts
        listed in its "urlsToWatch" configuration (Microsoft Graph and the
        Entra ID token endpoint, by default), tunnelling everything else
        through untouched.
 
    .PARAMETER ConfigFile
        Path to a devproxyrc.json/.yaml configuration file. Defaults to the
        configuration bundled with this module.
 
    .PARAMETER ApiPort
        Port for Dev Proxy's control API, used by Stop-MsGraphProxy for a
        graceful shutdown. Defaults to Dev Proxy's own default port, 8897.
 
    .PARAMETER NoRecord
        Don't start recording automatically. Without this switch, Dev Proxy
        starts recording immediately so Stop-MsGraphProxy has something to stop
        and report on.
 
    .PARAMETER Force
        Start a new instance even if one is already tracked as running.
 
    .PARAMETER CI
        Configure Dev Proxy for a non-interactive session (CI pipelines,
        Pester runs, etc.) instead of normal interactive use: sets
        HTTP_PROXY/HTTPS_PROXY for the current session so Graph calls route
        through the proxy, and trusts Dev Proxy's root certificate
        automatically on a best-effort basis (see the returned object's
        CertificateTrusted property, and Install-MsGraphProxyCertificate's
        help for what "best-effort" means).
 
    .PARAMETER EntraIDLicense
        Which Entra ID license tier the mocked tenant's subscribedSkus should
        report - Free, P1, P2 or Governance. Defaults to P2, so license-gated
        checks (e.g. Maester's Get-MtLicenseInformation) see a licensed tenant
        out of the box. Pass -EntraIDLicense explicitly to pick a different
        tier.
 
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational
        messages will be displayed that explain what would happen if the command
        were to run.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before
        executing any operations that change state.
     
    .EXAMPLE
        PS C:\> Start-MsGraphProxy
     
        Starts Dev Proxy using the configuration bundled with this module, recording from the start.
     
    .EXAMPLE
        PS C:\> Start-MsGraphProxy -ConfigFile 'C:\proxy\devproxyrc.json' -ApiPort 9000
 
        Starts Dev Proxy with a custom configuration and control-API port.
 
    .EXAMPLE
        PS C:\> Start-MsGraphProxy -CI
 
        Starts Dev Proxy configured for a CI pipeline: no certificate prompt to
        block startup, HTTP_PROXY/HTTPS_PROXY set for the current process, and
        its root certificate trusted automatically where possible.
 
    .LINK
        https://mynster-it.dk/docs/modules/msgraphProxy/commands/Start-MsGraphProxy
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [string]
        $ConfigFile = $script:MsGraphProxyDefaultConfigFile,

        [int]
        $ApiPort = $script:MsGraphProxyDefaultApiPort,

        [switch]
        $NoRecord,

        [switch]
        $Force,

        [switch]
        $CI,

        [ValidateSet('Free', 'P1', 'P2', 'Governance')]
        [string]
        $EntraIDLicense = 'P2'
    )

    $existing = Get-MsGraphProxyStatus
    if ($existing.Running -and -not $Force) {
        Write-Warning "Dev Proxy is already running (PID $($existing.Id)). Use -Force to start another instance anyway."
        return $existing
    }

    if (-not (Test-Path -Path $ConfigFile)) {
        throw "Config file not found: $ConfigFile. Pass -ConfigFile explicitly, or run Install-MsGraphProxy if this is the bundled default."
    }
    $resolvedConfigFile = (Resolve-Path -Path $ConfigFile).Path

    if (-not $PSCmdlet.ShouldProcess('Dev Proxy', 'Start')) {
        return
    }

    try {
        $exePath = Get-MsGraphProxyExePath
    } catch {
        Write-Verbose 'Dev Proxy is not installed yet; installing it now.'
        $exePath = Install-MsGraphProxy
    }

    $proxyPort = 8000
    if ($CI -or $PSBoundParameters.ContainsKey('EntraIDLicense')) {
        $licensePreset = Get-MsGraphProxyEntraIDLicensePreset -License $EntraIDLicense
        $derivedConfig = New-MsGraphProxyCIConfigFile -ConfigFile $resolvedConfigFile -CI:$CI -SubscribedSkus $licensePreset
        $resolvedConfigFile = $derivedConfig.ConfigFile
        $proxyPort = $derivedConfig.ProxyPort
    } else {
        $rawConfig = Get-Content -Raw -Path $resolvedConfigFile | ConvertFrom-Json -AsHashtable
        if ($rawConfig.ContainsKey('port')) {
            $proxyPort = [int]$rawConfig['port']
        }
    }

    $processArgs = @('--config-file', "`"$resolvedConfigFile`"", '--api-port', $ApiPort)
    if (-not $NoRecord) {
        $processArgs += '--record'
    }

    if (Test-Path -Path $script:MsGraphProxyStdOutLog) { Remove-Item -Path $script:MsGraphProxyStdOutLog -Force }
    if (Test-Path -Path $script:MsGraphProxyStdErrLog) { Remove-Item -Path $script:MsGraphProxyStdErrLog -Force }

    $params = @{
        FilePath               = $exePath
        ArgumentList           = $processArgs
        WorkingDirectory       = $(Split-Path -Path $exePath -Parent)
        PassThru               = $true
        RedirectStandardOutput = $script:MsGraphProxyStdOutLog
        RedirectStandardError  = $script:MsGraphProxyStdErrLog
    }
    $process = Start-Process @params

    [pscustomobject]@{
        Id         = $process.Id
        ConfigFile = $resolvedConfigFile
        ExePath    = $exePath
        ApiPort    = $ApiPort
        Recording  = -not $NoRecord
        StartedAt  = (Get-Date).ToString('o')
    } | ConvertTo-Json | Set-Content -Path $script:MsGraphProxyStateFile

    Write-Verbose "Dev Proxy started (PID $($process.Id)) using $resolvedConfigFile"

    $result = Get-MsGraphProxyStatus
    $ready = Wait-MsGraphProxyControlApi -ApiPort $ApiPort
    if ($ready) {
        $ready = Wait-MsGraphProxyPort -ProxyPort $proxyPort
    }

    if (-not $ready) {
        Write-Warning 'Dev Proxy did not become ready to serve requests in time.'
    }

    if ($CI) {
        $proxyUri = "http://127.0.0.1:$proxyPort"
        foreach ($name in 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy') {
            [System.Environment]::SetEnvironmentVariable($name, $proxyUri, 'Process')
        }

        if ($env:GITHUB_ENV) {
            foreach ($name in 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy') {
                Add-Content -Path $env:GITHUB_ENV -Value "$name=$proxyUri"
            }
        }

        [System.Net.Http.HttpClient]::DefaultProxy = [System.Net.WebProxy]::new($proxyUri)

        $certificateTrusted = $false
        if ($ready) {
            $certificateTrusted = Install-MsGraphProxyCertificate -ApiPort $ApiPort
        } else {
            Write-Warning 'Skipping automatic certificate trust since Dev Proxy never became ready.'
        }

        $result = $result | Add-Member -NotePropertyName CertificateTrusted -NotePropertyValue $certificateTrusted -PassThru
    }

    $result
}


function Stop-MsGraphProxy {
    <#
    .SYNOPSIS
        Stops the Dev Proxy process started by Start-MsGraphProxy.
     
    .DESCRIPTION
        If Dev Proxy is recording, first stops the recording through its
        control API. That triggers its reporting plugins (Graph minimal
        permissions, execution summary) to analyze what was recorded; their
        results are returned as part of the result object, under Recording.
 
        Then asks Dev Proxy to shut down gracefully through its control API.
        If it doesn't stop within -TimeoutSeconds, the process is force-killed
        instead, and any Windows system-proxy registration is cleared
        manually (a graceful shutdown does this on its own).
     
    .PARAMETER TimeoutSeconds
        How long to wait for a graceful shutdown before falling back to killing
        the process outright.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational
        messages will be displayed that explain what would happen if the command
        were to run.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before
        executing any operations that change state.
     
    .EXAMPLE
        PS C:\> Stop-MsGraphProxy
     
        Stops the running Dev Proxy instance and returns any recorded reports.
 
    .LINK
        https://mynster-it.dk/docs/modules/msgraphProxy/commands/Stop-MsGraphProxy
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [int]
        $TimeoutSeconds = 10
    )

    $status = Get-MsGraphProxyStatus
    if (-not $status.Running) {
        Write-Warning 'Dev Proxy is not running.'
        Remove-Item -Path $script:MsGraphProxyStateFile -Force -ErrorAction SilentlyContinue
        Clear-MsGraphProxySystemProxy
        return
    }

    if (-not $PSCmdlet.ShouldProcess("Dev Proxy (PID $($status.Id))", 'Stop')) {
        return
    }

    $apiPort = $status.ApiPort
    if (-not $apiPort) {
        $apiPort = $script:MsGraphProxyDefaultApiPort
    }

    $recording = $null
    if ($status.Recording -and $status.ExePath) {
        $recording = Receive-MsGraphProxyRecording -ApiPort $apiPort -WorkingDirectory (Split-Path -Path $status.ExePath -Parent)
    }

    $stoppedGracefully = $false
    try {
        Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$apiPort/proxy/stopProxy" -TimeoutSec 5 | Out-Null

        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
        while ((Get-Date) -lt $deadline) {
            if (-not (Get-Process -Id $status.Id -ErrorAction SilentlyContinue)) {
                $stoppedGracefully = $true
                break
            }
            Start-Sleep -Milliseconds 250
        }
    } catch {
        Write-Verbose "Graceful stop via the API failed: $_"
    }

    if (-not $stoppedGracefully) {
        Write-Warning "Dev Proxy didn't stop gracefully via its API; forcing termination and clearing the Windows system proxy."
        Stop-Process -Id $status.Id -Force -ErrorAction SilentlyContinue
        Clear-MsGraphProxySystemProxy
    }

    Remove-Item -Path $script:MsGraphProxyStateFile -Force -ErrorAction SilentlyContinue
    Write-Verbose "Dev Proxy (PID $($status.Id)) stopped."

    [pscustomobject]@{
        Id        = $status.Id
        StoppedAt = (Get-Date).ToString('o')
        Recording = $recording
    }
}


# Commands run on module import go here
# E.g. Argument Completers could be placed here

# Module-wide variables

$script:MsGraphProxyConfigRoot = Join-Path -Path $script:ModuleRoot -ChildPath 'config'
$script:MsGraphProxyDefaultConfigFile = Join-Path -Path $script:MsGraphProxyConfigRoot -ChildPath 'devproxyrc.json'
$dataRoot = if ($IsWindows) {
    $env:LOCALAPPDATA
} elseif ($env:XDG_DATA_HOME) {
    $env:XDG_DATA_HOME
} else {
    Join-Path -Path $HOME -ChildPath '.local/share'
}
$script:MsGraphProxyBinRoot = Join-Path -Path $dataRoot -ChildPath 'msgraphProxy' -AdditionalChildPath 'bin'
$script:MsGraphProxyStateFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'msgraphproxy-module-state.json'
$script:MsGraphProxyStdOutLog = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'msgraphproxy-devproxy-stdout.log'
$script:MsGraphProxyStdErrLog = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'msgraphproxy-devproxy-stderr.log'
$script:MsGraphProxyDefaultApiPort = 8897
$script:MsGraphProxyGitHubRepo = 'Mynster9361/msgraphProxy'
$script:MsGraphProxyRid = try { Get-MsGraphProxyRid } catch { $null }


Export-ModuleMember -Function 'Get-MsGraphProxyStatus','Install-MsGraphProxy','Install-MsGraphProxyCertificate','Start-MsGraphProxy','Stop-MsGraphProxy'