Modules/businessdev.ALbuild.Core/Public/Test-ALbuildLicense.ps1

function Test-ALbuildLicense {
    <#
    .SYNOPSIS
        Verifies an ALbuild commercial license against the licensing service.
 
    .DESCRIPTION
        Free-tier functionality performs no license check. Licensed features call this to verify
        a tenant's license via the 365 business development licensing service. The result is
        cached per tenant for the session. Network/service failures do NOT throw - they return an
        object with IsValid = $false and a Reason - so that license enforcement is an explicit
        decision of the caller (see Assert-ALbuildLicensed), never an accidental side effect.
 
        A verification is a WRITE: the service answers by moving the license's validity forward, so
        two builds verifying the same tenant in the same instant collide and one is rejected with
        HTTP 400. Requests therefore retry with jitter (Invoke-ALbuildLicenseRequest) - four parallel
        build jobs on one organization is an ordinary day, not an edge case.
 
        Two offline accommodations exist for trusted agents that cannot always reach the service:
          * ALBUILD_LICENSE_KEY - when this environment variable is set, the tenant is authorized
            without contacting the service (set it as a secret on agents in locked-down networks).
          * Grace window - a successful verification is cached to disk; if the service is later
            unreachable, that cached result keeps the feature working for LicenseGraceDays days
            (config, default 14). An explicit invalid/expired response is never graced (the cache
            is cleared), so a revoked license cannot keep working offline.
 
    .PARAMETER TenantId
        The Azure DevOps organization / collection id. Defaults to $env:System_CollectionId.
 
    .PARAMETER TenantName
        The Azure DevOps organization / collection URI. Defaults to $env:System_CollectionUri.
 
    .PARAMETER Refresh
        Bypass the per-tenant session cache and re-query the service.
 
    .EXAMPLE
        Test-ALbuildLicense -TenantId $env:System_CollectionId
 
    .OUTPUTS
        PSCustomObject with IsValid, Status, IsTrial, ExpiresOn, TenantId, TenantName, Reason.
    #>

    [CmdletBinding()]
    param(
        [string] $TenantId = $env:System_CollectionId,
        [string] $TenantName = $env:System_CollectionUri,
        [switch] $Refresh
    )

    $normalizedName = if ($TenantName) {
        $TenantName -replace '^https?://', '' -replace '/+$', ''
    } else { '' }

    # Offline override for trusted agents that cannot reach the licensing service (e.g. a self-hosted
    # agent in a locked-down customer network). The organization sets ALBUILD_LICENSE_KEY as a secret
    # on those agents; its presence authorizes the licensed features without contacting the service.
    if (-not [string]::IsNullOrWhiteSpace($env:ALBUILD_LICENSE_KEY)) {
        return [PSCustomObject]@{
            IsValid = $true; Status = 'OfflineKey'; IsTrial = $false; ExpiresOn = $null
            TenantId = $TenantId; TenantName = $normalizedName
            Reason = 'Authorized by the offline license key (ALBUILD_LICENSE_KEY).'
        }
    }

    if ([string]::IsNullOrWhiteSpace($TenantId)) {
        return [PSCustomObject]@{
            IsValid = $false; Status = 'NoTenant'; IsTrial = $false; ExpiresOn = $null
            TenantId = $TenantId; TenantName = $TenantName
            Reason = 'No tenant id available (set -TenantId, ALBUILD_LICENSE_KEY, or run inside Azure DevOps).'
        }
    }

    if (-not $script:ALbuildLicenseCache) { $script:ALbuildLicenseCache = @{} }
    if (-not $Refresh -and $script:ALbuildLicenseCache.ContainsKey($TenantId)) {
        return $script:ALbuildLicenseCache[$TenantId]
    }

    $baseUrl = (Get-ALbuildConfig -Name 'LicensingBaseUrl').TrimEnd('/')
    $appId   = Get-ALbuildConfig -Name 'LicenseAppId'
    $url     = "$baseUrl/v1/apps/$appId/features/$appId/tenant/$TenantId/verifyLicense"
    $retry   = @{
        RetryCount             = [int](Get-ALbuildConfig -Name 'LicenseRetryCount')
        RetryDelayMilliseconds = [int](Get-ALbuildConfig -Name 'LicenseRetryDelayMilliseconds')
    }

    # Read a property defensively so a partial response (e.g. an explicit denial with no 'license'
    # node) yields a clean result under Set-StrictMode instead of throwing into the unreachable path.
    function Get-Field([object] $Object, [string] $Name) {
        if ($null -eq $Object) { return $null }
        $prop = $Object.PSObject.Properties[$Name]
        if ($prop) { $prop.Value } else { $null }
    }

    try {
        $response = Invoke-ALbuildLicenseRequest -Uri $url -Method Get @retry
        $license  = Get-Field $response 'license'
        $status   = [string](Get-Field $response 'status')
        $isOk     = $status -and ($status.ToLowerInvariant() -eq 'ok')

        # Auto-register a trial when the tenant has no registration yet (mirrors AL.build V1's
        # Check-License -> Register-Feature flow). Without this only manually-provisioned licenses
        # work: a brand-new tenant returns not-ok forever. Register a 30-day trial, then re-verify so
        # the result reflects the freshly-provisioned trial. Only on an explicit not-ok response - a
        # network failure lands in the catch below and uses the grace cache instead. An expired trial
        # comes back status 'ok' with a past trialPeriodEndingDate (Assert-ALbuildLicensed enforces
        # it), so it is never silently re-registered here.
        if (-not $isOk) {
            $companyName  = if ($normalizedName -like 'dev.azure.com/*') { $normalizedName -replace '^dev\.azure\.com/', '' } else { $normalizedName }
            $trialEnd     = (Get-Date).ToUniversalTime().AddDays(30).ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ')
            $registerUrl  = "$baseUrl/v1/apps/$appId/features/$appId/tenant/$TenantId/registerTrial"
            $registerBody = @{ tenantName = $normalizedName; companyName = $companyName; trialPeriodEndingDate = $trialEnd } | ConvertTo-Json
            try {
                Write-ALbuildLog -Level Information "No license registration for tenant '$TenantId'; registering a 30-day trial."
                $regResp   = Invoke-ALbuildLicenseRequest -Uri $registerUrl -Method Post -Body $registerBody @retry
                $regStatus = [string](Get-Field $regResp 'status')
                if ($regStatus -and ($regStatus.ToLowerInvariant() -eq 'ok')) {
                    Write-ALbuildLog -Level Success "Registered a trial for tenant '$TenantId'; re-verifying."
                    $response = Invoke-ALbuildLicenseRequest -Uri $url -Method Get @retry
                    $license  = Get-Field $response 'license'
                    $status   = [string](Get-Field $response 'status')
                    $isOk     = $status -and ($status.ToLowerInvariant() -eq 'ok')
                }
                else {
                    Write-ALbuildLog -Level Warning "Trial registration for tenant '$TenantId' returned status '$regStatus'."
                }
            }
            catch {
                Write-ALbuildLog -Level Warning "Could not register a trial for tenant '$TenantId': $($_.Exception.Message)."
            }
        }

        $licenseKey = [string](Get-Field $license 'licenseKey')
        $isTrial    = $isOk -and ($null -ne $license) -and [string]::IsNullOrEmpty($licenseKey)
        $expires    = $null
        $trialEnd   = Get-Field $license 'trialPeriodEndingDate'
        if ($isOk -and $trialEnd) {
            [datetime] $parsed = [datetime]::MinValue
            if ([datetime]::TryParse([string]$trialEnd, [ref] $parsed)) { $expires = $parsed }
        }

        $result = [PSCustomObject]@{
            IsValid    = [bool]$isOk
            Status     = if ($status) { $status } else { 'Unknown' }
            IsTrial    = [bool]$isTrial
            ExpiresOn  = $expires
            TenantId   = $TenantId
            TenantName = $normalizedName
            Reason     = if ($isOk) { '' } else { "Licensing service returned status '$status'." }
        }

        # Persist a valid verification for the offline grace window; drop the cache on an explicit
        # denial so a revoked license cannot keep working offline.
        if ($isOk) {
            Save-ALbuildLicenseCacheEntry -TenantId $TenantId -Status $status -IsTrial ([bool]$isTrial) -ExpiresOn $expires
        }
        else {
            Save-ALbuildLicenseCacheEntry -TenantId $TenantId -Clear
        }
    }
    catch {
        # Every attempt failed. Fall back to a previously-verified license within the grace window so a
        # trusted agent that has verified before keeps working through a transient outage. (An agent
        # that can never reach the service should use ALBUILD_LICENSE_KEY.)
        #
        # Name the failure precisely. A status code means the service answered and refused, and
        # reporting that as 'unreachable' sends whoever reads the log hunting a network problem that
        # does not exist.
        $failureStatus = Get-ALbuildHttpStatusCode -ErrorRecord $_
        $failureText = if ($failureStatus -gt 0) {
            "The licensing service rejected the request with HTTP $failureStatus (url '$url')."
        }
        else {
            "Could not reach the licensing service: $($_.Exception.Message) (url '$url')."
        }
        $graceDays = [int](Get-ALbuildConfig -Name 'LicenseGraceDays')
        $cached = if ($graceDays -gt 0) { Get-ALbuildLicenseCacheEntry -TenantId $TenantId } else { $null }
        if ($cached -and ((((Get-Date) - $cached.VerifiedAt).TotalDays) -le $graceDays)) {
            $ageDays = [int]((Get-Date) - $cached.VerifiedAt).TotalDays
            Write-ALbuildLog -Level Warning "$failureText Using the cached license verified $ageDays day(s) ago (grace window $graceDays days)."
            $result = [PSCustomObject]@{
                IsValid = $true; Status = 'Cached'; IsTrial = $cached.IsTrial; ExpiresOn = $cached.ExpiresOn
                TenantId = $TenantId; TenantName = $normalizedName
                Reason = 'Using the cached license (licensing service unreachable).'
            }
        }
        else {
            $result = [PSCustomObject]@{
                IsValid = $false; Status = 'Error'; IsTrial = $false; ExpiresOn = $null
                TenantId = $TenantId; TenantName = $normalizedName
                Reason = "$failureText Set ALBUILD_LICENSE_KEY on a trusted agent that cannot reach the service."
            }
        }
    }

    $script:ALbuildLicenseCache[$TenantId] = $result
    return $result
}