param( [switch]$SkipProtectedParity, [switch]$ReturnIssues, [string]$DocumentPath = '' ) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot if ([string]::IsNullOrWhiteSpace($DocumentPath)) { $DocumentPath = Join-Path $root 'APP.openapi.json' } $document = Get-Content -Raw -Encoding UTF8 -LiteralPath $DocumentPath | ConvertFrom-Json $issues = New-Object System.Collections.Generic.List[string] $feedListPath = '/genealogy/app/genealogies/{genealogyId}/feeds' $feedDetailPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}' $rootCommentPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments' $legacyFeedPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/page' $legacyCommentPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/page' $identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' $cursorPattern = '^[A-Za-z0-9_-]{1,512}$' $operations = @( [pscustomobject]@{ Path = $feedListPath Method = 'get' Id = 'appListFamilyFeeds' Parameters = @('header:clientid', 'path:genealogyId', 'query:cursor', 'query:limit') SuccessRef = '#/components/schemas/RAppFamilyFeedCursorPage' CursorRef = '#/components/schemas/FamilyFeedCursor' CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple') CursorOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL') }, [pscustomobject]@{ Path = $feedDetailPath Method = 'get' Id = 'appGetFamilyFeed' Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId') SuccessRef = '#/components/schemas/RAppFamilyFeedReadItem' CursorRef = '' CursorScope = @() CursorOrder = @() }, [pscustomobject]@{ Path = $rootCommentPath Method = 'get' Id = 'appListFamilyFeedRootComments' Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId', 'query:cursor', 'query:limit') SuccessRef = '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' CursorRef = '#/components/schemas/FamilyFeedRootCommentCursor' CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple') CursorOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL') } ) $responseRefs = [ordered]@{ '400' = '#/components/schemas/RFamilyFeedReadBadRequest' '401' = '#/components/schemas/RFamilyFeedReadUnauthorized' '404' = '#/components/schemas/RFamilyFeedReadNotFound' '429' = '#/components/schemas/RFamilyFeedReadRateLimited' '500' = '#/components/schemas/RFamilyFeedReadUnavailable' } function Add-Issue([string]$Message) { $script:issues.Add($Message) } function Get-ExactProperty([object]$Object, [string]$Name) { if ($null -eq $Object) { return $null } $matches = @($Object.PSObject.Properties | Where-Object { $_.Name -ceq $Name }) if ($matches.Count -eq 1) { return $matches[0] } return $null } function Test-ContainsExact([object]$Object, [string]$Name) { return $null -ne (Get-ExactProperty $Object $Name) } function Test-IsJsonArray([object]$Value) { return $null -ne $Value -and $Value.GetType().IsArray } function Test-IsJsonBoolean([object]$Value, [bool]$Expected) { return $Value -is [System.Boolean] -and $Value -eq $Expected } function Test-ContainsExactString([object[]]$Values, [string]$Candidate) { foreach ($value in @($Values)) { if ([string]$value -ceq $Candidate) { return $true } } return $false } function Assert-CanonicalKeyCasing( [object]$Object, [string[]]$CanonicalNames, [string]$Label, [bool]$AllowExtensions = $true ) { if (-not $Object) { return } foreach ($property in @($Object.PSObject.Properties)) { if ($AllowExtensions -and $property.Name.StartsWith('x-', [System.StringComparison]::Ordinal)) { continue } if (Test-ContainsExactString $CanonicalNames $property.Name) { continue } if (@($CanonicalNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) { Add-Issue "$Label keyword casing is invalid: $($property.Name)" } else { Add-Issue "$Label contains an unowned keyword: $($property.Name)" } } } function Assert-AllowedOperationKeys( [object]$Object, [string[]]$ExpectedExtensions, [string]$Label ) { if (-not $Object) { return } $standardNames = @( 'tags', 'summary', 'description', 'externalDocs', 'operationId', 'parameters', 'requestBody', 'responses', 'callbacks', 'deprecated', 'security', 'servers' ) foreach ($property in @($Object.PSObject.Properties)) { if ((Test-ContainsExactString $standardNames $property.Name) -or (Test-ContainsExactString $ExpectedExtensions $property.Name)) { continue } $allNames = @($standardNames) + @($ExpectedExtensions) if (@($allNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) { Add-Issue "$Label operation keyword casing is invalid: $($property.Name)" } else { Add-Issue "$Label operation contains an unowned keyword: $($property.Name)" } } } function Test-IsNonNullable([object]$Schema) { if (-not $Schema) { return $false } $nullable = Get-ExactProperty $Schema 'nullable' return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false) } function Assert-ExactArray([object]$Value, [string[]]$Expected, [string]$Label) { if (-not (Test-IsJsonArray $Value)) { Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]" return } $actual = @($Value) if ($actual.Count -ne $Expected.Count) { Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]" return } for ($index = 0; $index -lt $Expected.Count; $index++) { if ($actual[$index] -isnot [string] -or [string]$actual[$index] -cne $Expected[$index]) { Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]" return } } } function Get-LocalComponentName([string]$Ref, [string]$Section, [string]$Label) { $pattern = '^#/components/' + [regex]::Escape($Section) + '/(?[^/]+)$' $match = [regex]::Match($Ref, $pattern) if (-not $match.Success) { Add-Issue "$Label must use an exact local #/components/$Section/... ref; actual: $Ref" return '' } return $match.Groups['name'].Value } function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) { if (-not $Schema) { return } foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) { if (-not (Test-ContainsExactString $Allowed $keyword) -and (Test-ContainsExact $Schema $keyword)) { Add-Issue "$Label must not define conflicting schema keyword: $keyword" } } } function Assert-AllowedSchemaKeywords( [object]$Schema, [string]$Label, [string[]]$Allowed, [string[]]$AllowedExtensions = @() ) { if (-not $Schema) { return } $annotations = @('title', 'description', 'example', 'examples', 'deprecated', 'externalDocs', 'xml') foreach ($property in @($Schema.PSObject.Properties)) { if ((Test-ContainsExactString $annotations $property.Name) -or (Test-ContainsExactString $Allowed $property.Name) -or (Test-ContainsExactString $AllowedExtensions $property.Name)) { continue } Add-Issue "$Label contains an unowned schema keyword: $($property.Name)" } } function Test-IsPureComponentRef( [object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label ) { if (-not $Value) { return $false } $names = @($Value.PSObject.Properties.Name) $actualRef = [string]$Value.'$ref' if ($names.Count -ne 1 -or $names[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) { Add-Issue "$Label must be the sole exact local ref $ExpectedRef; actual: $actualRef" return $false } [void](Get-LocalComponentName $actualRef $Section $Label) return $true } function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) { return Test-IsPureComponentRef $Schema $ExpectedRef 'schemas' $Label } function Get-Schema([string]$Name) { $schemas = Get-ExactProperty $document.components 'schemas' $property = if ($schemas) { Get-ExactProperty $schemas.Value $Name } else { $null } if (-not $property) { Add-Issue "missing schema owner: $Name" return $null } return $property.Value } function Assert-CanonicalPathItemKeys([string]$Path) { $pathProperty = Get-ExactProperty $document.paths $Path if (-not $pathProperty) { return } $canonical = @('$ref', 'summary', 'description', 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'servers', 'parameters') foreach ($property in @($pathProperty.Value.PSObject.Properties)) { if ($property.Name.StartsWith('x-', [System.StringComparison]::Ordinal) -or (Test-ContainsExactString $canonical $property.Name)) { continue } if (@($canonical | Where-Object { $_ -ieq $property.Name }).Count -gt 0) { Add-Issue "$Path Path Item keyword casing is invalid: $($property.Name)" } else { Add-Issue "$Path Path Item contains an unowned keyword: $($property.Name)" } } } function Get-Operation([string]$Path, [string]$Method) { $pathProperty = Get-ExactProperty $document.paths $Path if (-not $pathProperty) { Add-Issue "missing path: $Path" return $null } Assert-CanonicalPathItemKeys $Path $operationProperty = Get-ExactProperty $pathProperty.Value $Method if (-not $operationProperty) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path" return $null } return $operationProperty.Value } function Resolve-Parameter([object]$Parameter, [string]$Label) { if (-not $Parameter) { return $null } $refProperty = Get-ExactProperty $Parameter '$ref' if (-not $refProperty) { return $Parameter } $referenceKeys = @($Parameter.PSObject.Properties.Name) if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') { Add-Issue "$Label parameter ref must contain only its exact local `$ref under OpenAPI 3.0.1" } $name = Get-LocalComponentName ([string]$refProperty.Value) 'parameters' $Label if (-not $name) { return $null } $parameters = Get-ExactProperty $document.components 'parameters' $owner = if ($parameters) { Get-ExactProperty $parameters.Value $name } else { $null } if (-not $owner) { Add-Issue "$Label references missing parameter owner: $name" return $null } return $owner.Value } function Assert-ParameterObject([object]$Parameter, [string]$Label) { if (-not $Parameter) { return } $allowed = @( 'name', 'in', 'description', 'required', 'deprecated', 'schema', 'example', 'examples' ) foreach ($property in @($Parameter.PSObject.Properties)) { if (Test-ContainsExactString $allowed $property.Name) { continue } if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) { Add-Issue "$Label Parameter Object keyword casing is invalid: $($property.Name)" } else { Add-Issue "$Label Parameter Object contains an unowned keyword: $($property.Name)" } } $schemaProperty = Get-ExactProperty $Parameter 'schema' if (-not $schemaProperty) { Add-Issue "$Label Parameter Object must define exact schema" } } function Get-OperationParameters([string]$Path, [object]$Operation, [string]$Label) { if (-not $Operation) { return @() } $pathItem = (Get-ExactProperty $document.paths $Path).Value $merged = [System.Collections.Generic.Dictionary[string, object]]::new( [System.StringComparer]::Ordinal ) foreach ($entry in @( [pscustomobject]@{ Scope = 'path'; Owner = $pathItem }, [pscustomobject]@{ Scope = 'operation'; Owner = $Operation } )) { $parametersProperty = Get-ExactProperty $entry.Owner 'parameters' if (-not $parametersProperty) { continue } if (-not (Test-IsJsonArray $parametersProperty.Value)) { Add-Issue "$Label $($entry.Scope) parameters must be a JSON array" continue } $scopeSeen = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::Ordinal ) foreach ($parameter in @($parametersProperty.Value)) { $resolved = Resolve-Parameter $parameter "$Label $($entry.Scope)" if (-not $resolved) { continue } $nameProperty = Get-ExactProperty $resolved 'name' $inProperty = Get-ExactProperty $resolved 'in' if (-not $nameProperty -or -not $inProperty) { Add-Issue "$Label $($entry.Scope) parameter must define exact name and in" continue } Assert-ParameterObject $resolved "$Label $($entry.Scope) $([string]$inProperty.Value):$([string]$nameProperty.Value)" $parameterIn = [string]$inProperty.Value $parameterName = [string]$nameProperty.Value if (-not (Test-ContainsExactString @('query', 'header', 'path', 'cookie') $parameterIn) -or [string]::IsNullOrWhiteSpace($parameterName)) { Add-Issue "$Label $($entry.Scope) parameter identity is invalid: ${parameterIn}:${parameterName}" continue } $identityName = if ($parameterIn -ceq 'header') { $parameterName.ToLowerInvariant() } else { $parameterName } $identity = "${parameterIn}:${identityName}" if (-not $scopeSeen.Add($identity)) { Add-Issue "$Label contains a duplicate parameter in one scope: ${parameterIn}:${parameterName}" continue } $merged[$identity] = $resolved } } return @($merged.Values) } function Get-Parameter([object[]]$Parameters, [string]$In, [string]$Name, [string]$Label) { $matches = @($Parameters | Where-Object { [string]$_.in -ceq $In -and [string]$_.name -ceq $Name }) if ($matches.Count -ne 1) { Add-Issue "$Label must define exactly one ${In}:${Name}" return $null } return $matches[0] } function Assert-ExactParameters([object[]]$Parameters, [string[]]$Expected, [string]$Label) { $actual = @($Parameters | ForEach-Object { "$([string]$_.in):$([string]$_.name)" } | Sort-Object) $wanted = @($Expected | Sort-Object) if (($actual -join ',') -cne ($wanted -join ',')) { Add-Issue "$Label parameters must be exactly [$($wanted -join ',')]; actual: [$($actual -join ',')]" } } function Assert-ParameterRequired([object]$Parameter, [bool]$Expected, [string]$Label) { if (-not $Parameter) { return } $required = Get-ExactProperty $Parameter 'required' if (-not $required -or -not (Test-IsJsonBoolean $required.Value $Expected)) { Add-Issue "$Label required must be the JSON boolean $Expected" } } function Resolve-Response([object]$Response, [string]$Label) { if (-not $Response) { return $null } $refProperty = Get-ExactProperty $Response '$ref' if (-not $refProperty) { return $Response } $referenceKeys = @($Response.PSObject.Properties.Name) if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') { Add-Issue "$Label response ref must contain only its exact local `$ref under OpenAPI 3.0.1" } $name = Get-LocalComponentName ([string]$refProperty.Value) 'responses' $Label if (-not $name) { return $null } $responses = Get-ExactProperty $document.components 'responses' $owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null } if (-not $owner) { Add-Issue "$Label references missing response owner: $name" return $null } return $owner.Value } function Resolve-Header([object]$Header, [string]$Label) { if (-not $Header) { return $null } $refProperty = Get-ExactProperty $Header '$ref' if (-not $refProperty) { return $Header } $referenceKeys = @($Header.PSObject.Properties.Name) if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') { Add-Issue "$Label header ref must contain only its exact local `$ref under OpenAPI 3.0.1" } $name = Get-LocalComponentName ([string]$refProperty.Value) 'headers' $Label if (-not $name) { return $null } $headers = Get-ExactProperty $document.components 'headers' $owner = if ($headers) { Get-ExactProperty $headers.Value $name } else { $null } if (-not $owner) { Add-Issue "$Label references missing header owner: $name" return $null } return $owner.Value } function Get-Response([object]$Operation, [string]$Status, [string]$Label) { if (-not $Operation) { return $null } $responsesProperty = Get-ExactProperty $Operation 'responses' $property = if ($responsesProperty) { Get-ExactProperty $responsesProperty.Value $Status } else { $null } if (-not $property) { Add-Issue "$Label missing response $Status" return $null } return Resolve-Response $property.Value "$Label $Status" } function Assert-ExactResponseSet([object]$Operation, [string]$Label) { if (-not $Operation) { return } $responsesProperty = Get-ExactProperty $Operation 'responses' if (-not $responsesProperty) { Add-Issue "$Label must define responses" return } $actual = @($responsesProperty.Value.PSObject.Properties.Name | Sort-Object) $wanted = @('200', '400', '401', '404', '429', '500') if (($actual -join ',') -cne ($wanted -join ',')) { Add-Issue "$Label responses must be exactly [$($wanted -join ',')]; actual: [$($actual -join ',')]" } } function Get-JsonResponseSchemaRef([object]$Response, [string]$Label) { if (-not $Response) { return '' } $contentProperty = Get-ExactProperty $Response 'content' $media = if ($contentProperty) { @($contentProperty.Value.PSObject.Properties) } else { @() } if ($media.Count -ne 1 -or $media[0].Name -cne 'application/json') { Add-Issue "$Label must expose only application/json" return '' } $schemaProperty = Get-ExactProperty $media[0].Value 'schema' if (-not $schemaProperty) { Add-Issue "$Label must define a response schema" return '' } $schema = $schemaProperty.Value $ref = [string]$schema.'$ref' if (-not $ref) { Add-Issue "$Label response schema must use an exact local component ref" return '' } [void](Test-IsPureSchemaRef $schema $ref "$Label response schema") return $ref } function Assert-PrivateNoStore([object]$Response, [string]$Label) { if (-not $Response) { return } $headersProperty = Get-ExactProperty $Response 'headers' $cacheProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Cache-Control' } else { $null } if (-not $cacheProperty) { Add-Issue "$Label must define Cache-Control: private, no-store" return } $header = Resolve-Header $cacheProperty.Value "$Label Cache-Control" if (-not $header) { return } foreach ($property in @($header.PSObject.Properties)) { if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) { Add-Issue "$Label Cache-Control contains an unowned Header Object keyword: $($property.Name)" } } $schemaProperty = Get-ExactProperty $header 'schema' $schema = if ($schemaProperty) { $schemaProperty.Value } else { $null } if (-not $schema -or [string]$schema.type -cne 'string' -or -not (Test-IsNonNullable $schema) -or -not (Test-IsJsonArray $schema.enum) -or @($schema.enum).Count -ne 1 -or [string]$schema.enum[0] -cne 'private, no-store') { Add-Issue "$Label Cache-Control must be the single non-null value private, no-store" } if ($schema) { Assert-NoConflictingSchemaKeywords $schema "$Label Cache-Control" @('enum') Assert-AllowedSchemaKeywords $schema "$Label Cache-Control" @('type', 'enum', 'nullable') } } function Assert-RetryAfter([object]$Response, [string]$Label) { if (-not $Response) { return } $headersProperty = Get-ExactProperty $Response 'headers' $retryProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Retry-After' } else { $null } if (-not $retryProperty) { Add-Issue "$Label must define Retry-After" return } $header = Resolve-Header $retryProperty.Value "$Label Retry-After" if (-not $header) { return } foreach ($property in @($header.PSObject.Properties)) { if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) { Add-Issue "$Label Retry-After contains an unowned Header Object keyword: $($property.Name)" } } $schemaProperty = Get-ExactProperty $header 'schema' $schema = if ($schemaProperty) { $schemaProperty.Value } else { $null } if (-not $schema -or [string]$schema.type -cne 'integer' -or -not (Test-IsNonNullable $schema) -or [int]$schema.minimum -ne 1 -or [int]$schema.maximum -ne 300) { Add-Issue "$Label Retry-After must be a non-null integer in 1..300 seconds" } if ($schema) { Assert-NoConflictingSchemaKeywords $schema "$Label Retry-After" Assert-AllowedSchemaKeywords $schema "$Label Retry-After" @('type', 'minimum', 'maximum', 'nullable') } } function Assert-ResponseHeaders([object]$Response, [string]$Status, [string]$Label) { if (-not $Response) { return } foreach ($property in @($Response.PSObject.Properties)) { if (-not (Test-ContainsExactString @('description', 'headers', 'content') $property.Name)) { Add-Issue "$Label contains an unowned Response Object keyword: $($property.Name)" } } $headersProperty = Get-ExactProperty $Response 'headers' $headerNames = if ($headersProperty) { @($headersProperty.Value.PSObject.Properties.Name) } else { @() } $allowed = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id') if ($Status -ceq '429') { $allowed += 'Retry-After' } $unexpected = @($headerNames | Where-Object { -not (Test-ContainsExactString $allowed $_) }) if ($unexpected.Count -gt 0) { Add-Issue "$Label contains unowned response headers: $($unexpected -join ',')" } Assert-PrivateNoStore $Response $Label if ($Status -ceq '429') { Assert-RetryAfter $Response $Label } } function Assert-SaToken([object]$Operation, [string]$Label) { if (-not $Operation) { return } $securityProperty = Get-ExactProperty $Operation 'security' if (-not $securityProperty -or -not (Test-IsJsonArray $securityProperty.Value)) { Add-Issue "$Label security must be a JSON array" return } $requirements = @($securityProperty.Value) if ($requirements.Count -ne 1) { Add-Issue "$Label must have exactly one security requirement" return } $names = @($requirements[0].PSObject.Properties.Name) if ($names.Count -ne 1 -or $names[0] -cne 'SaToken') { Add-Issue "$Label must require only exact SaToken" return } $scopes = $requirements[0].SaToken if (-not (Test-IsJsonArray $scopes) -or @($scopes).Count -ne 0) { Add-Issue "$Label SaToken scopes must be an empty JSON array" } } function Assert-SaTokenScheme { $componentsProperty = Get-ExactProperty $document 'components' $schemesProperty = if ($componentsProperty) { Get-ExactProperty $componentsProperty.Value 'securitySchemes' } else { $null } $schemeProperty = if ($schemesProperty) { Get-ExactProperty $schemesProperty.Value 'SaToken' } else { $null } if (-not $schemeProperty) { Add-Issue 'components.securitySchemes.SaToken must be the exact security owner' return } $scheme = $schemeProperty.Value $allowed = @('type', 'description', 'name', 'in') foreach ($property in @($scheme.PSObject.Properties)) { if (Test-ContainsExactString $allowed $property.Name) { continue } if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) { Add-Issue "SaToken Security Scheme keyword casing is invalid: $($property.Name)" } else { Add-Issue "SaToken Security Scheme contains an unowned keyword: $($property.Name)" } } $typeProperty = Get-ExactProperty $scheme 'type' $inProperty = Get-ExactProperty $scheme 'in' $nameProperty = Get-ExactProperty $scheme 'name' if (-not $typeProperty -or [string]$typeProperty.Value -cne 'apiKey' -or -not $inProperty -or [string]$inProperty.Value -cne 'header' -or -not $nameProperty -or [string]$nameProperty.Value -cne 'Authorization') { Add-Issue 'SaToken must be the exact apiKey/header/Authorization security owner' } } function Assert-ClosedObject( [object]$Schema, [string]$Name, [string[]]$Fields, [string[]]$Required, [string[]]$AllowedExtensions = @() ) { if (-not $Schema) { return } $propertiesProperty = Get-ExactProperty $Schema 'properties' $requiredProperty = Get-ExactProperty $Schema 'required' $additionalProperty = Get-ExactProperty $Schema 'additionalProperties' $actualFields = if ($propertiesProperty) { @($propertiesProperty.Value.PSObject.Properties.Name | Sort-Object) } else { @() } $actualRequired = if ($requiredProperty -and (Test-IsJsonArray $requiredProperty.Value)) { @($requiredProperty.Value | Sort-Object) } else { @() } $expectedFields = @($Fields | Sort-Object) $expectedRequired = @($Required | Sort-Object) if ([string]$Schema.type -cne 'object' -or -not (Test-IsNonNullable $Schema) -or -not $additionalProperty -or -not (Test-IsJsonBoolean $additionalProperty.Value $false) -or ($actualFields -join ',') -cne ($expectedFields -join ',') -or ($actualRequired -join ',') -cne ($expectedRequired -join ',')) { Add-Issue "$Name must be closed with fields [$($expectedFields -join ',')] and required [$($expectedRequired -join ',')]" } Assert-NoConflictingSchemaKeywords $Schema $Name Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') $AllowedExtensions } function Assert-PropertyRef([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef) { if (-not $Schema) { return } $properties = Get-ExactProperty $Schema 'properties' $property = if ($properties) { Get-ExactProperty $properties.Value $Field } else { $null } if (-not $property) { Add-Issue "$SchemaName.$Field must use $ExpectedRef" return } [void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field") } function Assert-StringOwner( [string]$Name, [int]$MinLength, [int]$MaxLength, [string]$Pattern = '', [string[]]$AllowedExtensions = @() ) { $schema = Get-Schema $Name if (-not $schema) { return } if ([string]$schema.type -cne 'string' -or -not (Test-IsNonNullable $schema) -or [int]$schema.minLength -ne $MinLength -or [int]$schema.maxLength -ne $MaxLength -or ($Pattern -and [string]$schema.pattern -cne $Pattern)) { Add-Issue "$Name must be a non-null string length $MinLength..$MaxLength$(if ($Pattern) { ' with its exact pattern' } else { '' })" } Assert-NoConflictingSchemaKeywords $schema $Name $keywords = @('type', 'minLength', 'maxLength', 'nullable') if ($Pattern) { $keywords += 'pattern' } Assert-AllowedSchemaKeywords $schema $Name $keywords $AllowedExtensions return $schema } function Assert-DateTimeOwner([string]$Name) { $schema = Get-Schema $Name if (-not $schema) { return } if ([string]$schema.type -cne 'string' -or [string]$schema.format -cne 'date-time' -or -not (Test-IsNonNullable $schema) -or -not (Test-IsJsonBoolean $schema.'x-server-generated' $true) -or -not (Test-IsJsonBoolean $schema.'x-immutable' $true)) { Add-Issue "$Name must be a non-null immutable server-generated RFC3339 date-time" } Assert-NoConflictingSchemaKeywords $schema $Name Assert-AllowedSchemaKeywords $schema $Name @('type', 'format', 'nullable') @('x-server-generated', 'x-immutable') } function Assert-SuccessEnvelope([string]$Name, [string]$DataRef) { $schema = Get-Schema $Name Assert-ClosedObject $schema $Name @('code', 'data') @('code', 'data') if (-not $schema) { return } $code = $schema.properties.code if ([string]$code.type -cne 'integer' -or -not (Test-IsNonNullable $code) -or -not (Test-IsJsonArray $code.enum) -or @($code.enum).Count -ne 1 -or [int]$code.enum[0] -ne 200) { Add-Issue "$Name.code must be the single non-null integer value 200" } Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum') Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable') Assert-PropertyRef $schema $Name 'data' $DataRef } function Assert-FixedError([string]$Name, [int]$Status, [string[]]$BusinessCodes) { $schema = Get-Schema $Name Assert-ClosedObject $schema $Name @('businessCode', 'code', 'message') @('businessCode', 'code', 'message') if (-not $schema) { return } $code = $schema.properties.code $businessCode = $schema.properties.businessCode $message = $schema.properties.message if ([string]$code.type -cne 'integer' -or -not (Test-IsNonNullable $code) -or -not (Test-IsJsonArray $code.enum) -or @($code.enum).Count -ne 1 -or [int]$code.enum[0] -ne $Status) { Add-Issue "$Name.code must be the single non-null integer value $Status" } $actualCodes = @($businessCode.enum | Sort-Object) $expectedCodes = @($BusinessCodes | Sort-Object) if ([string]$businessCode.type -cne 'string' -or -not (Test-IsNonNullable $businessCode) -or -not (Test-IsJsonArray $businessCode.enum) -or ($actualCodes -join ',') -cne ($expectedCodes -join ',')) { Add-Issue "$Name.businessCode must be exactly [$($expectedCodes -join ',')]" } if ([string]$message.type -cne 'string' -or -not (Test-IsNonNullable $message) -or [int]$message.minLength -ne 1 -or [int]$message.maxLength -ne 200) { Add-Issue "$Name.message must be a non-null string length 1..200" } Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum') Assert-NoConflictingSchemaKeywords $businessCode "$Name.businessCode" @('enum') Assert-NoConflictingSchemaKeywords $message "$Name.message" Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable') Assert-AllowedSchemaKeywords $businessCode "$Name.businessCode" @('type', 'enum', 'nullable') Assert-AllowedSchemaKeywords $message "$Name.message" @('type', 'minLength', 'maxLength', 'nullable') } function Assert-CursorPage( [string]$Name, [string]$ItemRef, [string]$CursorRef, [string[]]$Order ) { $schema = Get-Schema $Name Assert-ClosedObject $schema $Name @('items', 'nextCursor') @('items') @( 'x-no-total', 'x-next-cursor-absent-at-end', 'x-order', 'x-read-window', 'x-new-items-after-window', 'x-deletion-or-visibility-change', 'x-edit-policy' ) if (-not $schema) { return } $items = $schema.properties.items if ([string]$items.type -cne 'array' -or -not (Test-IsNonNullable $items) -or [int]$items.minItems -ne 0 -or [int]$items.maxItems -ne 50) { Add-Issue "$Name.items must be a non-null array with 0..50 rows" } if ($items) { [void](Test-IsPureSchemaRef $items.items $ItemRef "$Name.items.items") Assert-NoConflictingSchemaKeywords $items "$Name.items" Assert-AllowedSchemaKeywords $items "$Name.items" @('type', 'items', 'minItems', 'maxItems', 'nullable') } Assert-PropertyRef $schema $Name 'nextCursor' $CursorRef $noTotal = Get-ExactProperty $schema 'x-no-total' $absentAtEnd = Get-ExactProperty $schema 'x-next-cursor-absent-at-end' $orderProperty = Get-ExactProperty $schema 'x-order' $readWindow = Get-ExactProperty $schema 'x-read-window' $newItems = Get-ExactProperty $schema 'x-new-items-after-window' $deletion = Get-ExactProperty $schema 'x-deletion-or-visibility-change' $editPolicy = Get-ExactProperty $schema 'x-edit-policy' if (-not $noTotal -or -not (Test-IsJsonBoolean $noTotal.Value $true) -or -not $absentAtEnd -or -not (Test-IsJsonBoolean $absentAtEnd.Value $true) -or -not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or -not $newItems -or [string]$newItems.Value -cne 'EXCLUDED_UNTIL_REFRESH' -or -not $deletion -or [string]$deletion.Value -cne 'OMIT_ON_LATER_PAGE' -or -not $editPolicy -or [string]$editPolicy.Value -cne 'LATEST_VISIBLE_AT_PAGE_READ') { Add-Issue "$Name cursor window/order/no-total semantics drifted" } Assert-ExactArray $(if ($orderProperty) { $orderProperty.Value } else { $null }) $Order "$Name x-order" } function Assert-NoForbiddenSuccessFields([string[]]$RootRefs) { $queue = New-Object System.Collections.Generic.Queue[string] $visited = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal) foreach ($rootRef in $RootRefs) { $name = Get-LocalComponentName $rootRef 'schemas' 'success graph root' if ($name) { $queue.Enqueue($name) } } $forbidden = '(?i)(phone|mobile|tenant|userId|appUser|publisherUser|audit|moderation|reviewer|operator|remark|createBy|updateBy|deleteFlag|userDeleted|parentComment|commentLevel|status|mediaOssId)' while ($queue.Count -gt 0) { $name = $queue.Dequeue() if (-not $visited.Add($name)) { continue } $schema = Get-Schema $name if (-not $schema) { continue } $properties = Get-ExactProperty $schema 'properties' if ($properties) { foreach ($property in @($properties.Value.PSObject.Properties)) { if ($property.Name -match $forbidden) { Add-Issue "success graph leaks forbidden/internal field: $name.$($property.Name)" } $propertyRef = Get-ExactProperty $property.Value '$ref' if ($propertyRef) { $child = Get-LocalComponentName ([string]$propertyRef.Value) 'schemas' "$name.$($property.Name)" if ($child) { $queue.Enqueue($child) } } $itemsProperty = Get-ExactProperty $property.Value 'items' $itemsRef = if ($itemsProperty) { Get-ExactProperty $itemsProperty.Value '$ref' } else { $null } if ($itemsRef) { $child = Get-LocalComponentName ([string]$itemsRef.Value) 'schemas' "$name.$($property.Name).items" if ($child) { $queue.Enqueue($child) } } } } } } function Test-SchemaNodeExposesFamilyFeedRead( [object]$Schema, [System.Collections.Generic.HashSet[string]]$VisitedSchemas, [string]$Label ) { if (-not $Schema) { return $false } $ownedNames = @( 'RAppFamilyFeedCursorPage', 'RAppFamilyFeedReadItem', 'RAppFamilyFeedRootCommentCursorPage', 'AppFamilyFeedCursorPage', 'AppFamilyFeedReadItem', 'AppFamilyFeedRootCommentCursorPage', 'AppFamilyFeedRootCommentReadItem' ) $refProperty = Get-ExactProperty $Schema '$ref' $refCaseVariants = @($Schema.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' }) if (-not $refProperty -and $refCaseVariants.Count -gt 0) { Add-Issue "$Label schema ref keyword casing is invalid" return $false } if ($refProperty) { $match = [regex]::Match( [string]$refProperty.Value, '^#/components/schemas/(?[^/]+)$' ) if (-not $match.Success) { Add-Issue "$Label schema ref is not an inspectable exact local component ref" return $false } $name = $match.Groups['name'].Value if (Test-ContainsExactString $ownedNames $name) { return $true } if (-not $VisitedSchemas.Add($name)) { return $false } $components = Get-ExactProperty $document 'components' $schemas = if ($components) { Get-ExactProperty $components.Value 'schemas' } else { $null } $owner = if ($schemas) { Get-ExactProperty $schemas.Value $name } else { $null } if (-not $owner) { Add-Issue "$Label references a missing schema owner: $name" return $false } return Test-SchemaNodeExposesFamilyFeedRead $owner.Value $VisitedSchemas "$Label -> $name" } $properties = Get-ExactProperty $Schema 'properties' if ($properties) { $fieldNames = @($properties.Value.PSObject.Properties.Name | Sort-Object) $feedProjectionFields = @( @('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt') | Sort-Object ) $commentProjectionFields = @( @('authorDisplayName', 'commentContent', 'commentId', 'publishedAt') | Sort-Object ) $isFeedProjection = ($fieldNames -join ',') -ceq ($feedProjectionFields -join ',') $isCommentProjection = ($fieldNames -join ',') -ceq ($commentProjectionFields -join ',') if ($isFeedProjection -or $isCommentProjection) { return $true } foreach ($property in @($properties.Value.PSObject.Properties)) { if (Test-SchemaNodeExposesFamilyFeedRead $property.Value $VisitedSchemas "$Label.$($property.Name)") { return $true } } } $items = Get-ExactProperty $Schema 'items' if ($items -and (Test-SchemaNodeExposesFamilyFeedRead $items.Value $VisitedSchemas "$Label.items")) { return $true } $additionalProperties = Get-ExactProperty $Schema 'additionalProperties' if ($additionalProperties -and $additionalProperties.Value -isnot [System.Boolean] -and (Test-SchemaNodeExposesFamilyFeedRead $additionalProperties.Value $VisitedSchemas "$Label.additionalProperties")) { return $true } foreach ($compositionName in @('allOf', 'oneOf', 'anyOf')) { $composition = Get-ExactProperty $Schema $compositionName if (-not $composition -or -not (Test-IsJsonArray $composition.Value)) { continue } foreach ($branch in @($composition.Value)) { if (Test-SchemaNodeExposesFamilyFeedRead $branch $VisitedSchemas "$Label.$compositionName") { return $true } } } return $false } function Test-ResponseNodeExposesFamilyFeedRead( [object]$Response, [System.Collections.Generic.HashSet[string]]$VisitedResponses, [System.Collections.Generic.HashSet[string]]$VisitedSchemas, [string]$Label ) { if (-not $Response) { return $false } $refProperty = Get-ExactProperty $Response '$ref' $refCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' }) if (-not $refProperty -and $refCaseVariants.Count -gt 0) { Add-Issue "$Label response ref keyword casing is invalid" return $false } if ($refProperty) { $match = [regex]::Match( [string]$refProperty.Value, '^#/components/responses/(?[^/]+)$' ) if (-not $match.Success) { Add-Issue "$Label response ref is not an inspectable exact local component ref" return $false } $name = $match.Groups['name'].Value if (-not $VisitedResponses.Add($name)) { return $false } $components = Get-ExactProperty $document 'components' $responses = if ($components) { Get-ExactProperty $components.Value 'responses' } else { $null } $owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null } if (-not $owner) { Add-Issue "$Label references a missing response owner: $name" return $false } return Test-ResponseNodeExposesFamilyFeedRead $owner.Value $VisitedResponses $VisitedSchemas "$Label -> $name" } $content = Get-ExactProperty $Response 'content' $contentCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq 'content' }) if (-not $content -and $contentCaseVariants.Count -gt 0) { Add-Issue "$Label Response Object content keyword casing is invalid" return $false } if (-not $content) { return $false } foreach ($media in @($content.Value.PSObject.Properties)) { $schema = Get-ExactProperty $media.Value 'schema' $schemaCaseVariants = @($media.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'schema' }) if (-not $schema -and $schemaCaseVariants.Count -gt 0) { Add-Issue "$Label media schema keyword casing is invalid" continue } if ($schema -and (Test-SchemaNodeExposesFamilyFeedRead $schema.Value $VisitedSchemas "$Label $($media.Name)")) { return $true } } return $false } function Scan-AppCallbackObject( [object]$Callback, [string]$Context, [System.Collections.Generic.HashSet[string]]$VisitedCallbacks ) { if (-not $Callback) { return } $refProperty = Get-ExactProperty $Callback '$ref' if ($refProperty) { $keys = @($Callback.PSObject.Properties.Name) if ($keys.Count -ne 1 -or $keys[0] -cne '$ref') { Add-Issue "$Context callback ref must contain only its exact local `$ref under OpenAPI 3.0.1" } $match = [regex]::Match( [string]$refProperty.Value, '^#/components/callbacks/(?[^/]+)$' ) if (-not $match.Success) { Add-Issue "$Context callback ref is not an inspectable exact local component ref" return } $name = $match.Groups['name'].Value if (-not $VisitedCallbacks.Add($name)) { return } $components = Get-ExactProperty $document 'components' $callbacks = if ($components) { Get-ExactProperty $components.Value 'callbacks' } else { $null } $owner = if ($callbacks) { Get-ExactProperty $callbacks.Value $name } else { $null } if (-not $owner) { Add-Issue "$Context references a missing callback owner: $name" return } Scan-AppCallbackObject $owner.Value "$Context -> $name" $VisitedCallbacks return } foreach ($expression in @($Callback.PSObject.Properties)) { Scan-AppPathItem $expression.Value "$Context callback $($expression.Name)" '' $false $VisitedCallbacks } } function Scan-AppPathItem( [object]$PathItem, [string]$Context, [string]$TopLevelPath, [bool]$IsTopLevel, [System.Collections.Generic.HashSet[string]]$VisitedCallbacks ) { if (-not $PathItem) { return } foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) { $operationProperty = Get-ExactProperty $PathItem $method if (-not $operationProperty) { continue } $operation = $operationProperty.Value $responses = Get-ExactProperty $operation 'responses' if ($responses) { foreach ($responseProperty in @($responses.Value.PSObject.Properties)) { if ($responseProperty.Name -notmatch '^(2[0-9][0-9]|default)$') { continue } $visitedResponses = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::Ordinal ) $visitedSchemas = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::Ordinal ) $exposes = Test-ResponseNodeExposesFamilyFeedRead ` $responseProperty.Value ` $visitedResponses ` $visitedSchemas ` "$Context $($method.ToUpperInvariant()) $($responseProperty.Name)" if (-not $exposes) { continue } $isCanonicalOwner = $IsTopLevel -and $method -ceq 'get' -and $responseProperty.Name -ceq '200' -and (Test-ContainsExactString @($feedListPath, $feedDetailPath, $rootCommentPath) $TopLevelPath) if (-not $isCanonicalOwner) { Add-Issue "alternate APP operation exposes the family-feed read projection: $Context $($method.ToUpperInvariant()) $($responseProperty.Name)" } } } $callbacks = Get-ExactProperty $operation 'callbacks' if ($callbacks) { foreach ($callbackProperty in @($callbacks.Value.PSObject.Properties)) { Scan-AppCallbackObject $callbackProperty.Value "$Context $($method.ToUpperInvariant()) callback $($callbackProperty.Name)" $VisitedCallbacks } } } } if (-not $SkipProtectedParity) { $parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js' $parityOutput = @(& node $parityScript 2>&1) if ($LASTEXITCODE -ne 0 -or -not (Test-ContainsExactString $parityOutput 'OPENAPI-YAML-JSON-PARITY PASS')) { Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')" } } Assert-CanonicalKeyCasing $document @( 'openapi', 'info', 'servers', 'paths', 'components', 'security', 'tags', 'externalDocs' ) 'OpenAPI root' $openapiProperty = Get-ExactProperty $document 'openapi' if (-not $openapiProperty -or [string]$openapiProperty.Value -cne '3.0.1') { Add-Issue 'OpenAPI version must be exact 3.0.1' } $pathsProperty = Get-ExactProperty $document 'paths' if (-not $pathsProperty) { Add-Issue 'OpenAPI root must define exact paths' } $componentsProperty = Get-ExactProperty $document 'components' if (-not $componentsProperty) { Add-Issue 'OpenAPI root must define exact components' } Assert-CanonicalKeyCasing $(if ($componentsProperty) { $componentsProperty.Value } else { $null }) @( 'schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks' ) 'OpenAPI components' Assert-SaTokenScheme foreach ($legacyPath in @($legacyFeedPagePath, $legacyCommentPagePath)) { $pathProperty = Get-ExactProperty $document.paths $legacyPath if (-not $pathProperty) { continue } $legacyGets = @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'get' }) if ($legacyGets.Count -gt 0) { Add-Issue "legacy duplicate GET owner must be removed: $legacyPath" } } $resolvedOperations = @{} foreach ($spec in $operations) { $operation = Get-Operation $spec.Path $spec.Method if (-not $operation) { continue } $resolvedOperations[$spec.Id] = $operation $operationIdProperty = Get-ExactProperty $operation 'operationId' if (-not $operationIdProperty -or [string]$operationIdProperty.Value -cne $spec.Id) { Add-Issue "$($spec.Method.ToUpperInvariant()) $($spec.Path) operationId must be $($spec.Id)" } $extensionNames = @( 'x-read-only', 'x-non-disclosing-not-found', 'x-authorize-every-request', 'x-cors-policy-owner', 'x-authorization-scope' ) if ($spec.CursorRef) { $extensionNames += @( 'x-cursor-scope', 'x-cursor-order', 'x-read-window', 'x-cursor-no-total', 'x-refresh-discards-cursor', 'x-authorize-every-page', 'x-invalid-or-expired-cursor', 'x-cross-scope-cursor' ) } Assert-AllowedOperationKeys $operation $extensionNames $spec.Id $requestBodyProperty = Get-ExactProperty $operation 'requestBody' if ($requestBodyProperty) { Add-Issue "$($spec.Id) is read-only and must not define requestBody" } if (Get-ExactProperty $operation 'callbacks') { Add-Issue "$($spec.Id) is read-only and must not define callbacks" } $pathItem = (Get-ExactProperty $document.paths $spec.Path).Value foreach ($forbiddenMethod in @('head', 'options', 'trace')) { if (Get-ExactProperty $pathItem $forbiddenMethod) { Add-Issue "$($spec.Path) must not expose an explicit $($forbiddenMethod.ToUpperInvariant()) read bypass" } } Assert-SaToken $operation $spec.Id Assert-ExactResponseSet $operation $spec.Id $parameters = @(Get-OperationParameters $spec.Path $operation $spec.Id) Assert-ExactParameters $parameters $spec.Parameters $spec.Id $clientid = Get-Parameter $parameters 'header' 'clientid' $spec.Id Assert-ParameterRequired $clientid $true "$($spec.Id) clientid" if ($clientid) { $clientSchemaProperty = Get-ExactProperty $clientid 'schema' $clientSchema = if ($clientSchemaProperty) { $clientSchemaProperty.Value } else { $null } if ([string]$clientSchema.type -cne 'string' -or -not (Test-IsNonNullable $clientSchema) -or [int]$clientSchema.minLength -ne 1 -or [int]$clientSchema.maxLength -ne 128) { Add-Issue "$($spec.Id) clientid must be a required non-null string length 1..128" } Assert-NoConflictingSchemaKeywords $clientSchema "$($spec.Id) clientid" Assert-AllowedSchemaKeywords $clientSchema "$($spec.Id) clientid" @('type', 'minLength', 'maxLength', 'nullable') } $genealogyId = Get-Parameter $parameters 'path' 'genealogyId' $spec.Id Assert-ParameterRequired $genealogyId $true "$($spec.Id) genealogyId" if ($genealogyId) { $schemaProperty = Get-ExactProperty $genealogyId 'schema' [void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/GenealogyId' "$($spec.Id) genealogyId") } if ($spec.Path -match '\{feedId\}') { $feedId = Get-Parameter $parameters 'path' 'feedId' $spec.Id Assert-ParameterRequired $feedId $true "$($spec.Id) feedId" if ($feedId) { $schemaProperty = Get-ExactProperty $feedId 'schema' [void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/FamilyFeedId' "$($spec.Id) feedId") } } if ($spec.CursorRef) { $cursor = Get-Parameter $parameters 'query' 'cursor' $spec.Id $limit = Get-Parameter $parameters 'query' 'limit' $spec.Id Assert-ParameterRequired $cursor $false "$($spec.Id) cursor" Assert-ParameterRequired $limit $false "$($spec.Id) limit" if ($cursor) { $schemaProperty = Get-ExactProperty $cursor 'schema' [void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) $spec.CursorRef "$($spec.Id) cursor") } if ($limit) { $limitSchemaProperty = Get-ExactProperty $limit 'schema' $limitSchema = if ($limitSchemaProperty) { $limitSchemaProperty.Value } else { $null } if ([string]$limitSchema.type -cne 'integer' -or -not (Test-IsNonNullable $limitSchema) -or [int]$limitSchema.minimum -ne 1 -or [int]$limitSchema.maximum -ne 50 -or [int]$limitSchema.default -ne 20) { Add-Issue "$($spec.Id) limit must be an optional non-null integer default 20 in 1..50" } Assert-NoConflictingSchemaKeywords $limitSchema "$($spec.Id) limit" Assert-AllowedSchemaKeywords $limitSchema "$($spec.Id) limit" @('type', 'minimum', 'maximum', 'default', 'nullable') } $cursorScope = Get-ExactProperty $operation 'x-cursor-scope' $cursorOrder = Get-ExactProperty $operation 'x-cursor-order' $readWindow = Get-ExactProperty $operation 'x-read-window' $cursorNoTotal = Get-ExactProperty $operation 'x-cursor-no-total' $refreshDiscards = Get-ExactProperty $operation 'x-refresh-discards-cursor' $authorizeEveryPage = Get-ExactProperty $operation 'x-authorize-every-page' $invalidCursor = Get-ExactProperty $operation 'x-invalid-or-expired-cursor' $crossScopeCursor = Get-ExactProperty $operation 'x-cross-scope-cursor' Assert-ExactArray $(if ($cursorScope) { $cursorScope.Value } else { $null }) $spec.CursorScope "$($spec.Id) x-cursor-scope" Assert-ExactArray $(if ($cursorOrder) { $cursorOrder.Value } else { $null }) $spec.CursorOrder "$($spec.Id) x-cursor-order" if (-not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or -not $cursorNoTotal -or -not (Test-IsJsonBoolean $cursorNoTotal.Value $true) -or -not $refreshDiscards -or -not (Test-IsJsonBoolean $refreshDiscards.Value $true) -or -not $authorizeEveryPage -or -not (Test-IsJsonBoolean $authorizeEveryPage.Value $true) -or -not $invalidCursor -or [string]$invalidCursor.Value -cne '400_FAMILY_FEED_CURSOR_INVALID' -or -not $crossScopeCursor -or [string]$crossScopeCursor.Value -cne '404_FAMILY_FEED_NOT_AVAILABLE') { Add-Issue "$($spec.Id) cursor/refresh/per-page authorization semantics drifted" } } $readOnly = Get-ExactProperty $operation 'x-read-only' $nonDisclosing = Get-ExactProperty $operation 'x-non-disclosing-not-found' $authorizeEveryRequest = Get-ExactProperty $operation 'x-authorize-every-request' $corsOwner = Get-ExactProperty $operation 'x-cors-policy-owner' if (-not $readOnly -or -not (Test-IsJsonBoolean $readOnly.Value $true) -or -not $nonDisclosing -or -not (Test-IsJsonBoolean $nonDisclosing.Value $true) -or -not $authorizeEveryRequest -or -not (Test-IsJsonBoolean $authorizeEveryRequest.Value $true) -or -not $corsOwner -or [string]$corsOwner.Value -cne 'APP_GATEWAY_PREFLIGHT') { Add-Issue "$($spec.Id) read-only/non-disclosing/auth/CORS extensions drifted" } $expectedAuthorization = if ($spec.Id -ceq 'appListFamilyFeeds') { @('tenant', 'genealogy', 'membership') } else { @('tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility') } $authorizationScope = Get-ExactProperty $operation 'x-authorization-scope' Assert-ExactArray $(if ($authorizationScope) { $authorizationScope.Value } else { $null }) $expectedAuthorization "$($spec.Id) x-authorization-scope" foreach ($status in @('200', '400', '401', '404', '429', '500')) { $response = Get-Response $operation $status $spec.Id if (-not $response) { continue } $actualRef = Get-JsonResponseSchemaRef $response "$($spec.Id) $status" $expectedRef = if ($status -ceq '200') { $spec.SuccessRef } else { $responseRefs[$status] } if ($actualRef -cne $expectedRef) { Add-Issue "$($spec.Id) $status schema ref must be $expectedRef; actual: $actualRef" } Assert-ResponseHeaders $response $status "$($spec.Id) $status" } } $visitedCallbacks = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::Ordinal ) if ($pathsProperty) { foreach ($pathProperty in @($pathsProperty.Value.PSObject.Properties)) { if (-not $pathProperty.Name.StartsWith('/genealogy/app/', [System.StringComparison]::Ordinal)) { continue } Scan-AppPathItem $pathProperty.Value $pathProperty.Name $pathProperty.Name $true $visitedCallbacks } } $allOperationIds = @{} foreach ($pathProperty in @($document.paths.PSObject.Properties)) { foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) { $operationProperty = Get-ExactProperty $pathProperty.Value $method if (-not $operationProperty) { continue } $operationIdProperty = Get-ExactProperty $operationProperty.Value 'operationId' if (-not $operationIdProperty) { continue } $id = [string]$operationIdProperty.Value if (-not $allOperationIds.ContainsKey($id)) { $allOperationIds[$id] = @() } $allOperationIds[$id] += "$($method.ToUpperInvariant()) $($pathProperty.Name)" } } foreach ($spec in $operations) { $owners = @($allOperationIds[$spec.Id]) if ($owners.Count -ne 1 -or $owners[0] -cne "$($spec.Method.ToUpperInvariant()) $($spec.Path)") { Add-Issue "$($spec.Id) must have exactly one global operation owner; actual: $($owners -join ' | ')" } } Assert-StringOwner 'GenealogyId' 1 128 $identifierPattern $feedIdOwner = Assert-StringOwner 'FamilyFeedId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics') if ($feedIdOwner -and (-not (Test-IsJsonBoolean $feedIdOwner.'x-opaque' $true) -or [string]$feedIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) { Add-Issue 'FamilyFeedId must be opaque and used by clients only for exact comparison' } $commentIdOwner = Assert-StringOwner 'FamilyFeedCommentId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics') if ($commentIdOwner -and (-not (Test-IsJsonBoolean $commentIdOwner.'x-opaque' $true) -or [string]$commentIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) { Add-Issue 'FamilyFeedCommentId must be opaque and used by clients only for exact comparison' } $feedCursorOwner = Assert-StringOwner 'FamilyFeedCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose') if ($feedCursorOwner -and (-not (Test-IsJsonBoolean $feedCursorOwner.'x-opaque' $true) -or [string]$feedCursorOwner.'x-purpose' -cne 'FAMILY_FEED_PAGE')) { Add-Issue 'FamilyFeedCursor purpose/opacity drifted' } $commentCursorOwner = Assert-StringOwner 'FamilyFeedRootCommentCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose') if ($commentCursorOwner -and (-not (Test-IsJsonBoolean $commentCursorOwner.'x-opaque' $true) -or [string]$commentCursorOwner.'x-purpose' -cne 'FAMILY_FEED_ROOT_COMMENT_PAGE')) { Add-Issue 'FamilyFeedRootCommentCursor purpose/opacity drifted' } $feedContentOwner = Assert-StringOwner 'FamilyFeedContent' 1 300 '' @('x-text-normalizer', 'x-length-unit') if ($feedContentOwner -and ([string]$feedContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_TEXT_V1' -or [string]$feedContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) { Add-Issue 'FamilyFeedContent normalization/length unit drifted' } $commentContentOwner = Assert-StringOwner 'FamilyFeedCommentContent' 1 1000 '' @('x-text-normalizer', 'x-length-unit') if ($commentContentOwner -and ([string]$commentContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_COMMENT_TEXT_V1' -or [string]$commentContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) { Add-Issue 'FamilyFeedCommentContent normalization/length unit drifted' } $displayNameOwner = Assert-StringOwner 'FamilyFeedAuthorDisplayName' 1 100 '' @('x-projection', 'x-missing-author-policy') if ($displayNameOwner -and ([string]$displayNameOwner.'x-projection' -cne 'AUTHORIZED_DISPLAY_NAME_ONLY' -or [string]$displayNameOwner.'x-missing-author-policy' -cne 'NON_EMPTY_SERVER_FALLBACK')) { Add-Issue 'FamilyFeedAuthorDisplayName projection/fallback drifted' } Assert-DateTimeOwner 'FamilyFeedPublishedAt' $feedItem = Get-Schema 'AppFamilyFeedReadItem' Assert-ClosedObject $feedItem 'AppFamilyFeedReadItem' @( 'authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt' ) @( 'authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt' ) @('x-projection', 'x-media-policy') Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedId' '#/components/schemas/FamilyFeedId' Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedContent' '#/components/schemas/FamilyFeedContent' Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName' Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt' if ($feedItem) { $hasMedia = $feedItem.properties.hasMedia if ([string]$hasMedia.type -cne 'boolean' -or -not (Test-IsNonNullable $hasMedia)) { Add-Issue 'AppFamilyFeedReadItem.hasMedia must be a required non-null boolean' } Assert-NoConflictingSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia' Assert-AllowedSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia' @('type', 'nullable') if ([string]$feedItem.'x-projection' -cne 'VISIBLE_FEED_PRESENTATION_ONLY' -or [string]$feedItem.'x-media-policy' -cne 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT') { Add-Issue 'AppFamilyFeedReadItem projection/media policy drifted' } } $commentItem = Get-Schema 'AppFamilyFeedRootCommentReadItem' Assert-ClosedObject $commentItem 'AppFamilyFeedRootCommentReadItem' @( 'authorDisplayName', 'commentContent', 'commentId', 'publishedAt' ) @( 'authorDisplayName', 'commentContent', 'commentId', 'publishedAt' ) @('x-projection', 'x-comment-level', 'x-deleted-placeholder-policy') Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentId' '#/components/schemas/FamilyFeedCommentId' Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentContent' '#/components/schemas/FamilyFeedCommentContent' Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName' Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt' if ($commentItem -and ( [string]$commentItem.'x-projection' -cne 'VISIBLE_COMMENT_PRESENTATION_ONLY' -or [string]$commentItem.'x-comment-level' -cne 'ROOT_ONLY' -or [string]$commentItem.'x-deleted-placeholder-policy' -cne 'EXCLUDE')) { Add-Issue 'AppFamilyFeedRootCommentReadItem visibility/root/deleted-placeholder policy drifted' } Assert-CursorPage 'AppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' @('publishedAt:DESC', 'feedId:DESC_ORDINAL') Assert-CursorPage 'AppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' @('publishedAt:ASC', 'commentId:ASC_ORDINAL') Assert-SuccessEnvelope 'RAppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedCursorPage' Assert-SuccessEnvelope 'RAppFamilyFeedReadItem' '#/components/schemas/AppFamilyFeedReadItem' Assert-SuccessEnvelope 'RAppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentCursorPage' Assert-FixedError 'RFamilyFeedReadBadRequest' 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID') Assert-FixedError 'RFamilyFeedReadUnauthorized' 401 @('AUTH_REQUIRED') Assert-FixedError 'RFamilyFeedReadNotFound' 404 @('FAMILY_FEED_NOT_AVAILABLE') Assert-FixedError 'RFamilyFeedReadRateLimited' 429 @('RATE_LIMITED') Assert-FixedError 'RFamilyFeedReadUnavailable' 500 @('FAMILY_FEED_READ_UNAVAILABLE') Assert-NoForbiddenSuccessFields @( '#/components/schemas/RAppFamilyFeedCursorPage', '#/components/schemas/RAppFamilyFeedReadItem', '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' ) if ($ReturnIssues) { return [string[]]@($issues) } if ($issues.Count -gt 0) { Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED' Write-Output "Issues: $($issues.Count)" $issues | ForEach-Object { Write-Output "- $_" } Write-Output '- Keep F01/F03 on the explicit genealogy-scoped fixture preview; do not connect the dormant broad getFeeds branch.' Write-Output '- After one backend JSON/YAML/live export passes, start with executable production read normalizer/coordinator tests and migrate F01/F03 atomically.' exit 1 } Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT PASS'