studio-ui/StudioAPI.psm1

<#
.SYNOPSIS
PowerShell module providing the Studio REST API.

.DESCRIPTION
Pure file-I/O HTTP API for workflow CRUD operations.
All YAML parsing/validation is handled client-side.
Designed to be imported by the standalone server.ps1 or
embedded into the full dotbot UI server in the future.

API namespace: /api/studio
#>


# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
$script:WorkflowsDir = $null
$script:StaticRoot   = $null
$script:LayoutFilename = '.studio-layout.json'
$script:DotbotHome   = $null

<#
.SYNOPSIS
Initialize the module with the workflows directory and static file root.
#>

function Initialize-StudioAPI {
    param(
        [Parameter(Mandatory)][string]$WorkflowsDir,
        [Parameter(Mandatory)][string]$StaticRoot
    )
    $script:WorkflowsDir = $WorkflowsDir
    $script:StaticRoot   = $StaticRoot
    $script:DotbotHome   = Split-Path $WorkflowsDir -Parent

    if (-not (Test-Path $script:WorkflowsDir)) {
        New-Item -ItemType Directory -Force -Path $script:WorkflowsDir | Out-Null
    }
}

# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
function Get-SafeWorkflowDir {
    param([string]$Name)

    if ([string]::IsNullOrWhiteSpace($Name)) {
        throw "Workflow name is required."
    }

    # Accept only simple directory names — no paths or traversal segments
    $safeName = [System.IO.Path]::GetFileName($Name)
    if ($safeName -ne $Name -or $safeName -eq '.' -or $safeName -eq '..') {
        throw "Invalid workflow name."
    }
    if ($safeName -notmatch '^[A-Za-z0-9._-]+$') {
        throw "Invalid workflow name."
    }

    # Canonicalize and verify the result stays under WorkflowsDir
    $workflowsRoot = [System.IO.Path]::GetFullPath($script:WorkflowsDir)
    $candidatePath = [System.IO.Path]::GetFullPath((Join-Path $workflowsRoot $safeName))
    $rootWithSep = $workflowsRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
    if (-not $candidatePath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) {
        throw "Invalid workflow name."
    }

    return $candidatePath
}

function Test-WorkflowExists {
    param([string]$Name)
    if ($Name -match '^([^:]+):(.+)$') {
        $dir = Get-RegistryWorkflowDir -RegistryName $Matches[1] -WorkflowName $Matches[2]
        return $null -ne $dir -and (Test-Path $dir -PathType Container)
    }
    $dir = Get-SafeWorkflowDir $Name
    return (Test-Path $dir) -and (Test-Path $dir -PathType Container)
}

function Get-RegistryWorkflowDir {
    param([string]$RegistryName, [string]$WorkflowName)

    # Validate names — same rules as Get-SafeWorkflowDir
    foreach ($n in @($RegistryName, $WorkflowName)) {
        if ([string]::IsNullOrWhiteSpace($n)) { return $null }
        $safe = [System.IO.Path]::GetFileName($n)
        if ($safe -ne $n -or $safe -eq '.' -or $safe -eq '..') { return $null }
        if ($safe -notmatch '^[A-Za-z0-9._-]+$') { return $null }
    }

    $registriesDir = Join-Path $script:DotbotHome 'registries'
    $candidatePath = [System.IO.Path]::GetFullPath((Join-Path $registriesDir $RegistryName 'workflows' $WorkflowName))

    # Verify it stays under the registries directory
    $rootWithSep = [System.IO.Path]::GetFullPath($registriesDir).TrimEnd(
        [System.IO.Path]::DirectorySeparatorChar,
        [System.IO.Path]::AltDirectorySeparatorChar
    ) + [System.IO.Path]::DirectorySeparatorChar
    if (-not $candidatePath.StartsWith($rootWithSep, [System.StringComparison]::OrdinalIgnoreCase)) {
        return $null
    }

    return $candidatePath
}

