OnetimeSecretV2.psm1

#Requires -Version 5.1
# OnetimeSecretV2 0.2.0 -- generated by OnetimeSecretV2.build.ps1; edit src/ instead.

Set-StrictMode -Version Latest

$script:ModuleRoot        = $PSScriptRoot
$script:ModuleVersion     = [string] (Import-PowerShellDataFile -Path (Join-Path $PSScriptRoot 'OnetimeSecretV2.psd1')).ModuleVersion
$script:DefaultConnection = $null

# Windows PowerShell 5.1 inherits the .NET Framework default protocol list,
# which on older Windows builds still excludes TLS 1.2.
if ($PSVersionTable.PSEdition -ne 'Core') {
    try {
        [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
    }
    catch {
        Write-Verbose ('Could not enable TLS 1.2: {0}' -f $_.Exception.Message)
    }
}

#region ConvertFrom-OtsRecord.ps1
# Converters from raw API records to the typed objects the module emits.
# Each output object carries a PSTypeName so OnetimeSecretV2.Format.ps1xml can
# shape its default view, and keeps the untouched API record in
# RawResponse for anything the typed projection does not cover.

function ConvertFrom-OtsReceipt {
    <#
    .SYNOPSIS
        Projects a receipt record to an 'OnetimeSecret.Receipt' object.
    .PARAMETER Record
        The receipt record from the API (record, or record.receipt for create calls).
    .PARAMETER Details
        The optional details envelope that accompanies show/burn responses.
    .PARAMETER SecretRecord
        For create responses, the sibling secret record (supplies the generated value).
    .PARAMETER Connection
        Used to construct share/receipt/burn URLs when the API omits them.
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Receipt')]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object] $Record,

        [Parameter()]
        [AllowNull()]
        [object] $Details,

        [Parameter()]
        [AllowNull()]
        [object] $SecretRecord,

        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

        [Parameter()]
        [AllowNull()]
        [string] $ShareDomain
    )

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

    $receiptId = [string] (Get-OtsProperty $Record 'identifier')
    $secretId  = [string] (Get-OtsProperty $Record 'secret_identifier')
    if (-not $secretId -and $null -ne $SecretRecord) {
        $secretId = [string] (Get-OtsProperty $SecretRecord 'identifier')
    }

    if (-not $ShareDomain) { $ShareDomain = [string] (Get-OtsProperty $Record 'share_domain') }

    $base = $Connection.ServerUri.AbsoluteUri.TrimEnd('/')
    $shareBase = $base
    if ($ShareDomain) {
        $shareBase = '{0}://{1}' -f $Connection.ServerUri.Scheme, $ShareDomain
    }

    # After a burn the server reports share_url with an empty identifier, so
    # only trust it when it still names the secret.
    $shareUrl = [string] (Get-OtsProperty $Record 'share_url')
    if ($secretId) {
        if (-not $shareUrl -or $shareUrl -notlike ('*' + $secretId)) { $shareUrl = '{0}/secret/{1}' -f $shareBase, $secretId }
    }
    else {
        $shareUrl = $null
    }

    $receiptUrl = [string] (Get-OtsProperty $Record 'receipt_url')
    if (-not $receiptUrl -and $receiptId) { $receiptUrl = '{0}/receipt/{1}' -f $base, $receiptId }

    $burnUrl = [string] (Get-OtsProperty $Record 'burn_url')
    if (-not $burnUrl -and $receiptId) { $burnUrl = '{0}/receipt/{1}/burn' -f $base, $receiptId }

    # The generated value is reported on the secret record at creation time and
    # under details.secret_value when a receipt is shown shortly afterwards.
    $value = $null
    if ($null -ne $SecretRecord) { $value = Get-OtsProperty $SecretRecord 'secret_value' }
    if ([string]::IsNullOrEmpty([string] $value) -and $null -ne $Details) { $value = Get-OtsProperty $Details 'secret_value' }
    if ([string]::IsNullOrEmpty([string] $value)) { $value = $null }

    $hasPassphrase = Get-OtsProperty $Record 'has_passphrase'
    if ($null -eq $hasPassphrase -and $null -ne $Details) { $hasPassphrase = Get-OtsProperty $Details 'has_passphrase' }

    $expiration = Get-OtsProperty $Record 'expiration'
    $expiresIn  = ConvertFrom-OtsDuration (Get-OtsProperty $Record 'expiration_in_seconds')
    $secretTtl  = ConvertFrom-OtsDuration (Get-OtsProperty $Record 'secret_ttl')
    $expires    = ConvertFrom-OtsTimestamp $expiration
    if ($null -eq $expires -and $null -ne $expiresIn) { $expires = (Get-Date).Add($expiresIn) }
    if ($null -eq $expires -and $null -ne $secretTtl) {
        $created = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'created')
        if ($null -ne $created) { $expires = $created.Add($secretTtl) }
    }

    $viewCount = $null
    if ($null -ne $Details) { $viewCount = Get-OtsProperty $Details 'view_count' }

    [PSCustomObject] @{
        PSTypeName        = 'OnetimeSecret.Receipt'
        ReceiptId         = $receiptId
        SecretId          = $secretId
        State             = [string] (Get-OtsProperty $Record 'state')
        ShareUrl          = $shareUrl
        ReceiptUrl        = $receiptUrl
        BurnUrl           = $burnUrl
        ShareDomain       = $ShareDomain
        HasPassphrase     = ConvertTo-OtsBoolean $hasPassphrase
        Recipients        = ConvertTo-OtsStringArray (Get-OtsProperty $Record 'recipients')
        Memo              = [string] (Get-OtsProperty $Record 'memo')
        Kind              = [string] (Get-OtsProperty $Record 'kind')
        Value             = $value
        Created           = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'created')
        Updated           = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'updated')
        Expires           = $expires
        ExpiresIn         = $expiresIn
        SecretTtl         = $secretTtl
        ReceiptTtl        = ConvertFrom-OtsDuration (Get-OtsProperty $Record 'receipt_ttl')
        NaturalExpiration = [string] (Get-OtsProperty $Record 'natural_expiration')
        IsViewed          = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_viewed')
        IsReceived        = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_received')
        IsBurned          = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_burned')
        IsExpired         = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_expired')
        IsOrphaned        = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_orphaned')
        IsDestroyed       = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_destroyed')
        Received          = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'received')
        Burned            = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'burned')
        ViewCount         = $viewCount
        ShortId           = [string] (Get-OtsProperty $Record 'shortid')
        SecretShortId     = [string] (Get-OtsProperty $Record 'secret_shortid')
        ServerUri         = $Connection.ServerUri
        RawResponse       = $Record
    }
}

function ConvertFrom-OtsSecret {
    <#
    .SYNOPSIS
        Projects a secret record to an 'OnetimeSecret.Secret' object.
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Secret')]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object] $Record,

        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

        [Parameter()]
        [switch] $AsSecureString
    )

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

    $value = Get-OtsProperty $Record 'secret_value'
    if ([string]::IsNullOrEmpty([string] $value)) {
        $value = $null
    }
    elseif ($AsSecureString) {
        $value = ConvertTo-OtsSecureString -Value ([string] $value)
    }

    $secretId = [string] (Get-OtsProperty $Record 'identifier')

    [PSCustomObject] @{
        PSTypeName    = 'OnetimeSecret.Secret'
        SecretId      = $secretId
        State         = [string] (Get-OtsProperty $Record 'state')
        Value         = $value
        HasPassphrase = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'has_passphrase')
        DisplayType   = [string] (Get-OtsProperty $Record 'display_type' 'text')
        Ttl           = ConvertFrom-OtsDuration (Get-OtsProperty $Record 'secret_ttl')
        Lifespan      = [string] (Get-OtsProperty $Record 'lifespan')
        Created       = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'created')
        Updated       = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'updated')
        IsViewed      = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_viewed')
        IsReceived    = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_received')
        ShortId       = [string] (Get-OtsProperty $Record 'shortid')
        ShareUrl      = '{0}/secret/{1}' -f $Connection.ServerUri.AbsoluteUri.TrimEnd('/'), $secretId
        ServerUri     = $Connection.ServerUri
        RawResponse   = $Record
    }
}

function ConvertFrom-OtsSecretStatus {
    <#
    .SYNOPSIS
        Projects a status record to an 'OnetimeSecret.SecretStatus' object.
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.SecretStatus')]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object] $Record,

        [Parameter()]
        [AllowNull()]
        [object] $Details,

        [Parameter()]
        [AllowEmptyString()]
        [string] $RequestedId,

        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    $state = [string] (Get-OtsProperty $Record 'state' 'unknown')
    $secretId = [string] (Get-OtsProperty $Record 'identifier')
    if (-not $secretId) { $secretId = $RequestedId }

    $currentExpiration = $null
    if ($null -ne $Details) { $currentExpiration = ConvertFrom-OtsTimestamp (Get-OtsProperty $Details 'current_expiration') }

    [PSCustomObject] @{
        PSTypeName    = 'OnetimeSecret.SecretStatus'
        SecretId      = $secretId
        State         = $state
        Exists        = ($state -ne 'unknown')
        HasPassphrase = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'has_passphrase')
        Expires       = $currentExpiration
        Ttl           = ConvertFrom-OtsDuration (Get-OtsProperty $Record 'secret_ttl')
        Created       = ConvertFrom-OtsTimestamp (Get-OtsProperty $Record 'created')
        IsViewed      = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_viewed')
        IsReceived    = ConvertTo-OtsBoolean (Get-OtsProperty $Record 'is_received')
        ServerUri     = $Connection.ServerUri
        RawResponse   = $Record
    }
}

