$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/{genealogyId}/invite-tickets/mine'; Method='get'; Id='appListMyGenealogyInviteTickets'; Responses=@('200','400','401','403','404','429','500'); Success='#/components/schemas/RGenealogyInviteTicketList'; Parameters=@('header:clientid','path:genealogyId') }, [pscustomobject]@{ Path='/genealogy/app/genealogies/{genealogyId}/invite-tickets'; Method='post'; Id='appIssueGenealogyInviteTicket'; Responses=@('200','400','401','403','404','409','422','429','500'); Success='#/components/schemas/RIssuedGenealogyInviteTicket'; Parameters=@('header:clientid','header:Idempotency-Key','path:genealogyId') }, [pscustomobject]@{ Path='/genealogy/app/genealogies/{genealogyId}/invite-tickets/{inviteTicketId}'; Method='delete'; Id='appRevokeGenealogyInviteTicket'; Responses=@('200','400','401','403','404','409','429','500'); Success='#/components/schemas/RRevokedGenealogyInviteTicket'; Parameters=@('header:clientid','path:genealogyId','path:inviteTicketId') }, [pscustomobject]@{ Path='/genealogy/app/genealogy-invite-tickets/resolve'; Method='post'; Id='appResolveGenealogyInviteTicket'; Responses=@('200','400','401','404','409','429','500'); Success='#/components/schemas/RResolvedGenealogyInviteTicket'; Parameters=@('header:clientid') }, [pscustomobject]@{ Path='/genealogy/app/genealogy-invite-redemptions'; Method='post'; Id='appRedeemGenealogyInviteTicket'; Responses=@('200','400','401','404','409','422','429','500'); Success='#/components/schemas/RGenealogyInviteRedemptionReceipt'; Parameters=@('header:clientid','header:Genealogy-Redemption-Token','header:Idempotency-Key') }, [pscustomobject]@{ Path='/genealogy/app/genealogy-invite-redemption-requests/{requestKey}'; Method='get'; Id='appGetGenealogyInviteRedemptionRequest'; Responses=@('200','400','401','404','429','500'); Success='#/components/schemas/RGenealogyInviteRedemptionRequestStatus'; Parameters=@('header:clientid','path:requestKey') } ) function Add-Issue([string]$Message) { $script:issues.Add($Message) } function Is-True([object]$Value) { return $Value -is [System.Boolean] -and $Value } 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] $operationProperty = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null } if (-not $operationProperty) { Add-Issue "missing $($Method.ToUpperInvariant()) $Path"; return $null } return $operationProperty.Value } function Get-Parameters([string]$Path, [object]$Operation) { if (-not $Operation) { return @() } $pathItem = $document.paths.PSObject.Properties[$Path].Value $parameters = [ordered]@{} foreach ($scope in @(@($pathItem.parameters), @($Operation.parameters))) { $scopeKeys = @{} foreach ($parameter in $scope) { if (-not $parameter) { continue } $resolved = $parameter if ($parameter.'$ref') { $ownerName = ([string]$parameter.'$ref').Split('/')[-1] $owner = $document.components.parameters.PSObject.Properties[$ownerName] if (-not $owner) { Add-Issue "missing parameter owner: $ownerName"; continue } $resolved = $owner.Value } $key = "$($resolved.in):$($resolved.name)".ToLowerInvariant() if ($scopeKeys.ContainsKey($key)) { Add-Issue "$Path duplicates parameter in one scope: $key"; continue } $scopeKeys[$key] = $true $parameters[$key] = $resolved } } return @($parameters.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 ($Schema.additionalProperties -is [System.Boolean] -and -not $Schema.additionalProperties) -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 ',')" } } 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 [int]$schema.minLength -ne $Min -or [int]$schema.maxLength -ne $Max -or [string]$schema.pattern -ne $Pattern) { Add-Issue "$Name lexical contract drifted" } } function Assert-Ref([object]$Schema, [string]$Name, [string]$Field, [string]$Ref) { if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property -or [string]$property.Value.'$ref' -ne $Ref) { Add-Issue "$Name.$Field must use $Ref" } } 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 @($property.Value.enum).Count -ne 1 -or $property.Value.enum[0] -ne $Value) { Add-Issue "$Name.$Field must be the single value $Value" } } 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) $expectedKeys = @($Mapping.Keys | Sort-Object) $actualKeys = @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object) if ($Schema.discriminator.propertyName -ne $Discriminator -or ($actualRefs -join ',') -ne ($expectedRefs -join ',') -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')) { Add-Issue "$Name discriminator/oneOf contract drifted" } foreach ($key in $expectedKeys) { if ([string]$Schema.discriminator.mapping.$key -ne [string]$Mapping[$key]) { Add-Issue "$Name mapping $key drifted" } } foreach ($forbidden in @('properties','required','allOf','anyOf','additionalProperties')) { if ($Schema.PSObject.Properties[$forbidden]) { Add-Issue "$Name union wrapper must not define $forbidden" } } } 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 @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne 200) { Add-Issue "$Name.code must be integer enum [200]" } Assert-Ref $schema $Name 'data' $DataRef } function Assert-ErrorEnvelope([string]$Name, [int]$Status, [string[]]$BusinessCodes, [string[]]$ExtraFields = @(), [hashtable]$ExtraRefs = @{}) { $schema = Get-Schema $Name $fields = @('code','businessCode') + @($ExtraFields) Assert-ClosedObject $schema $Name $fields $fields if (-not $schema) { return } if ($schema.properties.code.type -ne 'integer' -or @($schema.properties.code.enum).Count -ne 1 -or $schema.properties.code.enum[0] -ne $Status) { Add-Issue "$Name.code must be integer enum [$Status]" } $actualCodes = @($schema.properties.businessCode.enum | Sort-Object) if ($schema.properties.businessCode.type -ne 'string' -or ($actualCodes -join ',') -ne ((@($BusinessCodes | Sort-Object)) -join ',')) { Add-Issue "$Name.businessCode enum drifted" } foreach ($field in $ExtraRefs.Keys) { Assert-Ref $schema $Name $field $ExtraRefs[$field] } } function Assert-DateTime([object]$Schema, [string]$Name, [string]$Field) { if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property -or $property.Value.type -ne 'string' -or $property.Value.format -ne 'date-time') { Add-Issue "$Name.$Field must be RFC3339 date-time" } } function Assert-PathRef([string]$Path, [object]$Operation, [string]$Name, [string]$Ref) { if (-not $Operation) { return } $parameter = Get-Parameter $Path $Operation 'path' $Name if ($parameter -and (-not (Is-True $parameter.required) -or [string]$parameter.schema.'$ref' -ne $Ref)) { Add-Issue "$($Operation.operationId) $Name must be required and use $Ref" } } function Resolve-Response([object]$Response) { if (-not $Response) { return $null } if ($Response.'$ref') { $name = ([string]$Response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "missing response owner: $name"; return $null } return $owner.Value } return $Response } function Expected-ErrorRef([string]$OperationId, [string]$Status) { if ($OperationId -eq 'appGetGenealogyInviteRedemptionRequest' -and $Status -eq '404') { return '#/components/schemas/RGenealogyInviteRedemptionRequestNotAvailable' } if ($Status -eq '409') { if ($OperationId -eq 'appIssueGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteIssueConflict' } if ($OperationId -eq 'appRevokeGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteStateConflict' } if ($OperationId -eq 'appResolveGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteViewerConflict' } if ($OperationId -eq 'appRedeemGenealogyInviteTicket') { return '#/components/schemas/RGenealogyInviteRedemptionConflict' } } $operationSpecific = @{ appListMyGenealogyInviteTickets = @{ '400'='RGenealogyInviteListBadRequest'; '404'='RGenealogyInviteListNotFound' } appIssueGenealogyInviteTicket = @{ '400'='RGenealogyInviteIssueBadRequest'; '404'='RGenealogyInviteIssueNotFound'; '422'='RGenealogyInviteIssueUnprocessable' } appRevokeGenealogyInviteTicket = @{ '400'='RGenealogyInviteRevokeBadRequest'; '404'='RGenealogyInviteRevokeNotFound' } appResolveGenealogyInviteTicket = @{ '400'='RGenealogyInviteResolveBadRequest'; '404'='RGenealogyInviteTicketNotAvailable' } appRedeemGenealogyInviteTicket = @{ '400'='RGenealogyInviteRedemptionBadRequest'; '404'='RGenealogyInviteGrantNotAvailable'; '422'='RGenealogyInviteRedemptionUnprocessable' } appGetGenealogyInviteRedemptionRequest = @{ '400'='RGenealogyInviteRedemptionStatusBadRequest' } } $specific = $operationSpecific[$OperationId][$Status] if ($specific) { return "#/components/schemas/$specific" } return @{ '401'='#/components/schemas/RGenealogyInviteUnauthorized'; '403'='#/components/schemas/RGenealogyInviteForbidden'; '429'='#/components/schemas/RGenealogyInviteRateLimited'; '500'='#/components/schemas/RGenealogyInviteServerError' }[$Status] } function Assert-OperationResponses([object]$Operation, [object]$Entry) { if (-not $Operation) { return } $actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object) if (($actual -join ',') -ne ((@($Entry.Responses | Sort-Object)) -join ',')) { Add-Issue "$($Entry.Id) response set drifted" } foreach ($status in $actual) { if ($status -eq 'default' -or $status -match '^3') { Add-Issue "$($Entry.Id) must not use default or 3xx" } $response = Resolve-Response $Operation.responses.PSObject.Properties[$status].Value 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 "$($Entry.Id) $status must use only application/json" } $expected = if ($status -eq '200') { $Entry.Success } else { Expected-ErrorRef $Entry.Id $status } if ($media -and $expected -and [string]$media.Value.schema.'$ref' -ne $expected) { Add-Issue "$($Entry.Id) $status must use $expected" } $cache = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $cache -or [string]$cache.Value.'$ref' -ne '#/components/headers/PrivateNoStore') { Add-Issue "$($Entry.Id) $status must use PrivateNoStore" } if ($status -eq '429' -or ($Entry.Id -eq 'appGetGenealogyInviteRedemptionRequest' -and $status -eq '404')) { $retry = if ($response.headers) { $response.headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $retry -or [string]$retry.Value.'$ref' -ne '#/components/headers/RetryAfter') { Add-Issue "$($Entry.Id) $status must use RetryAfter" } } } } function Assert-RequestBody([object]$Operation, [string]$OperationId, [string]$Ref) { if (-not $Operation) { return } $content = $Operation.requestBody.content $media = if ($content) { $content.PSObject.Properties['application/json'] } else { $null } if (-not (Is-True $Operation.requestBody.required) -or -not $media -or $content.PSObject.Properties.Count -ne 1 -or [string]$media.Value.schema.'$ref' -ne $Ref) { Add-Issue "$OperationId must require only application/json with $Ref" } } $parity = @(& node (Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js') 2>&1) if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parity) { Add-Issue "protected JSON/YAML parity failed: $($parity -join ' | ')" } $resolved = @{} foreach ($entry in $operations) { $operation = Get-Operation $entry.Path $entry.Method $resolved[$entry.Id] = $operation if ($operation -and $operation.operationId -ne $entry.Id) { Add-Issue "$($entry.Method.ToUpperInvariant()) $($entry.Path) operationId must be $($entry.Id)" } if ($operation) { $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 "$($entry.Id) must require SaToken" } $actualParameters = @(Get-Parameters $entry.Path $operation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object) if (($actualParameters -join ',') -ne ((@($entry.Parameters | Sort-Object)) -join ',')) { Add-Issue "$($entry.Id) parameter set drifted: $($actualParameters -join ',')" } $client = Get-Parameter $entry.Path $operation 'header' 'clientid' if ($client -and (-not (Is-True $client.required) -or $client.schema.type -ne 'string' -or [int]$client.schema.minLength -lt 1)) { Add-Issue "$($entry.Id) clientid must be required and non-empty" } } Assert-OperationResponses $operation $entry } foreach ($entry in $operations) { $operation = $resolved[$entry.Id] if ($entry.Path -match '\{genealogyId\}') { Assert-PathRef $entry.Path $operation 'genealogyId' '#/components/schemas/GenealogyId' } if ($entry.Path -match '\{inviteTicketId\}') { Assert-PathRef $entry.Path $operation 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' } } 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 @($privateHeader.schema.enum).Count -ne 1 -or $privateHeader.schema.enum[0] -ne 'private, no-store') { Add-Issue 'PrivateNoStore header must be fixed private, no-store' } $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 [int]$retryHeader.schema.minimum -ne 1 -or [int]$retryHeader.schema.maximum -ne 120) { Add-Issue 'RetryAfter header must be integer 1..120 seconds' } Assert-StringOwner 'GenealogyInviteTicketId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' Assert-StringOwner 'GenealogyMembershipId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' Assert-StringOwner 'GenealogyId' 1 128 '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' Assert-StringOwner 'GenealogyInviteIssueRequestKey' 40 61 '^gii\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$' Assert-StringOwner 'GenealogyInviteRedemptionRequestKey' 40 61 '^gir\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$' Assert-StringOwner 'GenealogyInviteCode' 37 37 '^JPI1(?:-[0-9A-HJKMNP-TV-Z]{4}){6}-[0-9A-HJKMNP-TV-Z]{2}$' Assert-StringOwner 'GenealogyRedemptionToken' 43 512 '^[A-Za-z0-9_-]+$' foreach ($keyName in @('GenealogyInviteIssueRequestKey','GenealogyInviteRedemptionRequestKey')) { $keyOwner = Get-Schema $keyName if ($keyOwner -and ($keyOwner.'x-issued-at-source' -ne 'KEY_EPOCH_MILLISECONDS' -or [int]$keyOwner.'x-accept-window-seconds' -ne 600 -or [int]$keyOwner.'x-max-future-skew-seconds' -ne 300 -or [int]$keyOwner.'x-random-min-bits' -ne 128)) { Add-Issue "$keyName timing/random extensions drifted" } } $codeOwner = Get-Schema 'GenealogyInviteCode' if ($codeOwner -and (-not (Is-True $codeOwner.'x-sensitive') -or [int]$codeOwner.'x-random-min-bits' -ne 128 -or [int]$codeOwner.'x-valid-for-seconds' -ne 86400 -or [int]$codeOwner.'x-secret-replay-window-seconds' -ne 600 -or $codeOwner.'x-storage' -ne 'HMAC_LOOKUP_KMS_ENCRYPTED_REPLAY_WINDOW')) { Add-Issue 'GenealogyInviteCode security/lifetime extensions drifted' } $tokenOwner = Get-Schema 'GenealogyRedemptionToken' if ($tokenOwner -and (-not (Is-True $tokenOwner.'x-sensitive') -or [int]$tokenOwner.'x-random-min-bits' -ne 128 -or $tokenOwner.'x-storage' -ne 'HMAC_ONLY' -or [int]$tokenOwner.'x-max-ttl-seconds' -ne 300 -or $tokenOwner.'x-binding' -ne 'tenant,account,client,inviteTicketId,ticketVersion,genealogyId,authorizationEpoch')) { Add-Issue 'GenealogyRedemptionToken sensitivity/binding drifted' } $issue = $resolved['appIssueGenealogyInviteTicket'] if ($issue) { if ($issue.PSObject.Properties['requestBody']) { Add-Issue 'issue POST must not accept configurable body in the single-use 24-hour first release' } $key = Get-Parameter $operations[1].Path $issue 'header' 'Idempotency-Key' if ($key -and (-not (Is-True $key.required) -or [string]$key.schema.'$ref' -ne '#/components/schemas/GenealogyInviteIssueRequestKey')) { Add-Issue 'issue POST must use GenealogyInviteIssueRequestKey' } if ((@($issue.'x-idempotency-scope') -join ',') -ne 'method,path,genealogyId,tenant,account,client' -or $issue.'x-secret-replay-policy' -ne 'SAME_SECRET_WITHIN_600_SECONDS_THEN_METADATA' -or $issue.'x-secret-replay-storage' -ne 'KMS_ENVELOPE_600_SECONDS' -or $issue.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED' -or [int]$issue.'x-ticket-usage-limit' -ne 1 -or [int]$issue.'x-ticket-ttl-seconds' -ne 86400) { Add-Issue 'issue POST idempotency/single-use/lifetime extensions drifted' } if ($issue.'x-required-capability' -ne 'INVITE_MEMBER' -or $issue.'x-active-ticket-constraint' -ne 'DATABASE_MAX_20_PER_TENANT_GENEALOGY_ISSUER' -or (@($issue.'x-revalidates') -join ',') -ne 'GENEALOGY_READY,INVITATIONS_ENABLED,ISSUER_MEMBERSHIP,ISSUER_CAPABILITY' -or $issue.'x-secret-replay-storage' -ne 'KMS_ENVELOPE_600_SECONDS' -or $issue.'x-secret-after-window' -ne 'ISSUED_SECRET_UNAVAILABLE_NO_NEW_TICKET') { Add-Issue 'issue capability/constraint/secret-recovery extensions drifted' } if ($issue.'x-existing-key-precedence' -ne 'DIFFERENT_DIGEST_THEN_SECRET_WINDOW_THEN_METADATA' -or $issue.'x-first-claim-after-accept-window' -ne 'OPERATION_KEY_EXPIRED_ONLY_IF_ABSENT' -or [int]$issue.'x-control-record-retention-days' -lt 30 -or $issue.'x-atomic-commit' -ne 'KMS_CIPHERTEXT,TICKET,HMAC_LOOKUP,REQUEST_RECEIPT' -or $issue.'x-concurrent-same-key' -ne 'LOSER_READS_WINNER_RECEIPT' -or $issue.'x-fault-injection-effects' -ne 'ALL_OR_ZERO_TICKET' -or $issue.'x-client-marker' -ne 'sessionEpoch,genealogyId,requestKey,startedAt') { Add-Issue 'issue precedence/atomic control-record extensions drifted' } } $list = $resolved['appListMyGenealogyInviteTickets'] if ($list -and $list.PSObject.Properties['requestBody']) { Add-Issue 'ticket list GET must not define a request body' } if ($list -and ($list.'x-required-capability' -ne 'INVITE_MEMBER' -or $list.'x-issuer-scope' -ne 'TENANT_GENEALOGY_ISSUER_ACCOUNT_EQUALS_ACTOR' -or (@($list.'x-visible-ticket-states') -join ',') -ne 'ACTIVE' -or -not (Is-True $list.'x-never-returns-secret'))) { Add-Issue 'ticket list capability/visibility extensions drifted' } $revoke = $resolved['appRevokeGenealogyInviteTicket'] if ($revoke) { if ($revoke.PSObject.Properties['requestBody']) { Add-Issue 'revoke DELETE must not define a request body' } if ($revoke.'x-required-capability' -ne 'INVITE_MEMBER' -or $revoke.'x-issuer-scope' -ne 'TENANT_GENEALOGY_ISSUER_ACCOUNT_EQUALS_ACTOR' -or $revoke.'x-cross-issuer-error' -ne 'NON_DISCLOSING_404' -or $revoke.'x-ticket-cas' -ne 'ACTIVE_TO_REVOKED' -or -not (Is-True $revoke.'x-same-revoke-replays-receipt') -or $revoke.'x-race-winner' -ne 'REVOKE_OR_REDEEM_EXACTLY_ONE' -or (@($revoke.'x-revalidates') -join ',') -ne 'ISSUER_MEMBERSHIP,ISSUER_CAPABILITY,AUTHORIZATION_EPOCH') { Add-Issue 'revoke capability/CAS/replay/race extensions drifted' } } $resolve = $resolved['appResolveGenealogyInviteTicket'] if ($resolve) { Assert-RequestBody $resolve 'appResolveGenealogyInviteTicket' '#/components/schemas/GenealogyInviteResolveBody' } $resolveBody = Get-Schema 'GenealogyInviteResolveBody' Assert-ClosedObject $resolveBody 'GenealogyInviteResolveBody' @('inviteCode') @('inviteCode') Assert-Ref $resolveBody 'GenealogyInviteResolveBody' 'inviteCode' '#/components/schemas/GenealogyInviteCode' if ($resolve -and ((@($resolve.'x-domain-effects') -join ',') -ne 'NONE' -or $resolve.'x-resolve-effects' -ne 'REDEMPTION_GRANT_ONLY' -or -not (Is-True $resolve.'x-ticket-not-consumed') -or $resolve.'x-grant-write-policy' -ne 'ROTATE_ONE_ACTIVE_PER_BOUND_SUBJECT' -or [int]$resolve.'x-grant-ttl-seconds' -ne 300 -or $resolve.'x-grant-expiry-cleanup' -ne 'DELETE_OR_IRREVERSIBLE_EXPIRE' -or $resolve.'x-not-available-error' -ne 'INVITE_TICKET_NOT_AVAILABLE' -or (@($resolve.'x-rate-limit-dimensions') -join ',') -ne 'account,device,ip,codePrefix,global' -or $resolve.'x-timing-side-channel-policy' -ne 'UNIFORM_NOT_AVAILABLE' -or $resolve.'x-grant-invalidated-by' -ne 'INPUT_EDIT,SESSION_EPOCH,REVOKE,EXPIRE,TICKET_VERSION,AUTHORIZATION_EPOCH,INVITATIONS_DISABLED')) { Add-Issue 'resolve must have no ticket consumption/member write and only bounded grant security writes' } $redeem = $resolved['appRedeemGenealogyInviteTicket'] if ($redeem) { if ($redeem.PSObject.Properties['requestBody']) { Add-Issue 'redeem must not accept applicantName, relationDesc, applyReason, inviteCode, or another body' } $redemptionToken = Get-Parameter $operations[4].Path $redeem 'header' 'Genealogy-Redemption-Token' if ($redemptionToken -and (-not (Is-True $redemptionToken.required) -or [string]$redemptionToken.schema.'$ref' -ne '#/components/schemas/GenealogyRedemptionToken' -or -not (Is-True $redemptionToken.'x-sensitive'))) { Add-Issue 'redeem must require sensitive Genealogy-Redemption-Token' } $requestKey = Get-Parameter $operations[4].Path $redeem 'header' 'Idempotency-Key' if ($requestKey -and (-not (Is-True $requestKey.required) -or [string]$requestKey.schema.'$ref' -ne '#/components/schemas/GenealogyInviteRedemptionRequestKey')) { Add-Issue 'redeem must use GenealogyInviteRedemptionRequestKey' } if ((@($redeem.'x-domain-transaction-effects') -join ',') -ne 'INVITE_TICKET_CONSUMED,MEMBER_RELATION,SUCCEEDED_RECEIPT' -or (@($redeem.'x-member-unique-scope') -join ',') -ne 'tenant,genealogyId,account' -or $redeem.'x-ticket-cas' -ne 'ACTIVE_TO_CONSUMED' -or -not (Is-True $redeem.'x-no-join-application') -or -not (Is-True $redeem.'x-same-request-replays-receipt') -or $redeem.'x-active-pending-policy' -ne 'REJECT_ACTIVE_PENDING_APPLICATION') { Add-Issue 'redeem atomic membership/CAS/application-isolation extensions drifted' } if ((@($redeem.'x-idempotency-scope') -join ',') -ne 'method,path,tenant,account,client,redemptionTokenIdentity,inviteTicketId,genealogyId' -or $redeem.'x-different-digest-error' -ne 'IDEMPOTENCY_KEY_REUSED' -or (@($redeem.'x-transaction-revalidates') -join ',') -ne 'TOKEN_BINDING,TOKEN_EXPIRY,TICKET_VERSION,TICKET_ACTIVE,TICKET_NOT_EXPIRED,TICKET_UNUSED,GENEALOGY_READY,INVITATIONS_ENABLED,ISSUER_CAPABILITY,ACCOUNT_ELIGIBLE,CAPACITY' -or (@($redeem.'x-no-consume-errors') -join ',') -ne 'ALREADY_MEMBER,ACTIVE_PENDING_APPLICATION,ACCOUNT_NOT_ELIGIBLE,INVITE_TICKET_NOT_AVAILABLE' -or $redeem.'x-concurrency-policy' -ne 'TICKET_AND_MEMBER_CONSTRAINTS_BEFORE_CONSUME' -or $redeem.'x-grant-invalidation' -ne 'REVOKE,EXPIRE,TICKET_VERSION,AUTHORIZATION_EPOCH,INVITATIONS_DISABLED') { Add-Issue 'redeem canonical identity/revalidation/no-consume extensions drifted' } } $status = $resolved['appGetGenealogyInviteRedemptionRequest'] if ($status) { if ($status.PSObject.Properties['requestBody']) { Add-Issue 'redemption status GET must not define a request body' } $requestKey = Get-Parameter $operations[5].Path $status 'path' 'requestKey' if ($requestKey -and (-not (Is-True $requestKey.required) -or [string]$requestKey.schema.'$ref' -ne '#/components/schemas/GenealogyInviteRedemptionRequestKey')) { Add-Issue 'redemption status must use the redemption request-key owner' } if ((@($status.'x-state-transitions') -join ',') -ne 'ABSENT->PENDING,PENDING->SUCCEEDED,PENDING->FAILED_NO_COMMIT' -or -not (Is-True $status.'x-terminal-immutable') -or -not (Is-True $status.'x-read-only-no-write')) { Add-Issue 'redemption status transition/read-only extensions drifted' } if ([int]$status.'x-accept-window-seconds' -ne 600 -or [int]$status.'x-max-future-skew-seconds' -ne 300 -or [int]$status.'x-resolve-sla-seconds' -ne 120 -or $status.'x-absent-before-deadline' -ne '404_WITH_ACCEPT_UNTIL_AND_RETRY_AFTER' -or $status.'x-absent-after-deadline' -ne 'COMPUTED_FAILED_NO_COMMIT_NO_WRITE' -or $status.'x-subject-isolation' -ne 'TENANT_ACCOUNT_CLIENT_404' -or $status.'x-fencing-policy' -ne 'WATCHDOG_AND_WORKER_TERMINAL_CAS') { Add-Issue 'redemption status timing/isolation/fencing extensions drifted' } if ($status.'x-existing-terminal-precedence' -ne 'RETURN_STORED_TERMINAL_BEFORE_ABSENT_COMPUTATION' -or [int]$status.'x-terminal-receipt-retention-days' -lt 30 -or [int]$status.'x-client-marker-ttl-days' -ne 30 -or $status.'x-cleanup-order' -ne 'CLIENT_MARKER_EXPIRES_BEFORE_TERMINAL_RECEIPT' -or $status.'x-post-cleanup-safety' -ne 'NEVER_INFER_FAILED_AGAINST_COMMITTED_DOMAIN_EFFECTS') { Add-Issue 'redemption terminal retention/cleanup safety extensions drifted' } } $listItem = Get-Schema 'GenealogyInviteTicketMetadata' Assert-ClosedObject $listItem 'GenealogyInviteTicketMetadata' @('inviteTicketId','state','issuedAt','expiresAt','usageLimit') @('inviteTicketId','state','issuedAt','expiresAt','usageLimit') Assert-Ref $listItem 'GenealogyInviteTicketMetadata' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' Assert-SingleEnum $listItem 'GenealogyInviteTicketMetadata' 'state' 'ACTIVE' Assert-DateTime $listItem 'GenealogyInviteTicketMetadata' 'issuedAt' Assert-DateTime $listItem 'GenealogyInviteTicketMetadata' 'expiresAt' if ($listItem -and ($listItem.properties.usageLimit.type -ne 'integer' -or @($listItem.properties.usageLimit.enum).Count -ne 1 -or $listItem.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'GenealogyInviteTicketMetadata.usageLimit must be integer enum [1]' } if ($listItem -and ($listItem.properties.PSObject.Properties['inviteCode'] -or $listItem.properties.PSObject.Properties['inviteeUserId'])) { Add-Issue 'ticket metadata must not expose inviteCode or invitee identity' } $listModel = Get-Schema 'GenealogyInviteTicketList' Assert-ClosedObject $listModel 'GenealogyInviteTicketList' @('items') @('items') if ($listModel -and ($listModel.properties.items.type -ne 'array' -or [string]$listModel.properties.items.items.'$ref' -ne '#/components/schemas/GenealogyInviteTicketMetadata' -or [int]$listModel.properties.items.maxItems -ne 20)) { Add-Issue 'GenealogyInviteTicketList must contain at most 20 active metadata items' } $issued = Get-Schema 'IssuedGenealogyInviteTicket' Assert-ClosedObject $issued 'IssuedGenealogyInviteTicket' @('inviteTicketId','inviteCode','state','issuedAt','expiresAt','usageLimit') @('inviteTicketId','inviteCode','state','issuedAt','expiresAt','usageLimit') Assert-Ref $issued 'IssuedGenealogyInviteTicket' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' Assert-Ref $issued 'IssuedGenealogyInviteTicket' 'inviteCode' '#/components/schemas/GenealogyInviteCode' Assert-SingleEnum $issued 'IssuedGenealogyInviteTicket' 'state' 'ACTIVE' Assert-DateTime $issued 'IssuedGenealogyInviteTicket' 'issuedAt' Assert-DateTime $issued 'IssuedGenealogyInviteTicket' 'expiresAt' if ($issued -and ($issued.properties.usageLimit.type -ne 'integer' -or @($issued.properties.usageLimit.enum).Count -ne 1 -or $issued.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'IssuedGenealogyInviteTicket.usageLimit must be integer enum [1]' } if ($issued -and ($issued.'x-expires-at-formula' -ne 'issuedAt+86400s' -or [int]$issued.'x-secret-replay-window-seconds' -ne 600)) { Add-Issue 'IssuedGenealogyInviteTicket expiry/replay-window formula drifted' } $issuedSnapshot = Get-Schema 'GenealogyInviteIssuedTicketSnapshot' Assert-ClosedObject $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' @('inviteTicketId','issuedAt','expiresAt','usageLimit') @('inviteTicketId','issuedAt','expiresAt','usageLimit') Assert-Ref $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' Assert-DateTime $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'issuedAt' Assert-DateTime $issuedSnapshot 'GenealogyInviteIssuedTicketSnapshot' 'expiresAt' if ($issuedSnapshot -and ($issuedSnapshot.properties.usageLimit.type -ne 'integer' -or @($issuedSnapshot.properties.usageLimit.enum).Count -ne 1 -or $issuedSnapshot.properties.usageLimit.enum[0] -ne 1)) { Add-Issue 'GenealogyInviteIssuedTicketSnapshot.usageLimit must be integer enum [1]' } $revoked = Get-Schema 'RevokedGenealogyInviteTicket' Assert-ClosedObject $revoked 'RevokedGenealogyInviteTicket' @('inviteTicketId','state','revokedAt') @('inviteTicketId','state','revokedAt') Assert-Ref $revoked 'RevokedGenealogyInviteTicket' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' Assert-SingleEnum $revoked 'RevokedGenealogyInviteTicket' 'state' 'REVOKED' Assert-DateTime $revoked 'RevokedGenealogyInviteTicket' 'revokedAt' $target = Get-Schema 'GenealogyInviteTarget' Assert-ClosedObject $target 'GenealogyInviteTarget' @('genealogyId','genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel') @('genealogyId','genealogyName','surname','regionName') Assert-Ref $target 'GenealogyInviteTarget' 'genealogyId' '#/components/schemas/GenealogyId' if ($target) { foreach ($field in @('genealogyName','surname','regionName','ancestralHall','parentGenealogyName','branchName','certificationLabel')) { $property = $target.properties.PSObject.Properties[$field] if (-not $property -or $property.Value.type -ne 'string' -or [int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 100 -or $property.Value.'x-text-normalizer' -ne 'INVITE_TARGET_TEXT_V1') { Add-Issue "GenealogyInviteTarget.$field must use INVITE_TARGET_TEXT_V1 and length 1..100" } } } $resolvedModel = Get-Schema 'ResolvedGenealogyInviteTicket' Assert-ClosedObject $resolvedModel 'ResolvedGenealogyInviteTicket' @('redemptionToken','redemptionExpiresAt','target') @('redemptionToken','redemptionExpiresAt','target') Assert-Ref $resolvedModel 'ResolvedGenealogyInviteTicket' 'redemptionToken' '#/components/schemas/GenealogyRedemptionToken' Assert-Ref $resolvedModel 'ResolvedGenealogyInviteTicket' 'target' '#/components/schemas/GenealogyInviteTarget' Assert-DateTime $resolvedModel 'ResolvedGenealogyInviteTicket' 'redemptionExpiresAt' $receipt = Get-Schema 'GenealogyInviteRedemptionReceipt' Assert-ClosedObject $receipt 'GenealogyInviteRedemptionReceipt' @('genealogyId','membershipId','role','status','joinedAt') @('genealogyId','membershipId','role','status','joinedAt') Assert-Ref $receipt 'GenealogyInviteRedemptionReceipt' 'genealogyId' '#/components/schemas/GenealogyId' Assert-Ref $receipt 'GenealogyInviteRedemptionReceipt' 'membershipId' '#/components/schemas/GenealogyMembershipId' Assert-SingleEnum $receipt 'GenealogyInviteRedemptionReceipt' 'role' 'MEMBER' Assert-SingleEnum $receipt 'GenealogyInviteRedemptionReceipt' 'status' 'JOINED' Assert-DateTime $receipt 'GenealogyInviteRedemptionReceipt' 'joinedAt' $requestStatus = Get-Schema 'GenealogyInviteRedemptionRequestStatus' Assert-Union $requestStatus 'GenealogyInviteRedemptionRequestStatus' 'status' @{ PENDING='#/components/schemas/PendingGenealogyInviteRedemptionRequest'; SUCCEEDED='#/components/schemas/SucceededGenealogyInviteRedemptionRequest'; FAILED_NO_COMMIT='#/components/schemas/FailedGenealogyInviteRedemptionRequest' } foreach ($branch in @( @{ Name='PendingGenealogyInviteRedemptionRequest'; Status='PENDING'; Fields=@('status','resolveBy','retryAfterSeconds'); Required=@('status','resolveBy','retryAfterSeconds') }, @{ Name='SucceededGenealogyInviteRedemptionRequest'; Status='SUCCEEDED'; Fields=@('status','result'); Required=@('status','result') }, @{ Name='FailedGenealogyInviteRedemptionRequest'; Status='FAILED_NO_COMMIT'; Fields=@('status'); Required=@('status') } )) { $schema = Get-Schema $branch.Name Assert-ClosedObject $schema $branch.Name $branch.Fields $branch.Required Assert-SingleEnum $schema $branch.Name 'status' $branch.Status } $pendingStatus = Get-Schema 'PendingGenealogyInviteRedemptionRequest' Assert-DateTime $pendingStatus 'PendingGenealogyInviteRedemptionRequest' 'resolveBy' if ($pendingStatus -and ($pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30)) { Add-Issue 'PendingGenealogyInviteRedemptionRequest.retryAfterSeconds must be integer 1..30' } $succeeded = Get-Schema 'SucceededGenealogyInviteRedemptionRequest' Assert-Ref $succeeded 'SucceededGenealogyInviteRedemptionRequest' 'result' '#/components/schemas/GenealogyInviteRedemptionReceipt' $failed = Get-Schema 'FailedGenealogyInviteRedemptionRequest' if ($failed -and (($failed.'x-domain-effects' -join ',') -ne 'NONE' -or -not ($failed.'x-ticket-consumed' -is [System.Boolean] -and -not $failed.'x-ticket-consumed'))) { Add-Issue 'FAILED_NO_COMMIT must guarantee no ticket consumption or member write' } $currentTicket = Get-Schema 'GenealogyInviteTicketCurrentState' Assert-ClosedObject $currentTicket 'GenealogyInviteTicketCurrentState' @('inviteTicketId','state','changedAt') @('inviteTicketId','state','changedAt') Assert-Ref $currentTicket 'GenealogyInviteTicketCurrentState' 'inviteTicketId' '#/components/schemas/GenealogyInviteTicketId' Assert-DateTime $currentTicket 'GenealogyInviteTicketCurrentState' 'changedAt' if ($currentTicket -and ($currentTicket.properties.state.type -ne 'string' -or (@($currentTicket.properties.state.enum | Sort-Object) -join ',') -ne 'CONSUMED,EXPIRED,REVOKED')) { Add-Issue 'GenealogyInviteTicketCurrentState.state enum drifted' } Assert-ErrorEnvelope 'RGenealogyInviteListBadRequest' 400 @('INVALID_REQUEST') Assert-ErrorEnvelope 'RGenealogyInviteIssueBadRequest' 400 @('OPERATION_KEY_INVALID') Assert-ErrorEnvelope 'RGenealogyInviteRevokeBadRequest' 400 @('INVALID_REQUEST') Assert-ErrorEnvelope 'RGenealogyInviteResolveBadRequest' 400 @('INVITE_CODE_INVALID') Assert-ErrorEnvelope 'RGenealogyInviteRedemptionBadRequest' 400 @('OPERATION_KEY_INVALID','REDEMPTION_TOKEN_INVALID') Assert-ErrorEnvelope 'RGenealogyInviteRedemptionStatusBadRequest' 400 @('OPERATION_KEY_INVALID') Assert-ErrorEnvelope 'RGenealogyInviteUnauthorized' 401 @('AUTHENTICATION_REQUIRED') Assert-ErrorEnvelope 'RGenealogyInviteForbidden' 403 @('INVITE_MANAGEMENT_FORBIDDEN') Assert-ErrorEnvelope 'RGenealogyInviteListNotFound' 404 @('GENEALOGY_NOT_FOUND') Assert-ErrorEnvelope 'RGenealogyInviteIssueNotFound' 404 @('GENEALOGY_NOT_FOUND') Assert-ErrorEnvelope 'RGenealogyInviteRevokeNotFound' 404 @('GENEALOGY_NOT_FOUND','INVITE_TICKET_NOT_FOUND') Assert-ErrorEnvelope 'RGenealogyInviteTicketNotAvailable' 404 @('INVITE_TICKET_NOT_AVAILABLE') Assert-ErrorEnvelope 'RGenealogyInviteGrantNotAvailable' 404 @('INVITE_TICKET_NOT_AVAILABLE') $issueConflict = Get-Schema 'RGenealogyInviteIssueConflict' Assert-Union $issueConflict 'RGenealogyInviteIssueConflict' 'businessCode' @{ ACTIVE_INVITE_TICKET_LIMIT='#/components/schemas/RGenealogyInviteActiveLimitConflict' IDEMPOTENCY_KEY_REUSED='#/components/schemas/RGenealogyInviteIssueKeyConflict' OPERATION_KEY_EXPIRED='#/components/schemas/RGenealogyInviteIssueExpiredConflict' ISSUED_SECRET_UNAVAILABLE='#/components/schemas/RGenealogyInviteIssuedSecretUnavailable' } Assert-ErrorEnvelope 'RGenealogyInviteActiveLimitConflict' 409 @('ACTIVE_INVITE_TICKET_LIMIT') Assert-ErrorEnvelope 'RGenealogyInviteIssueKeyConflict' 409 @('IDEMPOTENCY_KEY_REUSED') Assert-ErrorEnvelope 'RGenealogyInviteIssueExpiredConflict' 409 @('OPERATION_KEY_EXPIRED') Assert-ErrorEnvelope 'RGenealogyInviteIssuedSecretUnavailable' 409 @('ISSUED_SECRET_UNAVAILABLE') @('ticket') @{ ticket='#/components/schemas/GenealogyInviteIssuedTicketSnapshot' } Assert-ErrorEnvelope 'RGenealogyInviteStateConflict' 409 @('INVITE_TICKET_CONSUMED','INVITE_TICKET_EXPIRED') @('current') @{ current='#/components/schemas/GenealogyInviteTicketCurrentState' } Assert-ErrorEnvelope 'RGenealogyInviteViewerConflict' 409 @('ACTIVE_PENDING_APPLICATION','ALREADY_MEMBER') $redemptionConflict = Get-Schema 'RGenealogyInviteRedemptionConflict' Assert-Union $redemptionConflict 'RGenealogyInviteRedemptionConflict' 'businessCode' @{ IDEMPOTENCY_KEY_REUSED='#/components/schemas/RGenealogyInviteRedemptionKeyReusedConflict' OPERATION_KEY_EXPIRED='#/components/schemas/RGenealogyInviteRedemptionKeyExpiredConflict' ACTIVE_PENDING_APPLICATION='#/components/schemas/RGenealogyInviteActivePendingConflict' ALREADY_MEMBER='#/components/schemas/RGenealogyInviteAlreadyMemberConflict' INVITE_TICKET_STATE_CHANGED='#/components/schemas/RGenealogyInviteRedemptionTicketConflict' } Assert-ErrorEnvelope 'RGenealogyInviteRedemptionKeyReusedConflict' 409 @('IDEMPOTENCY_KEY_REUSED') Assert-ErrorEnvelope 'RGenealogyInviteRedemptionKeyExpiredConflict' 409 @('OPERATION_KEY_EXPIRED') Assert-ErrorEnvelope 'RGenealogyInviteActivePendingConflict' 409 @('ACTIVE_PENDING_APPLICATION') Assert-ErrorEnvelope 'RGenealogyInviteAlreadyMemberConflict' 409 @('ALREADY_MEMBER') Assert-ErrorEnvelope 'RGenealogyInviteRedemptionTicketConflict' 409 @('INVITE_TICKET_STATE_CHANGED') @('current') @{ current='#/components/schemas/GenealogyInviteTicketCurrentState' } Assert-ErrorEnvelope 'RGenealogyInviteIssueUnprocessable' 422 @('GENEALOGY_NOT_READY','INVITATIONS_DISABLED') Assert-ErrorEnvelope 'RGenealogyInviteRedemptionUnprocessable' 422 @('ACCOUNT_NOT_ELIGIBLE','CAPACITY_EXCEEDED','GENEALOGY_NOT_READY','INVITATIONS_DISABLED') Assert-ErrorEnvelope 'RGenealogyInviteRateLimited' 429 @('RATE_LIMITED') Assert-ErrorEnvelope 'RGenealogyInviteServerError' 500 @('INTERNAL_ERROR') $statusNotAvailable = Get-Schema 'RGenealogyInviteRedemptionRequestNotAvailable' Assert-ClosedObject $statusNotAvailable 'RGenealogyInviteRedemptionRequestNotAvailable' @('code','businessCode','acceptUntil') @('code','businessCode','acceptUntil') if ($statusNotAvailable) { if ($statusNotAvailable.properties.code.type -ne 'integer' -or @($statusNotAvailable.properties.code.enum).Count -ne 1 -or $statusNotAvailable.properties.code.enum[0] -ne 404 -or $statusNotAvailable.properties.businessCode.type -ne 'string' -or @($statusNotAvailable.properties.businessCode.enum).Count -ne 1 -or $statusNotAvailable.properties.businessCode.enum[0] -ne 'INVITE_REDEMPTION_REQUEST_NOT_AVAILABLE') { Add-Issue 'redemption status 404 code/businessCode drifted' } Assert-DateTime $statusNotAvailable 'RGenealogyInviteRedemptionRequestNotAvailable' 'acceptUntil' } Assert-Envelope 'RGenealogyInviteTicketList' '#/components/schemas/GenealogyInviteTicketList' Assert-Envelope 'RIssuedGenealogyInviteTicket' '#/components/schemas/IssuedGenealogyInviteTicket' Assert-Envelope 'RRevokedGenealogyInviteTicket' '#/components/schemas/RevokedGenealogyInviteTicket' Assert-Envelope 'RResolvedGenealogyInviteTicket' '#/components/schemas/ResolvedGenealogyInviteTicket' Assert-Envelope 'RGenealogyInviteRedemptionReceipt' '#/components/schemas/GenealogyInviteRedemptionReceipt' Assert-Envelope 'RGenealogyInviteRedemptionRequestStatus' '#/components/schemas/GenealogyInviteRedemptionRequestStatus' function Visit-SensitiveSchema([object]$Schema, [string]$Label, [string]$Trail, [string[]]$Allowed, [string[]]$RefStack, [int]$Depth = 0) { if (-not $Schema) { return } if ($Depth -gt 40) { Add-Issue "$Label response schema closure exceeds depth 40 at $Trail"; return } if ($Schema.'$ref') { $ref = [string]$Schema.'$ref' if ($ref -notmatch '^#/components/schemas/') { Add-Issue "$Label uses unsupported response ref: $ref"; return } $name = $ref.Split('/')[-1] if ($RefStack -contains $name) { return } $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "$Label response closure is missing schema: $name"; return } if ((Is-True $owner.Value.'x-sensitive') -and $Allowed -notcontains $Trail) { Add-Issue "$Label response closure exposes sensitive schema $name at $Trail" } Visit-SensitiveSchema $owner.Value $Label $Trail $Allowed (@($RefStack) + $name) ($Depth + 1) return } if ($Schema.properties) { foreach ($property in $Schema.properties.PSObject.Properties) { $propertyTrail = "$Trail.$($property.Name)" $forbidden = @('inviteCode','redemptionToken','inviteeUserId','inviteeAccountId','issuerUserId','issuerAccountId','inviterUserId','phone','applicantName','relationDesc','applyReason','auditRemark','joinApplication') if ($forbidden -contains $property.Name -and $Allowed -notcontains $propertyTrail) { Add-Issue "$Label response closure exposes forbidden field: $propertyTrail" } Visit-SensitiveSchema $property.Value $Label $propertyTrail $Allowed $RefStack ($Depth + 1) } } foreach ($keyword in @('oneOf','allOf','anyOf')) { foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) { Visit-SensitiveSchema $branch $Label $Trail $Allowed $RefStack ($Depth + 1) } } if ($Schema.items) { Visit-SensitiveSchema $Schema.items $Label "$Trail[]" $Allowed $RefStack ($Depth + 1) } if ($Schema.additionalProperties -and -not ($Schema.additionalProperties -is [System.Boolean])) { Visit-SensitiveSchema $Schema.additionalProperties $Label "$Trail.*" $Allowed $RefStack ($Depth + 1) } } foreach ($entry in $operations) { $operation = $resolved[$entry.Id] if (-not $operation) { continue } foreach ($responseProperty in $operation.responses.PSObject.Properties) { $statusCode = $responseProperty.Name $response = Resolve-Response $responseProperty.Value $media = if ($response -and $response.content) { $response.content.PSObject.Properties['application/json'] } else { $null } if (-not $media) { continue } $allowed = @() if ($entry.Id -eq 'appIssueGenealogyInviteTicket' -and $statusCode -eq '200') { $allowed = @('$.data.inviteCode') } if ($entry.Id -eq 'appResolveGenealogyInviteTicket' -and $statusCode -eq '200') { $allowed = @('$.data.redemptionToken') } Visit-SensitiveSchema $media.Value.schema "$($entry.Id) $statusCode" '$' $allowed @() } } $joinPath = '/genealogy/app/genealogies/{genealogyId}/join-applies' $ordinaryJoin = Get-Operation $joinPath 'post' if ($ordinaryJoin) { $ordinaryContent = $ordinaryJoin.requestBody.content.PSObject.Properties['application/json'] if (-not $ordinaryContent -or [string]$ordinaryContent.Value.schema.'$ref' -ne '#/components/schemas/AppGenealogyJoinApplicationBody') { Add-Issue 'ordinary join POST must remain owned by AppGenealogyJoinApplicationBody' } $ordinarySchema = if ($ordinaryContent) { $ordinaryContent.Value.schema } else { $null } function Visit-OrdinaryJoinSchema([object]$Schema, [string[]]$RefStack = @()) { if (-not $Schema) { return } if ($Schema.'$ref') { $name = ([string]$Schema.'$ref').Split('/')[-1] if ($name -in @('GenealogyInviteCode','GenealogyRedemptionToken','GenealogyInviteTicketId','GenealogyInviteRedemptionRequestKey')) { Add-Issue "ordinary join schema must not reference invitation owner: $name" } if ($RefStack -contains $name) { return } $owner = $document.components.schemas.PSObject.Properties[$name] if ($owner) { Visit-OrdinaryJoinSchema $owner.Value (@($RefStack) + $name) } return } foreach ($property in @($Schema.properties.PSObject.Properties)) { if ($property.Name -in @('inviteCode','inviteTicketId','redemptionToken','redemptionRequestKey')) { Add-Issue "ordinary join schema must not consume invitation field: $($property.Name)" } Visit-OrdinaryJoinSchema $property.Value $RefStack } foreach ($keyword in @('oneOf','allOf','anyOf')) { foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) { Visit-OrdinaryJoinSchema $branch $RefStack } } if ($Schema.items) { Visit-OrdinaryJoinSchema $Schema.items $RefStack } } Visit-OrdinaryJoinSchema $ordinarySchema } $expectedInvitationOperations = @($operations | ForEach-Object { "$($_.Method) $($_.Path)" } | Sort-Object) $actualInvitationOperations = @() function Test-InvitationContractReachable([object]$Schema, [string]$Label, [string[]]$RefStack = @(), [int]$Depth = 0) { if (-not $Schema) { return $false } if ($Depth -gt 40) { Add-Issue "$Label contract closure exceeds depth 40"; return $true } if ($Schema.'$ref') { $ref = [string]$Schema.'$ref' if ($ref -notmatch '^#/components/schemas/') { Add-Issue "$Label uses unsupported schema ref: $ref"; return $true } $name = $ref.Split('/')[-1] if ($name -in @('GenealogyInviteCode','GenealogyRedemptionToken','GenealogyInviteTicketId','GenealogyInviteIssueRequestKey','GenealogyInviteRedemptionRequestKey')) { return $true } if ($RefStack -contains $name) { return $false } $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "$Label contract closure is missing schema: $name"; return $true } return Test-InvitationContractReachable $owner.Value $Label (@($RefStack) + $name) ($Depth + 1) } foreach ($property in @($Schema.properties.PSObject.Properties)) { if ($property.Name -in @('inviteCode','inviteTicketId','redemptionToken','redemptionRequestKey','issueRequestKey')) { return $true } if (Test-InvitationContractReachable $property.Value $Label $RefStack ($Depth + 1)) { return $true } } foreach ($keyword in @('oneOf','allOf','anyOf')) { foreach ($branch in @($Schema.PSObject.Properties[$keyword].Value)) { if (Test-InvitationContractReachable $branch $Label $RefStack ($Depth + 1)) { return $true } } } if ($Schema.items -and (Test-InvitationContractReachable $Schema.items $Label $RefStack ($Depth + 1))) { return $true } if ($Schema.additionalProperties -and -not ($Schema.additionalProperties -is [System.Boolean]) -and (Test-InvitationContractReachable $Schema.additionalProperties $Label $RefStack ($Depth + 1))) { return $true } return $false } foreach ($pathProperty in $document.paths.PSObject.Properties) { $path = $pathProperty.Name foreach ($methodProperty in $pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in @('get','post','put','delete','patch') }) { $operation = $methodProperty.Value foreach ($parameter in @(Get-Parameters $path $operation)) { $parameterRef = [string]$parameter.schema.'$ref' if ($parameter.in -in @('path','query') -and ($parameter.name -match '(?i)invite.*code|redemption.*token' -or $parameterRef -in @('#/components/schemas/GenealogyInviteCode','#/components/schemas/GenealogyRedemptionToken'))) { Add-Issue "secret must not appear in path/query parameter: $($methodProperty.Name) $path $($parameter.name)" } } $semanticText = "$path $($operation.operationId) $($operation.summary)" $usesInvitationOwner = $false foreach ($parameter in @(Get-Parameters $path $operation)) { if (Test-InvitationContractReachable $parameter.schema "$($methodProperty.Name) $path parameter") { $usesInvitationOwner = $true } } if ($operation.requestBody) { $requestBody = $operation.requestBody if ($requestBody.'$ref') { $requestBodyName = ([string]$requestBody.'$ref').Split('/')[-1] $requestBodyOwner = $document.components.requestBodies.PSObject.Properties[$requestBodyName] if (-not $requestBodyOwner) { Add-Issue "$($methodProperty.Name) $path is missing requestBody owner $requestBodyName" } else { $requestBody = $requestBodyOwner.Value } } foreach ($media in @($requestBody.content.PSObject.Properties)) { if (Test-InvitationContractReachable $media.Value.schema "$($methodProperty.Name) $path request") { $usesInvitationOwner = $true } } } foreach ($responseProperty in @($operation.responses.PSObject.Properties)) { $response = Resolve-Response $responseProperty.Value foreach ($media in @($response.content.PSObject.Properties)) { if (Test-InvitationContractReachable $media.Value.schema "$($methodProperty.Name) $path response $($responseProperty.Name)") { $usesInvitationOwner = $true } } } $unrelatedInvitation = $path -match '(?i)/ceremonies/' -or $path -match '(?i)/promotions?$' -or $path -match '(?i)/appPromotion(?:/|$)' $semanticMatch = $semanticText -match '(?i)invite|invitation|direct[-_/ ]?join|join[-_/ ]?by[-_/ ]?code|邀请码|直接加入|凭码加入' if ($path -like '/genealogy/app/*' -and ($usesInvitationOwner -or (-not $unrelatedInvitation -and $semanticMatch))) { $actualInvitationOperations += "$($methodProperty.Name) $path" } } } if ((@($actualInvitationOperations | Sort-Object) -join ',') -ne ($expectedInvitationOperations -join ',')) { Add-Issue "APP invitation owner set must be exactly six operations; actual=$(@($actualInvitationOperations | Sort-Object) -join ',')" } if ($issues.Count -gt 0) { Write-Output 'INVITE-TICKET-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 'INVITE-TICKET-OPENAPI-CONTRACT PASS'