function Get-RegistryWorkflows {
    $registriesJsonPath = Join-Path $script:DotbotHome 'registries.json'
    if (-not (Test-Path $registriesJsonPath)) { return @() }

    try {
        $registriesConfig = Get-Content -Path $registriesJsonPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        return @()
    }
    if (-not $registriesConfig.registries) { return @() }

    $result = @()
    foreach ($reg in $registriesConfig.registries) {
        # Validate registry name — same rules as Get-RegistryWorkflowDir
        $regName = $reg.name
        if ([string]::IsNullOrWhiteSpace($regName)) { continue }
        $safeName = [System.IO.Path]::GetFileName($regName)
        if ($safeName -ne $regName -or $safeName -eq '.' -or $safeName -eq '..') { continue }
        if ($safeName -notmatch '^[A-Za-z0-9._-]+$') { continue }

        $regWorkflowsDir = Join-Path $script:DotbotHome 'registries' $regName 'workflows'
        if (-not (Test-Path $regWorkflowsDir)) { continue }

        $folders = Get-ChildItem -Path $regWorkflowsDir -Directory -ErrorAction SilentlyContinue | Sort-Object Name
        foreach ($folder in $folders) {
            $yamlPath = Join-Path $folder.FullName 'workflow.yaml'
            $yaml = $null
            if (Test-Path $yamlPath) {
                $yaml = Get-Content -Path $yamlPath -Raw -Encoding UTF8
            }
            $result += @{
                folder   = "$($reg.name):$($folder.Name)"
                yaml     = $yaml
                registry = $reg.name
            }
        }
    }
    return $result
}

function Send-Json {
    param(
        [System.Net.HttpListenerResponse]$Response,
        [object]$Data,
        [int]$StatusCode = 200
    )
    $json = $Data | ConvertTo-Json -Depth 20 -Compress
    $buffer = [System.Text.Encoding]::UTF8.GetBytes($json)
    $Response.StatusCode = $StatusCode
    $Response.ContentType = 'application/json; charset=utf-8'
    $Response.ContentLength64 = $buffer.Length
    $Response.OutputStream.Write($buffer, 0, $buffer.Length)
}

function Send-Text {
    param(
        [System.Net.HttpListenerResponse]$Response,
        [string]$Text,
        [string]$ContentType = 'text/plain; charset=utf-8',
        [int]$StatusCode = 200
    )
    $buffer = [System.Text.Encoding]::UTF8.GetBytes($Text)
    $Response.StatusCode = $StatusCode
    $Response.ContentType = $ContentType
    $Response.ContentLength64 = $buffer.Length
    $Response.OutputStream.Write($buffer, 0, $buffer.Length)
}

function Send-Error {
    param(
        [System.Net.HttpListenerResponse]$Response,
        [string]$Message,
        [int]$StatusCode = 500
    )
    Send-Json -Response $Response -Data @{ error = $Message } -StatusCode $StatusCode
}

function Read-RequestBody {
    param([System.Net.HttpListenerRequest]$Request)
    $reader = [System.IO.StreamReader]::new($Request.InputStream, $Request.ContentEncoding)
    try {
        return $reader.ReadToEnd()
    } finally {
        $reader.Close()
    }
}

function Copy-DirectoryRecursive {
    param([string]$Source, [string]$Destination)
    New-Item -ItemType Directory -Force -Path $Destination | Out-Null
    $items = Get-ChildItem -Path $Source
    foreach ($item in $items) {
        $destPath = Join-Path $Destination $item.Name
        if ($item.PSIsContainer) {
            Copy-DirectoryRecursive -Source $item.FullName -Destination $destPath
        } else {
            Copy-Item -Path $item.FullName -Destination $destPath -Force
        }
    }
}

# ---------------------------------------------------------------------------
# MIME type helper for static file serving
# ---------------------------------------------------------------------------
function Get-MimeType {
    param([string]$FilePath)
    $ext = [System.IO.Path]::GetExtension($FilePath).ToLower()
    switch ($ext) {
        '.html' { 'text/html; charset=utf-8' }
        '.js'   { 'application/javascript; charset=utf-8' }
        '.css'  { 'text/css; charset=utf-8' }
        '.json' { 'application/json; charset=utf-8' }
        '.png'  { 'image/png' }
        '.svg'  { 'image/svg+xml' }
        '.ico'  { 'image/x-icon' }
        default { 'application/octet-stream' }
    }
}

