完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+350
View File
@@ -0,0 +1,350 @@
$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'