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.
 
        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"

    # 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-RestMethod -Uri $url -Method Get -TimeoutSec 30 -ErrorAction Stop
        $license  = Get-Field $response 'license'
        $status   = [string](Get-Field $response 'status')
        $isOk     = $status -and ($status.ToLowerInvariant() -eq 'ok')

        $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 {
        # Service unreachable (network/timeout). 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.)
        $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 "Licensing service unreachable ($($_.Exception.Message)); 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 = "Could not reach the licensing service: $($_.Exception.Message). Set ALBUILD_LICENSE_KEY on a trusted agent that cannot reach the service."
            }
        }
    }

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