plugins/vault/private/Resolve-JaxVaultSecrets.ps1

function Resolve-JaxVaultSecrets {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [object] $Data, # Hashtable, Array, or String

        [Parameter(Mandatory = $false)]
        [hashtable] $Cache = @{} # Simple session cache for secrets
    )

    if ($null -eq $Data) {
        return $null
    }

    # Check if disabled
    if (Test-Path Env:\JAX_NO_CONFIG_SECRETS) {
        return $Data
    }

    if ($Data -is [string]) {
        # Pattern: << kv2|ENGINE/mount:PATH:KEY >>
        # Regex to capture content between << and >>
        if ($Data -match '<<\s*kv2\|(?<path>[^>]+)\s*>>') {
            # Extract content: "demo/data/secret:key"
            # Note: User example: "<< kv2|{{ secrets.default_engine }}:infra/...:key >>"
            # We assume variable expansion happened BEFORE this call.

            # Simple replacement loop to handle multiple secrets in one string?
            # User example shows one whole string replacement, but let's support robust regex replace.

            $resolvedString = $Data
            $matches = [regex]::Matches($Data, '<<\s*kv2\|(?<content>[^>]+)\s*>>')
            foreach ($m in $matches) {
                $raw = $m.Groups['content'].Value.Trim()
                # Split by ':'
                # Format: MOUNT:PATH:KEY
                # Example: kv:ci/robot:user
                # In Vault KV2, path often includes 'data' or not depending on mount, but usually user provides full path.
                # Three-part example: "kv:ci/robot:user" -> mount "kv", path "ci/robot", key "user".
                # Or just Path:Key?


                $parts = $raw -split ':'
                if ($parts.Count -lt 2) {
                    Write-Warning "Invalid secret format '$raw'. Expected 'ENGINE:PATH:KEY' or 'PATH:KEY'."
                    continue
                }

                $mount = $parts[0]
                $path = $parts[1]
                $key = if ($parts.Count -gt 2) { $parts[2] } else { $null }

                # If only 2 parts, maybe default mount? Or Path:Key?
                # Let's assume 3 parts for safety as per user example.

                # Lookup secret
                $cacheKey = "{0}:{1}" -f $mount, $path
                $secretData = $null

                if ($Cache.ContainsKey($cacheKey)) {
                    Write-Debug "Cache hit for secret '$cacheKey'"
                    $secretData = $Cache[$cacheKey]
                } else {
                    Write-Debug "Cache miss for secret '$cacheKey'"
                    try {
                        # Use Vault CLI. Prefer the "mount/path" form since it matches common manual usage:
                        # vault kv get secret/external-services/open-ai
                        # and avoids subtle -mount parsing differences across vault versions.
                        $secretRef = ('{0}/{1}' -f $mount, $path).TrimEnd('/')
                        Write-Debug "Fetching vault secret '$secretRef' (key '$key')"

                        function Invoke-JaxVaultCliCapture {
                            param(
                                [Parameter(Mandatory = $true)] [string[]] $Args
                            )
                            $errFile = New-TemporaryFile
                            try {
                                $stdout = (& vault @Args 2> $errFile) | Out-String
                                $exitCode = $LASTEXITCODE
                                $stderr = ''
                                try {
                                    $stderr = Get-Content -Path $errFile -Raw -ErrorAction SilentlyContinue
                                } catch {
                                    $stderr = ''
                                }
                                return @{
                                    ExitCode = $exitCode
                                    StdOut   = $stdout
                                    StdErr   = $stderr
                                }
                            } finally {
                                Remove-Item -Path $errFile -Force -ErrorAction SilentlyContinue
                            }
                        }

                        # 1) Try JSON output (preferred, supports caching all keys)
                        $result = Invoke-JaxVaultCliCapture -Args @('kv', 'get', '-format=json', $secretRef)
                        if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($result.StdOut)) {
                            Write-Debug ("Vault JSON fetch failed for '{0}'. ExitCode={1}. StdErr={2}" -f $secretRef, $result.ExitCode, ($result.StdErr.Trim() -replace '\s+', ' '))

                            # 1b) Fallback: explicit mount flag
                            $result = Invoke-JaxVaultCliCapture -Args @('kv', 'get', '-mount', $mount, '-format=json', $path)
                        }

                        if ($result.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($result.StdOut)) {
                            $obj = $result.StdOut | ConvertFrom-Json
                            $secretData = $obj.data.data # KV2 structure
                            $Cache[$cacheKey] = $secretData
                        } else {
                            # 2) Last resort: field fetch (older vault CLIs / kv behaviors)
                            if (-not [string]::IsNullOrWhiteSpace($key)) {
                                $fieldResult = Invoke-JaxVaultCliCapture -Args @('kv', 'get', ('-field={0}' -f $key), $secretRef)
                                if ($fieldResult.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($fieldResult.StdOut)) {
                                    $fieldResult = Invoke-JaxVaultCliCapture -Args @('kv', 'get', '-mount', $mount, ('-field={0}' -f $key), $path)
                                }

                                if ($fieldResult.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace($fieldResult.StdOut)) {
                                    $secretData = @{ $key = ($fieldResult.StdOut.TrimEnd()) }
                                    $Cache[$cacheKey] = $secretData
                                } else {
                                    Write-Warning "Failed to fetch secret '$secretRef'. ExitCode=$($fieldResult.ExitCode)"
                                }
                            } else {
                                Write-Warning "Failed to fetch secret '$secretRef'. ExitCode=$($result.ExitCode)"
                            }
                        }
                    } catch {
                        Write-Warning "Error fetching secret '$mount/$path': $($_.Exception.Message)"
                    }
                }


                if ($secretData -and $key) {
                    $found = $false
                    $val = $null

                    if ($secretData -is [System.Collections.IDictionary]) {
                        $hasKey = $false
                        if ($secretData.PSObject.Methods.Name -contains 'ContainsKey') {
                            try { $hasKey = [bool]$secretData.ContainsKey($key) } catch { $hasKey = $false }
                        } else {
                            try { $hasKey = [bool]$secretData.Contains($key) } catch { $hasKey = $false }
                        }
                        if ($hasKey) {
                            $val = $secretData[$key]
                            $found = $true
                        }
                    }
                    if (-not $found -and $secretData -and $secretData.PSObject.Properties[$key]) {
                        $val = $secretData.$key
                        $found = $true
                    }

                    if ($found) {
                        $resolvedString = $resolvedString.Replace($m.Value, $val)
                    } else {
                        Write-Warning "Secret '$mount/$path' found but key '$key' missing."
                    }
                }
            }
            return $resolvedString
        }
        return $Data
    } elseif ($Data -is [System.Collections.IDictionary]) {
        # Recurse keys (copy to avoid modification during iteration if needed, but we modify values)
        # Use a copy to be safe if it's a generic dictionary, or modify in place if it's hashtable?
        # Better return a new structure or modify in place if explicitly allowed.
        # Let's return a copy to avoid side effects on cached configs.
        $newDict = if ($Data -is [System.Collections.Specialized.OrderedDictionary]) { [ordered]@{} } else { @{} }
        foreach ($k in $Data.Keys) {
            $newDict[$k] = Resolve-JaxVaultSecrets -Data $Data[$k] -Cache $Cache
        }
        return $newDict
    } elseif ($Data -is [System.Collections.IEnumerable]) {
        $newList = @()
        foreach ($item in $Data) {
            $newList += Resolve-JaxVaultSecrets -Data $item -Cache $Cache
        }
        return $newList
    }

    return $Data
}