$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $issues = New-Object System.Collections.Generic.List[string] $operations = @( [pscustomobject]@{ Path = '/genealogy/app/genealogies/public'; Method = 'get'; Id = 'appSearchPublicGenealogies'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RGenealogySearchCursorPage'; Parameters = @('header:clientid','query:cursor','query:keyword','query:limit') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies'; Method = 'post'; Id = 'appCreateGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationReceipt'; Parameters = @('header:Idempotency-Key','header:clientid','path:genealogyId') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/join-apply-requests/{requestKey}'; Method = 'get'; Id = 'appGetGenealogyJoinApplicationRequest'; Responses = @('200','400','401','404','429','500'); SuccessRef = '#/components/schemas/RGenealogyJoinApplicationRequestStatus'; Parameters = @('header:clientid','path:requestKey') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/mine'; Method = 'get'; Id = 'appListMyGenealogyJoinApplications'; Responses = @('200','400','401','429','500'); SuccessRef = '#/components/schemas/RMyGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','query:cursor','query:limit') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/join-applies/{applyId}'; Method = 'delete'; Id = 'appWithdrawGenealogyJoinApplication'; Responses = @('200','400','401','404','409','429','500'); SuccessRef = '#/components/schemas/RWithdrawnGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/pending'; Method = 'get'; Id = 'appListPendingGenealogyJoinApplications'; Responses = @('200','400','401','403','404','429','500'); SuccessRef = '#/components/schemas/RPendingGenealogyJoinApplicationCursorPage'; Parameters = @('header:clientid','path:genealogyId','query:cursor','query:limit') }, [pscustomobject]@{ Path = '/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit'; Method = 'put'; Id = 'appReviewGenealogyJoinApplication'; Responses = @('200','400','401','403','404','409','422','429','500'); SuccessRef = '#/components/schemas/RReviewedGenealogyJoinApplicationReceipt'; Parameters = @('header:clientid','path:applyId','path:genealogyId') } ) function Add-Issue([string]$Message) { $script:issues.Add($Message) } function Test-JsonBoolean([object]$Value, [bool]$Expected) { return $Value -is [System.Boolean] -and $Value -eq $Expected } function Test-IsJsonArray([object]$Value) { return $null -ne $Value -and $Value.GetType().IsArray } 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 Test-IsNonNullable([object]$Schema) { if (-not $Schema) { return $false } $nullable = $Schema.PSObject.Properties['nullable'] return -not $nullable -or (Test-JsonBoolean $nullable.Value $false) } function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) { if (-not $Schema) { return } foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) { if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) { Add-Issue "$Label must not define conflicting schema keyword: $keyword" } } } function Assert-AllowedSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed) { if (-not $Schema) { return } $annotations = @('title', 'description', 'example', 'examples', 'deprecated') foreach ($property in @($Schema.PSObject.Properties)) { if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { 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 } $properties = @($Value.PSObject.Properties.Name) $actualRef = [string]$Value.'$ref' if ($properties.Count -ne 1 -or $properties[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-IsExactReferenceObjectRef([object]$Value, [string]$ExpectedRef, [string]$Section, [string]$Label) { if (-not $Value) { return $false } $actualRef = [string]$Value.'$ref' $semanticSiblings = @($Value.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($actualRef -cne $ExpectedRef -or $semanticSiblings.Count -gt 0) { Add-Issue "$Label must use exact local ref $ExpectedRef with only harmless summary/description siblings; actual: $actualRef; semantic siblings: $($semanticSiblings -join ',')" 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) { $property = $document.components.schemas.PSObject.Properties[$Name] if (-not $property) { Add-Issue "missing schema owner: $Name"; return $null } return $property.Value } function Get-Operation([string]$Path, [string]$Method) { $pathProperty = $document.paths.PSObject.Properties[$Path] $operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null } if (-not $operation) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path"; return $null } return $operation.Value } function Get-Parameters([string]$Path, [object]$Operation) { if (-not $Operation) { return @() } $pathItem = $document.paths.PSObject.Properties[$Path].Value $byIdentity = [ordered]@{} foreach ($scope in @(@($pathItem.parameters), @($Operation.parameters))) { $scopeIdentities = @{} foreach ($parameter in $scope) { if (-not $parameter) { continue } $resolved = $parameter if ($parameter.'$ref') { $name = Get-LocalComponentName ([string]$parameter.'$ref') 'parameters' "$Path parameter" if (-not $name) { continue } $parameterRefSiblings = @($parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($parameterRefSiblings.Count -gt 0) { Add-Issue "$Path parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')" } $owner = $document.components.parameters.PSObject.Properties[$name] if (-not $owner) { Add-Issue "missing parameter owner: $name"; continue } $resolved = $owner.Value } $identity = "$($resolved.in):$($resolved.name)".ToLowerInvariant() if ($scopeIdentities.ContainsKey($identity)) { Add-Issue "$Path contains duplicate parameter in one scope: $identity"; continue } $scopeIdentities[$identity] = $true $byIdentity[$identity] = $resolved } } return @($byIdentity.Values) } function Get-Parameter([string]$Path, [object]$Operation, [string]$In, [string]$Name) { $matches = @(Get-Parameters $Path $Operation | Where-Object { $_.in -eq $In -and $_.name -eq $Name }) if ($matches.Count -ne 1) { Add-Issue "$($Operation.operationId) must define exactly one ${In}:${Name}"; return $null } return $matches[0] } function Assert-ClosedObject([object]$Schema, [string]$Name, [string[]]$Fields, [string[]]$Required) { if (-not $Schema) { return } $actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object) $actualRequired = @($Schema.required | Sort-Object) if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or -not (Test-JsonBoolean $Schema.additionalProperties $false) -or ($actualFields -join ',') -ne ((@($Fields | Sort-Object)) -join ',') -or ($actualRequired -join ',') -ne ((@($Required | Sort-Object)) -join ',')) { Add-Issue "$Name must be closed; fields=$($Fields -join ','); required=$($Required -join ',')" } Assert-NoConflictingSchemaKeywords $Schema $Name Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') } function Assert-StringOwner([string]$Name, [int]$Min, [int]$Max, [string]$Pattern = '') { $schema = Get-Schema $Name if (-not $schema) { return } if ($schema.type -ne 'string' -or -not (Test-IsNonNullable $schema) -or [int]$schema.minLength -ne $Min -or [int]$schema.maxLength -ne $Max) { Add-Issue "$Name must be a non-null string length $Min..$Max" } if ($Pattern -and [string]$schema.pattern -ne $Pattern) { Add-Issue "$Name pattern drifted" } Assert-NoConflictingSchemaKeywords $schema $Name Assert-AllowedSchemaKeywords $schema $Name @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } function Assert-PropertyRef([object]$Schema, [string]$Name, [string]$Field, [string]$Ref) { if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property) { Add-Issue "$Name.$Field must use $Ref"; return } [void](Test-IsPureSchemaRef $property.Value $Ref "$Name.$Field") } function Assert-Union([object]$Schema, [string]$Name, [string]$Discriminator, [hashtable]$Mapping) { if (-not $Schema) { return } $expectedRefs = @($Mapping.Values | Sort-Object -Unique) $actualRefs = @($Schema.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object) $actualKeys = @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object) $expectedKeys = @($Mapping.Keys | Sort-Object) if ($Schema.discriminator.propertyName -ne $Discriminator) { Add-Issue "$Name discriminator property must be $Discriminator" } if (($actualRefs -join ',') -ne ($expectedRefs -join ',') -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')) { Add-Issue "$Name oneOf/mapping branches drifted" } foreach ($key in $expectedKeys) { if ([string]$Schema.discriminator.mapping.$key -ne [string]$Mapping[$key]) { Add-Issue "$Name mapping $key drifted" } } foreach ($branch in @($Schema.oneOf)) { $branchRef = [string]$branch.'$ref' if ($branchRef -notin $expectedRefs) { Add-Issue "$Name contains an unexpected oneOf branch: $branchRef" } else { [void](Test-IsPureSchemaRef $branch $branchRef "$Name oneOf branch") } } Assert-AllowedSchemaKeywords $Schema $Name @('oneOf', 'discriminator') if ($Schema.discriminator -and (@($Schema.discriminator.PSObject.Properties.Name | Sort-Object) -join ',') -cne 'mapping,propertyName') { Add-Issue "$Name discriminator must contain only mapping and propertyName" } } function Assert-SingleEnum([object]$Schema, [string]$Name, [string]$Field, [string]$Value) { if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property -or $property.Value.type -ne 'string' -or -not (Test-IsNonNullable $property.Value) -or -not (Test-IsJsonArray $property.Value.enum) -or @($property.Value.enum).Count -ne 1 -or $property.Value.enum[0] -ne $Value) { Add-Issue "$Name.$Field must be the single non-null value $Value" } if ($property) { Assert-NoConflictingSchemaKeywords $property.Value "$Name.$Field" @('enum') } if ($property) { Assert-AllowedSchemaKeywords $property.Value "$Name.$Field" @('type', 'enum', 'nullable') } } function Assert-RequestBody([object]$Operation, [string]$Label, [string]$Ref) { if (-not $Operation) { return } $content = $Operation.requestBody.content $media = if ($content) { $content.PSObject.Properties['application/json'] } else { $null } if (-not (Test-JsonBoolean $Operation.requestBody.required $true) -or -not $media -or $content.PSObject.Properties.Count -ne 1) { Add-Issue "$Label must require only application/json with $Ref" } elseif ($media) { [void](Test-IsPureSchemaRef $media.Value.schema $Ref "$Label request schema") } } function Assert-PathId([string]$Path, [object]$Operation, [string]$Name, [string]$Ref) { if (-not $Operation) { return } $parameter = Get-Parameter $Path $Operation 'path' $Name if ($parameter) { if (-not (Test-JsonBoolean $parameter.required $true)) { Add-Issue "$($Operation.operationId) $Name must be required" } [void](Test-IsPureSchemaRef $parameter.schema $Ref "$($Operation.operationId) $Name") } } function Assert-Envelope([string]$Name, [string]$DataRef) { $schema = Get-Schema $Name Assert-ClosedObject $schema $Name @('code','data') @('code','data') if (-not $schema) { return } if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne 200) { Add-Issue "$Name.code must be non-null integer enum [200]" } Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum') Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable') Assert-PropertyRef $schema $Name 'data' $DataRef } function Assert-ErrorEnvelope([string]$Name, [int]$Status, [string[]]$BusinessCodes, [bool]$HasCurrent = $false) { $schema = Get-Schema $Name $fields = if ($HasCurrent) { @('code','businessCode','current') } else { @('code','businessCode') } Assert-ClosedObject $schema $Name $fields $fields if (-not $schema) { return } if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne $Status) { Add-Issue "$Name.code must be non-null integer enum [$Status]" } $actualCodes = @($schema.properties.businessCode.enum | Sort-Object) if ($schema.properties.businessCode.type -ne 'string' -or -not (Test-IsNonNullable $schema.properties.businessCode) -or -not (Test-IsJsonArray $schema.properties.businessCode.enum) -or ($actualCodes -join ',') -ne ((@($BusinessCodes | Sort-Object)) -join ',')) { Add-Issue "$Name.businessCode enum drifted" } Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum') Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum') Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable') Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable') if ($HasCurrent) { Assert-PropertyRef $schema $Name 'current' '#/components/schemas/GenealogyJoinApplicationCurrentState' } } function Resolve-Response([object]$Response, [string]$Label = 'response') { if (-not $Response) { return $null } if ($Response.'$ref') { $name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label if (-not $name) { return $null } $responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($responseRefSiblings.Count -gt 0) { Add-Issue "$Label ref contains semantic sibling keywords: $($responseRefSiblings -join ',')" } $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "$Label references missing response owner: $name" return $null } return $owner.Value } return $Response } function Get-ExpectedResponseSchemaRef([string]$OperationId, [string]$Status, [string]$SuccessRef) { if ($Status -eq '200') { return $SuccessRef } if ($OperationId -eq 'appGetGenealogyJoinApplicationRequest' -and $Status -eq '404') { return '#/components/schemas/RGenealogyJoinApplicationRequestNotAvailable' } if ($Status -eq '409') { if ($OperationId -eq 'appCreateGenealogyJoinApplication') { return '#/components/schemas/RJoinApplicationKeyConflict' } if ($OperationId -in @('appWithdrawGenealogyJoinApplication','appReviewGenealogyJoinApplication')) { return '#/components/schemas/RJoinApplicationStateConflict' } } return @{ '400' = '#/components/schemas/RJoinApplicationBadRequest' '401' = '#/components/schemas/RJoinApplicationUnauthorized' '403' = '#/components/schemas/RJoinApplicationForbidden' '404' = '#/components/schemas/RJoinApplicationNotFound' '422' = '#/components/schemas/RJoinApplicationUnprocessable' '429' = '#/components/schemas/RJoinApplicationRateLimited' '500' = '#/components/schemas/RJoinApplicationServerError' }[$Status] } function Assert-ResponseContract([object]$Operation, [string]$Label, [string[]]$Statuses, [string]$SuccessRef) { if (-not $Operation) { return } $actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object) if (($actual -join ',') -ne ((@($Statuses | Sort-Object)) -join ',')) { Add-Issue "$Label response set drifted: $($actual -join ',')" } foreach ($status in $actual) { if ($status -eq 'default' -or $status -match '^3') { Add-Issue "$Label must not use default or 3xx responses" } $response = Resolve-Response $Operation.responses.PSObject.Properties[$status].Value "$Label $status response" if (-not $response) { continue } $media = if ($response.content) { $response.content.PSObject.Properties['application/json'] } else { $null } if (-not $media -or $response.content.PSObject.Properties.Count -ne 1) { Add-Issue "$Label $status must use only application/json" } $expectedRef = Get-ExpectedResponseSchemaRef $Operation.operationId $status $SuccessRef $actualRef = if ($media) { [string]$media.Value.schema.'$ref' } else { '' } if ($expectedRef -and $media) { [void](Test-IsPureSchemaRef $media.Value.schema $expectedRef "$Label $status response schema") } $cache = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $cache) { Add-Issue "$Label $status must use the shared PrivateNoStore header owner" } else { [void](Test-IsExactReferenceObjectRef $cache.Value '#/components/headers/PrivateNoStore' 'headers' "$Label $status Cache-Control") } if ($status -eq '429') { $retry = if ($response.headers) { $response.headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $retry) { Add-Issue "$Label 429 must use the shared RetryAfter header owner" } else { [void](Test-IsExactReferenceObjectRef $retry.Value '#/components/headers/RetryAfter' 'headers' "$Label 429 Retry-After") } } } } function Assert-SecurityAndClient([string]$Path, [object]$Operation, [string]$Label) { if (-not $Operation) { return } $security = @($Operation.security) if ($security.Count -ne 1 -or $security[0].PSObject.Properties.Count -ne 1 -or $security[0].PSObject.Properties.Name -notcontains 'SaToken') { Add-Issue "$Label must require SaToken without an anonymous alternative" } $client = Get-Parameter $Path $Operation 'header' 'clientid' if ($client -and (-not (Test-JsonBoolean $client.required $true) -or $client.schema.type -ne 'string' -or -not (Test-IsNonNullable $client.schema) -or [int]$client.schema.minLength -ne 1 -or [int]$client.schema.maxLength -ne 128)) { Add-Issue "$Label clientid must be a required non-null string bounded to 1..128" } if ($client) { Assert-NoConflictingSchemaKeywords $client.schema "$Label clientid" } if ($client) { Assert-AllowedSchemaKeywords $client.schema "$Label clientid" @('type', 'minLength', 'maxLength', 'nullable') } } $parityOutput = @(& node (Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js') 2>&1) if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) { Add-Issue "protected JSON/YAML parity failed: $($parityOutput -join ' | ')" } $resolvedOperations = @{} foreach ($entry in $operations) { $operation = Get-Operation $entry.Path $entry.Method $resolvedOperations[$entry.Id] = $operation if ($operation -and [string]$operation.operationId -ne $entry.Id) { Add-Issue "$($entry.Method.ToUpperInvariant()) $($entry.Path) operationId must be $($entry.Id)" } Assert-SecurityAndClient $entry.Path $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)" Assert-ResponseContract $operation "$($entry.Method.ToUpperInvariant()) $($entry.Path)" $entry.Responses $entry.SuccessRef if ($operation) { $actualParameters = @(Get-Parameters $entry.Path $operation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object) $expectedParameters = @($entry.Parameters | Sort-Object) if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) { Add-Issue "$($entry.Id) parameters must be exactly $($expectedParameters -join ','); actual=$($actualParameters -join ',')" } } } foreach ($entry in $operations) { $duplicates = @($document.paths.PSObject.Properties | ForEach-Object { $_.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') -and $_.Value.operationId -eq $entry.Id } }) if ($duplicates.Count -ne 1) { Add-Issue "operationId must be globally unique: $($entry.Id)" } } $headerOwners = if ($document.components.PSObject.Properties['headers']) { $document.components.headers } else { $null } $privateHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['PrivateNoStore'] } else { $null } $privateHeader = if ($privateHeaderProperty) { $privateHeaderProperty.Value } else { $null } if (-not $privateHeader -or $privateHeader.schema.type -ne 'string' -or -not (Test-IsNonNullable $privateHeader.schema) -or -not (Test-IsJsonArray $privateHeader.schema.enum) -or @($privateHeader.schema.enum).Count -ne 1 -or $privateHeader.schema.enum[0] -ne 'private, no-store') { Add-Issue 'PrivateNoStore header must be a fixed non-null string enum [private, no-store]' } if ($privateHeader) { Assert-NoConflictingSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('enum') } if ($privateHeader) { Assert-AllowedSchemaKeywords $privateHeader.schema 'PrivateNoStore header schema' @('type', 'enum', 'nullable') } $retryHeaderProperty = if ($headerOwners) { $headerOwners.PSObject.Properties['RetryAfter'] } else { $null } $retryHeader = if ($retryHeaderProperty) { $retryHeaderProperty.Value } else { $null } if (-not $retryHeader -or $retryHeader.schema.type -ne 'integer' -or -not (Test-IsNonNullable $retryHeader.schema) -or [int]$retryHeader.schema.minimum -ne 1 -or [int]$retryHeader.schema.maximum -ne 120) { Add-Issue 'RetryAfter header must be a non-null integer 1..120 seconds' } if ($retryHeader) { Assert-NoConflictingSchemaKeywords $retryHeader.schema 'RetryAfter header schema' } if ($retryHeader) { Assert-AllowedSchemaKeywords $retryHeader.schema 'RetryAfter header schema' @('type', 'minimum', 'maximum', 'nullable') } Assert-StringOwner 'GenealogyId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' Assert-StringOwner 'JoinApplicationId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' Assert-StringOwner 'JoinApplicationCursor' 1 512 '^[A-Za-z0-9_-]+$' Assert-StringOwner 'GenealogyJoinApplicationRequestKey' 40 61 '^gja\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$' $requestKeyOwner = Get-Schema 'GenealogyJoinApplicationRequestKey' if ($requestKeyOwner) { foreach ($extension in @( @{ Name = 'x-issued-at-source'; Value = 'KEY_EPOCH_MILLISECONDS' }, @{ Name = 'x-accept-window-seconds'; Value = 600 }, @{ Name = 'x-max-future-skew-seconds'; Value = 300 }, @{ Name = 'x-resolve-sla-seconds'; Value = 120 }, @{ Name = 'x-random-min-bits'; Value = 128 } )) { if ($requestKeyOwner.PSObject.Properties[$extension.Name].Value -ne $extension.Value) { Add-Issue "GenealogyJoinApplicationRequestKey $($extension.Name) must be $($extension.Value)" } } } $applyBody = Get-Schema 'AppGenealogyJoinApplicationBody' Assert-ClosedObject $applyBody 'AppGenealogyJoinApplicationBody' @('applicantName','relationDesc','applyReason') @('applicantName','relationDesc') foreach ($field in @( @{ Name = 'applicantName'; Max = 50 }, @{ Name = 'relationDesc'; Max = 100 }, @{ Name = 'applyReason'; Max = 500 } )) { $property = if ($applyBody) { $applyBody.properties.PSObject.Properties[$field.Name].Value } else { $null } if ($property -and ($property.type -ne 'string' -or [int]$property.minLength -ne 1 -or [int]$property.maxLength -ne $field.Max -or $property.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) { Add-Issue "AppGenealogyJoinApplicationBody.$($field.Name) must use JOIN_APPLICATION_TEXT_V1 and length 1..$($field.Max)" } } $reviewBody = Get-Schema 'AppGenealogyJoinReviewBody' if ($reviewBody) { Assert-Union $reviewBody 'AppGenealogyJoinReviewBody' 'decision' @{ APPROVE = '#/components/schemas/AppGenealogyJoinApproveBody' REJECT = '#/components/schemas/AppGenealogyJoinRejectBody' } if ($reviewBody.PSObject.Properties['additionalProperties']) { Add-Issue 'review union wrapper must leave closure to its two concrete branches under OpenAPI 3.0.1' } } $approveBody = Get-Schema 'AppGenealogyJoinApproveBody' $rejectBody = Get-Schema 'AppGenealogyJoinRejectBody' Assert-ClosedObject $approveBody 'AppGenealogyJoinApproveBody' @('decision') @('decision') Assert-ClosedObject $rejectBody 'AppGenealogyJoinRejectBody' @('decision','rejectionReason') @('decision','rejectionReason') Assert-SingleEnum $approveBody 'AppGenealogyJoinApproveBody' 'decision' 'APPROVE' Assert-SingleEnum $rejectBody 'AppGenealogyJoinRejectBody' 'decision' 'REJECT' if ($rejectBody -and ($rejectBody.properties.rejectionReason.type -ne 'string' -or [int]$rejectBody.properties.rejectionReason.minLength -ne 1 -or [int]$rejectBody.properties.rejectionReason.maxLength -ne 500 -or $rejectBody.properties.rejectionReason.'x-text-normalizer' -ne 'JOIN_APPLICATION_TEXT_V1')) { Add-Issue 'rejectionReason must use JOIN_APPLICATION_TEXT_V1 and length 1..500' } $searchItem = Get-Schema 'AppGenealogySearchItem' Assert-ClosedObject $searchItem 'AppGenealogySearchItem' @('genealogyId','genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel','memberCount','updatedAt','viewerState') @('genealogyId','genealogyName','surname','regionName','viewerState') Assert-PropertyRef $searchItem 'AppGenealogySearchItem' 'genealogyId' '#/components/schemas/GenealogyId' if ($searchItem) { $expectedViewerStates = @('NOT_JOINED','MEMBER','PENDING','REJECTED','FORMER_MEMBER','OWNER') if ((@($searchItem.properties.viewerState.enum | Sort-Object) -join ',') -ne ((@($expectedViewerStates | Sort-Object)) -join ',')) { Add-Issue 'AppGenealogySearchItem.viewerState enum drifted' } foreach ($forbidden in @('phone','managerName','managerPhone','userId','inviterUserId','auditUserId','canApply')) { if ($searchItem.properties.PSObject.Properties[$forbidden]) { Add-Issue "search projection leaks or duplicates state: $forbidden" } } } $pendingItem = Get-Schema 'PendingGenealogyJoinApplicationItem' Assert-ClosedObject $pendingItem 'PendingGenealogyJoinApplicationItem' @('applyId','applicantName','relationDesc','applyReason','submittedAt') @('applyId','applicantName','relationDesc','submittedAt') Assert-PropertyRef $pendingItem 'PendingGenealogyJoinApplicationItem' 'applyId' '#/components/schemas/JoinApplicationId' if ($pendingItem -and $pendingItem.properties.submittedAt.format -ne 'date-time') { Add-Issue 'pending submittedAt must be RFC3339 date-time' } $mineUnion = Get-Schema 'MyGenealogyJoinApplicationItem' $mineMapping = @{ PENDING = '#/components/schemas/MyPendingGenealogyJoinApplication' APPROVED = '#/components/schemas/MyApprovedGenealogyJoinApplication' REJECTED = '#/components/schemas/MyRejectedGenealogyJoinApplication' WITHDRAWN = '#/components/schemas/MyWithdrawnGenealogyJoinApplication' } Assert-Union $mineUnion 'MyGenealogyJoinApplicationItem' 'status' $mineMapping $mineBranches = @( @{ Name = 'MyPendingGenealogyJoinApplication'; Status = 'PENDING'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt') }, @{ Name = 'MyApprovedGenealogyJoinApplication'; Status = 'APPROVED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') }, @{ Name = 'MyRejectedGenealogyJoinApplication'; Status = 'REJECTED'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt','rejectionReason'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt','rejectionReason') }, @{ Name = 'MyWithdrawnGenealogyJoinApplication'; Status = 'WITHDRAWN'; Fields = @('applyId','genealogyId','genealogyName','status','relationDesc','applyReason','submittedAt','resolvedAt'); Required = @('applyId','genealogyId','genealogyName','status','relationDesc','submittedAt','resolvedAt') } ) foreach ($branch in $mineBranches) { $name = $branch.Name $schema = Get-Schema $name if ($schema) { Assert-ClosedObject $schema $name $branch.Fields $branch.Required Assert-SingleEnum $schema $name 'status' $branch.Status foreach ($field in @('applyId','genealogyId')) { Assert-PropertyRef $schema $name $field $(if ($field -eq 'applyId') { '#/components/schemas/JoinApplicationId' } else { '#/components/schemas/GenealogyId' }) } if ($schema.properties.submittedAt.format -ne 'date-time') { Add-Issue "$name.submittedAt must be date-time" } if ($schema.properties.PSObject.Properties['resolvedAt'] -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$name.resolvedAt must be date-time" } } } $rejectedMine = Get-Schema 'MyRejectedGenealogyJoinApplication' if ($rejectedMine -and @($rejectedMine.required) -notcontains 'rejectionReason') { Add-Issue 'REJECTED mine branch must require applicant-visible rejectionReason' } foreach ($pageName in @('GenealogySearchCursorPage','MyGenealogyJoinApplicationCursorPage','PendingGenealogyJoinApplicationCursorPage')) { $page = Get-Schema $pageName Assert-ClosedObject $page $pageName @('items','nextCursor') @('items') if ($page -and ($page.properties.PSObject.Properties['total'] -or $page.properties.PSObject.Properties['pageNum'])) { Add-Issue "$pageName must not expose total/pageNum" } } $pageItemRefs = @{ GenealogySearchCursorPage = '#/components/schemas/AppGenealogySearchItem' MyGenealogyJoinApplicationCursorPage = '#/components/schemas/MyGenealogyJoinApplicationItem' PendingGenealogyJoinApplicationCursorPage = '#/components/schemas/PendingGenealogyJoinApplicationItem' } foreach ($pageName in $pageItemRefs.Keys) { $page = $document.components.schemas.PSObject.Properties[$pageName].Value if (-not $page) { continue } if ($page.properties.items.type -ne 'array' -or [string]$page.properties.items.items.'$ref' -ne $pageItemRefs[$pageName] -or [int]$page.properties.items.maxItems -ne 50) { Add-Issue "$pageName.items must be a max-50 array of $($pageItemRefs[$pageName])" } Assert-PropertyRef $page $pageName 'nextCursor' '#/components/schemas/JoinApplicationCursor' } $receipt = Get-Schema 'GenealogyJoinApplicationReceipt' Assert-ClosedObject $receipt 'GenealogyJoinApplicationReceipt' @('applyId','genealogyId','status','submittedAt') @('applyId','genealogyId','status','submittedAt') Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'applyId' '#/components/schemas/JoinApplicationId' Assert-PropertyRef $receipt 'GenealogyJoinApplicationReceipt' 'genealogyId' '#/components/schemas/GenealogyId' Assert-SingleEnum $receipt 'GenealogyJoinApplicationReceipt' 'status' 'PENDING' if ($receipt -and $receipt.properties.submittedAt.format -ne 'date-time') { Add-Issue 'GenealogyJoinApplicationReceipt.submittedAt must be date-time' } Assert-Envelope 'RGenealogySearchCursorPage' '#/components/schemas/GenealogySearchCursorPage' Assert-Envelope 'RGenealogyJoinApplicationReceipt' '#/components/schemas/GenealogyJoinApplicationReceipt' Assert-Envelope 'RGenealogyJoinApplicationRequestStatus' '#/components/schemas/GenealogyJoinApplicationRequestStatus' Assert-Envelope 'RMyGenealogyJoinApplicationCursorPage' '#/components/schemas/MyGenealogyJoinApplicationCursorPage' Assert-Envelope 'RWithdrawnGenealogyJoinApplicationReceipt' '#/components/schemas/WithdrawnGenealogyJoinApplicationReceipt' Assert-Envelope 'RReviewedGenealogyJoinApplicationReceipt' '#/components/schemas/ReviewedGenealogyJoinApplicationReceipt' Assert-Envelope 'RPendingGenealogyJoinApplicationCursorPage' '#/components/schemas/PendingGenealogyJoinApplicationCursorPage' $reviewedUnion = Get-Schema 'ReviewedGenealogyJoinApplicationReceipt' Assert-Union $reviewedUnion 'ReviewedGenealogyJoinApplicationReceipt' 'status' @{ APPROVED = '#/components/schemas/ApprovedGenealogyJoinApplicationReceipt' REJECTED = '#/components/schemas/RejectedGenealogyJoinApplicationReceipt' } foreach ($branch in @( @{ Name='ApprovedGenealogyJoinApplicationReceipt'; Status='APPROVED'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') }, @{ Name='RejectedGenealogyJoinApplicationReceipt'; Status='REJECTED'; Fields=@('applyId','status','resolvedAt','rejectionReason'); Required=@('applyId','status','resolvedAt','rejectionReason') }, @{ Name='WithdrawnGenealogyJoinApplicationReceipt'; Status='WITHDRAWN'; Fields=@('applyId','status','resolvedAt'); Required=@('applyId','status','resolvedAt') } )) { $schema = Get-Schema $branch.Name Assert-ClosedObject $schema $branch.Name $branch.Fields $branch.Required Assert-SingleEnum $schema $branch.Name 'status' $branch.Status Assert-PropertyRef $schema $branch.Name 'applyId' '#/components/schemas/JoinApplicationId' if ($schema -and $schema.properties.resolvedAt.format -ne 'date-time') { Add-Issue "$($branch.Name).resolvedAt must be date-time" } } $currentState = Get-Schema 'GenealogyJoinApplicationCurrentState' Assert-ClosedObject $currentState 'GenealogyJoinApplicationCurrentState' @('applyId','status','resolvedAt') @('applyId','status') Assert-PropertyRef $currentState 'GenealogyJoinApplicationCurrentState' 'applyId' '#/components/schemas/JoinApplicationId' if ($currentState) { $states = @('PENDING','APPROVED','REJECTED','WITHDRAWN') if ((@($currentState.properties.status.enum | Sort-Object) -join ',') -ne ((@($states | Sort-Object)) -join ',')) { Add-Issue 'current-state status enum drifted' } } Assert-ErrorEnvelope 'RJoinApplicationBadRequest' 400 @('CURSOR_INVALID','INVALID_REQUEST','OPERATION_KEY_INVALID') Assert-ErrorEnvelope 'RJoinApplicationUnauthorized' 401 @('AUTHENTICATION_REQUIRED') Assert-ErrorEnvelope 'RJoinApplicationForbidden' 403 @('JOIN_APPLICATION_FORBIDDEN') Assert-ErrorEnvelope 'RJoinApplicationNotFound' 404 @('GENEALOGY_NOT_FOUND','JOIN_APPLICATION_NOT_FOUND') Assert-ErrorEnvelope 'RJoinApplicationKeyConflict' 409 @('ACTIVE_PENDING_EXISTS','IDEMPOTENCY_KEY_REUSED','OPERATION_KEY_EXPIRED') Assert-ErrorEnvelope 'RJoinApplicationStateConflict' 409 @('JOIN_APPLICATION_DECISION_CONFLICT','JOIN_APPLICATION_STATE_CHANGED') $true if ($document.components.schemas.PSObject.Properties['RJoinApplicationConflict']) { Add-Issue 'remove broad RJoinApplicationConflict; each mutation operation must reference its precise 409 owner' } Assert-ErrorEnvelope 'RJoinApplicationUnprocessable' 422 @('GENEALOGY_NOT_PUBLIC_APPLY','JOIN_APPLICATION_NOT_ALLOWED','REJECTION_REASON_INVALID') Assert-ErrorEnvelope 'RJoinApplicationRateLimited' 429 @('RATE_LIMITED') Assert-ErrorEnvelope 'RJoinApplicationServerError' 500 @('INTERNAL_ERROR') $notAvailable = Get-Schema 'RGenealogyJoinApplicationRequestNotAvailable' Assert-ClosedObject $notAvailable 'RGenealogyJoinApplicationRequestNotAvailable' @('code','businessCode','acceptUntil') @('code','businessCode','acceptUntil') if ($notAvailable -and ($notAvailable.properties.code.enum[0] -ne 404 -or $notAvailable.properties.businessCode.enum[0] -ne 'JOIN_APPLICATION_REQUEST_NOT_AVAILABLE' -or $notAvailable.properties.acceptUntil.format -ne 'date-time')) { Add-Issue 'status 404 contract drifted' } $post = $resolvedOperations['appCreateGenealogyJoinApplication'] if ($post) { Assert-RequestBody $post 'join POST' '#/components/schemas/AppGenealogyJoinApplicationBody' Assert-PathId $operations[1].Path $post 'genealogyId' '#/components/schemas/GenealogyId' $key = Get-Parameter $operations[1].Path $post 'header' 'Idempotency-Key' if ($key) { if (-not (Test-JsonBoolean $key.required $true)) { Add-Issue 'join POST Idempotency-Key must be required' } [void](Test-IsPureSchemaRef $key.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'join POST Idempotency-Key') } $description = [string]$post.description foreach ($pattern in @('canonical.*method.*path.*genealogyId.*tenant.*account.*client.*body','unique.*account.*tenant.*genealogy.*PENDING','same key.*same canonical.*same receipt','same key.*different.*409','domain transaction.*application.*SUCCEEDED','FAILED_NO_COMMIT.*no domain effects','same transaction.*READY.*PUBLIC_APPLY.*application eligibility','single winner.*PUBLIC_APPLY.*MEMBER_ONLY')) { if ($description -notmatch "(?i)$pattern") { Add-Issue "join POST description misses: $pattern" } } if (-not (Test-IsJsonArray $post.'x-idempotency-scope') -or (@($post.'x-idempotency-scope') -join ',') -ne 'method,path,genealogyId,tenant,account,client,canonicalBody' -or -not (Test-IsJsonArray $post.'x-active-pending-unique-scope') -or (@($post.'x-active-pending-unique-scope') -join ',') -ne 'tenant,account,genealogyId' -or -not (Test-IsJsonArray $post.'x-domain-transaction-effects') -or (@($post.'x-domain-transaction-effects') -join ',') -ne 'JOIN_APPLICATION,SUCCEEDED_RECEIPT' -or -not (Test-IsJsonArray $post.'x-revalidates') -or (@($post.'x-revalidates') -join ',') -ne 'genealogyState,accessPreset,applicationEligibility' -or [string]$post.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or -not (Test-JsonBoolean $post.'x-same-request-replays-receipt' $true) -or $post.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED') { Add-Issue 'join POST machine-readable idempotency/replay/transaction extensions drifted' } } $status = $resolvedOperations['appGetGenealogyJoinApplicationRequest'] if ($status) { if ($status.PSObject.Properties['requestBody']) { Add-Issue 'status GET must not define a request body' } $requestKey = Get-Parameter $operations[2].Path $status 'path' 'requestKey' if ($requestKey) { if (-not (Test-JsonBoolean $requestKey.required $true)) { Add-Issue 'status requestKey must be required' } [void](Test-IsPureSchemaRef $requestKey.schema '#/components/schemas/GenealogyJoinApplicationRequestKey' 'status requestKey') } foreach ($pattern in @('read-only.*no side effect','ABSENT.*PENDING.*SUCCEEDED.*FAILED_NO_COMMIT','terminal.*immutable','cross-account.*tenant.*client.*404','before.*acceptUntil.*404','after.*acceptUntil.*computed.*FAILED_NO_COMMIT.*no write','PENDING.*resolveBy','domain effect.*SUCCEEDED.*same transaction')) { if ([string]$status.description -notmatch "(?i)$pattern") { Add-Issue "status GET description misses: $pattern" } } if (-not (Test-JsonBoolean $status.'x-read-only' $true) -or -not (Test-JsonBoolean $status.'x-expired-absent-zero-write' $true)) { Add-Issue 'status GET read-only/zero-write extensions drifted' } $status404 = Resolve-Response $status.responses.PSObject.Properties['404'].Value 'status GET 404 response' $status404Retry = if ($status404.headers) { $status404.headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $status404Retry) { Add-Issue 'status GET 404 must use RetryAfter before acceptUntil' } else { [void](Test-IsExactReferenceObjectRef $status404Retry.Value '#/components/headers/RetryAfter' 'headers' 'status GET 404 Retry-After') } } $statusUnion = Get-Schema 'GenealogyJoinApplicationRequestStatus' Assert-Union $statusUnion 'GenealogyJoinApplicationRequestStatus' 'status' @{ PENDING = '#/components/schemas/PendingGenealogyJoinApplicationRequest' SUCCEEDED = '#/components/schemas/SucceededGenealogyJoinApplicationRequest' FAILED_NO_COMMIT = '#/components/schemas/FailedGenealogyJoinApplicationRequest' } if ($statusUnion -and (-not (Test-JsonBoolean $statusUnion.'x-terminal-immutable' $true) -or -not (Test-IsJsonArray $statusUnion.'x-state-transitions') -or (@($statusUnion.'x-state-transitions') -join ',') -ne 'ABSENT->PENDING,PENDING->SUCCEEDED,PENDING->FAILED_NO_COMMIT')) { Add-Issue 'operation status transitions or terminal immutability drifted' } $pendingStatus = Get-Schema 'PendingGenealogyJoinApplicationRequest' $succeededStatus = Get-Schema 'SucceededGenealogyJoinApplicationRequest' $failedStatus = Get-Schema 'FailedGenealogyJoinApplicationRequest' Assert-ClosedObject $pendingStatus 'PendingGenealogyJoinApplicationRequest' @('status','resolveBy','retryAfterSeconds') @('status','resolveBy','retryAfterSeconds') Assert-SingleEnum $pendingStatus 'PendingGenealogyJoinApplicationRequest' 'status' 'PENDING' if ($pendingStatus -and ($pendingStatus.properties.resolveBy.format -ne 'date-time' -or $pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30)) { Add-Issue 'PENDING operation status timing fields drifted' } Assert-ClosedObject $succeededStatus 'SucceededGenealogyJoinApplicationRequest' @('status','result') @('status','result') Assert-SingleEnum $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'status' 'SUCCEEDED' Assert-PropertyRef $succeededStatus 'SucceededGenealogyJoinApplicationRequest' 'result' '#/components/schemas/GenealogyJoinApplicationReceipt' Assert-ClosedObject $failedStatus 'FailedGenealogyJoinApplicationRequest' @('status') @('status') Assert-SingleEnum $failedStatus 'FailedGenealogyJoinApplicationRequest' 'status' 'FAILED_NO_COMMIT' if ($failedStatus -and ($failedStatus.'x-domain-effects' -ne 'NONE' -or -not (Test-JsonBoolean $failedStatus.'x-active-application-created' $false))) { Add-Issue 'FAILED_NO_COMMIT must machine-lock zero application effects' } foreach ($listId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) { $operation = $resolvedOperations[$listId] if (-not $operation) { continue } $limit = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'limit' $cursor = Get-Parameter ($operations | Where-Object Id -eq $listId).Path $operation 'query' 'cursor' if ($limit -and ($limit.schema.type -ne 'integer' -or -not (Test-IsNonNullable $limit.schema) -or [int]$limit.schema.minimum -ne 1 -or [int]$limit.schema.maximum -ne 50)) { Add-Issue "$listId limit must be a non-null integer 1..50" } if ($limit) { Assert-NoConflictingSchemaKeywords $limit.schema "$listId limit" Assert-AllowedSchemaKeywords $limit.schema "$listId limit" @('type', 'minimum', 'maximum', 'nullable') } if ($cursor) { [void](Test-IsPureSchemaRef $cursor.schema '#/components/schemas/JoinApplicationCursor' "$listId cursor") } foreach ($pattern in @('stable.*cursor','no total','tenant.*account.*client','filter.*cursor','tie-breaker')) { if ([string]$operation.description -notmatch "(?i)$pattern") { Add-Issue "$listId pagination description misses: $pattern" } } $expectedOrder = if ($listId -eq 'appSearchPublicGenealogies') { 'updatedAt:desc,genealogyId:desc' } else { 'submittedAt:desc,applyId:desc' } if (-not (Test-IsJsonArray $operation.'x-cursor-scope') -or (@($operation.'x-cursor-scope') -join ',') -ne 'tenant,account,client,filters' -or -not (Test-IsJsonArray $operation.'x-cursor-order') -or (@($operation.'x-cursor-order') -join ',') -ne $expectedOrder -or -not (Test-JsonBoolean $operation.'x-cursor-no-total' $true)) { Add-Issue "$listId cursor extensions drifted" } } $search = $resolvedOperations['appSearchPublicGenealogies'] if ($search) { $keyword = Get-Parameter $operations[0].Path $search 'query' 'keyword' if ($keyword -and (-not (Test-JsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or -not (Test-IsNonNullable $keyword.schema) -or [int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -ne 50)) { Add-Issue 'search keyword must be required non-null string length 1..50' } if ($keyword) { Assert-NoConflictingSchemaKeywords $keyword.schema 'search keyword' Assert-AllowedSchemaKeywords $keyword.schema 'search keyword' @('type', 'minLength', 'maxLength', 'nullable') } } $withdraw = $resolvedOperations['appWithdrawGenealogyJoinApplication'] if ($withdraw) { if ($withdraw.PSObject.Properties['requestBody']) { Add-Issue 'withdraw DELETE must not define a request body' } Assert-PathId $operations[4].Path $withdraw 'applyId' '#/components/schemas/JoinApplicationId' foreach ($pattern in @('WHERE status=PENDING','same withdraw.*same 200','audit.*race.*409','current.*state','terminal.*immutable','cross-account.*tenant.*404')) { if ([string]$withdraw.description -notmatch "(?i)$pattern") { Add-Issue "withdraw description misses: $pattern" } } if ($withdraw.'x-cas-where' -ne 'status=PENDING' -or -not (Test-JsonBoolean $withdraw.'x-same-action-replay' $true)) { Add-Issue 'withdraw CAS extensions drifted' } } $audit = $resolvedOperations['appReviewGenealogyJoinApplication'] if ($audit) { Assert-RequestBody $audit 'audit PUT' '#/components/schemas/AppGenealogyJoinReviewBody' Assert-PathId $operations[6].Path $audit 'genealogyId' '#/components/schemas/GenealogyId' Assert-PathId $operations[6].Path $audit 'applyId' '#/components/schemas/JoinApplicationId' foreach ($pattern in @('WHERE status=PENDING','same decision.*same 200','different rejection.*409','opposite decision.*409','permission.*READY.*PUBLIC_APPLY','same transaction.*member.*application','unique member')) { if ([string]$audit.description -notmatch "(?i)$pattern") { Add-Issue "audit description misses: $pattern" } } if ($audit.'x-cas-where' -ne 'status=PENDING' -or -not (Test-IsJsonArray $audit.'x-transaction-effects') -or (@($audit.'x-transaction-effects') -join ',') -ne 'UNIQUE_MEMBER_RELATION,APPLICATION_TERMINAL_STATE' -or -not (Test-IsJsonArray $audit.'x-revalidates') -or (@($audit.'x-revalidates') -join ',') -ne 'permission,genealogyState,accessPreset' -or -not (Test-JsonBoolean $audit.'x-same-decision-replays-receipt' $true) -or $audit.'x-opposite-decision-error' -ne 'JOIN_APPLICATION_DECISION_CONFLICT') { Add-Issue 'audit CAS/replay/transaction extensions drifted' } } $pendingOperation = $resolvedOperations['appListPendingGenealogyJoinApplications'] Assert-PathId $operations[5].Path $pendingOperation 'genealogyId' '#/components/schemas/GenealogyId' foreach ($readId in @('appSearchPublicGenealogies','appListMyGenealogyJoinApplications','appListPendingGenealogyJoinApplications')) { $read = $resolvedOperations[$readId] if ($read -and $read.PSObject.Properties['requestBody']) { Add-Issue "$readId must not define a request body" } } foreach ($legacy in @('GenealogyJoinApplyBody','GenealogyJoinAuditBody','AppGenealogyJoinApplyBody','AppGenealogyJoinAuditBody')) { if ($document.components.schemas.PSObject.Properties[$legacy]) { Add-Issue "legacy APP join schema must be removed: $legacy" } } # No successful join response may expose account, phone, inviter, or auditor identities anywhere in its recursive schema closure. $queue = New-Object System.Collections.Generic.Queue[string] $seen = @{} foreach ($entry in $operations) { $queue.Enqueue(([string]$entry.SuccessRef).Split('/')[-1]) } while ($queue.Count -gt 0) { $name = $queue.Dequeue() if ($seen.ContainsKey($name)) { continue } $seen[$name] = $true $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "successful response closure is missing schema: $name"; continue } $schema = $owner.Value foreach ($forbidden in @('phone','appUserId','appUserPhone','inviterUserId','inviterPhone','auditUserId','auditPhone')) { if ($schema.properties -and $schema.properties.PSObject.Properties.Name -contains $forbidden) { Add-Issue "$name leaks forbidden identity field: $forbidden" } } $json = $schema | ConvertTo-Json -Depth 40 -Compress foreach ($match in [regex]::Matches($json, '#/components/schemas/(?[A-Za-z0-9._-]+)')) { $queue.Enqueue($match.Groups['Name'].Value) } } if ($issues.Count -gt 0) { Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED' Write-Output "Issues: $($issues.Count)" $issues | ForEach-Object { Write-Output "- $_" } Write-Output 'Only accept APP.openapi.json and APP.openapi.yaml re-exported together from one backend version; never hand-edit the protected files.' exit 1 } Write-Output 'JOIN-APPLICATION-OPENAPI-CONTRACT PASS'