完成40%

This commit is contained in:
rain
2026-07-23 17:21:27 +08:00
parent f1edc6b533
commit bb6431b319
114 changed files with 10931 additions and 877 deletions
+625
View File
@@ -0,0 +1,625 @@
$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'