#endregion ConvertFrom-OtsRecord.ps1

#region ConvertFrom-OtsSecureString.ps1
function ConvertFrom-OtsSecureString {
    <#
    .SYNOPSIS
        Extracts the plain-text value of a SecureString.
    .DESCRIPTION
        Works on Windows PowerShell 5.1 and PowerShell 7 alike (the -AsPlainText
        switch of ConvertFrom-SecureString is 7.0+ only). The unmanaged copy is
        zeroed as soon as the managed string has been produced.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [securestring] $SecureString
    )

    $pointer = [System.IntPtr]::Zero
    try {
        $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
        return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
    }
    finally {
        if ($pointer -ne [System.IntPtr]::Zero) {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
        }
    }
}

function ConvertTo-OtsSecureString {
    <#
    .SYNOPSIS
        Wraps a plain string in a SecureString without the -Force dance.
    #>

    [CmdletBinding()]
    [OutputType([securestring])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string] $Value
    )

    $secure = New-Object -TypeName System.Security.SecureString
    foreach ($char in $Value.ToCharArray()) {
        $secure.AppendChar($char)
    }
    $secure.MakeReadOnly()
    return $secure
}

#endregion ConvertFrom-OtsSecureString.ps1

#region ConvertFrom-OtsValue.ps1
# Small coercion helpers for the loosely typed values the API returns.
# Timestamps arrive as epoch numbers, epoch strings or ISO-8601 strings;
# booleans arrive as true/false, "true"/"false" or null; durations arrive
# as seconds in either numeric or string form.

function ConvertFrom-OtsTimestamp {
    <#
    .SYNOPSIS
        Converts an API timestamp (epoch seconds or ISO-8601) to a local DateTime.
    #>

    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Value
    )

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

    $text = ([string] $Value).Trim()
    if ($text.Length -eq 0) { return $null }

    $seconds = 0.0
    $isNumber = [double]::TryParse(
        $text,
        [System.Globalization.NumberStyles]::Float,
        [System.Globalization.CultureInfo]::InvariantCulture,
        [ref] $seconds)

    if ($isNumber) {
        if ($seconds -le 0) { return $null }
        $epoch = New-Object -TypeName System.DateTime -ArgumentList 1970, 1, 1, 0, 0, 0, ([System.DateTimeKind]::Utc)
        return $epoch.AddSeconds($seconds).ToLocalTime()
    }

    $parsed = [datetime]::MinValue
    $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
    if ([datetime]::TryParse($text, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref] $parsed)) {
        return [datetime]::SpecifyKind($parsed, [System.DateTimeKind]::Utc).ToLocalTime()
    }

    return $null
}

function ConvertFrom-OtsDuration {
    <#
    .SYNOPSIS
        Converts a duration in seconds (number or numeric string) to a TimeSpan.
    #>

    [CmdletBinding()]
    [OutputType([timespan])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Value
    )

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

    $seconds = 0.0
    $text = ([string] $Value).Trim()
    if ($text.Length -eq 0) { return $null }

    if ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref] $seconds)) {
        return [timespan]::FromSeconds($seconds)
    }

    return $null
}

function ConvertTo-OtsBoolean {
    <#
    .SYNOPSIS
        Normalises the API's mixed boolean representations to [bool].
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Value
    )

    if ($null -eq $Value) { return $false }
    if ($Value -is [bool]) { return $Value }

    $text = ([string] $Value).Trim().ToLowerInvariant()
    switch ($text) {
        '' { return $false }
        'false' { return $false }
        '0' { return $false }
        'no' { return $false }
        'true' { return $true }
        '1' { return $true }
        'yes' { return $true }
    }

    # Anything else non-empty (for example a timestamp recorded when an event
    # happened) counts as "it happened".
    return $true
}

function ConvertTo-OtsStringArray {
    <#
    .SYNOPSIS
        Normalises a scalar-or-array API value to a string array.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Value
    )

    if ($null -eq $Value) { return @() }

    if ($Value -is [string]) {
        if ($Value.Trim().Length -eq 0) { return @() }
        return @($Value.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }

    return @($Value | ForEach-Object { [string] $_ } | Where-Object { $_ })
}

#endregion ConvertFrom-OtsValue.ps1

#region ConvertTo-OtsIdentifier.ps1
function ConvertTo-OtsIdentifier {
    <#
    .SYNOPSIS
        Accepts a bare identifier or a share/receipt URL and returns the identifier.
    .DESCRIPTION
        People paste the link they were given at least as often as the key
        inside it, so every cmdlet that takes an identifier accepts
        "https://eu.onetimesecret.com/secret/abc123", "/secret/abc123" or
        plain "abc123". The identifier is the path segment after /secret/ or
        /receipt/ (and the legacy /private/ alias).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $Value,

        [Parameter()]
        [ValidateSet('Secret', 'Receipt', 'Any')]
        [string] $Kind = 'Any'
    )

    $candidate = $Value.Trim()

    $segments = switch ($Kind) {
        'Secret' { 'secret' }
        'Receipt' { 'receipt|private|metadata' }
        default { 'secret|receipt|private|metadata' }
    }

    $pattern = '(?:^|/)(?:{0})/(?<id>[A-Za-z0-9_-]+)' -f $segments
    if ($candidate -match $pattern) {
        return $Matches['id']
    }

    if ($candidate -match '^[A-Za-z0-9_-]+$') {
        return $candidate
    }

    throw ("'{0}' is not a valid Onetime Secret identifier or link." -f $Value)
}

#endregion ConvertTo-OtsIdentifier.ps1

#region Get-OtsPathPrefix.ps1
function Get-OtsPathPrefix {
    <#
    .SYNOPSIS
        Returns 'guest/' for anonymous connections, '' otherwise.
    .DESCRIPTION
        The v2 API mirrors the secret and receipt endpoints under /guest for
        unauthenticated use. Endpoints without a guest twin (listing receipts,
        updating a memo, status checks) do not call this helper.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    if ($Connection.IsAnonymous) { return 'guest/' }
    return ''
}

function Assert-OtsAuthenticated {
    <#
    .SYNOPSIS
        Throws when an authenticated-only operation is attempted anonymously.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

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

    if ($Connection.IsAnonymous) {
        throw ('{0} requires an authenticated connection. Run Connect-OtsAccount with -Credential.' -f $Operation)
    }
}

#endregion Get-OtsPathPrefix.ps1

#region Get-OtsProperty.ps1
function Get-OtsProperty {
    <#
    .SYNOPSIS
        Reads a property from an API response object without tripping strict mode.
    .DESCRIPTION
        API payloads omit fields freely. This helper returns the property value
        when present (including an explicit null) and $Default otherwise, for
        both PSCustomObject (ConvertFrom-Json output) and hashtable input.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $InputObject,

        [Parameter(Mandatory, Position = 1)]
        [string] $Name,

        [Parameter(Position = 2)]
        [AllowNull()]
        [object] $Default = $null
    )

    if ($null -eq $InputObject) {
        return $Default
    }

    if ($InputObject -is [System.Collections.IDictionary]) {
        if ($InputObject.Contains($Name)) {
            return $InputObject[$Name]
        }
        return $Default
    }

    $property = $InputObject.PSObject.Properties[$Name]
    if ($null -ne $property) {
        return $property.Value
    }

    return $Default
}

#endregion Get-OtsProperty.ps1

