$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml') $issues = New-Object System.Collections.Generic.List[string] function Add-Issue { param([string]$Message) $script:issues.Add($Message) } function Get-Post { param([string]$Path) $pathProperty = $document.paths.PSObject.Properties[$Path] if (-not $pathProperty -or -not $pathProperty.Value.post) { Add-Issue "JSON missing POST $Path" return $null } return $pathProperty.Value.post } function Get-Schema { param([string]$Name) $property = $document.components.schemas.PSObject.Properties[$Name] if (-not $property) { Add-Issue "JSON missing schema owner: $Name" return $null } return $property.Value } function Assert-SecurityAndClient { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $hasSaToken = $false foreach ($requirement in @($Operation.security)) { if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true } } if (-not $hasSaToken) { Add-Issue "JSON $Label must require SaToken" } $client = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($client.Count -ne 1 -or $client[0].required -ne $true -or $client[0].schema.type -ne 'string') { Add-Issue "JSON $Label must require one string clientid header" } } function Get-ResponseRef { param([object]$Operation, [string]$Label) if (-not $Operation) { return '' } $response = $Operation.responses.PSObject.Properties['200'].Value if (-not $response) { Add-Issue "JSON $Label missing 200 response" return '' } if ($response.'$ref') { $responseName = ([string]$response.'$ref').Split('/')[-1] $response = $document.components.responses.PSObject.Properties[$responseName].Value } $media = @($response.content.PSObject.Properties) if ($media.Count -eq 0) { Add-Issue "JSON $Label missing response content" return '' } return [string]$media[0].Value.schema.'$ref' } $singlePath = '/genealogy/app/notifications/{notificationId}/read' $allPath = '/genealogy/app/notifications/read-all' $notificationIdPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' $single = Get-Post $singlePath $all = Get-Post $allPath Assert-SecurityAndClient $single "POST $singlePath" Assert-SecurityAndClient $all "POST $allPath" if ((Get-ResponseRef $single "POST $singlePath") -ne '#/components/schemas/RVoid') { Add-Issue "JSON POST $singlePath must return RVoid" } if ((Get-ResponseRef $all "POST $allPath") -ne '#/components/schemas/RVoid') { Add-Issue "JSON POST $allPath must return RVoid" } if ($single) { $ids = @($single.parameters | Where-Object { $_.name -eq 'notificationId' -and $_.in -eq 'path' }) if ($ids.Count -ne 1 -or $ids[0].required -ne $true -or $ids[0].schema.type -ne 'string' -or $ids[0].schema.minLength -ne 1 -or $ids[0].schema.maxLength -ne 128 -or $ids[0].schema.pattern -ne $notificationIdPattern) { Add-Issue 'JSON notificationId path must be a required 1..128 URL-safe opaque string' } if ($single.requestBody) { Add-Issue 'JSON mark-read operation must not accept a request body' } } if ($all -and $all.requestBody) { Add-Issue 'JSON read-all operation must not accept a request body' } $void = Get-Schema 'RVoid' if ($void) { if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') { Add-Issue 'JSON RVoid must require integer code' } } $notice = Get-Schema 'NotificationVo' if ($notice) { if ('notificationId' -notin @($notice.required)) { Add-Issue 'JSON NotificationVo.required missing: notificationId' } $id = $notice.properties.notificationId if ($id.type -ne 'string' -or [int]$id.minLength -ne 1 -or [int]$id.maxLength -ne 128 -or $id.pattern -ne $notificationIdPattern) { Add-Issue 'JSON NotificationVo.notificationId must be the same 1..128 URL-safe opaque string' } } foreach ($yamlFact in @( ' /genealogy/app/notifications/{notificationId}/read:', ' /genealogy/app/notifications/read-all:', '#/components/schemas/RVoid', ' NotificationVo:', ' RVoid:', ' - notificationId', ' - code' )) { if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" } } if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Both operations must be idempotent for the current account; repeated calls return success without duplicate side effects.') $lines.Add('- The backend must define cross-account 403/404 behavior, read-all cutoff semantics, concurrent new-message behavior, and count/list refresh rules.') $lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.') throw ($lines -join [Environment]::NewLine) } Write-Output 'NOTIFICATION-READ-STATE-OPENAPI-CONTRACT PASS'