$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] $logoutPath = '/genealogy/app/auth/logout' function Add-Issue { param([string]$Message) $script:issues.Add($Message) } 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 Get-Response { param([object]$Operation, [string]$Status) if (-not $Operation) { return $null } $property = $Operation.responses.PSObject.Properties[$Status] if (-not $property) { Add-Issue "JSON DELETE $logoutPath missing $Status response" return $null } $response = $property.Value if ($response.'$ref') { $name = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing response owner: $name" return $null } $response = $owner.Value } return $response } function Get-ResponseSchemaRef { param([object]$Operation, [string]$Status) $response = Get-Response $Operation $Status if (-not $response) { return '' } $media = $response.content.PSObject.Properties['application/json'] if (-not $media) { Add-Issue "JSON DELETE $logoutPath $Status must use application/json" return '' } return [string]$media.Value.schema.'$ref' } function Assert-PrivateNoStore { param([object]$Response, [string]$Status) if (-not $Response) { return } $property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $property) { Add-Issue "JSON DELETE $logoutPath $Status must document Cache-Control: private, no-store" return } $header = $property.Value if ($header.'$ref') { $name = ([string]$header.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$name] if ($owner) { $header = $owner.Value } } $evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example) if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') { Add-Issue "JSON DELETE $logoutPath $Status Cache-Control must specify private, no-store" } } $pathProperty = $document.paths.PSObject.Properties[$logoutPath] $operation = if ($pathProperty) { $pathProperty.Value.delete } else { $null } if (-not $operation) { Add-Issue "JSON missing DELETE $logoutPath" } if ($operation) { $hasSaToken = $false foreach ($requirement in @($operation.security)) { if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true } } if (-not $hasSaToken) { Add-Issue "JSON DELETE $logoutPath must require SaToken" } $clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or $clientHeaders[0].schema.type -ne 'string' -or [int]$clientHeaders[0].schema.minLength -lt 1) { Add-Issue "JSON DELETE $logoutPath must require one non-empty string clientid header" } if ($operation.requestBody) { Add-Issue "JSON DELETE $logoutPath must not accept a request body" } $semantics = [string]$operation.description foreach ($semanticPattern in @( '(?i)presented (access token|credential family)', '(?i)other device sessions remain valid', '(?i)repeated.*(idempotent|no additional side effects)', '(?i)active.*revoked.*expired.*same 200', '(?i)successful revocation.*token.*rejected' )) { if ($semantics -notmatch $semanticPattern) { Add-Issue "JSON DELETE $logoutPath description is missing scope/idempotency semantics: $semanticPattern" } } foreach ($status in @('200', '400', '401', '429', '500')) { if (-not $operation.responses.PSObject.Properties[$status]) { Add-Issue "JSON DELETE $logoutPath missing documented response: $status" } } } $successResponse = Get-Response $operation '200' $terminalResponse = Get-Response $operation '401' $successRef = Get-ResponseSchemaRef $operation '200' $terminalRef = Get-ResponseSchemaRef $operation '401' if ($successRef -ne '#/components/schemas/RVoid') { Add-Issue "JSON DELETE $logoutPath 200 must return RVoid; actual: $successRef" } if ($terminalRef -ne '#/components/schemas/RLogoutRejected') { Add-Issue "JSON DELETE $logoutPath 401 must return RLogoutRejected; actual: $terminalRef" } Assert-PrivateNoStore $successResponse '200' Assert-PrivateNoStore $terminalResponse '401' $void = Get-Schema 'RVoid' $terminal = Get-Schema 'RLogoutRejected' if ($void) { if ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer') { Add-Issue 'JSON RVoid must require integer code' } } if ($terminal) { foreach ($field in @('code', 'businessCode')) { if ($field -notin @($terminal.required)) { Add-Issue "JSON RLogoutRejected.required missing: $field" } } $codes = @($terminal.properties.businessCode.enum | Sort-Object) if ($terminal.properties.code.type -ne 'integer' -or $terminal.properties.businessCode.type -ne 'string' -or ($codes -join ',') -ne 'TOKEN_CLIENT_MISMATCH,TOKEN_INVALID') { Add-Issue 'JSON RLogoutRejected must expose only TOKEN_CLIENT_MISMATCH/TOKEN_INVALID rejection codes' } } foreach ($yamlFact in @( ' /genealogy/app/auth/logout:', ' delete:', ' name: clientid', '#/components/schemas/RVoid', '#/components/schemas/RLogoutRejected', ' RVoid:', ' RLogoutRejected:', ' - TOKEN_CLIENT_MISMATCH', ' - TOKEN_INVALID', ' Cache-Control:' )) { 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('LOGOUT-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Logout revokes only the presented current-device credential family; other device sessions remain valid.') $lines.Add('- Runtime proof must show that a token cannot access a protected endpoint after 200, while a second-device token still can.') $lines.Add('- The client starts DELETE with an in-memory token snapshot, immediately clears local session once, never restores it, and never persists a retry token.') $lines.Add('- Active, already-revoked, and expired credentials issued for this client all converge to the same 200 RVoid; 401 is only TOKEN_INVALID/TOKEN_CLIENT_MISMATCH and is not success.') $lines.Add('- Generic 401, network, timeout, malformed responses, and 5xx mean remote revocation is unconfirmed; only a valid 200 response confirms server-side termination.') $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 'LOGOUT-OPENAPI-CONTRACT PASS'