#region Invoke-OtsRequest.ps1
function Invoke-OtsRequest {
    <#
    .SYNOPSIS
        Sends one request to the Onetime Secret API and returns the parsed JSON.
    .DESCRIPTION
        The single HTTP chokepoint for the module. It builds the URL from the
        connection's server URI and API version, applies Basic auth and the
        client identification headers, serialises the body as JSON, retries
        idempotent GETs on 429/502/503/504 with exponential backoff (honouring
        Retry-After), and converts failures into rich ErrorRecords via
        New-OtsErrorRecord.

        On PowerShell 7+ HTTP failures are read from the response
        (-SkipHttpErrorCheck) rather than caught as exceptions: the exception
        Invoke-WebRequest would throw carries the whole request, Authorization
        header included, and PowerShell records it in $Error even when it is
        caught here. Windows PowerShell 5.1 has no such switch; its
        WebException exposes only the response, so the catch path is safe there.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

        [Parameter()]
        [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
        [string] $Method = 'GET',

        # Path relative to the API root, e.g. 'secret/conceal'.
        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Body,

        [Parameter()]
        [ValidateRange(0, 10)]
        [int] $MaxRetry = 2
    )

    $uri = '{0}/api/{1}/{2}' -f $Connection.ServerUri.AbsoluteUri.TrimEnd('/'), $Connection.ApiVersion, $Path.TrimStart('/')

    $headers = @{
        'Accept'           = 'application/json'
        'X-Onetime-Client' = 'powershell:{0}/{1}' -f $PSVersionTable.PSVersion, $script:ModuleVersion
    }

    if (-not $Connection.IsAnonymous) {
        $token = $Connection.Credential.GetNetworkCredential().Password
        $pair = '{0}:{1}' -f $Connection.Credential.UserName, $token
        $headers['Authorization'] = 'Basic ' + [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
    }

    $request = @{
        Uri             = $uri
        Method          = $Method
        Headers         = $headers
        UserAgent       = $Connection.UserAgent
        TimeoutSec      = $Connection.TimeoutSec
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }

    if ($null -ne $Body) {
        $json = ConvertTo-Json -InputObject $Body -Depth 10 -Compress
        $request['ContentType'] = 'application/json; charset=utf-8'
        $request['Body'] = [System.Text.Encoding]::UTF8.GetBytes($json)
    }

    if ($PSVersionTable.PSVersion.Major -ge 7) {
        $request['SkipHttpErrorCheck'] = $true
    }

    $attempt = 0
    while ($true) {
        $failure = $null
        $cause = $null
        try {
            Write-Verbose ('{0} {1}' -f $Method, $uri)
            $response = Invoke-WebRequest @request

            $statusCode = 0
            if ($null -ne $response -and $null -ne $response.PSObject.Properties['StatusCode']) {
                try { $statusCode = [int] $response.StatusCode } catch { $statusCode = 0 }
            }

            if ($statusCode -ge 400) {
                $failure = ConvertFrom-OtsHttpResponse -Response $response -StatusCode $statusCode
            }
            else {
                $content = $response.Content
                if ($content -is [byte[]]) {
                    $content = [System.Text.Encoding]::UTF8.GetString($content)
                }
                if ([string]::IsNullOrWhiteSpace($content)) {
                    return $null
                }
                # The parser's message is not kept as the cause: Windows
                # PowerShell 5.1 quotes the input in it, and reveal and
                # receipt bodies carry secret values.
                try { return ($content | ConvertFrom-Json) }
                catch { $failure = [PSCustomObject] @{ StatusCode = $statusCode; Body = $null; InvalidJson = $true } }
            }
        }
        catch {
            $failure = ConvertFrom-OtsHttpError -ErrorRecord $_
            $cause = $_.Exception.Message
        }

        $retryable = ($Method -eq 'GET') -and ($failure.StatusCode -in @(429, 502, 503, 504)) -and ($attempt -lt $MaxRetry)

        if ($retryable) {
            $attempt++
            $delay = [math]::Pow(2, $attempt)
            if ($failure.RetryAfter -gt 0) { $delay = [math]::Max($delay, $failure.RetryAfter) }
            Write-Verbose ('{0} from {1}; retry {2}/{3} in {4}s' -f $failure.StatusCode, $uri, $attempt, $MaxRetry, $delay)
            Start-Sleep -Seconds $delay
            continue
        }

        $PSCmdlet.ThrowTerminatingError((New-OtsErrorRecord -Failure $failure -Method $Method -Uri $uri -Cause $cause))
    }
}

function ConvertFrom-OtsHttpResponse {
    <#
    .SYNOPSIS
        Builds the failure object for a non-2xx response returned with -SkipHttpErrorCheck.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object] $Response,

        [Parameter(Mandatory)]
        [int] $StatusCode
    )

    $retryAfter = 0
    try {
        $headers = $Response.Headers
        if ($null -ne $headers) {
            $value = $null
            if ($headers -is [System.Collections.IDictionary]) {
                # Hashtable (mocks) and Dictionary<string, IEnumerable<string>>
                # (PowerShell 7) disagree on Contains/ContainsKey; walk the keys.
                foreach ($key in $headers.Keys) {
                    if ($key -eq 'Retry-After') { $value = $headers[$key]; break }
                }
            }
            else {
                $value = $headers['Retry-After']
            }
            $value = [string] (@($value) | Select-Object -First 1)
            if ($value -match '^\d+$') { $retryAfter = [int] $value }
        }
    }
    catch {
        Write-Debug ('Retry-After could not be read: {0}' -f $_.Exception.Message)
    }

    $rawBody = $Response.Content
    if ($rawBody -is [byte[]]) { $rawBody = [System.Text.Encoding]::UTF8.GetString($rawBody) }
    $rawBody = [string] $rawBody

    $body = $null
    if (-not [string]::IsNullOrWhiteSpace($rawBody)) {
        try { $body = $rawBody | ConvertFrom-Json } catch { $body = $null }
    }

    return [PSCustomObject] @{
        StatusCode = $StatusCode
        Body       = $body
        RawBody    = $rawBody
        RetryAfter = $retryAfter
    }
}

function ConvertFrom-OtsHttpError {
    <#
    .SYNOPSIS
        Normalises the differing HTTP error shapes of Windows PowerShell and PowerShell 7.
    .DESCRIPTION
        Windows PowerShell throws System.Net.WebException with an HttpWebResponse;
        PowerShell 7 throws HttpResponseException with an HttpResponseMessage and
        puts the body in ErrorDetails (only reachable here through mocks, since
        Invoke-OtsRequest passes -SkipHttpErrorCheck on 7+). Connection
        failures have no response at all. All three end up as the same small
        object.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord
    )

    $statusCode = 0
    $rawBody = $null
    $retryAfter = 0
    $response = $null

    $exception = $ErrorRecord.Exception
    if ($null -ne $exception -and $null -ne $exception.PSObject.Properties['Response']) {
        $response = $exception.Response
    }

    if ($null -ne $response) {
        try { $statusCode = [int] $response.StatusCode } catch { $statusCode = 0 }

        try {
            $responseHeaders = $response.Headers
            if ($responseHeaders -is [System.Net.WebHeaderCollection]) {
                $value = $responseHeaders['Retry-After']
                if ($value -match '^\d+$') { $retryAfter = [int] $value }
            }
            elseif ($null -ne $responseHeaders.PSObject.Properties['RetryAfter'] -and $null -ne $responseHeaders.RetryAfter -and $null -ne $responseHeaders.RetryAfter.Delta) {
                $retryAfter = [int] $responseHeaders.RetryAfter.Delta.TotalSeconds
            }
        }
        catch {
            Write-Debug ('Retry-After could not be read: {0}' -f $_.Exception.Message)
        }
    }

    if ($null -ne $ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
        $rawBody = $ErrorRecord.ErrorDetails.Message
    }
    elseif ($null -ne $response -and $null -ne $response.PSObject.Methods['GetResponseStream']) {
        try {
            $stream = $response.GetResponseStream()
            if ($null -ne $stream) {
                $reader = New-Object -TypeName System.IO.StreamReader -ArgumentList $stream
                $rawBody = $reader.ReadToEnd()
                $reader.Dispose()
            }
        }
        catch {
            Write-Debug ('Error body could not be read: {0}' -f $_.Exception.Message)
        }
    }

    $body = $null
    if (-not [string]::IsNullOrWhiteSpace($rawBody)) {
        try { $body = $rawBody | ConvertFrom-Json } catch { $body = $null }
    }

    return [PSCustomObject] @{
        StatusCode = $statusCode
        Body       = $body
        RawBody    = $rawBody
        RetryAfter = $retryAfter
    }
}