# ---------------------------------------------------------------------------
# Route handler — called for every incoming request
# ---------------------------------------------------------------------------
<#
.SYNOPSIS
Handle a single HTTP request. Returns $true if handled, $false if not matched.
#>

function Invoke-StudioRequest {
    param(
        [Parameter(Mandatory)][System.Net.HttpListenerContext]$Context
    )

    $req = $Context.Request
    $res = $Context.Response
    $method = $req.HttpMethod
    $path = $req.Url.AbsolutePath

    # Add CORS headers
    $res.Headers.Add('Access-Control-Allow-Origin', '*')
    $res.Headers.Add('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
    $res.Headers.Add('Access-Control-Allow-Headers', 'Content-Type')

    # Handle CORS preflight
    if ($method -eq 'OPTIONS') {
        $res.StatusCode = 204
        $res.Close()
        return $true
    }

    try {
        # ---------------------------------------------------------------
        # API routes: /api/studio/...
        # ---------------------------------------------------------------
        $apiPrefix = '/api/studio'

        if ($path -eq $apiPrefix -or $path -eq "$apiPrefix/") {
            # GET /api/studio — List all workflows (return folder + raw YAML)
            # POST /api/studio — Create a new workflow
            if ($method -eq 'GET') {
                $result = @()
                $folders = Get-ChildItem -Path $script:WorkflowsDir -Directory -ErrorAction SilentlyContinue |
                           Sort-Object Name
                foreach ($folder in $folders) {
                    $yamlPath = Join-Path $folder.FullName 'workflow.yaml'
                    $yaml = $null
                    if (Test-Path $yamlPath) {
                        $yaml = Get-Content -Path $yamlPath -Raw -Encoding UTF8
                    }
                    $result += @{ folder = $folder.Name; yaml = $yaml; registry = $null }
                }
                # Append workflows from external registries
                $result += Get-RegistryWorkflows
                Send-Json -Response $res -Data $result
                return $true
            }
            elseif ($method -eq 'POST') {
                $body = Read-RequestBody -Request $req | ConvertFrom-Json
                $name = $body.name
                if (-not $name -or -not $name.Trim()) {
                    Send-Error -Response $res -Message 'Workflow name is required' -StatusCode 400
                    return $true
                }
                if (Test-WorkflowExists $name) {
                    Send-Error -Response $res -Message "Workflow '$name' already exists" -StatusCode 409
                    return $true
                }
                $dir = Get-SafeWorkflowDir $name
                New-Item -ItemType Directory -Force -Path $dir | Out-Null
                New-Item -ItemType Directory -Force -Path (Join-Path $dir 'recipes' 'prompts') | Out-Null
                New-Item -ItemType Directory -Force -Path (Join-Path $dir 'recipes' 'agents') | Out-Null
                New-Item -ItemType Directory -Force -Path (Join-Path $dir 'recipes' 'skills') | Out-Null

                # Write skeleton workflow.yaml if yaml provided, otherwise use default
                if ($body.yaml) {
                    Set-Content -Path (Join-Path $dir 'workflow.yaml') -Value $body.yaml -Encoding UTF8 -NoNewline
                } else {
                    $skeleton = @"
name: $name
version: 1.0.0
description: ""
min_dotbot_version: 3.5.0
requires: {}
tasks: []
"@

                    Set-Content -Path (Join-Path $dir 'workflow.yaml') -Value $skeleton -Encoding UTF8 -NoNewline
                }
                Send-Json -Response $res -Data @{ success = $true; name = $name } -StatusCode 201
                return $true
            }
            else {
                Send-Error -Response $res -Message 'Method not allowed' -StatusCode 405
                return $true
            }
        }

        if ($path.StartsWith("$apiPrefix/")) {
            $remainder = $path.Substring($apiPrefix.Length + 1)  # strip "/api/studio/"
            $segments = $remainder.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)

            if ($segments.Count -eq 0) {
                Send-Error -Response $res -Message 'Not found' -StatusCode 404
                return $true
            }

            $workflowName = [System.Uri]::UnescapeDataString($segments[0])

            # -- Single-segment: /api/studio/:name --
            if ($segments.Count -eq 1) {
                if ($method -eq 'GET') {
                    # Read workflow: return raw YAML + layout + prompt files
                    if (-not (Test-WorkflowExists $workflowName)) {
                        Send-Error -Response $res -Message "Workflow '$workflowName' not found" -StatusCode 404
                        return $true
                    }
                    # Resolve directory — registry workflows use "Registry:name" format
                    if ($workflowName -match '^([^:]+):(.+)$') {
                        $dir = Get-RegistryWorkflowDir -RegistryName $Matches[1] -WorkflowName $Matches[2]
                    } else {
                        $dir = Get-SafeWorkflowDir $workflowName
                    }
                    $yamlPath = Join-Path $dir 'workflow.yaml'
                    $yaml = $null
                    if (Test-Path $yamlPath) {
                        $yaml = Get-Content -Path $yamlPath -Raw -Encoding UTF8
                    }

                    $layoutPath = Join-Path $dir $script:LayoutFilename
                    $layout = $null
                    if (Test-Path $layoutPath) {
                        $layout = Get-Content -Path $layoutPath -Raw -Encoding UTF8
                    }

                    $promptDir = Join-Path $dir 'recipes' 'prompts'
                    $promptFiles = @()
                    if (Test-Path $promptDir) {
                        $promptFiles = Get-ChildItem -Path $promptDir -File -ErrorAction SilentlyContinue |
                                       ForEach-Object { $_.Name } | Sort-Object
                    }

                    $agentsDir = Join-Path $dir 'recipes' 'agents'
                    $agentFiles = @()
                    if (Test-Path $agentsDir) {
                        $agentFiles = Get-ChildItem -Path $agentsDir -Directory -ErrorAction SilentlyContinue |
                                      ForEach-Object { $_.Name } | Sort-Object
                    }

                    $skillsDir = Join-Path $dir 'recipes' 'skills'
                    $skillFiles = @()
                    if (Test-Path $skillsDir) {
                        $skillFiles = Get-ChildItem -Path $skillsDir -Directory -ErrorAction SilentlyContinue |
                                      ForEach-Object { $_.Name } | Sort-Object
                    }

                    Send-Json -Response $res -Data @{
                        yaml        = $yaml
                        layout      = $layout
                        promptFiles = $promptFiles
                        agentFiles  = $agentFiles
                        skillFiles  = $skillFiles
                    }
                    return $true
                }
                elseif ($method -eq 'PUT') {
                    # Registry workflows are read-only
                    if ($workflowName -match '^[^:]+:.+$') {
                        Send-Error -Response $res -Message 'Registry workflows are read-only' -StatusCode 403
                        return $true
                    }
                    # Save workflow: receive raw YAML + optional layout
                    $body = Read-RequestBody -Request $req | ConvertFrom-Json
                    if (-not $body.yaml) {
                        Send-Error -Response $res -Message 'Request body must include yaml' -StatusCode 400
                        return $true
                    }
                    $dir = Get-SafeWorkflowDir $workflowName
                    if (-not (Test-Path $dir)) {
                        New-Item -ItemType Directory -Force -Path $dir | Out-Null
                    }
                    Set-Content -Path (Join-Path $dir 'workflow.yaml') -Value $body.yaml -Encoding UTF8 -NoNewline
                    if ($body.layout) {
                        Set-Content -Path (Join-Path $dir $script:LayoutFilename) -Value $body.layout -Encoding UTF8 -NoNewline
                    }
                    Send-Json -Response $res -Data @{ success = $true }
                    return $true
                }
                elseif ($method -eq 'DELETE') {
                    # Registry workflows are read-only
                    if ($workflowName -match '^[^:]+:.+$') {
                        Send-Error -Response $res -Message 'Registry workflows are read-only' -StatusCode 403
                        return $true
                    }
                    if (-not (Test-WorkflowExists $workflowName)) {
                        Send-Error -Response $res -Message "Workflow '$workflowName' not found" -StatusCode 404
                        return $true
                    }
                    $dir = Get-SafeWorkflowDir $workflowName
                    Remove-Item -Path $dir -Recurse -Force
                    Send-Json -Response $res -Data @{ success = $true }
                    return $true
                }
                else {
                    Send-Error -Response $res -Message 'Method not allowed' -StatusCode 405
                    return $true
                }
            }

            # -- /api/studio/:name/copy --
            if ($segments.Count -eq 2 -and $segments[1] -eq 'copy' -and $method -eq 'POST') {
                $body = Read-RequestBody -Request $req | ConvertFrom-Json
                $newName = $body.newName
                if (-not $newName -or -not $newName.Trim()) {
                    Send-Error -Response $res -Message 'New workflow name is required' -StatusCode 400
                    return $true
                }
                if (-not (Test-WorkflowExists $workflowName)) {
                    Send-Error -Response $res -Message "Source workflow '$workflowName' not found" -StatusCode 404
                    return $true
                }
                if (Test-WorkflowExists $newName) {
                    Send-Error -Response $res -Message "Workflow '$newName' already exists" -StatusCode 409
                    return $true
                }
                # Resolve source — may be a registry workflow (Registry:name format)
                if ($workflowName -match '^([^:]+):(.+)$') {
                    $srcDir = Get-RegistryWorkflowDir -RegistryName $Matches[1] -WorkflowName $Matches[2]
                } else {
                    $srcDir = Get-SafeWorkflowDir $workflowName
                }
                # Destination is always a local workflow
                $destDir = Get-SafeWorkflowDir $newName
                Copy-DirectoryRecursive -Source $srcDir -Destination $destDir
                Send-Json -Response $res -Data @{ success = $true; name = $newName } -StatusCode 201
                return $true
            }

            # -- /api/studio/:name/layout --
            if ($segments.Count -eq 2 -and $segments[1] -eq 'layout' -and $method -eq 'PUT') {
                $bodyText = Read-RequestBody -Request $req
                $dir = Get-SafeWorkflowDir $workflowName
                if (-not (Test-Path $dir)) {
                    New-Item -ItemType Directory -Force -Path $dir | Out-Null
                }
                Set-Content -Path (Join-Path $dir $script:LayoutFilename) -Value $bodyText -Encoding UTF8 -NoNewline
                Send-Json -Response $res -Data @{ success = $true }
                return $true
            }

            # -- /api/studio/:name/files[/...] --
            if ($segments.Count -ge 2 -and $segments[1] -eq 'files') {
                if ($workflowName -match '^([^:]+):(.+)$') {
                    $dir = Get-RegistryWorkflowDir -RegistryName $Matches[1] -WorkflowName $Matches[2]
                } else {
                    $dir = Get-SafeWorkflowDir $workflowName
                }

                if ($segments.Count -eq 2 -and $method -eq 'GET') {
                    # List files in workflow root
                    $files = @()
                    if (Test-Path $dir) {
                        $items = Get-ChildItem -Path $dir -ErrorAction SilentlyContinue | Sort-Object Name
                        foreach ($item in $items) {
                            if ($item.PSIsContainer) {
                                $files += "$($item.Name)/"
                            } else {
                                $files += $item.Name
                            }
                        }
                    }
                    Send-Json -Response $res -Data $files
                    return $true
                }

                if ($segments.Count -ge 3) {
                    $filePath = ($segments[2..($segments.Count - 1)] | ForEach-Object { [System.Uri]::UnescapeDataString($_) }) -join '/'

                    # Reject path traversal attempts explicitly
                    if ($filePath -match '(^|[\/])\.\.([\/ ]|$)') {
                        Send-Error -Response $res -Message 'Invalid file path' -StatusCode 400
                        return $true
                    }

                    $fullPath = Join-Path $dir $filePath
                    # Canonicalize both paths for safe comparison
                    $canonicalDir = [System.IO.Path]::GetFullPath($dir + [System.IO.Path]::DirectorySeparatorChar)
                    $canonicalFull = [System.IO.Path]::GetFullPath($fullPath)

                    $pathComparison = if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
                        [System.StringComparison]::OrdinalIgnoreCase
                    } else {
                        [System.StringComparison]::Ordinal
                    }

                    if (-not $canonicalFull.StartsWith($canonicalDir, $pathComparison)) {
                        Send-Error -Response $res -Message 'Invalid file path' -StatusCode 400
                        return $true
                    }

                    if ($method -eq 'GET') {
                        if (Test-Path $canonicalFull -PathType Leaf) {
                            $content = Get-Content -Path $canonicalFull -Raw -Encoding UTF8
                            Send-Text -Response $res -Text $content
                        } else {
                            Send-Error -Response $res -Message "File not found: $filePath" -StatusCode 404
                        }
                        return $true
                    }
                    elseif ($method -eq 'PUT') {
                        # Write/create a file
                        $parentDir = Split-Path $canonicalFull -Parent
                        if (-not (Test-Path $parentDir)) {
                            New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
                        }
                        $reader = New-Object System.IO.StreamReader($req.InputStream, [System.Text.Encoding]::UTF8)
                        $body = $reader.ReadToEnd()
                        $reader.Close()
                        Set-Content -Path $canonicalFull -Value $body -Encoding UTF8 -NoNewline
                        Send-Json -Response $res -Data @{ success = $true }
                        return $true
                    }
                    else {
                        Send-Error -Response $res -Message 'Method not allowed' -StatusCode 405
                        return $true
                    }
                }

                Send-Error -Response $res -Message 'Bad request' -StatusCode 400
                return $true
            }

            # Unmatched API path
            Send-Error -Response $res -Message 'Not found' -StatusCode 404
            return $true
        }

        # ---------------------------------------------------------------
        # Static file serving (for standalone mode)
        # ---------------------------------------------------------------
        if ($script:StaticRoot -and (Test-Path $script:StaticRoot)) {
            $filePath = $path.TrimStart('/', '\')
            if (-not $filePath -or $filePath -eq '') { $filePath = 'index.html' }

            # Reject path traversal in static file requests
            $pathSegments = $filePath -split '[/\\]'
            $isTraversal = $pathSegments -contains '..'

            if (-not $isTraversal) {
                $staticRootFull = [System.IO.Path]::GetFullPath($script:StaticRoot)
                $staticRootPrefix = $staticRootFull.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
                $fullPath = [System.IO.Path]::GetFullPath((Join-Path $staticRootFull $filePath))

                if ($fullPath.StartsWith($staticRootPrefix, [System.StringComparison]::OrdinalIgnoreCase) -and (Test-Path $fullPath -PathType Leaf)) {
                    $contentType = Get-MimeType $fullPath
                    $fileBytes = [System.IO.File]::ReadAllBytes($fullPath)
                    $res.StatusCode = 200
                    $res.ContentType = $contentType
                    $res.ContentLength64 = $fileBytes.Length
                    $res.OutputStream.Write($fileBytes, 0, $fileBytes.Length)
                    return $true
                }
            }

            # SPA fallback: serve index.html for non-API routes
            $indexPath = Join-Path ([System.IO.Path]::GetFullPath($script:StaticRoot)) 'index.html'
            if (Test-Path $indexPath -PathType Leaf) {
                $contentType = 'text/html; charset=utf-8'
                $fileBytes = [System.IO.File]::ReadAllBytes($indexPath)
                $res.StatusCode = 200
                $res.ContentType = $contentType
                $res.ContentLength64 = $fileBytes.Length
                $res.OutputStream.Write($fileBytes, 0, $fileBytes.Length)
                return $true
            }
        }

        # Not handled
        return $false
    }
    catch {
        try {
            Send-Error -Response $res -Message $_.Exception.Message -StatusCode 500
        } catch {
            # Response may already be closed
        }
        return $true
    }
    finally {
        try { $res.Close() } catch { }
    }
}

Export-ModuleMember -Function Initialize-StudioAPI, Invoke-StudioRequest