$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml') $issues = New-Object System.Collections.Generic.List[string] $phonePath = '/genealogy/app/auth/phone' $sendPath = '/genealogy/app/auth/phone/sms/code' $publicSendPath = '/genealogy/app/auth/sms/code' function Add-Issue { param([string]$Message) $script:issues.Add($Message) } function Get-Schema { param([string]$Name) $property = $document.components.schemas.PSObject.Properties[$Name] if (-not $property) { Add-Issue "JSON missing schema owner: $Name" return $null } return $property.Value } function Get-Operation { param([string]$Path, [string]$Method) $pathProperty = $document.paths.PSObject.Properties[$Path] $operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null } if (-not $operation) { Add-Issue "JSON missing $($Method.ToUpperInvariant()) $Path" return $null } return $operation.Value } function Assert-SaTokenOnly { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $requirements = @($Operation.security) $valid = $requirements.Count -eq 1 -and $requirements[0].PSObject.Properties.Count -eq 1 -and $requirements[0].PSObject.Properties.Name -contains 'SaToken' if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" } } function Assert-ClientIdHeader { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $headers = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($headers.Count -ne 1 -or $headers[0].required -ne $true -or $headers[0].schema.type -ne 'string' -or [int]$headers[0].schema.minLength -lt 1) { Add-Issue "JSON $Label must require one non-empty string clientid header" } } function Assert-RequestBody { param([object]$Operation, [string]$Label, [string]$ExpectedRef) if (-not $Operation) { return } $media = $Operation.requestBody.content.PSObject.Properties['application/json'] if ($Operation.requestBody.required -ne $true -or -not $media) { Add-Issue "JSON $Label must require an application/json body" } elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) { Add-Issue "JSON $Label request body must use $ExpectedRef" } } function Get-Response { param([object]$Operation, [string]$Label, [string]$Status) if (-not $Operation) { return $null } $property = $Operation.responses.PSObject.Properties[$Status] if (-not $property) { Add-Issue "JSON $Label missing $Status response" return $null } $response = $property.Value if ($response.'$ref') { $name = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing response owner: $name" return $null } $response = $owner.Value } return $response } function Get-JsonSchemaRef { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return '' } $media = $Response.content.PSObject.Properties['application/json'] if (-not $media) { Add-Issue "JSON $Label $Status must use application/json" return '' } return [string]$media.Value.schema.'$ref' } function Assert-PrivateNoStore { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return } $property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $property) { Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store" return } $header = $property.Value if ($header.'$ref') { $name = ([string]$header.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$name] if ($owner) { $header = $owner.Value } } $evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example) if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') { Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store" } } function Assert-FieldRef { param([string]$SchemaName, [string]$Field, [string]$ExpectedRef) $schema = Get-Schema $SchemaName if (-not $schema) { return } $property = $schema.properties.PSObject.Properties[$Field] $actual = if ($property) { [string]$property.Value.'$ref' } else { '' } if ($actual -ne $ExpectedRef) { Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual" } } $phoneOperation = Get-Operation $phonePath 'put' $sendOperation = Get-Operation $sendPath 'post' $publicSendOperation = Get-Operation $publicSendPath 'post' Assert-SaTokenOnly $phoneOperation "PUT $phonePath" Assert-SaTokenOnly $sendOperation "POST $sendPath" Assert-ClientIdHeader $phoneOperation "PUT $phonePath" Assert-ClientIdHeader $sendOperation "POST $sendPath" Assert-RequestBody $phoneOperation "PUT $phonePath" '#/components/schemas/PhoneChangeBody' Assert-RequestBody $sendOperation "POST $sendPath" '#/components/schemas/PhoneChangeSmsCodeBody' if ($publicSendOperation) { $publicBodyRef = [string]$publicSendOperation.requestBody.content.'application/json'.schema.'$ref' if ($publicBodyRef -ne '#/components/schemas/SmsCodeBody') { Add-Issue "JSON POST $publicSendPath must keep SmsCodeBody as the public-scene owner" } } $phoneBody = Get-Schema 'PhoneChangeBody' $sendBody = Get-Schema 'PhoneChangeSmsCodeBody' $newPhone = Get-Schema 'NewBoundPhone' $smsSecret = Get-Schema 'SmsCodeSecret' if ($phoneBody) { $properties = @($phoneBody.properties.PSObject.Properties.Name | Sort-Object) $required = @($phoneBody.required | Sort-Object) if ($phoneBody.type -ne 'object' -or $phoneBody.additionalProperties -ne $false -or ($properties -join ',') -ne 'currentPassword,phone,smsCode' -or ($required -join ',') -ne 'currentPassword,phone,smsCode') { Add-Issue 'JSON PhoneChangeBody must be a closed object requiring only currentPassword/phone/smsCode' } } if ($sendBody) { $properties = @($sendBody.properties.PSObject.Properties.Name | Sort-Object) $required = @($sendBody.required | Sort-Object) if ($sendBody.type -ne 'object' -or $sendBody.additionalProperties -ne $false -or ($properties -join ',') -ne 'phone,validToken' -or ($required -join ',') -ne 'phone,validToken') { Add-Issue 'JSON PhoneChangeSmsCodeBody must be a closed object requiring only phone/validToken; scene, client and tenant come from the protected operation context' } if (-not $sendBody.properties.validToken -or $sendBody.properties.validToken.type -ne 'string' -or $sendBody.properties.validToken.writeOnly -ne $true -or [int]$sendBody.properties.validToken.minLength -lt 1) { Add-Issue 'JSON PhoneChangeSmsCodeBody.validToken must be a non-empty writeOnly string' } } if ($newPhone) { if ($newPhone.type -ne 'string' -or $newPhone.writeOnly -ne $true -or [int]$newPhone.minLength -ne 11 -or [int]$newPhone.maxLength -ne 11 -or [string]$newPhone.pattern -ne '^1[3-9][0-9]{9}$' -or $newPhone.example) { Add-Issue 'JSON NewBoundPhone must be a writeOnly canonical 11-digit mainland mobile number without an example' } } if ($smsSecret) { if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or [int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or [string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) { Add-Issue 'JSON SmsCodeSecret must be exactly six ASCII decimal digits, preserve leading zero, be writeOnly, and expose no example' } $smsDescription = [string]$smsSecret.description foreach ($pattern in @('(?i)CSPRNG', '(?i)leading zero', '(?i)single-use', '(?i)5 minutes', '(?i)5 failed')) { if ($smsDescription -notmatch $pattern) { Add-Issue "JSON SmsCodeSecret description is missing OTP security semantics: $pattern" } } } Assert-FieldRef 'PhoneChangeBody' 'currentPassword' '#/components/schemas/CurrentPasswordSecret' Assert-FieldRef 'PhoneChangeBody' 'phone' '#/components/schemas/NewBoundPhone' Assert-FieldRef 'PhoneChangeSmsCodeBody' 'phone' '#/components/schemas/NewBoundPhone' # 短信码是跨登录、注册、找回、换绑和注销的单一 wire 合同。升级为六位时必须一次. # 删除所有四位入口,不能让同一服务端生成器长期同时接受 4/6 位密码等价物.. $otpSchemaNames = @( 'SmsLoginBody' 'PasswordRegisterBody' 'PasswordResetBody' 'PhoneChangeBody' 'AccountDeactivateBody' ) foreach ($schemaName in $otpSchemaNames) { Assert-FieldRef $schemaName 'smsCode' '#/components/schemas/SmsCodeSecret' } $publicSmsBody = Get-Schema 'SmsCodeBody' if ($publicSmsBody) { $scenes = @($publicSmsBody.properties.sceneCode.enum | Sort-Object) $expectedScenes = @('APP_ACCOUNT_DEACTIVATE', 'APP_FORGOT_PASSWORD', 'APP_REGISTER', 'APP_SMS_LOGIN') | Sort-Object if (($scenes -join ',') -ne ($expectedScenes -join ',')) { Add-Issue 'JSON public SmsCodeBody.sceneCode must exclude APP_PHONE_CHANGE and retain only the four public/shared scenes' } } if ($sendOperation) { $semantics = [string]$sendOperation.description foreach ($pattern in @( '(?i)scene.*APP_PHONE_CHANGE.*server' '(?i)validToken.*tenant.*client.*phone' '(?i)account.*session.*credentialEpoch' '(?i)one active.*generation' '(?i)resend.*invalidates.*previous' '(?i)60 seconds' '(?i)failed attempt.*not reset' '(?i)rate limit' )) { if ($semantics -notmatch $pattern) { Add-Issue "JSON POST $sendPath description is missing protected send semantics: $pattern" } } } if ($phoneOperation) { $semantics = [string]$phoneOperation.description foreach ($pattern in @( '(?i)current password.*re-authentication' '(?i)single transaction.*phone.*sms.*credentialEpoch' '(?i)unique.*phone' '(?i)all.*access.*refresh.*including.*current' '(?i)old phone.*outbox' '(?i)200.*sessions.*invalidated' '(?i)no password.*STEP_UP_UNAVAILABLE' )) { if ($semantics -notmatch $pattern) { Add-Issue "JSON PUT $phonePath description is missing identity/session semantics: $pattern" } } } $operations = @( [pscustomobject]@{ Operation = $sendOperation; Label = "POST $sendPath" }, [pscustomobject]@{ Operation = $phoneOperation; Label = "PUT $phonePath" } ) foreach ($entry in $operations) { if (-not $entry.Operation) { continue } $responses = @{} foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) { $responses[$status] = Get-Response $entry.Operation $entry.Label $status [void](Get-JsonSchemaRef $responses[$status] $entry.Label $status) Assert-PrivateNoStore $responses[$status] $entry.Label $status } if ((Get-JsonSchemaRef $responses['200'] $entry.Label '200') -ne '#/components/schemas/RVoid') { Add-Issue "JSON $($entry.Label) 200 must return RVoid" } foreach ($status in @('409', '422', '429')) { if ((Get-JsonSchemaRef $responses[$status] $entry.Label $status) -ne '#/components/schemas/RPhoneChangeRejected') { Add-Issue "JSON $($entry.Label) $status must return RPhoneChangeRejected" } } $retryAfter = if ($responses['429'] -and $responses['429'].headers) { $responses['429'].headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $retryAfter) { Add-Issue "JSON $($entry.Label) 429 must document Retry-After" } } $void = Get-Schema 'RVoid' $rejected = Get-Schema 'RPhoneChangeRejected' if ($void -and ('code' -notin @($void.required) -or $void.properties.code.type -ne 'integer')) { Add-Issue 'JSON RVoid must require integer code' } if ($rejected) { foreach ($field in @('code', 'businessCode')) { if ($field -notin @($rejected.required)) { Add-Issue "JSON RPhoneChangeRejected.required missing: $field" } } $codes = @($rejected.properties.businessCode.enum | Sort-Object) $expectedCodes = @( 'CREDENTIAL_VERSION_CONFLICT' 'CURRENT_PASSWORD_INCORRECT' 'NEW_PHONE_SAME_AS_CURRENT' 'PHONE_ALREADY_BOUND' 'SEND_RATE_LIMITED' 'SMS_CODE_ATTEMPTS_EXCEEDED' 'SMS_CODE_EXPIRED' 'SMS_CODE_INVALID' 'STEP_UP_UNAVAILABLE' 'VERIFICATION_REQUIRED' ) | Sort-Object if ($rejected.properties.code.type -ne 'integer' -or $rejected.properties.businessCode.type -ne 'string' -or ($codes -join ',') -ne ($expectedCodes -join ',')) { Add-Issue 'JSON RPhoneChangeRejected must expose the ten stable send/change business codes' } } foreach ($yamlFact in @( ' /genealogy/app/auth/phone/sms/code:' ' /genealogy/app/auth/phone:' '#/components/schemas/PhoneChangeSmsCodeBody' '#/components/schemas/PhoneChangeBody' '#/components/schemas/CurrentPasswordSecret' '#/components/schemas/NewBoundPhone' '#/components/schemas/SmsCodeSecret' '#/components/schemas/RPhoneChangeRejected' ' PhoneChangeSmsCodeBody:' ' NewBoundPhone:' ' SmsCodeSecret:' ' pattern: ^[0-9]{6}$' ' RPhoneChangeRejected:' ' - STEP_UP_UNAVAILABLE' ' - VERIFICATION_REQUIRED' ' Cache-Control:' ' Retry-After:' )) { if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" } } if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Split APP_PHONE_CHANGE sending into the authenticated POST /genealogy/app/auth/phone/sms/code operation; keep one shared backend OTP service, and remove this scene from the public sender.') $lines.Add('- Upgrade every active SMS-code consumer atomically from four digits to one six-ASCII-digit SmsCodeSecret; no 4/6 compatibility window is allowed.') $lines.Add('- PUT requires the current raw password plus the new-phone OTP; a strict 200 means the phone, OTP consumption, credential epoch, all-session revocation, and old-phone notification outbox are durable.') $lines.Add('- Timeout, malformed response, 5xx, or process death after final PUT dispatch is outcome-unknown: clear the same-epoch local session, return to A01, and never retry automatically.') $lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.') throw ($lines -join [Environment]::NewLine) } Write-Output 'PHONE-CHANGE-OPENAPI-CONTRACT PASS'