function New-OtsErrorRecord {
    <#
    .SYNOPSIS
        Builds the ErrorRecord thrown for a failed API call.
    .DESCRIPTION
        The API's error envelope is { error, error_type, field, error_id,
        request_id } (ADR-013). Those fields are surfaced three ways so callers
        can branch however they prefer:

          - FullyQualifiedErrorId "OnetimeSecret.Api.<error_type>" (or Http<status>;
                                   OnetimeSecret.InvalidResponse for a 2xx body
                                   that is not JSON)
          - ErrorCategory mapped from the HTTP status
          - Exception.Data StatusCode, ErrorType, Field, ErrorId, RequestId, Uri, Method, Response

        The transport exception is deliberately not chained as InnerException:
        on PowerShell 7 it carries the HttpRequestMessage, Authorization header
        and body included, and anything that serialises the error (logging,
        ConvertTo-Json, transcripts) would write the credential out. Only its
        message survives, as a plain inner exception.
    #>

    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds an in-memory ErrorRecord; nothing outside the process changes.')]
    param(
        [Parameter(Mandatory)]
        [pscustomobject] $Failure,

        [Parameter(Mandatory)]
        [string] $Method,

        [Parameter(Mandatory)]
        [string] $Uri,

        # Message of the underlying transport error, when there was one.
        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string] $Cause
    )

    $status = [int] $Failure.StatusCode
    $body = $Failure.Body

    $errorType = [string] (Get-OtsProperty $body 'error_type')
    $field     = [string] (Get-OtsProperty $body 'field')
    $errorId   = [string] (Get-OtsProperty $body 'error_id')
    $requestId = [string] (Get-OtsProperty $body 'request_id')

    $serverMessage = [string] (Get-OtsProperty $body 'error')
    if ([string]::IsNullOrWhiteSpace($serverMessage)) { $serverMessage = [string] (Get-OtsProperty $body 'message') }

    $accountRequired = ($field -eq 'requires_account') -or ($errorType -match 'AccountRequired|RequiresAccount')

    if ($Failure.PSObject.Properties['InvalidJson']) {
        $category = [System.Management.Automation.ErrorCategory]::InvalidResult
        $message = 'The response from {0} (HTTP {1}) is not valid JSON.' -f $Uri, $status
        $fqid = 'OnetimeSecret.InvalidResponse'
        $recommended = 'Check that the server URI points at a Onetime Secret server and not at a proxy or login page.'
    }
    elseif ($status -eq 0) {
        $category = [System.Management.Automation.ErrorCategory]::ConnectionError
        $message = 'Could not reach {0}: {1}' -f $Uri, $Cause
        $fqid = 'OnetimeSecret.ConnectionError'
        $recommended = 'Check the server URI and your network connection.'
    }
    else {
        $category = switch ($status) {
            400 { [System.Management.Automation.ErrorCategory]::InvalidArgument }
            401 { [System.Management.Automation.ErrorCategory]::AuthenticationError }
            403 { [System.Management.Automation.ErrorCategory]::PermissionDenied }
            404 { [System.Management.Automation.ErrorCategory]::ObjectNotFound }
            409 { [System.Management.Automation.ErrorCategory]::ResourceExists }
            422 { [System.Management.Automation.ErrorCategory]::InvalidData }
            429 { [System.Management.Automation.ErrorCategory]::LimitsExceeded }
            default {
                if ($status -ge 500) { [System.Management.Automation.ErrorCategory]::ResourceUnavailable }
                else { [System.Management.Automation.ErrorCategory]::InvalidOperation }
            }
        }
        if ($accountRequired) { $category = [System.Management.Automation.ErrorCategory]::AuthenticationError }

        if ([string]::IsNullOrWhiteSpace($serverMessage)) {
            $serverMessage = switch ($status) {
                401 { 'Authentication required.' }
                403 { 'Permission denied.' }
                404 { 'Not found.' }
                422 { 'The request was rejected by validation.' }
                429 { 'Rate limit exceeded.' }
                default { 'The request failed.' }
            }
        }

        $label = if ($errorType) { $errorType } else { 'HTTP {0}' -f $status }
        $message = 'Onetime Secret API returned {0} ({1}): {2}' -f $status, $label, $serverMessage
        if ($field) { $message += ' [field: {0}]' -f $field }
        if ($requestId) { $message += ' [request_id: {0}]' -f $requestId }

        $fqid = if ($errorType) { 'OnetimeSecret.Api.{0}' -f $errorType } else { 'OnetimeSecret.Api.Http{0}' -f $status }

        $recommended = switch ($status) {
            401 { 'Check the customer extid (username) and API token (password) passed to Connect-OtsAccount.' }
            404 { 'The secret or receipt may have been viewed, burned or expired, or the identifier is wrong.' }
            429 { 'Slow down and retry later.' }
            default { $null }
        }
        if ($accountRequired) { $recommended = 'This operation needs an authenticated account. Run Connect-OtsAccount with a credential.' }
    }

    if ([string]::IsNullOrWhiteSpace($Cause)) {
        $exception = New-Object -TypeName System.Exception -ArgumentList $message
    }
    else {
        $exception = New-Object -TypeName System.Exception -ArgumentList $message, (New-Object -TypeName System.Exception -ArgumentList $Cause)
    }
    $exception.Data['StatusCode'] = $status
    $exception.Data['ErrorType']  = $errorType
    $exception.Data['Field']      = $field
    $exception.Data['ErrorId']    = $errorId
    $exception.Data['RequestId']  = $requestId
    $exception.Data['Method']     = $Method
    $exception.Data['Uri']        = $Uri
    $exception.Data['Response']   = $body

    $record = New-Object -TypeName System.Management.Automation.ErrorRecord -ArgumentList $exception, $fqid, $category, $Uri
    $record.ErrorDetails = New-Object -TypeName System.Management.Automation.ErrorDetails -ArgumentList $message
    if ($recommended) { $record.ErrorDetails.RecommendedAction = $recommended }

    return $record
}

#endregion Invoke-OtsRequest.ps1

#region New-OtsConnectionObject.ps1
# Connection construction and validation. A connection is a plain
# PSCustomObject tagged with the 'OnetimeSecret.Connection' type name so it
# can be bound with [PSTypeName()] on parameters without a PowerShell class
# (which would not be visible to callers without `using module`).

# The apex domain serves the marketing site; the API lives on the regional
# hosts, self-hosted installs and custom domains.
$script:ApexHosts = @('onetimesecret.com', 'www.onetimesecret.com')
$script:RegionalHosts = @(
    'https://us.onetimesecret.com'
    'https://eu.onetimesecret.com'
    'https://ca.onetimesecret.com'
    'https://uk.onetimesecret.com'
    'https://nz.onetimesecret.com'
)

# A customer external id ("extid") is an opaque, case-insensitive identifier
# that always begins with "ur". The API also accepts the account email.
$script:CustomerExtidPattern = '^ur[a-z0-9]+$'

function Resolve-OtsServerUri {
    <#
    .SYNOPSIS
        Validates and normalises a server base URI.
    #>

    [CmdletBinding()]
    [OutputType([uri])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $ServerUri
    )

    $uri = $null
    if (-not [uri]::TryCreate($ServerUri.Trim(), [System.UriKind]::Absolute, [ref] $uri) -or
        $uri.Scheme -notin @('http', 'https')) {
        throw ("ServerUri must be an absolute http(s) URL such as {0}; got '{1}'." -f $script:RegionalHosts[0], $ServerUri)
    }

    if ($uri.Host.ToLowerInvariant() -in $script:ApexHosts) {
        throw ("{0} is the Onetime Secret website, not an API host. Use your region's host ({1}), your self-hosted domain or your custom domain." -f $uri.Host, ($script:RegionalHosts -join ', '))
    }

    # Keep any path prefix (reverse-proxied installs) but drop a trailing slash
    # so request paths can be appended predictably.
    $builder = New-Object -TypeName System.UriBuilder -ArgumentList $uri
    $builder.Path = $builder.Path.TrimEnd('/')
    $builder.Query = ''
    $builder.Fragment = ''
    return $builder.Uri
}

function Test-OtsUsername {
    <#
    .SYNOPSIS
        Returns $true when a Basic-auth username looks like an extid or an email.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string] $Username
    )

    if ([string]::IsNullOrWhiteSpace($Username)) { return $false }
    if ($Username -match $script:CustomerExtidPattern) { return $true }
    return ($Username -match '^[^@\s]+@[^@\s]+\.[^@\s]+$')
}

function Get-OtsUserAgent {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    $edition = if ($PSVersionTable.ContainsKey('PSEdition')) { $PSVersionTable.PSEdition } else { 'Desktop' }
    $os = [System.Environment]::OSVersion.Platform
    return 'OnetimeSecretV2-PowerShell/{0} (PowerShell/{1}; {2}; {3})' -f $script:ModuleVersion, $PSVersionTable.PSVersion, $edition, $os
}

function New-OtsConnectionObject {
    <#
    .SYNOPSIS
        Builds a validated 'OnetimeSecret.Connection' object.
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Connection')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds an in-memory connection object; nothing outside the process changes.')]
    param(
        [Parameter(Mandatory)]
        [string] $ServerUri,

        [Parameter()]
        [AllowNull()]
        [pscredential] $Credential,

        [Parameter()]
        [ValidateRange(1, 3600)]
        [int] $TimeoutSec = 30,

        [Parameter()]
        [AllowEmptyString()]
        [string] $UserAgent
    )

    $resolvedUri = Resolve-OtsServerUri -ServerUri $ServerUri

    if ($null -ne $Credential -and -not (Test-OtsUsername -Username $Credential.UserName)) {
        throw ("'{0}' is not a customer extid or account email. The extid is the ""ur..."" identifier shown at the bottom of the user menu when you are signed in; pass it as the credential's username with your API token as the password." -f $Credential.UserName)
    }

    if ([string]::IsNullOrWhiteSpace($UserAgent)) {
        $UserAgent = Get-OtsUserAgent
    }

    $username = $null
    if ($null -ne $Credential) { $username = $Credential.UserName }

    $connection = [PSCustomObject] @{
        PSTypeName  = 'OnetimeSecret.Connection'
        ServerUri   = $resolvedUri
        Username    = $username
        IsAnonymous = ($null -eq $Credential)
        ApiVersion  = 'v2'
        TimeoutSec  = $TimeoutSec
        UserAgent   = $UserAgent
        Credential  = $Credential
    }

    return $connection
}

