Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/Deployment/Invoke-LserpReadOnlySessionPreflight.ps1
2026-08-14 14:28:28 +08:00

1609 lines
63 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$CliPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedCliSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
[string]$ExpectedCliVersion,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
[string]$ExpectedSignerThumbprint,
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)]
[int]$ErpProcessId,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedErpSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedDatabaseScopeFingerprint,
[Parameter(Mandatory = $true)]
[ValidateLength(1, 256)]
[string]$ExpectedUserId,
[Parameter(Mandatory = $true)]
[ValidateLength(1, 256)]
[string]$ExpectedUserName,
[Parameter(Mandatory = $true)]
[ValidateLength(1, 256)]
[string]$ExpectedAccountBook,
[Parameter(Mandatory = $true)]
[ValidateLength(1, 256)]
[string]$ExpectedSubSystemId,
[Parameter(Mandatory = $true)]
[bool]$ExpectedIsAdministrator,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')]
[string]$ExpectedRolloutCustomerId,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedRolloutPolicySha256,
[Parameter(Mandatory = $true)]
[ValidateCount(1, 16)]
[string[]]$ModuleCodes,
[Parameter(Mandatory = $true)][string]$OutputPath,
[switch]$RequirePurchaseWorkflow,
[switch]$RequireLeaveWorkflow,
[switch]$RequireDiagnosisWorkflow,
[ValidateRange(1000, 300000)]
[int]$BridgeTimeoutMilliseconds = 180000
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
throw 'lserp_readonly_session_preflight_failed:windows_powershell_51_required'
}
$utf8 = [Text.UTF8Encoding]::new($false, $true)
$maximumResponseCharacters = 4 * 1024 * 1024
$cliLock = $null
$erpLock = $null
$workingDirectory = $null
$outputFull = $null
$published = $false
$safeModuleCode = '^[A-Za-z0-9_.:-]{1,64}$'
$safeCommandName = '^[a-z0-9][a-z0-9_.:-]{0,127}$'
$safeVersion = '^[0-9]+(?:\.[0-9]+){0,3}$'
$safeCorrelationId = '^[A-Za-z0-9_.:-]{8,128}$'
$safeSha256 = '^[a-f0-9]{64}$'
$expectedAdministratorText = if ($ExpectedIsAdministrator) {
'true'
} else { 'false' }
function Throw-PreflightError([string]$Code) {
throw ('lserp_readonly_session_preflight_failed:' + $Code)
}
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
try {
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
while ($null -ne $current) {
if (-not $current.Exists -or
(($current.Attributes -band
[IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-PreflightError $Code
}
$current = $current.Parent
}
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError $Code
}
}
function Resolve-RegularFile(
[string]$Path,
[long]$MaximumBytes,
[string]$Code
) {
try {
$full = [IO.Path]::GetFullPath($Path)
if (-not [IO.File]::Exists($full)) { Throw-PreflightError $Code }
$item = Get-Item -LiteralPath $full -Force
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-PreflightError $Code
}
Assert-NoReparseDirectoryChain `
([IO.Path]::GetDirectoryName($full)) $Code
return $full
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError $Code
}
}
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
try {
$full = [IO.Path]::GetFullPath($Path)
if ([IO.Path]::GetExtension($full) -ine '.json' -or
[IO.File]::Exists($full) -or
[IO.Directory]::Exists($full)) {
Throw-PreflightError $Code
}
$directory = [IO.Path]::GetDirectoryName($full)
Assert-NoReparseDirectoryChain $directory $Code
return $full
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError $Code
}
}
function New-RestrictedWorkingDirectory {
$path = $null
try {
$localApplicationData = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::LocalApplicationData)
if ([string]::IsNullOrWhiteSpace($localApplicationData)) {
Throw-PreflightError 'restricted_working_root_invalid'
}
$root = Join-Path $localApplicationData `
'Langsu\Lserp\ReadOnlySessionPreflight'
[IO.Directory]::CreateDirectory($root) | Out-Null
Assert-NoReparseDirectoryChain $root 'restricted_working_root_invalid'
$path = Join-Path $root ('run-' + [Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($path) | Out-Null
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
$localSystem = [Security.Principal.SecurityIdentifier]::new(
[Security.Principal.WellKnownSidType]::LocalSystemSid,
$null)
$security = New-Object Security.AccessControl.DirectorySecurity
$security.SetOwner($currentUser)
$security.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit `
-bor [Security.AccessControl.InheritanceFlags]::ObjectInherit
$propagation = [Security.AccessControl.PropagationFlags]::None
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($identity in @($currentUser, $localSystem)) {
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$identity,
[Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
$propagation,
$allow)
$security.AddAccessRule($rule)
}
[IO.Directory]::SetAccessControl($path, $security)
$verified = [IO.Directory]::GetAccessControl($path)
if (-not $verified.AreAccessRulesProtected) {
Throw-PreflightError 'restricted_working_acl_invalid'
}
return $path
}
catch {
if ($null -ne $path -and [IO.Directory]::Exists($path)) {
try { [IO.Directory]::Delete($path, $true) } catch { }
}
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError 'restricted_working_acl_invalid'
}
}
function ConvertTo-WindowsProcessArgument([string]$Value) {
if ($null -eq $Value -or $Value.Length -eq 0) { return '""' }
if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) {
return $Value
}
$builder = New-Object Text.StringBuilder
[void]$builder.Append([char]34)
$slashes = 0
foreach ($character in $Value.ToCharArray()) {
if ([int]$character -eq 92) {
$slashes++
continue
}
if ([int]$character -eq 34) {
for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append([char]34)
}
else {
for ($index = 0; $index -lt $slashes; $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append($character)
}
$slashes = 0
}
for ($index = 0; $index -lt ($slashes * 2); $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append([char]34)
return $builder.ToString()
}
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
if ($null -eq $Value) { return $false }
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
if ($names.Count -ne $Expected.Count) { return $false }
foreach ($name in $Expected) {
if ($names -cnotcontains $name) { return $false }
}
return $true
}
function Test-JsonArray([object]$Value) {
return $null -ne $Value -and $Value -is [array]
}
function Read-JsonInt32(
[object]$Raw,
[int]$Minimum,
[int]$Maximum,
[string]$Code
) {
if (($Raw -isnot [int] -and $Raw -isnot [long]) -or
[long]$Raw -lt $Minimum -or [long]$Raw -gt $Maximum) {
Throw-PreflightError $Code
}
return [int]$Raw
}
function Assert-SafeDisplayText(
[object]$Raw,
[int]$MinimumLength,
[int]$MaximumLength,
[string]$Code
) {
if ($null -eq $Raw -or $Raw -isnot [string]) {
Throw-PreflightError $Code
}
$value = [string]$Raw
if ($value.Length -lt $MinimumLength -or
$value.Length -gt $MaximumLength) {
Throw-PreflightError $Code
}
foreach ($character in $value.ToCharArray()) {
if ([char]::IsControl($character)) { Throw-PreflightError $Code }
}
}
function Assert-ExpectedSessionValue([string]$Value, [string]$Code) {
Assert-SafeDisplayText $Value 1 256 $Code
if ([string]::IsNullOrWhiteSpace($Value) -or
$Value -cne $Value.Trim()) {
Throw-PreflightError $Code
}
}
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString(
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
}
finally { $sha.Dispose() }
}
function Get-ScopedValueSha256([string]$Scope, [string]$Value) {
return Get-Sha256Hex ($utf8.GetBytes(
'lserp-readonly-preflight-v1|' + $Scope + '|' + $Value))
}
function Read-CliEnvelope([string]$Text, [int]$ExitCode) {
try {
if ([string]::IsNullOrWhiteSpace($Text) -or
$Text.Length -gt $maximumResponseCharacters) {
Throw-PreflightError 'cli_response_invalid'
}
$document = $Text | ConvertFrom-Json
if ($null -eq $document -or
$null -eq $document.ok -or
$document.ok -isnot [bool] -or
([string]$document.correlationId) -cnotmatch $safeCorrelationId) {
Throw-PreflightError 'cli_response_invalid'
}
if ($document.ok) {
if ($ExitCode -ne 0 -or
-not (Test-ExactProperties $document `
@('ok', 'correlationId', 'data')) -or
$null -eq $document.data) {
Throw-PreflightError 'cli_response_invalid'
}
}
else {
if ($ExitCode -eq 0 -or
-not (Test-ExactProperties $document `
@('ok', 'correlationId', 'error')) -or
-not (Test-ExactProperties $document.error `
@('code', 'message', 'exitCode')) -or
([string]$document.error.code) -cnotmatch `
'^[a-z0-9_.-]{1,128}$' -or
[int]$document.error.exitCode -ne $ExitCode) {
Throw-PreflightError 'cli_response_invalid'
}
}
return $document
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError 'cli_response_invalid'
}
}
function Assert-TrustedCliUnchanged {
$actual = (Get-FileHash -LiteralPath $script:cliFull `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne $script:expectedCliHash) {
Throw-PreflightError 'cli_hash_changed'
}
}
function Read-ExactErpProcessIdentity {
$process = $null
try {
$process = Get-Process -Id $ErpProcessId -ErrorAction Stop
if ($process.HasExited) {
Throw-PreflightError 'erp_process_identity_invalid'
}
$executablePath = [string]$process.MainModule.FileName
$startTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks
if ([string]::IsNullOrWhiteSpace($executablePath) -or
[IO.Path]::GetFileName($executablePath) -cne 'Ls_ERP.exe') {
Throw-PreflightError 'erp_process_identity_invalid'
}
return [pscustomobject]@{
ExecutablePath = [IO.Path]::GetFullPath($executablePath)
StartTimeUtcTicks = $startTimeUtcTicks
}
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError 'erp_process_identity_invalid'
}
finally {
if ($null -ne $process) { $process.Dispose() }
}
}
function Assert-TrustedErpUnchanged {
$current = Read-ExactErpProcessIdentity
if ($current.ExecutablePath -cne $script:erpFull -or
$current.StartTimeUtcTicks -ne $script:erpStartTimeUtcTicks) {
Throw-PreflightError 'erp_process_changed'
}
$actual = (Get-FileHash -LiteralPath $script:erpFull `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne $script:expectedErpHash) {
Throw-PreflightError 'erp_hash_changed'
}
}
function Assert-ReadOnlyArguments([string[]]$Arguments) {
$identityShape = $Arguments.Count -eq 3 -and
$Arguments[0] -ceq 'version' -and
$Arguments[1] -ceq '--correlation-id' -and
$Arguments[2] -cmatch $safeCorrelationId
if ($identityShape) { return }
$pidText = [string]$ErpProcessId
$timeoutText = [string]$BridgeTimeoutMilliseconds
$readShape = $Arguments.Count -eq 20 -and
$Arguments[0] -ceq 'bridge' -and
$Arguments[1] -cin @('health', 'context', 'capabilities') -and
$Arguments[2] -ceq '--erp-process-id' -and
$Arguments[3] -ceq $pidText -and
$Arguments[4] -ceq '--expected-database-scope-fingerprint' -and
$Arguments[5] -ceq
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and
$Arguments[6] -ceq '--expected-user-id' -and
$Arguments[7] -ceq $ExpectedUserId -and
$Arguments[8] -ceq '--expected-user-name' -and
$Arguments[9] -ceq $ExpectedUserName -and
$Arguments[10] -ceq '--expected-account-book' -and
$Arguments[11] -ceq $ExpectedAccountBook -and
$Arguments[12] -ceq '--expected-subsystem-id' -and
$Arguments[13] -ceq $ExpectedSubSystemId -and
$Arguments[14] -ceq '--expected-is-administrator' -and
$Arguments[15] -ceq $expectedAdministratorText -and
$Arguments[16] -ceq '--timeout-ms' -and
$Arguments[17] -ceq $timeoutText -and
$Arguments[18] -ceq '--correlation-id' -and
$Arguments[19] -cmatch $safeCorrelationId
if ($readShape) { return }
$planShape = $Arguments.Count -eq 23 -and
$Arguments[0] -ceq 'bridge' -and
$Arguments[1] -ceq 'plan' -and
$Arguments[2] -ceq 'module.parameters' -and
$Arguments[3] -ceq '--input' -and
$Arguments[5] -ceq '--erp-process-id' -and
$Arguments[6] -ceq $pidText -and
$Arguments[7] -ceq '--expected-database-scope-fingerprint' -and
$Arguments[8] -ceq
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -and
$Arguments[9] -ceq '--expected-user-id' -and
$Arguments[10] -ceq $ExpectedUserId -and
$Arguments[11] -ceq '--expected-user-name' -and
$Arguments[12] -ceq $ExpectedUserName -and
$Arguments[13] -ceq '--expected-account-book' -and
$Arguments[14] -ceq $ExpectedAccountBook -and
$Arguments[15] -ceq '--expected-subsystem-id' -and
$Arguments[16] -ceq $ExpectedSubSystemId -and
$Arguments[17] -ceq '--expected-is-administrator' -and
$Arguments[18] -ceq $expectedAdministratorText -and
$Arguments[19] -ceq '--timeout-ms' -and
$Arguments[20] -ceq $timeoutText -and
$Arguments[21] -ceq '--correlation-id' -and
$Arguments[22] -cmatch $safeCorrelationId
if ($planShape) {
$input = Resolve-RegularFile $Arguments[4] (4KB) `
'readonly_module_input_invalid'
$workingRoot = [IO.Path]::GetFullPath($script:workingDirectory)
$expectedPrefix = $workingRoot.TrimEnd(
[IO.Path]::DirectorySeparatorChar,
[IO.Path]::AltDirectorySeparatorChar) +
[IO.Path]::DirectorySeparatorChar
if (-not $input.StartsWith(
$expectedPrefix,
[StringComparison]::OrdinalIgnoreCase) -or
[IO.Path]::GetExtension($input) -ine '.json') {
Throw-PreflightError 'readonly_module_input_invalid'
}
return
}
Throw-PreflightError 'readonly_command_shape_denied'
}
function Invoke-TrustedReadOnlyCli([string[]]$Arguments, [string]$Step) {
Assert-ReadOnlyArguments $Arguments
Assert-TrustedCliUnchanged
$process = New-Object Diagnostics.Process
try {
$start = New-Object Diagnostics.ProcessStartInfo
$start.FileName = $script:cliFull
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($script:cliFull)
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$start.RedirectStandardInput = $true
$start.StandardOutputEncoding = $utf8
$start.StandardErrorEncoding = $utf8
$start.Arguments = (($Arguments | ForEach-Object {
ConvertTo-WindowsProcessArgument ([string]$_)
}) -join ' ')
$process.StartInfo = $start
if (-not $process.Start()) {
Throw-PreflightError ($Step + '_process_start_failed')
}
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$process.StandardInput.Close()
$processTimeout = [Math]::Min(
330000,
$BridgeTimeoutMilliseconds + 30000)
if (-not $process.WaitForExit($processTimeout)) {
try { $process.Kill() } catch { }
Throw-PreflightError ($Step + '_timeout')
}
$process.WaitForExit()
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
if (($process.ExitCode -eq 0 -and
([string]::IsNullOrWhiteSpace($stdout) -or
-not [string]::IsNullOrWhiteSpace($stderr))) -or
($process.ExitCode -ne 0 -and
([string]::IsNullOrWhiteSpace($stderr) -or
-not [string]::IsNullOrWhiteSpace($stdout)))) {
Throw-PreflightError ($Step + '_stream_contract_invalid')
}
$raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr }
$envelope = Read-CliEnvelope $raw $process.ExitCode
if (-not $envelope.ok) {
Throw-PreflightError (
$Step + '_' + [string]$envelope.error.code)
}
return $envelope
}
catch {
if ($_.Exception.Message.StartsWith(
'lserp_readonly_session_preflight_failed:')) { throw }
Throw-PreflightError ($Step + '_process_failed')
}
finally { $process.Dispose() }
}
function Invoke-ReadBridge([string]$Action, [string]$Step) {
if ($Action -cnotin @('health', 'context', 'capabilities')) {
Throw-PreflightError 'readonly_bridge_action_denied'
}
return Invoke-TrustedReadOnlyCli @(
'bridge', $Action,
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant(),
'--expected-user-id', $ExpectedUserId,
'--expected-user-name', $ExpectedUserName,
'--expected-account-book', $ExpectedAccountBook,
'--expected-subsystem-id', $ExpectedSubSystemId,
'--expected-is-administrator', $expectedAdministratorText,
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id',
('readonly-' + $Action + '-' + [Guid]::NewGuid().ToString('N'))
) $Step
}
function Read-RuntimeCliIdentity([object]$Data) {
if (-not (Test-ExactProperties $Data @(
'component', 'version', 'protocolVersion', 'bridgeOnly',
'databaseDirectAccess', 'sessionSource')) -or
[string]$Data.component -cne 'lserp-agent-cli' -or
[string]$Data.version -cne $ExpectedCliVersion -or
[string]$Data.protocolVersion -cne '1.0' -or
$Data.bridgeOnly -isnot [bool] -or
$Data.bridgeOnly -ne $true -or
$Data.databaseDirectAccess -isnot [bool] -or
$Data.databaseDirectAccess -ne $false -or
[string]$Data.sessionSource -cne
'current_logged_in_erp_process') {
Throw-PreflightError 'cli_runtime_identity_invalid'
}
return [pscustomobject]@{
Component = [string]$Data.component
Version = [string]$Data.version
ProtocolVersion = [string]$Data.protocolVersion
BridgeOnly = [bool]$Data.bridgeOnly
DatabaseDirectAccess = [bool]$Data.databaseDirectAccess
SessionSource = [string]$Data.sessionSource
}
}
function Write-NewUtf8File([string]$Path, [string]$Text) {
$bytes = $utf8.GetBytes($Text)
$stream = [IO.FileStream]::new(
$Path,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::Write,
[IO.FileShare]::None)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
}
finally { $stream.Dispose() }
}
function Invoke-ModuleParameterPlan([string]$ModuleCode, [int]$Index) {
$inputPath = Join-Path $script:workingDirectory (
'module-' + $Index.ToString('D2') + '.json')
$input = [ordered]@{ moduleCode = $ModuleCode }
Write-NewUtf8File $inputPath (
$input | ConvertTo-Json -Compress -Depth 4)
return Invoke-TrustedReadOnlyCli @(
'bridge', 'plan', 'module.parameters',
'--input', $inputPath,
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant(),
'--expected-user-id', $ExpectedUserId,
'--expected-user-name', $ExpectedUserName,
'--expected-account-book', $ExpectedAccountBook,
'--expected-subsystem-id', $ExpectedSubSystemId,
'--expected-is-administrator', $expectedAdministratorText,
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id',
('readonly-module-' + [Guid]::NewGuid().ToString('N'))
) ('module_parameters_' + $Index.ToString('D2'))
}
function Assert-SafeContextModule([object]$Module, [string]$Code) {
if (-not (Test-ExactProperties $Module `
@('moduleCode', 'navigationCode', 'moduleName')) -or
([string]$Module.moduleCode) -cnotmatch $safeModuleCode -or
([string]$Module.navigationCode) -cnotmatch $safeModuleCode) {
Throw-PreflightError $Code
}
Assert-SafeDisplayText $Module.moduleName 1 128 $Code
}
function Read-SessionContext([object]$Data, [string]$Code) {
if (-not (Test-ExactProperties $Data @(
'userId', 'userName', 'accountBook', 'subSystemId',
'databaseScopeFingerprint', 'subSystemName',
'isAdministrator', 'activeModule', 'openModuleCount',
'openModulesTruncated', 'openModules'))) {
Throw-PreflightError $Code
}
foreach ($field in @('userId', 'userName', 'accountBook',
'subSystemId', 'subSystemName')) {
Assert-SafeDisplayText $Data.$field 1 256 $Code
}
$databaseScope = ([string]$Data.databaseScopeFingerprint).ToLowerInvariant()
if ($databaseScope -cnotmatch $safeSha256 -or
$databaseScope -cne
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or
$Data.isAdministrator -isnot [bool] -or
$Data.openModulesTruncated -isnot [bool]) {
Throw-PreflightError $Code
}
if (([string]$Data.userId) -cne $ExpectedUserId -or
([string]$Data.userName) -cne $ExpectedUserName -or
([string]$Data.accountBook) -cne $ExpectedAccountBook -or
([string]$Data.subSystemId) -cne $ExpectedSubSystemId -or
[bool]$Data.isAdministrator -ne $ExpectedIsAdministrator) {
Throw-PreflightError 'expected_session_scope_mismatch'
}
$openCount = 0
if (-not [int]::TryParse(
[string]$Data.openModuleCount,
[ref]$openCount) -or
$openCount -lt 0 -or $openCount -gt 10000) {
Throw-PreflightError $Code
}
$openModules = @($Data.openModules)
if ($openModules.Count -gt 50 -or
$openCount -lt $openModules.Count -or
[bool]$Data.openModulesTruncated -ne
($openCount -gt $openModules.Count)) {
Throw-PreflightError $Code
}
$navigationCodes = New-Object `
'System.Collections.Generic.HashSet[string]' `
([StringComparer]::OrdinalIgnoreCase)
foreach ($module in $openModules) {
Assert-SafeContextModule $module $Code
if (-not $navigationCodes.Add([string]$module.navigationCode)) {
Throw-PreflightError $Code
}
}
if ($null -ne $Data.activeModule) {
Assert-SafeContextModule $Data.activeModule $Code
if (-not $Data.openModulesTruncated -and
-not $navigationCodes.Contains(
[string]$Data.activeModule.navigationCode)) {
Throw-PreflightError $Code
}
}
return [pscustomobject]@{
UserId = [string]$Data.userId
UserName = [string]$Data.userName
AccountBook = [string]$Data.accountBook
SubSystemId = [string]$Data.subSystemId
SubSystemName = [string]$Data.subSystemName
DatabaseScopeFingerprint = $databaseScope
IsAdministrator = [bool]$Data.isAdministrator
ActiveModule = $Data.activeModule
OpenModuleCount = $openCount
}
}
function Test-SameSession([object]$First, [object]$Second) {
return $First.UserId -ceq $Second.UserId -and
$First.UserName -ceq $Second.UserName -and
$First.AccountBook -ceq $Second.AccountBook -and
$First.SubSystemId -ceq $Second.SubSystemId -and
$First.SubSystemName -ceq $Second.SubSystemName -and
$First.DatabaseScopeFingerprint -ceq
$Second.DatabaseScopeFingerprint -and
$First.IsAdministrator -eq $Second.IsAdministrator
}
function Read-Health([object]$Data) {
if (-not (Test-ExactProperties $Data @(
'status', 'protocolVersion', 'serverTimeUtc',
'commandCount', 'enabledCommandCount',
'operationalPolicy', 'rolloutPolicy', 'workflowUat')) -or
[string]$Data.status -cne 'ready' -or
[string]$Data.protocolVersion -cne '1.0' -or
-not (Test-ExactProperties $Data.rolloutPolicy @(
'configured', 'failClosed', 'customerId',
'databaseScopeFingerprint', 'sourceSha256',
'defaultAction', 'ruleCount')) -or
$Data.rolloutPolicy.configured -ne $true -or
$Data.rolloutPolicy.failClosed -ne $true -or
[string]$Data.rolloutPolicy.defaultAction -cne 'deny' -or
[string]$Data.rolloutPolicy.customerId -cne
$ExpectedRolloutCustomerId -or
([string]$Data.rolloutPolicy.databaseScopeFingerprint).ToLowerInvariant() `
-cne $ExpectedDatabaseScopeFingerprint.ToLowerInvariant() -or
([string]$Data.rolloutPolicy.sourceSha256).ToLowerInvariant() -cne
$ExpectedRolloutPolicySha256.ToLowerInvariant()) {
Throw-PreflightError 'bridge_health_invalid'
}
$total = 0
$enabled = 0
$ruleCount = 0
if (-not [int]::TryParse([string]$Data.commandCount, [ref]$total) -or
-not [int]::TryParse(
[string]$Data.enabledCommandCount,
[ref]$enabled) -or
-not [int]::TryParse(
[string]$Data.rolloutPolicy.ruleCount,
[ref]$ruleCount) -or
$total -lt 1 -or $total -gt 512 -or
$enabled -lt 1 -or $enabled -gt $total -or
$ruleCount -lt 1 -or $ruleCount -gt 128) {
Throw-PreflightError 'bridge_health_invalid'
}
return [pscustomobject]@{
CommandCount = $total
EnabledCommandCount = $enabled
RuleCount = $ruleCount
}
}
function Read-Capabilities([object]$Data) {
if (-not (Test-ExactProperties $Data @('commands'))) {
Throw-PreflightError 'bridge_capabilities_invalid'
}
$commands = @($Data.commands)
if ($commands.Count -lt 1 -or $commands.Count -gt 512) {
Throw-PreflightError 'bridge_capabilities_invalid'
}
$map = [Collections.Generic.Dictionary[string, object]]::new(
[StringComparer]::Ordinal)
foreach ($command in $commands) {
if (-not (Test-ExactProperties $command @(
'name', 'version', 'description', 'schemaVersion',
'inputSchema', 'risk', 'requiresConfirmation',
'requiresIdempotencyKey')) -or
([string]$command.name) -cnotmatch $safeCommandName -or
([string]$command.version) -cnotmatch $safeVersion -or
([string]$command.schemaVersion) -cnotmatch $safeVersion -or
([string]$command.risk) -cnotin @(
'read', 'draft', 'navigate', 'write', 'critical') -or
$command.requiresConfirmation -isnot [bool] -or
$command.requiresIdempotencyKey -isnot [bool] -or
$null -eq $command.inputSchema -or
$map.ContainsKey([string]$command.name)) {
Throw-PreflightError 'bridge_capabilities_invalid'
}
Assert-SafeDisplayText $command.description 1 512 `
'bridge_capabilities_invalid'
$map.Add([string]$command.name, $command)
}
return $map
}
function Test-CapabilityContract(
[object]$Actual,
[string]$Version,
[string]$SchemaVersion,
[string]$Risk,
[bool]$Confirmation,
[bool]$Idempotency
) {
return $null -ne $Actual -and
[string]$Actual.version -ceq $Version -and
[string]$Actual.schemaVersion -ceq $SchemaVersion -and
[string]$Actual.risk -ceq $Risk -and
[bool]$Actual.requiresConfirmation -eq $Confirmation -and
[bool]$Actual.requiresIdempotencyKey -eq $Idempotency
}
function Read-NativeExecutionProfile(
[object]$Profile,
[string]$Action,
[string]$ModuleKind
) {
if ($null -eq $Profile -or
$Profile.available -isnot [bool]) {
Throw-PreflightError 'module_native_execution_profile_invalid'
}
if ([bool]$Profile.available) {
if (-not (Test-ExactProperties $Profile @(
'available', 'nativeSaveFamily',
'profileFingerprint')) -or
([string]$Profile.profileFingerprint) -cnotmatch $safeSha256) {
Throw-PreflightError 'module_native_execution_profile_invalid'
}
$family = [string]$Profile.nativeSaveFamily
$allowedFamilies = if ($ModuleKind -ceq 'document') {
@(
'legacy.bill-save.p-bill-save-pr3',
'legacy.bill-save.p-bill-save-pr70'
)
} else {
@(
'legacy.base-save.p-base-save',
'legacy.base-save.p-base-save70'
)
}
if ($family -cnotin $allowedFamilies -or
($Action -ceq 'update' -and $ModuleKind -ceq 'document')) {
Throw-PreflightError 'module_native_execution_profile_invalid'
}
return [ordered]@{
available = $true
nativeSaveFamily = $family
profileFingerprint =
([string]$Profile.profileFingerprint).ToLowerInvariant()
code = $null
}
}
if (-not (Test-ExactProperties $Profile @('available', 'code')) -or
([string]$Profile.code) -cnotmatch '^[a-z0-9_.-]{1,128}$' -or
($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and
[string]$Profile.code -cne
'dynamic_module_update_bill_unsupported')) {
Throw-PreflightError 'module_native_execution_profile_invalid'
}
return [ordered]@{
available = $false
nativeSaveFamily = $null
profileFingerprint = $null
code = [string]$Profile.code
}
}
function Assert-ExactParameterIds(
[object]$Raw,
[string[]]$Expected,
[string]$Code
) {
if (-not (Test-JsonArray $Raw)) {
Throw-PreflightError $Code
}
$actual = @($Raw)
$expectedValues = @($Expected)
if ($actual.Count -ne $expectedValues.Count) {
Throw-PreflightError $Code
}
for ($index = 0; $index -lt $actual.Count; $index++) {
if ($actual[$index] -isnot [string] -or
[string]$actual[$index] -cne [string]$expectedValues[$index]) {
Throw-PreflightError $Code
}
}
}
function Read-ParameterPayloadContract(
[object]$Payload,
[string]$ModuleKind,
[string]$ConfigurationFingerprint,
[object[]]$MasterParameters,
[object[]]$DetailParameters
) {
if (-not (Test-ExactProperties $Payload @(
'format', 'valueEncoding', 'moduleCodeRequired',
'configurationFingerprint', 'masterValuesRequired',
'detailRowsRequired', 'minimumDetailRows',
'maximumDetailRows', 'masterParameterIds',
'requiredMasterParameterIds', 'detailParameterIds',
'requiredDetailParameterIds', 'unknownParameterPolicy',
'duplicateParameterPolicy', 'lookupPolicy',
'fieldConstraintPolicy', 'configurationDriftPolicy')) -or
[string]$Payload.format -cne 'parameter_entries_v1' -or
[string]$Payload.valueEncoding -cne 'invariant_text' -or
$Payload.moduleCodeRequired -isnot [bool] -or
$Payload.moduleCodeRequired -ne $true -or
([string]$Payload.configurationFingerprint).ToLowerInvariant() `
-cne $ConfigurationFingerprint -or
$Payload.masterValuesRequired -isnot [bool] -or
$Payload.masterValuesRequired -ne $true -or
$Payload.detailRowsRequired -isnot [bool] -or
[string]$Payload.unknownParameterPolicy -cne 'reject' -or
[string]$Payload.duplicateParameterPolicy -cne 'reject' -or
[string]$Payload.lookupPolicy -cne
'server_resolve_unique_or_stop' -or
[string]$Payload.fieldConstraintPolicy -cne
'server_enforced_from_current_low_code_configuration' -or
[string]$Payload.configurationDriftPolicy -cne
'reject_and_replan') {
Throw-PreflightError 'module_parameter_payload_contract_invalid'
}
$document = $ModuleKind -ceq 'document'
$minimumRows = Read-JsonInt32 `
$Payload.minimumDetailRows 0 1 `
'module_parameter_payload_contract_invalid'
$maximumRows = Read-JsonInt32 `
$Payload.maximumDetailRows 0 1000 `
'module_parameter_payload_contract_invalid'
$expectedMinimumRows = if ($document) { 1 } else { 0 }
$expectedMaximumRows = if ($document) { 1000 } else { 0 }
if ([bool]$Payload.detailRowsRequired -ne $document -or
$minimumRows -ne $expectedMinimumRows -or
$maximumRows -ne $expectedMaximumRows) {
Throw-PreflightError 'module_parameter_payload_contract_invalid'
}
$editableMaster = @($MasterParameters | Where-Object {
$_.editable -eq $true
} | ForEach-Object { [string]$_.parameterId })
$requiredMaster = @($MasterParameters | Where-Object {
$_.required -eq $true
} | ForEach-Object { [string]$_.parameterId })
$editableDetail = @($DetailParameters | Where-Object {
$_.editable -eq $true
} | ForEach-Object { [string]$_.parameterId })
$requiredDetail = @($DetailParameters | Where-Object {
$_.required -eq $true
} | ForEach-Object { [string]$_.parameterId })
Assert-ExactParameterIds `
$Payload.masterParameterIds $editableMaster `
'module_parameter_payload_contract_invalid'
Assert-ExactParameterIds `
$Payload.requiredMasterParameterIds $requiredMaster `
'module_parameter_payload_contract_invalid'
Assert-ExactParameterIds `
$Payload.detailParameterIds $editableDetail `
'module_parameter_payload_contract_invalid'
Assert-ExactParameterIds `
$Payload.requiredDetailParameterIds $requiredDetail `
'module_parameter_payload_contract_invalid'
return [ordered]@{
format = 'parameter_entries_v1'
valueEncoding = 'invariant_text'
moduleCodeRequired = $true
masterValuesRequired = $true
detailRowsRequired = $document
minimumDetailRows = $minimumRows
maximumDetailRows = $maximumRows
masterParameterIdCount = $editableMaster.Count
requiredMasterParameterIdCount = $requiredMaster.Count
detailParameterIdCount = $editableDetail.Count
requiredDetailParameterIdCount = $requiredDetail.Count
unknownParameterPolicy = 'reject'
duplicateParameterPolicy = 'reject'
lookupPolicy = 'server_resolve_unique_or_stop'
fieldConstraintPolicy =
'server_enforced_from_current_low_code_configuration'
configurationDriftPolicy = 'reject_and_replan'
}
}
function Read-ModuleExecutionReadiness(
[object]$Available,
[object]$Command,
[object]$ReadinessCode,
[object]$Blocker,
[string]$Action,
[string]$ModuleKind,
[object]$NativeProfile
) {
$expectedCommand = if ($Action -ceq 'create') {
'module.record.create'
} else {
'module.record.resolve-update'
}
$expectedReadyCode = if ($Action -ceq 'create') {
'dynamic_module_write_ready'
} else {
'dynamic_module_update_ready'
}
if ($Available -isnot [bool] -or
$ReadinessCode -isnot [string] -or
[string]$ReadinessCode -cnotmatch '^[a-z0-9_.-]{1,128}$') {
Throw-PreflightError 'module_parameter_readiness_invalid'
}
if ([bool]$Available) {
if ($Command -isnot [string] -or
[string]$Command -cne $expectedCommand -or
[string]$ReadinessCode -cne $expectedReadyCode -or
$null -ne $Blocker -or
$NativeProfile.available -ne $true -or
($Action -ceq 'update' -and $ModuleKind -ceq 'document')) {
Throw-PreflightError 'module_parameter_readiness_invalid'
}
}
else {
if ($null -ne $Command -or
[string]$ReadinessCode -ceq $expectedReadyCode) {
Throw-PreflightError 'module_parameter_readiness_invalid'
}
Assert-SafeDisplayText `
$Blocker 1 512 'module_parameter_readiness_invalid'
}
if ($Action -ceq 'update' -and $ModuleKind -ceq 'document' -and
([bool]$Available -or
[string]$ReadinessCode -cne
'dynamic_module_update_bill_unsupported')) {
Throw-PreflightError 'module_parameter_readiness_invalid'
}
return [ordered]@{
available = [bool]$Available
blocked = -not [bool]$Available
command = if ($null -eq $Command) { $null } else {
[string]$Command
}
readinessCode = [string]$ReadinessCode
}
}
function Read-Parameter(
[object]$Parameter,
[string]$ExpectedScope,
[Collections.Generic.HashSet[string]]$Ids,
[hashtable]$Counts
) {
if (-not (Test-ExactProperties $Parameter @(
'parameterId', 'label', 'scope', 'valueType', 'valueFormat',
'controlTypeId', 'required', 'editable', 'inputSupported',
'inputMode', 'requiresDedicatedAdapter', 'hasDefault',
'requiresLookup', 'maximumEncodedBytes',
'maximumDecimalPlaces')) -or
([string]$Parameter.parameterId) -cnotmatch '^[md][a-f0-9]{16}$' -or
[string]$Parameter.scope -cne $ExpectedScope -or
([string]$Parameter.inputMode) -cnotin @(
'scalar', 'lookup-single', 'unsupported') -or
$Parameter.required -isnot [bool] -or
$Parameter.editable -isnot [bool] -or
$Parameter.inputSupported -isnot [bool] -or
$Parameter.requiresDedicatedAdapter -isnot [bool] -or
$Parameter.hasDefault -isnot [bool] -or
$Parameter.requiresLookup -isnot [bool] -or
-not $Ids.Add([string]$Parameter.parameterId)) {
Throw-PreflightError 'module_parameter_contract_invalid'
}
Assert-SafeDisplayText $Parameter.label 1 128 `
'module_parameter_contract_invalid'
if ($Parameter.valueType -isnot [string] -or
$Parameter.valueFormat -isnot [string] -or
(([string]$Parameter.valueType + '|' +
[string]$Parameter.valueFormat) -cnotin @(
'string|utf8-text',
'number|invariant-decimal',
'integer|invariant-integer',
'boolean|true-or-false',
'date|yyyy-MM-dd',
'local-date-time|yyyy-MM-ddTHH:mm:ss',
'local-date-time|yyyy-MM-ddTHH:mm',
'time|HH:mm:ss',
'time|HH:mm',
'year-month|yyyy-MM',
'local-date-half-day|yyyy-MM-dd|am-or-pm'
))) {
Throw-PreflightError 'module_parameter_contract_invalid'
}
[void](Read-JsonInt32 `
$Parameter.controlTypeId 0 100000 `
'module_parameter_contract_invalid')
if ($null -ne $Parameter.maximumEncodedBytes) {
[void](Read-JsonInt32 `
$Parameter.maximumEncodedBytes 1 1048576 `
'module_parameter_contract_invalid')
}
if ($null -ne $Parameter.maximumDecimalPlaces) {
[void](Read-JsonInt32 `
$Parameter.maximumDecimalPlaces 0 28 `
'module_parameter_contract_invalid')
}
$mode = [string]$Parameter.inputMode
if (($mode -ceq 'scalar' -and
(-not $Parameter.inputSupported -or
$Parameter.requiresDedicatedAdapter -or
$Parameter.requiresLookup)) -or
($mode -ceq 'lookup-single' -and
(-not $Parameter.inputSupported -or
$Parameter.requiresDedicatedAdapter -or
-not $Parameter.requiresLookup)) -or
($mode -ceq 'unsupported' -and
($Parameter.inputSupported -or $Parameter.editable -or
-not $Parameter.requiresDedicatedAdapter)) -or
($Parameter.required -and
(-not $Parameter.editable -or $Parameter.hasDefault)) -or
($null -ne $Parameter.maximumDecimalPlaces -and
[string]$Parameter.valueType -cne 'number')) {
Throw-PreflightError 'module_parameter_contract_invalid'
}
$Counts[$mode] = [int]$Counts[$mode] + 1
if ($Parameter.editable) { $Counts.editable++ }
if ($Parameter.required) { $Counts.required++ }
if ($Parameter.requiresDedicatedAdapter) { $Counts.dedicated++ }
}
function Read-ModuleParameterContract(
[object]$Data,
[string]$RequestedCode,
[object]$Session
) {
if (-not (Test-ExactProperties $Data @('plan'))) {
Throw-PreflightError 'module_parameter_plan_invalid'
}
$plan = $Data.plan
if (-not (Test-ExactProperties $plan @(
'planId', 'commandName', 'commandVersion', 'moduleCode',
'risk', 'createdAtUtc', 'expiresAtUtc', 'valid',
'executionAllowed', 'inputFingerprint', 'outcomeCode',
'title', 'preview', 'data', 'warnings')) -or
([string]$plan.planId) -cnotmatch '^[A-Fa-f0-9]{32}$' -or
[string]$plan.commandName -cne 'module.parameters' -or
[string]$plan.commandVersion -cne '1.1' -or
([string]$plan.moduleCode) -cnotmatch $safeModuleCode -or
[string]$plan.risk -cne 'read' -or
$plan.valid -ne $true -or
$plan.executionAllowed -ne $false -or
([string]$plan.inputFingerprint) -cnotmatch $safeSha256 -or
[string]$plan.outcomeCode -cne 'module_parameters_ready' -or
$null -ne $plan.preview -or
-not (Test-JsonArray $plan.warnings) -or
@($plan.warnings).Count -ne 0 -or
-not (Test-ExactProperties $plan.data @(
'title', 'outcomeCode', 'parameterContract')) -or
[string]$plan.data.outcomeCode -cne 'module_parameters_ready') {
Throw-PreflightError 'module_parameter_plan_invalid'
}
$contract = $plan.data.parameterContract
if (-not (Test-ExactProperties $contract @(
'schemaVersion', 'source', 'metadataTrust', 'moduleCode',
'moduleName', 'moduleKind', 'configurationFingerprint',
'sessionScope', 'masterParameterCount',
'detailParameterCount', 'masterParameters',
'detailParameters', 'nativeExecutionProfiles',
'payloadContract', 'genericWriteExecutionAvailable',
'writeExecutionBlocker', 'contractFingerprint',
'writeCommand', 'writeReadinessCode',
'genericUpdateExecutionAvailable', 'updateCommand',
'updateReadinessCode', 'updateExecutionBlocker')) -or
[string]$contract.schemaVersion -cne '1.1' -or
[string]$contract.source -cne
'current_erp_database_low_code_configuration' -or
[string]$contract.metadataTrust -cne 'untrusted_display_data' -or
[string]$contract.moduleCode -cne [string]$plan.moduleCode -or
([string]$contract.moduleCode) -cnotmatch $safeModuleCode -or
([string]$contract.moduleKind) -cnotin @(
'document', 'master_data') -or
([string]$contract.configurationFingerprint) -cnotmatch $safeSha256 -or
([string]$contract.contractFingerprint) -cnotmatch $safeSha256 -or
-not (Test-JsonArray $contract.masterParameters) -or
-not (Test-JsonArray $contract.detailParameters) -or
-not (Test-ExactProperties $contract.nativeExecutionProfiles @(
'create', 'update')) -or
$contract.genericWriteExecutionAvailable -isnot [bool] -or
$contract.genericUpdateExecutionAvailable -isnot [bool]) {
Throw-PreflightError 'module_parameter_contract_invalid'
}
Assert-SafeDisplayText $contract.moduleName 1 128 `
'module_parameter_contract_invalid'
if (-not (Test-ExactProperties $contract.sessionScope @(
'userId', 'accountBook', 'subSystemId',
'databaseScopeFingerprint')) -or
[string]$contract.sessionScope.userId -cne $Session.UserId -or
[string]$contract.sessionScope.accountBook -cne
$Session.AccountBook -or
[string]$contract.sessionScope.subSystemId -cne
$Session.SubSystemId -or
([string]$contract.sessionScope.databaseScopeFingerprint).ToLowerInvariant() `
-cne $Session.DatabaseScopeFingerprint) {
Throw-PreflightError 'module_parameter_scope_mismatch'
}
$masterCount = Read-JsonInt32 `
$contract.masterParameterCount 0 512 `
'module_parameter_contract_invalid'
$detailCount = Read-JsonInt32 `
$contract.detailParameterCount 0 512 `
'module_parameter_contract_invalid'
$masterParameters = @($contract.masterParameters)
$detailParameters = @($contract.detailParameters)
if ($masterCount + $detailCount -gt 512 -or
([string]$contract.moduleKind -ceq 'master_data' -and
$detailCount -ne 0) -or
$masterParameters.Count -ne $masterCount -or
$detailParameters.Count -ne $detailCount) {
Throw-PreflightError 'module_parameter_contract_invalid'
}
$ids = New-Object 'System.Collections.Generic.HashSet[string]' `
([StringComparer]::Ordinal)
$counts = @{
scalar = 0
'lookup-single' = 0
unsupported = 0
editable = 0
required = 0
dedicated = 0
}
foreach ($parameter in $masterParameters) {
Read-Parameter $parameter 'master' $ids $counts
}
foreach ($parameter in $detailParameters) {
Read-Parameter $parameter 'detail' $ids $counts
}
$moduleKind = [string]$contract.moduleKind
$configurationFingerprint =
([string]$contract.configurationFingerprint).ToLowerInvariant()
$createProfile = Read-NativeExecutionProfile `
$contract.nativeExecutionProfiles.create 'create' $moduleKind
$updateProfile = Read-NativeExecutionProfile `
$contract.nativeExecutionProfiles.update 'update' $moduleKind
$payloadSummary = Read-ParameterPayloadContract `
$contract.payloadContract `
$moduleKind `
$configurationFingerprint `
$masterParameters `
$detailParameters
$writeReadiness = Read-ModuleExecutionReadiness `
$contract.genericWriteExecutionAvailable `
$contract.writeCommand `
$contract.writeReadinessCode `
$contract.writeExecutionBlocker `
'create' `
$moduleKind `
$createProfile
$updateReadiness = Read-ModuleExecutionReadiness `
$contract.genericUpdateExecutionAvailable `
$contract.updateCommand `
$contract.updateReadinessCode `
$contract.updateExecutionBlocker `
'update' `
$moduleKind `
$updateProfile
return [ordered]@{
requestedCode = $RequestedCode
moduleCode = [string]$contract.moduleCode
moduleKind = $moduleKind
contractSource =
'current_erp_database_low_code_configuration'
sessionScopeBound = $true
configurationFingerprint = $configurationFingerprint
contractFingerprint =
([string]$contract.contractFingerprint).ToLowerInvariant()
masterParameterCount = $masterCount
detailParameterCount = $detailCount
inputModeCounts = [ordered]@{
scalar = [int]$counts.scalar
lookupSingle = [int]$counts['lookup-single']
unsupported = [int]$counts.unsupported
}
editableParameterCount = [int]$counts.editable
requiredParameterCount = [int]$counts.required
dedicatedAdapterParameterCount = [int]$counts.dedicated
nativeExecutionProfiles = [ordered]@{
create = $createProfile
update = $updateProfile
}
payloadContract = $payloadSummary
genericWriteExecutionAvailable = $writeReadiness.available
writeExecutionBlocked = $writeReadiness.blocked
writeCommand = $writeReadiness.command
writeReadinessCode = $writeReadiness.readinessCode
genericUpdateExecutionAvailable = $updateReadiness.available
updateExecutionBlocked = $updateReadiness.blocked
updateCommand = $updateReadiness.command
updateReadinessCode = $updateReadiness.readinessCode
planExecutionAllowed = $false
}
}
function Write-ReportCreateNew([object]$Report, [string]$Path) {
$json = ($Report | ConvertTo-Json -Depth 12) + [Environment]::NewLine
Write-NewUtf8File $Path $json
}
try {
Assert-ExpectedSessionValue `
$ExpectedUserId 'expected_user_id_invalid'
Assert-ExpectedSessionValue `
$ExpectedUserName 'expected_user_name_invalid'
Assert-ExpectedSessionValue `
$ExpectedAccountBook 'expected_account_book_invalid'
Assert-ExpectedSessionValue `
$ExpectedSubSystemId 'expected_subsystem_id_invalid'
if ($RequireDiagnosisWorkflow -and -not $ExpectedIsAdministrator) {
Throw-PreflightError 'diagnosis_requires_expected_administrator'
}
$normalizedModules = New-Object System.Collections.Generic.List[string]
$seenModules = New-Object 'System.Collections.Generic.HashSet[string]' `
([StringComparer]::OrdinalIgnoreCase)
foreach ($rawCode in $ModuleCodes) {
$code = if ($null -eq $rawCode) { '' } else { $rawCode.Trim() }
if ($code -cnotmatch $safeModuleCode -or
-not $seenModules.Add($code)) {
Throw-PreflightError 'module_codes_invalid'
}
$normalizedModules.Add($code)
}
$cliFull = Resolve-RegularFile $CliPath 128MB 'cli_file_invalid'
if ([IO.Path]::GetFileName($cliFull) -ine 'lserp-agent-cli.exe') {
Throw-PreflightError 'cli_filename_invalid'
}
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
if ($cliFull -ieq $outputFull) {
Throw-PreflightError 'trusted_path_conflict'
}
$erpIdentity = Read-ExactErpProcessIdentity
$erpFull = Resolve-RegularFile `
$erpIdentity.ExecutablePath 1GB 'erp_file_invalid'
if ($erpFull -ieq $cliFull -or $erpFull -ieq $outputFull) {
Throw-PreflightError 'trusted_path_conflict'
}
$erpStartTimeUtcTicks = $erpIdentity.StartTimeUtcTicks
$cliLock = [IO.File]::Open(
$cliFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$expectedCliHash = $ExpectedCliSha256.ToLowerInvariant()
Assert-TrustedCliUnchanged
$signature = Get-AuthenticodeSignature -LiteralPath $cliFull
$actualSignerThumbprint = if ($null -eq $signature.SignerCertificate) {
''
} else {
([string]$signature.SignerCertificate.Thumbprint).Replace(
' ', '').ToUpperInvariant()
}
if ($signature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid -or
$actualSignerThumbprint -cne
$ExpectedSignerThumbprint.ToUpperInvariant()) {
Throw-PreflightError 'cli_signature_invalid'
}
$erpLock = [IO.File]::Open(
$erpFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$expectedErpHash = $ExpectedErpSha256.ToLowerInvariant()
Assert-TrustedErpUnchanged
$erpSignature = Get-AuthenticodeSignature -LiteralPath $erpFull
$actualErpSignerThumbprint = if (
$null -eq $erpSignature.SignerCertificate) {
''
} else {
([string]$erpSignature.SignerCertificate.Thumbprint).Replace(
' ', '').ToUpperInvariant()
}
if ($erpSignature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid -or
$actualErpSignerThumbprint -cne
$ExpectedSignerThumbprint.ToUpperInvariant()) {
Throw-PreflightError 'erp_signature_invalid'
}
$workingDirectory = New-RestrictedWorkingDirectory
$identityEnvelope = Invoke-TrustedReadOnlyCli @(
'version', '--correlation-id',
('readonly-version-' + [Guid]::NewGuid().ToString('N'))
) 'cli_runtime_identity'
$runtimeCliIdentity = Read-RuntimeCliIdentity $identityEnvelope.data
$healthEnvelope = Invoke-ReadBridge 'health' 'bridge_health'
$health = Read-Health $healthEnvelope.data
$firstContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_before'
$firstSession = Read-SessionContext `
$firstContextEnvelope.data 'bridge_context_before_invalid'
$capabilitiesEnvelope = Invoke-ReadBridge `
'capabilities' 'bridge_capabilities'
$capabilities = Read-Capabilities $capabilitiesEnvelope.data
$targetContracts = [ordered]@{
'module.parameters' = @('1.1', '1.1', 'read', $false, $false)
'purchase.invoice.resolve' = @('1.4', '1.4', 'draft', $false, $false)
'purchase.invoice.create' = @('1.4', '1.4', 'write', $true, $true)
'hr.leave.resolve' = @('1.4', '1.4', 'draft', $false, $false)
'hr.leave.create' = @('1.2', '1.2', 'write', $true, $true)
'hr.leave.submit' = @('1.0', '1.0', 'write', $true, $true)
'module.diagnose' = @('1.0', '1.0', 'read', $false, $false)
'module.trace-initialization' = @(
'1.2', '1.0', 'critical', $true, $true)
}
$requiredNames = New-Object 'System.Collections.Generic.HashSet[string]' `
([StringComparer]::Ordinal)
[void]$requiredNames.Add('module.parameters')
if ($RequirePurchaseWorkflow) {
[void]$requiredNames.Add('purchase.invoice.resolve')
[void]$requiredNames.Add('purchase.invoice.create')
}
if ($RequireLeaveWorkflow) {
[void]$requiredNames.Add('hr.leave.resolve')
[void]$requiredNames.Add('hr.leave.create')
[void]$requiredNames.Add('hr.leave.submit')
}
if ($RequireDiagnosisWorkflow) {
[void]$requiredNames.Add('module.diagnose')
[void]$requiredNames.Add('module.trace-initialization')
}
$capabilityReadiness = New-Object System.Collections.Generic.List[object]
foreach ($entry in $targetContracts.GetEnumerator()) {
$actual = if ($capabilities.ContainsKey([string]$entry.Key)) {
$capabilities[[string]$entry.Key]
} else { $null }
$contract = @($entry.Value)
$semanticsValid = Test-CapabilityContract `
$actual $contract[0] $contract[1] $contract[2] `
([bool]$contract[3]) ([bool]$contract[4])
$required = $requiredNames.Contains([string]$entry.Key)
if (($null -ne $actual -and -not $semanticsValid) -or
($required -and $null -eq $actual)) {
Throw-PreflightError `
('required_capability_invalid_' + [string]$entry.Key)
}
$capabilityReadiness.Add([ordered]@{
command = [string]$entry.Key
required = $required
available = $null -ne $actual
contractValid = $semanticsValid
version = if ($null -eq $actual) { $null } else {
[string]$actual.version
}
schemaVersion = if ($null -eq $actual) { $null } else {
[string]$actual.schemaVersion
}
risk = if ($null -eq $actual) { $null } else {
[string]$actual.risk
}
})
}
$moduleResults = New-Object System.Collections.Generic.List[object]
$resolvedModules = New-Object `
'System.Collections.Generic.HashSet[string]' `
([StringComparer]::OrdinalIgnoreCase)
for ($index = 0; $index -lt $normalizedModules.Count; $index++) {
$moduleEnvelope = Invoke-ModuleParameterPlan `
$normalizedModules[$index] ($index + 1)
$moduleResult = Read-ModuleParameterContract `
$moduleEnvelope.data `
$normalizedModules[$index] `
$firstSession
if (-not $resolvedModules.Add([string]$moduleResult.moduleCode)) {
Throw-PreflightError 'resolved_module_codes_duplicate'
}
$moduleResults.Add($moduleResult)
}
$lastContextEnvelope = Invoke-ReadBridge 'context' 'bridge_context_after'
$lastSession = Read-SessionContext `
$lastContextEnvelope.data 'bridge_context_after_invalid'
if (-not (Test-SameSession $firstSession $lastSession)) {
Throw-PreflightError 'erp_session_changed_during_preflight'
}
Assert-TrustedCliUnchanged
Assert-TrustedErpUnchanged
$activeModule = if ($null -eq $lastSession.ActiveModule) {
$null
} else {
[ordered]@{
moduleCode = [string]$lastSession.ActiveModule.moduleCode
navigationCode = [string]$lastSession.ActiveModule.navigationCode
}
}
$report = [ordered]@{
schemaVersion = '1.5'
evidenceType = 'lserp_readonly_session_preflight'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
passed = $true
readOnlySessionReady = $true
productionWriteAuthorized = $false
erpProcessId = $ErpProcessId
erpExecutable = [ordered]@{
sha256 = $expectedErpHash
signerThumbprint = $actualErpSignerThumbprint
}
cli = [ordered]@{
component = $runtimeCliIdentity.Component
version = $runtimeCliIdentity.Version
protocolVersion = $runtimeCliIdentity.ProtocolVersion
bridgeOnly = $runtimeCliIdentity.BridgeOnly
databaseDirectAccess = $runtimeCliIdentity.DatabaseDirectAccess
sessionSource = $runtimeCliIdentity.SessionSource
sha256 = $expectedCliHash
signerThumbprint = $actualSignerThumbprint
}
rolloutPolicy = [ordered]@{
customerId = $ExpectedRolloutCustomerId
databaseScopeFingerprint =
$ExpectedDatabaseScopeFingerprint.ToLowerInvariant()
sourceSha256 =
$ExpectedRolloutPolicySha256.ToLowerInvariant()
defaultAction = 'deny'
ruleCount = $health.RuleCount
}
session = [ordered]@{
databaseScopeFingerprint =
$lastSession.DatabaseScopeFingerprint
userIdSha256 = Get-ScopedValueSha256 `
'user-id' $lastSession.UserId
userNameSha256 = Get-ScopedValueSha256 `
'user-name' $lastSession.UserName
accountBookSha256 = Get-ScopedValueSha256 `
'account-book' $lastSession.AccountBook
subSystemIdSha256 = Get-ScopedValueSha256 `
'subsystem-id' $lastSession.SubSystemId
subSystemNameSha256 = Get-ScopedValueSha256 `
'subsystem-name' $lastSession.SubSystemName
isAdministrator = $lastSession.IsAdministrator
activeModule = $activeModule
openModuleCount = $lastSession.OpenModuleCount
}
bridge = [ordered]@{
protocolVersion = '1.0'
registeredCommandCount = $health.CommandCount
enabledCommandCount = $health.EnabledCommandCount
}
requestedReadiness = [ordered]@{
purchaseWorkflow = [bool]$RequirePurchaseWorkflow
leaveWorkflow = [bool]$RequireLeaveWorkflow
diagnosisWorkflow = [bool]$RequireDiagnosisWorkflow
}
capabilityReadiness = @($capabilityReadiness)
modules = @($moduleResults)
readOnlyBoundary = [ordered]@{
allowedOperations = @(
'cli.version',
'bridge.health',
'bridge.context',
'bridge.capabilities',
'bridge.plan:module.parameters'
)
commandExecuteInvoked = $false
workflowPlanInvoked = $false
directDatabaseConnectionUsed = $false
businessWriteAttempted = $false
rawBusinessValuesEmitted = $false
rawParameterLabelsEmitted = $false
temporaryModuleInputsRetained = $false
}
checks = @(
[ordered]@{ code = 'cli_integrity'; passed = $true },
[ordered]@{ code = 'cli_runtime_identity'; passed = $true },
[ordered]@{ code = 'exact_erp_process'; passed = $true },
[ordered]@{ code = 'default_deny_rollout'; passed = $true },
[ordered]@{ code = 'rollout_database_scope'; passed = $true },
[ordered]@{ code = 'expected_database_scope'; passed = $true },
[ordered]@{ code = 'expected_session_scope'; passed = $true },
[ordered]@{ code = 'capability_contracts'; passed = $true },
[ordered]@{ code = 'dynamic_module_contracts'; passed = $true },
[ordered]@{ code = 'dynamic_module_execution_contracts'; passed = $true },
[ordered]@{ code = 'session_stability'; passed = $true },
[ordered]@{ code = 'read_only_boundary'; passed = $true }
)
note = '本报告只证明指定 ERP 会话的只读桥、发布范围、动态参数载荷和原生执行就绪合同;不授权生产写入,也不替代客户 UAT、事务、幂等、审计及恢复验收。'
}
Write-ReportCreateNew $report $outputFull
$published = $true
}
finally {
if ($null -ne $cliLock) { $cliLock.Dispose() }
if ($null -ne $erpLock) { $erpLock.Dispose() }
if ($null -ne $workingDirectory -and
[IO.Directory]::Exists($workingDirectory)) {
try { [IO.Directory]::Delete($workingDirectory, $true) }
catch {
if ($published -and [IO.File]::Exists($outputFull)) {
try { [IO.File]::Delete($outputFull) } catch { }
}
Throw-PreflightError 'restricted_working_cleanup_failed'
}
}
}
Write-Output $outputFull