function Resolve-OtsConnection {
    <#
    .SYNOPSIS
        Picks the connection a cmdlet should use.
    .DESCRIPTION
        Precedence: an explicit -Connection argument, then the module default
        set by Connect-OtsAccount, then the ONETIME_BASE_URL /
        ONETIME_CUSTOMER_EXTID / ONETIME_API_TOKEN environment variables
        (which become the module default on first use).
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Connection')]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Connection
    )

    if ($null -ne $Connection) {
        if ($Connection.PSObject.TypeNames -notcontains 'OnetimeSecret.Connection') {
            throw 'Connection must be an object returned by Connect-OtsAccount -PassThru or Get-OtsConnection.'
        }
        return $Connection
    }

    if ($null -ne $script:DefaultConnection) {
        return $script:DefaultConnection
    }

    $baseUrl = [System.Environment]::GetEnvironmentVariable('ONETIME_BASE_URL')
    if (-not [string]::IsNullOrWhiteSpace($baseUrl)) {
        $credential = $null
        $extid = [System.Environment]::GetEnvironmentVariable('ONETIME_CUSTOMER_EXTID')
        $token = [System.Environment]::GetEnvironmentVariable('ONETIME_API_TOKEN')
        if (-not [string]::IsNullOrWhiteSpace($extid) -and -not [string]::IsNullOrWhiteSpace($token)) {
            $credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $extid, (ConvertTo-OtsSecureString -Value $token)
        }

        Write-Verbose 'No explicit connection; using ONETIME_* environment variables.'
        $script:DefaultConnection = New-OtsConnectionObject -ServerUri $baseUrl -Credential $credential
        return $script:DefaultConnection
    }

    throw 'Not connected. Run Connect-OtsAccount (or set ONETIME_BASE_URL, ONETIME_CUSTOMER_EXTID and ONETIME_API_TOKEN) first.'
}

#endregion New-OtsConnectionObject.ps1

#region Connect-OtsAccount.ps1
function Connect-OtsAccount {
    <#
    .SYNOPSIS
        Establishes the connection that other OnetimeSecretV2 cmdlets use.

    .DESCRIPTION
        Validates the server URI and credential, then stores the resulting
        connection as the module default so subsequent cmdlets need no
        -Connection argument. Use -PassThru to also return the connection object,
        which can be passed explicitly when talking to several servers at once.

        Authentication is HTTP Basic: the credential's username is your customer
        extid (the "ur..." identifier at the bottom of the user menu when signed
        in) or your account email, and the password is your API token from the
        account's API settings page.

        Alternatively, set the ONETIME_BASE_URL, ONETIME_CUSTOMER_EXTID and
        ONETIME_API_TOKEN environment variables and skip this cmdlet entirely;
        the module connects on first use.

        Onetime Secret runs region-isolated deployments, so there is no default
        server. Pick your region's host (https://us.onetimesecret.com,
        https://eu.onetimesecret.com, https://ca.onetimesecret.com,
        https://uk.onetimesecret.com, https://nz.onetimesecret.com), your
        self-hosted instance or your custom domain.

    .PARAMETER ServerUri
        Base URL of the API host, for example https://eu.onetimesecret.com.
        The apex domain onetimesecret.com is the website and is rejected.

    .PARAMETER Credential
        PSCredential whose username is the customer extid (or account email) and
        whose password is the API token.

    .PARAMETER Anonymous
        Connect without credentials. Only the guest endpoints are available
        (create, reveal, burn, show); listing receipts and setting memos are not.

    .PARAMETER TimeoutSec
        Per-request timeout in seconds. Defaults to 30.

    .PARAMETER UserAgent
        Override the User-Agent header. Defaults to a string identifying this
        module and the PowerShell version.

    .PARAMETER PassThru
        Return the connection object in addition to setting it as the default.

    .EXAMPLE
        Connect-OtsAccount -ServerUri https://eu.onetimesecret.com -Credential (Get-Credential -UserName 'ur1abc23def' -Message 'API token')

        Prompts for the API token and connects as the given customer.

    .EXAMPLE
        $token = ConvertTo-SecureString $env:ONETIME_API_TOKEN -AsPlainText -Force
        $cred = [pscredential]::new('ur1abc23def', $token)
        Connect-OtsAccount https://us.onetimesecret.com -Credential $cred

        Connects non-interactively from an automation script.

    .EXAMPLE
        $eu = Connect-OtsAccount https://eu.onetimesecret.com -Anonymous -PassThru
        New-OtsSecret -Secret 'hunter2' -Connection $eu

        Uses an explicit anonymous connection for a single call.

    .OUTPUTS
        OnetimeSecret.Connection (only with -PassThru)

    .LINK
        https://api.onetimesecret.com/doc/api-v2/authentication
    #>

    [CmdletBinding(DefaultParameterSetName = 'Credential')]
    [OutputType('OnetimeSecret.Connection')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [Alias('BaseUrl', 'Server', 'Uri')]
        [string] $ServerUri,

        [Parameter(Mandatory, ParameterSetName = 'Credential')]
        [System.Management.Automation.Credential()]
        [pscredential] $Credential,

        [Parameter(Mandatory, ParameterSetName = 'Anonymous')]
        [switch] $Anonymous,

        [Parameter()]
        [ValidateRange(1, 3600)]
        [int] $TimeoutSec = 30,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $UserAgent,

        [Parameter()]
        [switch] $PassThru
    )

    try {
        $parameters = @{
            ServerUri  = $ServerUri
            TimeoutSec = $TimeoutSec
        }
        if ($PSCmdlet.ParameterSetName -eq 'Credential') { $parameters['Credential'] = $Credential }
        if ($PSBoundParameters.ContainsKey('UserAgent')) { $parameters['UserAgent'] = $UserAgent }

        $connection = New-OtsConnectionObject @parameters
        $script:DefaultConnection = $connection

        if ($connection.IsAnonymous) {
            Write-Verbose ('Connected anonymously to {0}' -f $connection.ServerUri)
        }
        else {
            Write-Verbose ('Connected to {0} as {1}' -f $connection.ServerUri, $connection.Username)
        }

        if ($PassThru) { return $connection }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}

#endregion Connect-OtsAccount.ps1

#region Disconnect-OtsAccount.ps1
function Disconnect-OtsAccount {
    <#
    .SYNOPSIS
        Forgets the default connection set by Connect-OtsAccount.

    .DESCRIPTION
        Clears the module-level default connection. Connection objects you
        obtained with -PassThru are unaffected; there is no server-side session
        to end because the API is authenticated per request.

    .EXAMPLE
        Disconnect-OtsAccount

    .OUTPUTS
        None
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param()

    if ($null -eq $script:DefaultConnection) {
        Write-Verbose 'No default connection to clear.'
        return
    }

    $target = $script:DefaultConnection.ServerUri.ToString()
    if ($PSCmdlet.ShouldProcess($target, 'Clear default connection')) {
        $script:DefaultConnection = $null
    }
}

#endregion Disconnect-OtsAccount.ps1

#region Get-OtsConnection.ps1
function Get-OtsConnection {
    <#
    .SYNOPSIS
        Returns the connection the module is currently using.

    .DESCRIPTION
        Returns the default connection set by Connect-OtsAccount, or one built
        from the ONETIME_* environment variables when nothing has been set yet.
        Returns nothing when neither exists.

    .EXAMPLE
        Get-OtsConnection

        ServerUri Username IsAnonymous
        --------- -------- -----------
        https://eu.onetimesecret.com/ ur1abc23def False

    .OUTPUTS
        OnetimeSecret.Connection
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Connection')]
    param()

    try {
        Resolve-OtsConnection
    }
    catch {
        Write-Verbose $_.Exception.Message
    }
}

#endregion Get-OtsConnection.ps1

#region Get-OtsReceipt.ps1
function Get-OtsReceipt {
    <#
    .SYNOPSIS
        Gets receipts: the creator-side record of a secret's state and links.

    .DESCRIPTION
        With -ReceiptId, returns the receipt for one or more secrets you
        created, including whether the secret has been viewed, burned or has
        expired, and the share, receipt and burn URLs.

        With no identifier, lists the authenticated account's receipts for
        secrets created in the last 30 days (requires a credential).

    .PARAMETER ReceiptId
        The receipt identifier, or the receipt link it appears in.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Get-OtsReceipt

        ReceiptId SecretId State Created Expires
        --------- -------- ----- ------- -------
        rcp5f8d... sec3kd9f... new 20/10/2026 14:04:05 27/10/2026 14:04:05
        rcp2ab1... secxx91c... received 19/10/2026 09:12:33

    .EXAMPLE
        Get-OtsReceipt -ReceiptId rcp5f8d... | Select-Object State, IsReceived, Received

        Checks whether a specific secret has been picked up.

    .EXAMPLE
        Get-OtsReceipt | Where-Object IsReceived | Format-Table ReceiptId, Received, Recipients

    .OUTPUTS
        OnetimeSecret.Receipt

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_showreceipt
    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_listreceipts
    #>

    [CmdletBinding(DefaultParameterSetName = 'Recent')]
    [OutputType('OnetimeSecret.Receipt')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ById', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string[]] $ReceiptId,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $prefix = Get-OtsPathPrefix -Connection $resolved
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Recent') {
            try {
                Assert-OtsAuthenticated -Connection $resolved -Operation 'Listing receipts'
                $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path 'receipt/recent'
                foreach ($record in @(Get-OtsProperty $response 'records' @())) {
                    ConvertFrom-OtsReceipt -Record $record -Connection $resolved
                }
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
            return
        }

        foreach ($item in $ReceiptId) {
            try {
                $identifier = ConvertTo-OtsIdentifier -Value $item -Kind Receipt
                $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path ('{0}receipt/{1}' -f $prefix, [uri]::EscapeDataString($identifier))
                ConvertFrom-OtsReceipt -Record (Get-OtsProperty $response 'record') -Details (Get-OtsProperty $response 'details') -Connection $resolved
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Get-OtsReceipt.ps1

#region Get-OtsSecret.ps1
function Get-OtsSecret {
    <#
    .SYNOPSIS
        Gets a secret's metadata without revealing its value.

    .DESCRIPTION
        Returns state, passphrase requirement and timing for a secret, leaving
        the value untouched. Use Receive-OtsSecret to reveal (and thereby
        destroy) it.

        The server records this access as a preview on the secret's receipt,
        which its creator can see. For a lighter check that only answers "does
        it still exist and what state is it in", use Get-OtsSecretStatus.

    .PARAMETER SecretId
        The secret identifier, or the full share link it appears in.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Get-OtsSecret -SecretId 'https://eu.onetimesecret.com/secret/sec3kd9f...'

    .EXAMPLE
        New-OtsSecret -Secret 'hunter2' | Get-OtsSecret

        Reads the metadata of a secret just created, via SecretId on the pipeline.

    .OUTPUTS
        OnetimeSecret.Secret

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_showsecret
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.Secret')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string[]] $SecretId,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $prefix = Get-OtsPathPrefix -Connection $resolved
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        foreach ($item in $SecretId) {
            try {
                $identifier = ConvertTo-OtsIdentifier -Value $item -Kind Secret
                $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path ('{0}secret/{1}' -f $prefix, [uri]::EscapeDataString($identifier))
                ConvertFrom-OtsSecret -Record (Get-OtsProperty $response 'record') -Connection $resolved
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Get-OtsSecret.ps1

#region Get-OtsSecretStatus.ps1
function Get-OtsSecretStatus {
    <#
    .SYNOPSIS
        Checks whether secrets still exist and what state they are in.

    .DESCRIPTION
        A lightweight, non-consuming check. Unknown identifiers come back with
        State 'unknown' and Exists $false rather than an error, which makes
        this the right call for polling ("has the recipient picked it up
        yet?"). Several identifiers are checked in a single request.

    .PARAMETER SecretId
        One or more secret identifiers or share links.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Get-OtsSecretStatus -SecretId sec3kd9f..., sec8xy2a...

        SecretId State Exists Expires
        -------- ----- ------ -------
        sec3kd9f... new True 20/10/2026 15:04:05
        sec8xy2a... unknown False

    .EXAMPLE
        Get-OtsReceipt | Get-OtsSecretStatus

        Checks every recent receipt's secret in one request.

    .OUTPUTS
        OnetimeSecret.SecretStatus

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_listsecretstatus
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.SecretStatus')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string[]] $SecretId,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $identifiers = New-Object -TypeName System.Collections.Generic.List[string]
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        foreach ($item in $SecretId) {
            try {
                $identifiers.Add((ConvertTo-OtsIdentifier -Value $item -Kind Secret))
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }

    end {
        if ($identifiers.Count -eq 0) { return }

        try {
            if ($identifiers.Count -eq 1) {
                $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path ('secret/{0}/status' -f [uri]::EscapeDataString($identifiers[0]))
                ConvertFrom-OtsSecretStatus -Record (Get-OtsProperty $response 'record') -Details (Get-OtsProperty $response 'details') -RequestedId $identifiers[0] -Connection $resolved
                return
            }

            $response = Invoke-OtsRequest -Connection $resolved -Method POST -Path 'secret/status' -Body @{ identifiers = @($identifiers) }
            $records = @(Get-OtsProperty $response 'records' @())

            # Keep the caller's order and report identifiers the server left out.
            $byId = @{}
            foreach ($record in $records) {
                $key = [string] (Get-OtsProperty $record 'identifier')
                if ($key) { $byId[$key] = $record }
            }

            foreach ($identifier in $identifiers) {
                $record = $null
                if ($byId.ContainsKey($identifier)) { $record = $byId[$identifier] }
                ConvertFrom-OtsSecretStatus -Record $record -RequestedId $identifier -Connection $resolved
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }
}

#endregion Get-OtsSecretStatus.ps1

#region Get-OtsStatus.ps1
function Get-OtsStatus {
    <#
    .SYNOPSIS
        Reports whether the Onetime Secret service is up.

    .DESCRIPTION
        Calls the unauthenticated status endpoint and returns the service's
        own health report plus the server it came from. Useful as a connectivity
        check before a batch of operations, or in monitoring.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .PARAMETER ServerUri
        Check a server without connecting first.

    .EXAMPLE
        Get-OtsStatus

        ServerUri Status IsHealthy
        --------- ------ ---------
        https://eu.onetimesecret.com/ nominal True

    .EXAMPLE
        'https://us.onetimesecret.com', 'https://eu.onetimesecret.com' | Get-OtsStatus

        Checks several regions without connecting.

    .OUTPUTS
        OnetimeSecret.ServiceStatus

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_systemstatus
    #>

    [CmdletBinding(DefaultParameterSetName = 'Connection')]
    [OutputType('OnetimeSecret.ServiceStatus')]
    param(
        [Parameter(ParameterSetName = 'Connection')]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

        [Parameter(Mandatory, ParameterSetName = 'ServerUri', Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [Alias('BaseUrl', 'Server')]
        [string[]] $ServerUri
    )

    process {
        $targets = @()
        try {
            if ($PSCmdlet.ParameterSetName -eq 'ServerUri') {
                foreach ($uri in $ServerUri) { $targets += New-OtsConnectionObject -ServerUri $uri }
            }
            else {
                $targets += Resolve-OtsConnection -Connection $Connection
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }

        foreach ($target in $targets) {
            try {
                $response = Invoke-OtsRequest -Connection $target -Method GET -Path 'status'
                $status = [string] (Get-OtsProperty $response 'status')

                [PSCustomObject] @{
                    PSTypeName  = 'OnetimeSecret.ServiceStatus'
                    ServerUri   = $target.ServerUri
                    Status      = $status
                    IsHealthy   = (ConvertTo-OtsBoolean (Get-OtsProperty $response 'success')) -and ($status -in @('nominal', 'ok', 'healthy'))
                    Locale      = [string] (Get-OtsProperty $response 'locale')
                    RawResponse = $response
                }
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Get-OtsStatus.ps1

#region Get-OtsSupportedLocale.ps1
function Get-OtsSupportedLocale {
    <#
    .SYNOPSIS
        Lists the locales a Onetime Secret server can serve.

    .DESCRIPTION
        Calls the unauthenticated supported-locales endpoint and returns the
        locale codes the server has translations for, its default locale, and
        the locale it chose for this request. Useful when building a share
        link or notification for a recipient in a given language, or when
        checking what a self-hosted instance has enabled.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .PARAMETER ServerUri
        Query a server without connecting first.

    .EXAMPLE
        Get-OtsSupportedLocale

        ServerUri DefaultLocale Locales
        --------- ------------- -------
        https://eu.onetimesecret.com/ en {ar, bg, ca_ES, cs...}

    .EXAMPLE
        (Get-OtsSupportedLocale -ServerUri https://eu.onetimesecret.com).Locales -contains 'de'

        Checks whether German is available before localising a message.

    .OUTPUTS
        OnetimeSecret.SupportedLocales

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_getsupportedlocales
    #>

    [CmdletBinding(DefaultParameterSetName = 'Connection')]
    [OutputType('OnetimeSecret.SupportedLocales')]
    param(
        [Parameter(ParameterSetName = 'Connection')]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection,

        [Parameter(Mandatory, ParameterSetName = 'ServerUri', Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [Alias('BaseUrl', 'Server')]
        [string[]] $ServerUri
    )

    process {
        $targets = @()
        try {
            if ($PSCmdlet.ParameterSetName -eq 'ServerUri') {
                foreach ($uri in $ServerUri) { $targets += New-OtsConnectionObject -ServerUri $uri }
            }
            else {
                $targets += Resolve-OtsConnection -Connection $Connection
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }

        foreach ($target in $targets) {
            try {
                $response = Invoke-OtsRequest -Connection $target -Method GET -Path 'supported-locales'

                [PSCustomObject] @{
                    PSTypeName    = 'OnetimeSecret.SupportedLocales'
                    ServerUri     = $target.ServerUri
                    Locales       = ConvertTo-OtsStringArray (Get-OtsProperty $response 'locales')
                    DefaultLocale = [string] (Get-OtsProperty $response 'default_locale')
                    Locale        = [string] (Get-OtsProperty $response 'locale')
                    RawResponse   = $response
                }
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Get-OtsSupportedLocale.ps1

#region Get-OtsVersion.ps1
function Get-OtsVersion {
    <#
    .SYNOPSIS
        Reports the version of the Onetime Secret server you are connected to.

    .DESCRIPTION
        Calls the version endpoint and returns the server's build version as
        a [version] plus the server it came from. Useful for confirming which
        release a self-hosted instance runs, or for gating features on it.

        The endpoint requires an authenticated connection. The OpenAPI
        document lists it without a security requirement, but the server
        (onetimesecret.com and current self-hosted releases) withholds the
        build version from anonymous callers because it fingerprints the
        install for CVE matching. Anonymously, or with bad credentials, the
        call fails with an AuthenticationError (HTTP 401). Use Get-OtsStatus
        for an unauthenticated health check.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Get-OtsVersion

        ServerUri Version Locale
        --------- ------- ------
        https://eu.onetimesecret.com/ 0.26.5 en

    .EXAMPLE
        if ((Get-OtsVersion).Version -lt [version] '0.26.0') { throw 'Server too old.' }

        Checks the server release before relying on a newer feature.

    .OUTPUTS
        OnetimeSecret.ServiceVersion

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_systemversion
    #>

    [CmdletBinding()]
    [OutputType('OnetimeSecret.ServiceVersion')]
    param(
        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    process {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path 'version'
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }

        # The server reports [major, minor, patch]; a self-hosted build may
        # append a prerelease label. Keep the raw list, and offer a [version]
        # where the numeric components allow one.
        $components = @(Get-OtsProperty $response 'version' @() | ForEach-Object { [string] $_ })
        $versionText = $components -join '.'
        $version = $null
        $numeric = @($components | Where-Object { $_ -match '^\d+$' })
        if ($numeric.Count -ge 2) {
            try { $version = [version] ($numeric -join '.') } catch { $version = $null }
        }

        [PSCustomObject] @{
            PSTypeName        = 'OnetimeSecret.ServiceVersion'
            ServerUri         = $resolved.ServerUri
            Version           = $version
            VersionString     = $versionText
            VersionComponents = $components
            Locale            = [string] (Get-OtsProperty $response 'locale')
            RawResponse       = $response
        }
    }
}

#endregion Get-OtsVersion.ps1

#region New-OtsSecret.ps1
function New-OtsSecret {
    <#
    .SYNOPSIS
        Creates a one-time secret and returns its receipt, including the share link.

    .DESCRIPTION
        Conceals a value you supply (-Secret or -SecureSecret), or asks the
        server to generate a random one (-Generate). The returned receipt object
        has the ShareUrl to hand to the recipient, the ReceiptId you keep to
        check on or burn the secret, and the generated Value when -Generate was
        used.

        Anonymous connections use the guest endpoints and are subject to the
        guest limits (shorter maximum TTL, no custom share domain).

    .PARAMETER Secret
        The secret content as plain text. Accepts pipeline input, so
        `Get-Content token.txt -Raw | New-OtsSecret` works.

    .PARAMETER SecureSecret
        The secret content as a SecureString, for callers that already hold it
        that way.

    .PARAMETER Generate
        Have the server generate a random secret instead of supplying one. The
        generated value is returned in the receipt's Value property. The server
        discloses it only once, so New-OtsSecret reads it back immediately;
        it will not appear on later Get-OtsReceipt calls.

    .PARAMETER Length
        Length of the generated secret (4-128). The server default applies when
        omitted.

    .PARAMETER CharacterSet
        Character classes to draw the generated secret from: Uppercase,
        Lowercase, Numbers, Symbols. The server default applies when omitted.

    .PARAMETER ExcludeAmbiguous
        Leave out look-alike characters (such as 0/O and 1/l) from a generated
        secret.

    .PARAMETER Passphrase
        Optional passphrase the recipient must supply to reveal the secret.

    .PARAMETER Ttl
        How long the secret stays retrievable, as a TimeSpan (for example
        (New-TimeSpan -Hours 1) or '01:00:00'). Integers are not accepted
        because PowerShell would read them as ticks. The server default and
        plan limits apply when omitted.

    .PARAMETER Recipient
        Email address(es) to notify with the share link.

    .PARAMETER ShareDomain
        Custom domain to build the share link on (authenticated accounts with a
        verified domain only).

    .PARAMETER DisplayType
        How the recipient's page renders the secret: 'text' (default) or
        'markdown'.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        New-OtsSecret -Secret 'hunter2' -Ttl (New-TimeSpan -Hours 1)

        ReceiptId SecretId State Expires ShareUrl
        --------- -------- ----- ------- --------
        rcp5f8... sec3kd... new 20/10/2026 15:04:05 https://eu.onetimesecret.com/secret/sec3kd...

    .EXAMPLE
        $pass = Read-Host -AsSecureString 'Passphrase'
        $link = (New-OtsSecret -Secret $apiKey -Passphrase $pass -Recipient ops@example.com).ShareUrl

        Protects the secret with a passphrase and emails the link.

    .EXAMPLE
        New-OtsSecret -Generate -Length 24 -CharacterSet Uppercase,Lowercase,Numbers | Select-Object Value, ShareUrl

        Generates a 24-character alphanumeric secret and shows both the value
        and the link to share.

    .EXAMPLE
        Get-Content ./kubeconfig -Raw | New-OtsSecret -Ttl '2:00:00' -DisplayType markdown

        Conceals a file's contents from the pipeline.

    .OUTPUTS
        OnetimeSecret.Receipt

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_concealsecret
    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_generatesecret
    #>

    [CmdletBinding(DefaultParameterSetName = 'Conceal', SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType('OnetimeSecret.Receipt')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'Secret',
        Justification = 'The secret content is the payload being shared, not a credential; SecureString input is offered alongside.')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Conceal', Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [Alias('Value', 'Content')]
        [string] $Secret,

        [Parameter(Mandatory, ParameterSetName = 'ConcealSecure')]
        [securestring] $SecureSecret,

        [Parameter(Mandatory, ParameterSetName = 'Generate')]
        [switch] $Generate,

        [Parameter(ParameterSetName = 'Generate')]
        [ValidateRange(4, 128)]
        [int] $Length,

        [Parameter(ParameterSetName = 'Generate')]
        [ValidateSet('Uppercase', 'Lowercase', 'Numbers', 'Symbols')]
        [string[]] $CharacterSet,

        [Parameter(ParameterSetName = 'Generate')]
        [switch] $ExcludeAmbiguous,

        [Parameter()]
        [securestring] $Passphrase,

        [Parameter()]
        [Alias('TimeToLive', 'ExpiresIn')]
        [timespan] $Ttl,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]] $Recipient,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ShareDomain,

        [Parameter()]
        [ValidateSet('text', 'markdown')]
        [string] $DisplayType,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $prefix = Get-OtsPathPrefix -Connection $resolved

            if ($PSBoundParameters.ContainsKey('Ttl') -and $Ttl.TotalSeconds -lt 1) {
                throw 'Ttl must be at least one second. Pass a TimeSpan such as (New-TimeSpan -Hours 1) or ''01:00:00''; a bare integer is interpreted as ticks.'
            }
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        try {
            $isGenerate = $PSCmdlet.ParameterSetName -eq 'Generate'

            $payload = [ordered] @{
                kind = if ($isGenerate) { 'generate' } else { 'conceal' }
            }

            if ($isGenerate) {
                if ($PSBoundParameters.ContainsKey('Length')) { $payload['length'] = $Length }

                $characterSets = [ordered] @{}
                if ($PSBoundParameters.ContainsKey('CharacterSet')) {
                    $characterSets['uppercase'] = [bool] ($CharacterSet -contains 'Uppercase')
                    $characterSets['lowercase'] = [bool] ($CharacterSet -contains 'Lowercase')
                    $characterSets['numbers']   = [bool] ($CharacterSet -contains 'Numbers')
                    $characterSets['symbols']   = [bool] ($CharacterSet -contains 'Symbols')
                }
                if ($ExcludeAmbiguous) { $characterSets['exclude_ambiguous'] = $true }
                if ($characterSets.Count -gt 0) { $payload['character_sets'] = $characterSets }
            }
            else {
                $plain = if ($PSCmdlet.ParameterSetName -eq 'ConcealSecure') { ConvertFrom-OtsSecureString -SecureString $SecureSecret } else { $Secret }
                if ([string]::IsNullOrEmpty($plain)) { throw 'The secret content is empty.' }
                $payload['secret'] = $plain
            }

            if ($Passphrase) { $payload['passphrase'] = ConvertFrom-OtsSecureString -SecureString $Passphrase }
            if ($PSBoundParameters.ContainsKey('Ttl')) { $payload['ttl'] = [int] [math]::Floor($Ttl.TotalSeconds) }
            if ($Recipient) { $payload['recipient'] = @($Recipient) }
            if ($ShareDomain) { $payload['share_domain'] = $ShareDomain }
            if ($DisplayType) { $payload['display_type'] = $DisplayType }

            $description = if ($isGenerate) { 'Generate secret' } else { 'Conceal secret' }
            if (-not $PSCmdlet.ShouldProcess($resolved.ServerUri.ToString(), $description)) { return }

            $path = if ($isGenerate) { '{0}secret/generate' -f $prefix } else { '{0}secret/conceal' -f $prefix }
            $response = Invoke-OtsRequest -Connection $resolved -Method POST -Path $path -Body @{ secret = $payload }

            $record = Get-OtsProperty $response 'record'
            $receipt = Get-OtsProperty $record 'receipt'
            $secretRecord = Get-OtsProperty $record 'secret'
            $details = Get-OtsProperty $response 'details'
            $domain = [string] (Get-OtsProperty $record 'share_domain')

            if ($isGenerate) {
                # The server hands out a generated value exactly once: on the
                # first read of the receipt after creation, not in the create
                # response itself. Fetch it now so the caller gets it in Value;
                # a later Get-OtsReceipt would come back empty. Only the details
                # are taken: the shown record's links use the server's
                # configured host, so keeping the created record builds them
                # from the connection exactly as for a concealed secret.
                $receiptId = [string] (Get-OtsProperty $receipt 'identifier')
                try {
                    $shown = Invoke-OtsRequest -Connection $resolved -Method GET -Path ('{0}receipt/{1}' -f $prefix, [uri]::EscapeDataString($receiptId))
                    $details = Get-OtsProperty $shown 'details' $details
                }
                catch {
                    # Name the failure by error id and status only: the body
                    # of a failed or garbled receipt read can hold the value.
                    $reason = ($_.FullyQualifiedErrorId -split ',')[0]
                    $status = [int] $_.Exception.Data['StatusCode']
                    if ($status -gt 0) { $reason = '{0}, HTTP {1}' -f $reason, $status }
                    Write-Warning ('The secret was generated but its value could not be read back ({0}). Open {1} to see it.' -f $reason, ('{0}/receipt/{1}' -f $resolved.ServerUri.AbsoluteUri.TrimEnd('/'), $receiptId))
                }
            }

            ConvertFrom-OtsReceipt -Record $receipt -SecretRecord $secretRecord -Details $details -Connection $resolved -ShareDomain $domain
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }
}

#endregion New-OtsSecret.ps1

#region Receive-OtsSecret.ps1
function Receive-OtsSecret {
    <#
    .SYNOPSIS
        Reveals a secret's value. This consumes the secret; it cannot be read again.

    .DESCRIPTION
        Retrieves the secret content and destroys it on the server in the same
        step. Supply -Passphrase when the creator set one. Supports -WhatIf and
        -Confirm because the operation is irreversible.

        By default Value is a plain string so it can be used directly in
        scripts; pass -AsSecureString to receive it as a SecureString instead.

    .PARAMETER SecretId
        The secret identifier, or the full share link it appears in.

    .PARAMETER Passphrase
        The passphrase the creator protected the secret with, if any.

    .PARAMETER AsSecureString
        Return Value as a SecureString.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        (Receive-OtsSecret -SecretId 'https://eu.onetimesecret.com/secret/sec3kd9f...').Value

        Prints the secret content.

    .EXAMPLE
        $pass = Read-Host -AsSecureString 'Passphrase'
        Receive-OtsSecret sec3kd9f... -Passphrase $pass -AsSecureString

        Reveals a passphrase-protected secret and keeps the value as a SecureString.

    .EXAMPLE
        Receive-OtsSecret sec3kd9f... -WhatIf

        Shows what would happen without consuming the secret.

    .OUTPUTS
        OnetimeSecret.Secret

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_secret_revealsecret
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType('OnetimeSecret.Secret')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string] $SecretId,

        [Parameter()]
        [securestring] $Passphrase,

        [Parameter()]
        [switch] $AsSecureString,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $prefix = Get-OtsPathPrefix -Connection $resolved
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        try {
            $identifier = ConvertTo-OtsIdentifier -Value $SecretId -Kind Secret

            if (-not $PSCmdlet.ShouldProcess($identifier, 'Reveal and destroy secret')) { return }

            $body = @{ continue = $true }
            if ($Passphrase) { $body['passphrase'] = ConvertFrom-OtsSecureString -SecureString $Passphrase }

            $response = Invoke-OtsRequest -Connection $resolved -Method POST -Path ('{0}secret/{1}/reveal' -f $prefix, [uri]::EscapeDataString($identifier)) -Body $body
            ConvertFrom-OtsSecret -Record (Get-OtsProperty $response 'record') -Connection $resolved -AsSecureString:$AsSecureString
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }
}

#endregion Receive-OtsSecret.ps1

#region Remove-OtsSecret.ps1
function Remove-OtsSecret {
    <#
    .SYNOPSIS
        Burns a secret before anyone reads it.

    .DESCRIPTION
        Permanently destroys an unread secret using its receipt identifier (the
        creator's handle; the secret identifier in the share link does not
        work here). Returns the updated receipt. Supports -WhatIf and -Confirm.

    .PARAMETER ReceiptId
        The receipt identifier, or the receipt link it appears in. Receipt
        objects from New-OtsSecret and Get-OtsReceipt bind this from the pipeline.

    .PARAMETER Passphrase
        The passphrase the secret was created with, if any.

    .PARAMETER PassThru
        Return the updated receipt. By default nothing is returned, in keeping
        with other Remove-* cmdlets.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Remove-OtsSecret -ReceiptId rcp5f8d...

    .EXAMPLE
        Get-OtsReceipt | Where-Object State -eq 'new' | Remove-OtsSecret -Confirm:$false

        Burns every unread secret the account created in the last 30 days.

    .EXAMPLE
        $receipt = New-OtsSecret -Secret 'oops'
        $receipt | Remove-OtsSecret -PassThru

        Creates and immediately burns a secret, returning the burned receipt.

    .OUTPUTS
        OnetimeSecret.Receipt (only with -PassThru)

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_receipt_burnsecret
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType('OnetimeSecret.Receipt')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string[]] $ReceiptId,

        [Parameter()]
        [securestring] $Passphrase,

        [Parameter()]
        [switch] $PassThru,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            $prefix = Get-OtsPathPrefix -Connection $resolved
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        foreach ($item in $ReceiptId) {
            try {
                $identifier = ConvertTo-OtsIdentifier -Value $item -Kind Receipt

                if (-not $PSCmdlet.ShouldProcess($identifier, 'Burn secret')) { continue }

                $body = @{ continue = $true }
                if ($Passphrase) { $body['passphrase'] = ConvertFrom-OtsSecureString -SecureString $Passphrase }

                $null = Invoke-OtsRequest -Connection $resolved -Method POST -Path ('{0}receipt/{1}/burn' -f $prefix, [uri]::EscapeDataString($identifier)) -Body $body

                if ($PassThru) {
                    # The burn response echoes the receipt as it was before the
                    # burn, so read it again to report the real state.
                    $response = Invoke-OtsRequest -Connection $resolved -Method GET -Path ('{0}receipt/{1}' -f $prefix, [uri]::EscapeDataString($identifier))
                    ConvertFrom-OtsReceipt -Record (Get-OtsProperty $response 'record') -Details (Get-OtsProperty $response 'details') -Connection $resolved
                }
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Remove-OtsSecret.ps1

#region Set-OtsReceipt.ps1
function Set-OtsReceipt {
    <#
    .SYNOPSIS
        Updates a receipt's memo.

    .DESCRIPTION
        Sets the private memo on a receipt (up to 500 characters) so you can
        note what a secret was for. Only the receipt's owner can do this, so an
        authenticated connection is required. Returns the updated receipt.

    .PARAMETER ReceiptId
        The receipt identifier, or the receipt link it appears in.

    .PARAMETER Memo
        The memo text. Pass an empty string to clear it.

    .PARAMETER PassThru
        Return the updated receipt.

    .PARAMETER Connection
        Connection to use instead of the default from Connect-OtsAccount.

    .EXAMPLE
        Set-OtsReceipt -ReceiptId rcp5f8d... -Memo 'DB password for the migration on Friday'

    .EXAMPLE
        New-OtsSecret -Generate | Set-OtsReceipt -Memo 'Temporary SFTP credential' -PassThru

    .OUTPUTS
        OnetimeSecret.Receipt (only with -PassThru)

    .LINK
        https://api.onetimesecret.com/doc/api-v2/operation/operation-v2_updatereceipt
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
    [OutputType('OnetimeSecret.Receipt')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id', 'Key')]
        [string[]] $ReceiptId,

        [Parameter(Mandatory, Position = 1)]
        [AllowEmptyString()]
        [ValidateLength(0, 500)]
        [string] $Memo,

        [Parameter()]
        [switch] $PassThru,

        [Parameter()]
        [PSTypeName('OnetimeSecret.Connection')]
        $Connection
    )

    begin {
        try {
            $resolved = Resolve-OtsConnection -Connection $Connection
            Assert-OtsAuthenticated -Connection $resolved -Operation 'Updating a receipt'
        }
        catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        foreach ($item in $ReceiptId) {
            try {
                $identifier = ConvertTo-OtsIdentifier -Value $item -Kind Receipt

                if (-not $PSCmdlet.ShouldProcess($identifier, 'Set memo')) { continue }

                $response = Invoke-OtsRequest -Connection $resolved -Method PATCH -Path ('receipt/{0}' -f [uri]::EscapeDataString($identifier)) -Body @{ memo = $Memo }

                if ($PassThru) {
                    ConvertFrom-OtsReceipt -Record (Get-OtsProperty $response 'record') -Details (Get-OtsProperty $response 'details') -Connection $resolved
                }
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
        }
    }
}

#endregion Set-OtsReceipt.ps1

Export-ModuleMember -Function @(
    'Connect-OtsAccount'
    'Disconnect-OtsAccount'
    'Get-OtsConnection'
    'Get-OtsReceipt'
    'Get-OtsSecret'
    'Get-OtsSecretStatus'
    'Get-OtsStatus'
    'Get-OtsSupportedLocale'
    'Get-OtsVersion'
    'New-OtsSecret'
    'Receive-OtsSecret'
    'Remove-OtsSecret'
    'Set-OtsReceipt'
)