diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md
index 21e8e4f..0e57946 100644
--- a/docs/PLAYOUT_OPERATIONS.md
+++ b/docs/PLAYOUT_OPERATIONS.md
@@ -215,6 +215,35 @@ Network Monitoring/PGM 관찰 담당자:
대상, cut, selector, page, 횟수 중 하나라도 바뀌면 같은 회차 승인을 재사용하지 않는다.
+#### 5001 PREPARE 전용 one-shot Gate A
+
+현재 5001/page 1 연결 검증은 [`New-LegacyPgmPreparePlan.ps1`](../scripts/New-LegacyPgmPreparePlan.ps1),
+[`Invoke-LegacyPgmPrepareGate.ps1`](../scripts/Invoke-LegacyPgmPrepareGate.ps1),
+[`Test-LegacyPgmPrepareGate.mjs`](../scripts/Test-LegacyPgmPrepareGate.mjs)의 고정 계약을 사용한다.
+계획 생성과 `ValidatePlan`은 명령을 보내지 않으며, 실행은 clean/pushed `main`, 정확한 Release x64
+MSIX와 package runtime, 원본 `5001.t2s`, K3D 등록, PGM PID/시작 시각/listener, 15분 이내의
+별도 승인 파일이 모두 일치할 때만 열린다. 실제 DTO를 만드는 로컬 `database.local.json`도 내용이나
+비밀번호를 노출하지 않고 경로·크기·SHA-256으로 고정해 실행 동안 read lease를 유지한다.
+
+이 게이트의 허용 예산은 현재 PGM `CONNECT` 1회와 삼성전자 `5001` page 1 `PREPARE` 1회뿐이다.
+`TAKE IN`, `NEXT`, Page NEXT, `TAKE OUT`, `DISCONNECT`, 자동 재연결, DB write와 재시도는 모두 0회다.
+앱은 WebView 생성 전에 process-lifetime 권한을 캡처하고, native engine은 동일 권한으로 exact PGM,
+exact cut lease, exact Live profile과 SDK dispatch 직전 만료를 다시 검사한다. Gate A 동안 DB mutation,
+로컬 영구 저장, Fade와 배경 변경도 native 경계 전에 거부한다.
+Oracle/MariaDB 설정 override와 managed runtime startup hook/profiler 환경값은 process/user/machine
+어느 범위에서도 허용하지 않으며 app과 helper의 상속 환경에서도 제거한다.
+
+raw one-shot capability는 명령줄·계획·증적·Web 상태에 기록하지 않는다. package-context wrapper와
+Node helper는 current-user ACL named pipe로 각 parent가 OS PID·실행 파일·시작 시각·package/parent
+identity를 확인한 직후에만 필요한 값을 한 번 받는다. 감시 실패나 제한 시간 초과에서는 pipe를 먼저
+폐기하고 control-plane helper만 fail-stop한다. 이는 앱, PGM, Tornado 또는 K3D 프로세스를 종료하거나
+vendor cleanup 명령을 보내는 권한이 아니다.
+
+성공 시 앱과 session은 `PREPARED 5001` 상태로 그대로 두고 별도 승인 없는 cleanup을 하지 않는다.
+결과가 불명확하면 helper 증적을 `OutcomeUnknown`으로 남기고 앱 종료, 반대 명령, 재실행 또는 같은
+PREPARE 반복을 하지 않는다. 다음 단계는 Network Monitoring과 PGM을 사람이 확인한 뒤 새 계획과
+새 승인을 만드는 것이다.
+
### Gate B: TAKE IN 직전 승인
PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE IN 직전에 다음과 같이 명시적인 승인을 받아야 한다.
diff --git a/scripts/Invoke-LegacyPgmPrepareGate.ps1 b/scripts/Invoke-LegacyPgmPrepareGate.ps1
new file mode 100644
index 0000000..ae0f2a1
--- /dev/null
+++ b/scripts/Invoke-LegacyPgmPrepareGate.ps1
@@ -0,0 +1,1547 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('ValidatePlan', 'ExecuteAuthorizedGateA')]
+ [string] $Action,
+
+ [Parameter(Mandatory = $true)]
+ [string] $PlanPath,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern('^[0-9A-Fa-f]{64}$')]
+ [string] $ExpectedPlanSha256,
+
+ [string] $AuthorizationPath,
+
+ [ValidatePattern('^$|^[0-9A-Fa-f]{64}$')]
+ [string] $ExpectedAuthorizationSha256,
+
+ [string] $EvidencePath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [Text.UTF8Encoding]::new($false, $true)
+$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git'
+$expectedUrl = 'https://legacy-parity.mbn.local/index.html'
+$expectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity'
+$expectedPackageFamily = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0'
+$expectedPackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0'
+$expectedExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'
+$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'
+$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s'
+$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
+$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix'
+$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367'
+$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json'
+$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
+$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'))
+$expectedWindowTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik='))
+$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8'))
+$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q'))
+$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA='))
+$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
+$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA'))
+$liveAuthorizationName = 'MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT'
+$liveAuthorizationValue = 'I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH'
+$capabilityName = 'MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY'
+$pgmProcessIdName = 'MBN_LEGACY_PGM_GATE_A_PGM_PID'
+$pgmStartTimeUtcTicksName = 'MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS'
+$gateExpiresAtUtcTicksName = 'MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS'
+# This stays false until the native bridge independently consumes and enforces the
+# one-shot capability. Changing it requires a clean pushed commit and a newly sealed plan.
+$nativeOneShotPrepareGateReady = $false
+$script:application = $null
+$script:launchAttempted = $false
+$script:applicationLaunched = $false
+$script:gateConfigInstalled = $false
+$script:configRestored = $false
+$script:configRestoreAttempts = 0
+$script:nodeInvocations = 0
+$script:nodeExitCode = $null
+$script:nodeStillRunning = $false
+$script:phase = 'plan-validation'
+$script:status = 'NOT_STARTED'
+$script:failure = $null
+$script:backupPath = $null
+$script:nodeEvidencePath = $null
+$script:screenshotPath = $null
+$script:plan = $null
+$script:authorization = $null
+$script:planHash = $ExpectedPlanSha256.ToUpperInvariant()
+$script:authorizationHash = $null
+$script:gateCapability = $null
+$script:gateCapabilitySha256 = $null
+$script:gateCapabilityGenerations = 0
+$script:nodePermitRequests = 0
+$script:nodePermitDeliveries = 0
+$script:nodePermitClientProcessId = $null
+$script:packagePermitRequests = 0
+$script:packagePermitDeliveries = 0
+$script:packagePermitClientProcessId = $null
+$script:authorizationExpiresAtUtcTicks = $null
+$script:startedAtUtc = [DateTime]::UtcNow
+$script:fileLeases = New-Object 'Collections.Generic.List[IO.FileStream]'
+
+if ($null -eq ('LegacyPgmPreparePackageIdentity' -as [type])) {
+Add-Type -TypeDefinition @'
+using System;
+using Microsoft.Win32.SafeHandles;
+using System.Runtime.InteropServices;
+using System.Text;
+public static class LegacyPgmPreparePackageIdentity
+{
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetPackageFullName(IntPtr process, ref int length, StringBuilder value);
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool GetNamedPipeClientProcessId(IntPtr pipe, out uint clientProcessId);
+ public static string Read(IntPtr process)
+ {
+ int length = 0;
+ if (GetPackageFullName(process, ref length, null) != 122 || length < 2 || length > 4096)
+ throw new InvalidOperationException("Package identity is unavailable.");
+ var value = new StringBuilder(length);
+ if (GetPackageFullName(process, ref length, value) != 0 || value.Length == 0)
+ throw new InvalidOperationException("Package identity could not be read.");
+ return value.ToString();
+ }
+ public static int ReadNamedPipeClientProcessId(SafePipeHandle pipe)
+ {
+ if (pipe == null || pipe.IsInvalid || pipe.IsClosed)
+ throw new InvalidOperationException("Named pipe handle is unavailable.");
+ uint processId;
+ if (!GetNamedPipeClientProcessId(pipe.DangerousGetHandle(), out processId) || processId == 0 || processId > Int32.MaxValue)
+ throw new InvalidOperationException("Named pipe client identity is unavailable.");
+ return (int)processId;
+ }
+}
+'@
+}
+
+function Fail([string] $Message) { throw [InvalidOperationException]::new($Message) }
+
+function New-CurrentUserOneShotPipe([string] $Purpose) {
+ if ([string]::IsNullOrWhiteSpace($Purpose) -or $Purpose -cnotmatch '^[A-Za-z]+$') {
+ Fail 'The one-shot pipe purpose is invalid.'
+ }
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ if ($null -eq $identity.User) { Fail 'The current user SID is unavailable for pipe isolation.' }
+ $security = [IO.Pipes.PipeSecurity]::new()
+ $security.SetOwner($identity.User)
+ $security.SetAccessRuleProtection($true, $false)
+ $security.AddAccessRule([IO.Pipes.PipeAccessRule]::new(
+ $identity.User,
+ [IO.Pipes.PipeAccessRights]::ReadWrite,
+ [Security.AccessControl.AccessControlType]::Allow))
+ $name = "MBN_STOCK_WEBVIEW.GateA.$Purpose.$(([Guid]::NewGuid().ToString('N')).ToUpperInvariant())"
+ $options = [IO.Pipes.PipeOptions]::Asynchronous -bor [IO.Pipes.PipeOptions]::WriteThrough
+ $stream = [IO.Pipes.NamedPipeServerStream]::new(
+ $name,
+ # Node's libuv named-pipe client opens a duplex handle. The protocol is
+ # still parent-write-only; InOut is required only for Windows handle compatibility.
+ [IO.Pipes.PipeDirection]::InOut,
+ 1,
+ [IO.Pipes.PipeTransmissionMode]::Byte,
+ $options,
+ 4096,
+ 4096,
+ $security)
+ try {
+ return [pscustomobject][ordered]@{
+ Name = $name
+ Stream = $stream
+ Connection = $stream.WaitForConnectionAsync()
+ }
+ }
+ catch {
+ $stream.Dispose()
+ throw
+ }
+}
+
+function Close-OneShotPipe([object] $Pipe) {
+ if ($null -eq $Pipe) { return }
+ try { $Pipe.Stream.Dispose() } catch { }
+}
+
+function FullPath([string] $Path, [string] $Name) {
+ if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) {
+ Fail "$Name must be an absolute path."
+ }
+ return [IO.Path]::GetFullPath($Path)
+}
+
+function SamePath([string] $Left, [string] $Right) {
+ return [string]::Equals(
+ [IO.Path]::GetFullPath($Left).TrimEnd('\'),
+ [IO.Path]::GetFullPath($Right).TrimEnd('\'),
+ [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Test-Within([string] $Child, [string] $Parent) {
+ $childFull = [IO.Path]::GetFullPath($Child)
+ $parentPrefix = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
+ return $childFull.StartsWith($parentPrefix, [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Assert-NoReparse([string] $Path, [string] $Name) {
+ $current = [IO.Path]::GetFullPath($Path)
+ while (-not [string]::IsNullOrWhiteSpace($current)) {
+ if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) {
+ if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Fail "$Name contains a reparse point."
+ }
+ }
+ $parent = [IO.Directory]::GetParent($current)
+ if ($null -eq $parent) { break }
+ $current = $parent.FullName
+ }
+}
+
+function Convert-Hash([byte[]] $Bytes) {
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try { return ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-', '') }
+ finally { $algorithm.Dispose() }
+}
+
+function Read-SealedJson([string] $Path, [string] $ExpectedHash, [string] $Name) {
+ $full = FullPath $Path $Name
+ if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
+ Assert-NoReparse $full $Name
+ $bytes = [IO.File]::ReadAllBytes($full)
+ try {
+ if ($bytes.Length -eq 0 -or $bytes.Length -gt 2MB) { Fail "$Name has an unsafe size." }
+ if ((Convert-Hash $bytes) -cne $ExpectedHash.ToUpperInvariant()) { Fail "$Name raw-byte SHA-256 changed." }
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ Fail "$Name must be UTF-8 without BOM."
+ }
+ try { $text = $utf8NoBom.GetString($bytes) }
+ catch { Fail "$Name is not strict UTF-8." }
+ try { $value = $text | ConvertFrom-Json -ErrorAction Stop }
+ catch { Fail "$Name is not valid JSON." }
+ return [pscustomobject]@{ Path = $full; Length = $bytes.Length; Value = $value }
+ }
+ finally { [Array]::Clear($bytes, 0, $bytes.Length) }
+}
+
+function Require([object] $Object, [string] $Name, [string] $Context) {
+ if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) {
+ Fail "$Context.$Name is required."
+ }
+ return $Object.$Name
+}
+
+function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) {
+ if ($null -eq $Object) { Fail "$Context is required." }
+ $actual = @($Object.PSObject.Properties.Name)
+ if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." }
+ foreach ($name in $Expected) {
+ if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) { Fail "$Context property set is not exact: $name." }
+ }
+}
+
+function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) {
+ if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." }
+}
+
+function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) {
+ if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." }
+}
+
+function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) {
+ if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." }
+}
+
+function Assert-FilePin([object] $Pin, [string] $Name) {
+ Assert-ExactProperties $Pin @('path','length','sha256') $Name
+ if ($Pin.path -isnot [string] -or $Pin.sha256 -isnot [string] -or
+ ($Pin.length -isnot [int] -and $Pin.length -isnot [long])) {
+ Fail "$Name pin JSON types are invalid."
+ }
+ $path = FullPath ([string]$Pin.path) "$Name.path"
+ if (-not [IO.File]::Exists($path)) { Fail "$Name is missing." }
+ Assert-NoReparse $path $Name
+ $item = Get-Item -LiteralPath $path -Force
+ if ([int64]$Pin.length -ne [int64]$item.Length -or
+ [string]$Pin.sha256 -cnotmatch '^[0-9A-F]{64}$' -or
+ (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne [string]$Pin.sha256) {
+ Fail "$Name pin changed."
+ }
+ return $path
+}
+
+function Add-ReadLease([string] $Path, [string] $Name) {
+ try {
+ $stream = [IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ [void]$script:fileLeases.Add($stream)
+ }
+ catch { Fail "$Name could not be frozen against write/delete for this Gate A run." }
+}
+
+function Get-RuntimeTreePin([string] $Root) {
+ $fullRoot = FullPath $Root 'package.installLocation'
+ Assert-NoReparse $fullRoot 'package.installLocation'
+ $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
+ if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The runtime tree count is unsafe.' }
+ $rootPrefix = $fullRoot.TrimEnd('\') + '\'
+ $names = @($files | ForEach-Object {
+ Assert-NoReparse $_ 'runtime file'
+ if (-not $_.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { Fail 'Runtime file escaped root.' }
+ $name = $_.Substring($rootPrefix.Length).Replace('\','/')
+ if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) { Fail 'Unsafe runtime filename.' }
+ $name
+ })
+ [Array]::Sort($names, [StringComparer]::Ordinal)
+ $builder = [Text.StringBuilder]::new()
+ foreach ($name in $names) {
+ $file = Join-Path $fullRoot $name.Replace('/','\')
+ $item = Get-Item -LiteralPath $file -Force
+ [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append((Get-FileHash $file -Algorithm SHA256).Hash).Append("`n")
+ }
+ $bytes = $utf8NoBom.GetBytes($builder.ToString())
+ try { return [pscustomobject]@{ fileCount = $names.Length; manifestSha256 = Convert-Hash $bytes } }
+ finally { [Array]::Clear($bytes,0,$bytes.Length) }
+}
+
+function Add-RuntimeTreeReadLeases([string] $Root) {
+ $fullRoot = FullPath $Root 'package.installLocation'
+ Assert-NoReparse $fullRoot 'package.installLocation'
+ $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
+ if ($files.Count -eq 0 -or $files.Count -gt 4096) {
+ Fail 'The runtime tree lease count is unsafe.'
+ }
+ [Array]::Sort($files, [StringComparer]::OrdinalIgnoreCase)
+ foreach ($file in $files) {
+ Assert-NoReparse $file 'runtime file lease'
+ Add-ReadLease $file 'runtime file'
+ }
+}
+
+function Assert-ClosedBudgets([object] $Budgets, [string] $Context) {
+ $names = @('connect','prepare','takeIn','next','pageNext','takeOut','databaseWrite','appClose','retry')
+ Assert-ExactProperties $Budgets $names $Context
+ $expected = [ordered]@{connect=1;prepare=1;takeIn=0;next=0;pageNext=0;takeOut=0;databaseWrite=0;appClose=0;retry=0}
+ foreach ($name in $expected.Keys) { Assert-JsonInt $Budgets.$name ([int]$expected[$name]) "$Context.$name" }
+}
+
+function Assert-Plan([object] $Plan) {
+ Assert-ExactProperties $Plan @(
+ 'schemaVersion','kind','roundId','createdAtUtc','repository','package','automation',
+ 'configuration','cut','selector','k3d','pgm','webView','approvalPolicy','budgets','safety') 'plan'
+ Assert-JsonInt $Plan.schemaVersion 1 'plan.schemaVersion'
+ Assert-JsonString $Plan.kind 'LegacyPgmPrepareGatePlan' 'plan.kind'
+ if ($Plan.roundId -isnot [string] -or [string]$Plan.roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'Plan roundId is invalid.' }
+ Assert-ExactProperties $Plan.repository @('path','branch','commit','upstream','remoteUrl') 'plan.repository'
+ if (-not (SamePath ([string]$Plan.repository.path) $repositoryRoot) -or
+ [string]$Plan.repository.branch -cne 'main' -or [string]$Plan.repository.upstream -cne 'origin/main' -or
+ [string]$Plan.repository.remoteUrl -cne $expectedRemote -or [string]$Plan.repository.commit -cnotmatch '^[0-9a-f]{40}$') {
+ Fail 'Plan repository pin is invalid.'
+ }
+ Assert-ExactProperties $Plan.package @(
+ 'name','familyName','fullName','publisher','version','architecture','applicationId',
+ 'installLocation','executable','msix','runtimeTree') 'plan.package'
+ if ([string]$Plan.package.name -cne $expectedPackageName -or
+ [string]$Plan.package.familyName -cne $expectedPackageFamily -or
+ [string]$Plan.package.fullName -cne $expectedPackageFullName -or
+ [string]$Plan.package.publisher -cne 'CN=Comtrophy' -or [string]$Plan.package.version -cne '0.1.0.0' -or
+ [string]$Plan.package.architecture -cne 'X64' -or [string]$Plan.package.applicationId -cne 'LegacyParityApp') {
+ Fail 'Plan package pin is invalid.'
+ }
+ $expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
+ if (-not (SamePath ([string]$Plan.package.installLocation) $expectedInstallLocation)) {
+ Fail 'Plan package install location is not the exact Release x64 output.'
+ }
+ if (-not (SamePath ([string]$Plan.package.executable.path) (Join-Path $expectedInstallLocation $expectedExecutableName))) {
+ Fail 'Plan package executable escaped the exact Release x64 install location.'
+ }
+ if (-not (SamePath ([string]$Plan.package.msix.path) $approvedMsixPath) -or
+ [string]$Plan.package.msix.sha256 -cne $approvedMsixSha256) {
+ Fail 'Plan MSIX is not the exact Release x64 AppPackages artifact.'
+ }
+ Assert-ExactProperties $Plan.automation @('orchestrator','node','helper') 'plan.automation'
+ if (-not (SamePath ([string]$Plan.automation.node.path) $approvedNodePath) -or
+ [IO.Path]::GetFileName([string]$Plan.automation.node.path) -cne 'node.exe') {
+ Fail 'Plan Node runtime is not the approved canonical runtime.'
+ }
+ Assert-ExactProperties $Plan.configuration @('original','gate','database','sceneDirectory') 'plan.configuration'
+ if (SamePath ([string]$Plan.configuration.original.path) ([string]$Plan.configuration.gate.path)) {
+ Fail 'Plan original and Gate configuration paths must differ.'
+ }
+ if (-not (SamePath ([string]$Plan.configuration.database.path) $approvedDatabaseConfigPath)) {
+ Fail 'Plan database configuration is not the exact local application configuration.'
+ }
+ Assert-ExactProperties $Plan.cut @('sceneCode','pageNumberOneBased','file') 'plan.cut'
+ Assert-JsonString $Plan.cut.sceneCode '5001' 'plan.cut.sceneCode'
+ Assert-JsonInt $Plan.cut.pageNumberOneBased 1 'plan.cut.pageNumberOneBased'
+ if (-not [IO.Directory]::Exists([string]$Plan.configuration.sceneDirectory) -or
+ -not (SamePath ([string]$Plan.configuration.sceneDirectory) $approvedSceneDirectory) -or
+ -not (SamePath ([string]$Plan.cut.file.path) $approvedCutPath) -or
+ [string]$Plan.cut.file.sha256 -cne $approvedCutSha256) {
+ Fail 'Plan scene directory or 5001 cut path is not exact.'
+ }
+ Assert-ExactProperties $Plan.selector @(
+ 'marketText','groupCode','stockName','stockCode','cutLabel','graphicType','subtype','normalizedSubtype','sceneAlias') 'plan.selector'
+ $selectorExpected = [ordered]@{
+ marketText=$expectedMarketText;groupCode='KOSPI';stockName=$expectedStockName;stockCode='005930'
+ cutLabel=$expectedCutLabel;graphicType=$expectedGraphicType;subtype=$expectedSubtype
+ normalizedSubtype='CURRENT';sceneAlias='5001'
+ }
+ foreach ($name in $selectorExpected.Keys) {
+ Assert-JsonString $Plan.selector.$name ([string]$selectorExpected[$name]) "plan.selector.$name"
+ }
+ Assert-ExactProperties $Plan.k3d @('native','interop') 'plan.k3d'
+ Assert-ExactProperties $Plan.pgm @(
+ 'processId','startTimeUtc','processName','executable','windowTitle','listenerAddress','host','port') 'plan.pgm'
+ Assert-JsonInt $Plan.pgm.port 30001 'plan.pgm.port'
+ Assert-JsonString $Plan.pgm.windowTitle 'PGM' 'plan.pgm.windowTitle'
+ Assert-JsonString $Plan.pgm.listenerAddress '0.0.0.0' 'plan.pgm.listenerAddress'
+ Assert-JsonString $Plan.pgm.host '127.0.0.1' 'plan.pgm.host'
+ if ($Plan.pgm.processId -isnot [int] -or [int]$Plan.pgm.processId -le 0 -or
+ $Plan.pgm.startTimeUtc -isnot [string] -or $Plan.pgm.processName -isnot [string]) { Fail 'Plan PGM pin is invalid.' }
+ Assert-ExactProperties $Plan.webView @('address','port','url') 'plan.webView'
+ Assert-JsonString $Plan.webView.address '127.0.0.1' 'plan.webView.address'
+ Assert-JsonString $Plan.webView.url $expectedUrl 'plan.webView.url'
+ if ($Plan.webView.port -isnot [int] -or [int]$Plan.webView.port -lt 1024 -or [int]$Plan.webView.port -gt 65535 -or
+ [int]$Plan.webView.port -eq 30001) { Fail 'Plan WebView pin is invalid.' }
+ Assert-ExactProperties $Plan.approvalPolicy @(
+ 'requiresSeparateAuthorization','maximumAgeSeconds','maximumClockSkewSeconds') 'plan.approvalPolicy'
+ Assert-JsonBool $Plan.approvalPolicy.requiresSeparateAuthorization $true 'plan.approvalPolicy.requiresSeparateAuthorization'
+ Assert-JsonInt $Plan.approvalPolicy.maximumAgeSeconds 900 'plan.approvalPolicy.maximumAgeSeconds'
+ Assert-JsonInt $Plan.approvalPolicy.maximumClockSkewSeconds 5 'plan.approvalPolicy.maximumClockSkewSeconds'
+ Assert-ClosedBudgets $Plan.budgets 'plan.budgets'
+ Assert-ExactProperties $Plan.safety @(
+ 'automaticConnectOnly','nodeHelperInvocationMaximum','nodeHelperFailStopRequired',
+ 'packageWrapperFailStopRequired',
+ 'persistentGateConfigModeDryRun',
+ 'restoreOriginalBeforePrepare','nativeOneShotPrepareGateRequired',
+ 'leaveApplicationAndSessionUntouched','forceCloseForbidden',
+ 'automaticSessionCleanupForbiddenAfterLaunch','retryForbidden') 'plan.safety'
+ Assert-JsonInt $Plan.safety.nodeHelperInvocationMaximum 1 'plan.safety.nodeHelperInvocationMaximum'
+ foreach ($property in @($Plan.safety.PSObject.Properties | Where-Object {
+ [string]$_.Name -cne 'nodeHelperInvocationMaximum'
+ })) {
+ Assert-JsonBool $property.Value $true "plan.safety.$($property.Name)"
+ }
+}
+
+function Parse-CanonicalUtc([string] $Text, [string] $Name) {
+ $value = [DateTimeOffset]::MinValue
+ if (-not [DateTimeOffset]::TryParseExact(
+ $Text, "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::AssumeUniversal, [ref]$value)) { Fail "$Name is not canonical UTC." }
+ return $value.ToUniversalTime()
+}
+
+function Assert-OriginalConfigDryRun([object] $Pin) {
+ $capture = Read-SealedJson ([string]$Pin.path) ([string]$Pin.sha256) 'original config'
+ $modeProperties = @($capture.Value.PSObject.Properties | Where-Object {
+ [string]::Equals($_.Name, 'mode', [StringComparison]::OrdinalIgnoreCase)
+ })
+ if ($modeProperties.Count -ne 1 -or
+ -not [string]::Equals([string]$modeProperties[0].Value, 'DryRun', [StringComparison]::Ordinal)) {
+ Fail 'The original persistent playout configuration is not uniquely DryRun.'
+ }
+}
+
+function Assert-GateConfigSafety([object] $Configuration) {
+ $capture = Read-SealedJson ([string]$Configuration.gate.path) ([string]$Configuration.gate.sha256) 'Gate config'
+ $config = $capture.Value
+ $names = @(
+ 'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex',
+ 'legacySceneFadeDuration','legacySceneBackgroundKind','legacySceneBackgroundAssetPath',
+ 'legacySceneBackgroundVideoLoopCount','legacySceneBackgroundVideoLoopInfinite',
+ 'legacyBackgroundDirectory','sceneDirectory','testProcessWindowTitlePattern',
+ 'testSceneAllowlist','trustedLiveOutputEnabled','queueCapacity',
+ 'connectTimeoutMilliseconds','operationTimeoutMilliseconds','disconnectTimeoutMilliseconds',
+ 'processPollIntervalMilliseconds','reconnectDelayMilliseconds','maximumReconnectAttempts',
+ 'reconnectEnabled','maximumAutomaticRefreshesPerTakeIn')
+ Assert-ExactProperties $config $names 'Gate config JSON'
+ if ($config.testSceneAllowlist -isnot [Array]) { Fail 'Gate config.testSceneAllowlist must be a JSON array.' }
+ $allowlist = @($config.testSceneAllowlist)
+ Assert-JsonString $config.mode 'DryRun' 'Gate config.mode'
+ Assert-JsonString $config.host '127.0.0.1' 'Gate config.host'
+ Assert-JsonInt $config.port 30001 'Gate config.port'
+ Assert-JsonInt $config.tcpMode 1 'Gate config.tcpMode'
+ Assert-JsonInt $config.clientPort 0 'Gate config.clientPort'
+ Assert-JsonInt $config.layoutIndex 10 'Gate config.layoutIndex'
+ Assert-JsonInt $config.legacySceneFadeDuration 6 'Gate config.legacySceneFadeDuration'
+ Assert-JsonString $config.legacySceneBackgroundKind 'None' 'Gate config.legacySceneBackgroundKind'
+ Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'Gate config.legacySceneBackgroundVideoLoopCount'
+ Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'Gate config.legacySceneBackgroundVideoLoopInfinite'
+ Assert-JsonBool $config.trustedLiveOutputEnabled $true 'Gate config.trustedLiveOutputEnabled'
+ Assert-JsonInt $config.queueCapacity 64 'Gate config.queueCapacity'
+ Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'Gate config.connectTimeoutMilliseconds'
+ Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'Gate config.operationTimeoutMilliseconds'
+ Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'Gate config.disconnectTimeoutMilliseconds'
+ Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'Gate config.processPollIntervalMilliseconds'
+ Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'Gate config.reconnectDelayMilliseconds'
+ Assert-JsonInt $config.maximumReconnectAttempts 0 'Gate config.maximumReconnectAttempts'
+ Assert-JsonBool $config.reconnectEnabled $false 'Gate config.reconnectEnabled'
+ Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'Gate config.maximumAutomaticRefreshesPerTakeIn'
+ if ($null -ne $config.outputChannel -or $null -ne $config.legacySceneBackgroundAssetPath -or
+ $null -ne $config.legacyBackgroundDirectory -or $null -ne $config.testProcessWindowTitlePattern -or
+ -not (SamePath ([string]$config.sceneDirectory) ([string]$Configuration.sceneDirectory)) -or
+ $allowlist.Count -ne 1 -or [string]$allowlist[0] -cne '5001' -or
+ $allowlist[0] -isnot [string]) {
+ Fail 'The Gate config is not persistent DryRun with the closed 5001-only safety settings.'
+ }
+}
+
+function Assert-Authorization([object] $Authorization, [object] $Plan) {
+ Assert-ExactProperties $Authorization @(
+ 'schemaVersion','kind','roundId','planSha256','authorizedAtUtc','expiresAtUtc',
+ 'authorizationStatement','currentK3dLicenseValid',
+ 'runningTornadoIsLicensedMainProgram','currentPgmApproved','budgets') 'authorization'
+ Assert-JsonInt $Authorization.schemaVersion 1 'authorization.schemaVersion'
+ Assert-JsonString $Authorization.kind 'LegacyPgmPrepareGateAuthorization' 'authorization.kind'
+ Assert-JsonString $Authorization.roundId ([string]$Plan.roundId) 'authorization.roundId'
+ Assert-JsonString $Authorization.planSha256 $script:planHash 'authorization.planSha256'
+ Assert-JsonString $Authorization.authorizationStatement 'I_AUTHORIZE_CURRENT_PGM_LIVE_CONNECT_ONCE_AND_PREPARE_5001_PAGE_1_ONCE' 'authorization.authorizationStatement'
+ Assert-JsonBool $Authorization.currentK3dLicenseValid $true 'authorization.currentK3dLicenseValid'
+ Assert-JsonBool $Authorization.runningTornadoIsLicensedMainProgram $true 'authorization.runningTornadoIsLicensedMainProgram'
+ Assert-JsonBool $Authorization.currentPgmApproved $true 'authorization.currentPgmApproved'
+ if ($Authorization.authorizedAtUtc -isnot [string] -or $Authorization.expiresAtUtc -isnot [string]) {
+ Fail 'Authorization UTC fields must be JSON strings.'
+ }
+ Assert-ClosedBudgets $Authorization.budgets 'authorization.budgets'
+ $authorized = Parse-CanonicalUtc ([string]$Authorization.authorizedAtUtc) 'authorization.authorizedAtUtc'
+ $expires = Parse-CanonicalUtc ([string]$Authorization.expiresAtUtc) 'authorization.expiresAtUtc'
+ $now = [DateTimeOffset]::UtcNow
+ if ($expires -le $authorized -or ($expires - $authorized).TotalSeconds -gt 900 -or
+ $now -lt $authorized.AddSeconds(-5) -or $now -ge $expires.AddSeconds(-5)) { Fail 'Gate A authorization is not currently unexpired.' }
+ return $expires
+}
+
+function Invoke-Git([string[]] $Arguments) {
+ $output = & git -C $repositoryRoot @Arguments 2>&1
+ if ($LASTEXITCODE -ne 0) { Fail "Git read failed: $($Arguments -join ' ')." }
+ return (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
+}
+
+function Assert-CleanPushedCommit([object] $Repository) {
+ if ((Invoke-Git @('status','--porcelain=v1','--untracked-files=all')).Length -ne 0 -or
+ (Invoke-Git @('rev-parse','HEAD')) -cne [string]$Repository.commit -or
+ (Invoke-Git @('symbolic-ref','--short','HEAD')) -cne 'main' -or
+ (Invoke-Git @('rev-parse','@{upstream}')) -cne [string]$Repository.commit -or
+ (Invoke-Git @('remote','get-url','origin')) -cne $expectedRemote -or
+ (Invoke-Git @('rev-list','--left-right','--count','HEAD...@{upstream}')) -cne "0`t0") {
+ Fail 'The repository is not the exact clean pushed main commit.'
+ }
+}
+
+function Assert-NoUnsafeEnvironment {
+ foreach ($scope in @([EnvironmentVariableTarget]::Process,[EnvironmentVariableTarget]::User,[EnvironmentVariableTarget]::Machine)) {
+ foreach ($nameObject in [Environment]::GetEnvironmentVariables($scope).Keys) {
+ $name = [string]$nameObject
+ if ($name -like 'MBN_STOCK_PLAYOUT_*' -or $name -like 'MBN_STOCK_K3D_*' -or
+ $name -like 'WEBVIEW2_*' -or $name -like 'COREWEBVIEW2_*' -or
+ $name -like 'DOTNET_*' -or $name -like 'CORECLR_*' -or
+ $name -like 'COR_*' -or $name -like 'COMPlus_*' -or
+ $name -like 'MBN_STOCK_ORACLE_*' -or $name -like 'MBN_STOCK_MARIADB_*' -or
+ $name -like 'MBN_STOCK_DB_*' -or
+ $name -in @($capabilityName,$pgmProcessIdName,$pgmStartTimeUtcTicksName,$gateExpiresAtUtcTicksName)) {
+ Fail "A reserved launch environment variable is already present: $scope/$name."
+ }
+ }
+ }
+}
+
+function Assert-Pgm([object] $Pgm) {
+ $process = Get-Process -Id ([int]$Pgm.processId) -ErrorAction Stop
+ $process.Refresh()
+ $start = [DateTime]::ParseExact([string]$Pgm.startTimeUtc, "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime()
+ $exe = FullPath ([string]$Pgm.executable.path) 'PGM executable'
+ if ($process.HasExited -or [string]$process.ProcessName -cne [string]$Pgm.processName -or
+ -not (SamePath $process.Path $exe) -or $process.StartTime.ToUniversalTime().Ticks -ne $start.Ticks -or
+ [string]$process.MainWindowTitle -cne 'PGM') { Fail 'The current PGM process identity changed.' }
+ $listeners = @(Get-NetTCPConnection -State Listen -LocalPort 30001 -ErrorAction Stop)
+ if ($listeners.Count -ne 1 -or [int]$listeners[0].OwningProcess -ne $process.Id -or
+ [string]$listeners[0].LocalAddress -cne '0.0.0.0') { Fail 'The current PGM listener identity changed.' }
+ $tornadoProcesses = @(Get-Process -ErrorAction Stop | Where-Object { [string]$_.ProcessName -like 'Tornado2*' })
+ if ($tornadoProcesses.Count -ne 1 -or $tornadoProcesses[0].Id -ne $process.Id) {
+ Fail 'The Tornado process set is not the single frozen current PGM.'
+ }
+}
+
+function Assert-K3dRegistration([object] $K3d) {
+ $inspector = Join-Path $PSScriptRoot 'Inspect-K3DRegistration.ps1'
+ if (-not [IO.File]::Exists($inspector)) { Fail 'The read-only K3D registration inspector is unavailable.' }
+ $reports = @(& $inspector)
+ if ($reports.Count -ne 1) { Fail 'The K3D registration inspection result is not unique.' }
+ $report = $reports[0]
+ if ([string]$report.Status -cne 'Valid' -or [string]$report.RegistryHive -cne 'HKLM' -or
+ [string]$report.RegistryView -cne 'Registry64' -or [string]$report.CurrentUserOverrides -cne 'None' -or
+ $report.ComActivated -ne $false -or -not (SamePath ([string]$report.TypeLibPath) ([string]$K3d.native.path))) {
+ Fail 'The exact read-only K3D x64 registration pin changed.'
+ }
+ $classes = @($report.Classes)
+ if ($classes.Count -ne 2 -or @($classes | Where-Object {
+ -not (SamePath ([string]$_.ServerPath) ([string]$K3d.native.path)) -or
+ [string]$_.ServerMachine -cne 'AMD64' -or [string]$_.ThreadingModel -cne 'Apartment' -or
+ $_.ReciprocalMapping -ne $true
+ }).Count -ne 0) { Fail 'The K3D COM class registration pin changed.' }
+ $nativeDirectory = [IO.Directory]::GetParent([string]$K3d.native.path)
+ $vendorRoot = $nativeDirectory.Parent.Parent.Parent
+ if ($null -eq $vendorRoot -or
+ -not (SamePath (Join-Path $vendorRoot.FullName 'Bin\x64\C#\Interop.K3DAsyncEngineLib.dll') ([string]$K3d.interop.path))) {
+ Fail 'The K3D interop assembly is not the exact registered vendor sibling.'
+ }
+}
+
+function Assert-ExactPackageRegistration([object] $Package) {
+ $packages = @(Get-AppxPackage -Name $expectedPackageName -ErrorAction Stop)
+ if ($packages.Count -ne 1 -or -not $packages[0].IsDevelopmentMode -or
+ [string]$packages[0].SignatureKind -cne 'None' -or [string]$packages[0].Status -cne 'Ok' -or
+ [string]$packages[0].PackageFullName -cne $expectedPackageFullName -or
+ [string]$packages[0].PackageFamilyName -cne $expectedPackageFamily -or
+ [string]$packages[0].Publisher -cne 'CN=Comtrophy' -or
+ [string]$packages[0].Version -cne '0.1.0.0' -or [string]$packages[0].Architecture -cne 'X64' -or
+ -not (SamePath ([string]$packages[0].InstallLocation) ([string]$Package.installLocation))) {
+ Fail 'Exact Release x64 development package registration is absent.'
+ }
+}
+
+function Assert-ReadOnlyPlanEnvironment([object] $Plan) {
+ Assert-CleanPushedCommit $Plan.repository
+ Assert-NoUnsafeEnvironment
+ if (-not (SamePath (Assert-FilePin $Plan.automation.orchestrator 'Gate orchestrator') $PSCommandPath) -or
+ -not (SamePath (Assert-FilePin $Plan.automation.helper 'Node helper') (Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'))) {
+ Fail 'Automation file identity changed.'
+ }
+ [void](Assert-FilePin $Plan.automation.node 'Node runtime')
+ [void](Assert-FilePin $Plan.package.msix 'Release x64 MSIX')
+ [void](Assert-FilePin $Plan.package.executable 'package executable')
+ $tree = Get-RuntimeTreePin ([string]$Plan.package.installLocation)
+ if ($tree.fileCount -ne [int]$Plan.package.runtimeTree.fileCount -or
+ $tree.manifestSha256 -cne [string]$Plan.package.runtimeTree.manifestSha256) {
+ Fail 'Package runtime tree changed.'
+ }
+ [void](Assert-FilePin $Plan.configuration.original 'original config')
+ Assert-OriginalConfigDryRun $Plan.configuration.original
+ [void](Assert-FilePin $Plan.configuration.gate 'Gate config')
+ Assert-GateConfigSafety $Plan.configuration
+ [void](Assert-FilePin $Plan.configuration.database 'database config')
+ [void](Assert-FilePin $Plan.cut.file '5001 cut')
+ [void](Assert-FilePin $Plan.k3d.native 'K3D native binary')
+ [void](Assert-FilePin $Plan.k3d.interop 'K3D interop assembly')
+ [void](Assert-FilePin $Plan.pgm.executable 'PGM executable')
+ Assert-NoReparse ([string]$Plan.configuration.sceneDirectory) 'scene directory'
+ Assert-K3dRegistration $Plan.k3d
+ Assert-Pgm $Plan.pgm
+ Assert-ExactPackageRegistration $Plan.package
+ if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or
+ @(Get-NetTCPConnection -State Listen -LocalPort ([int]$Plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) {
+ Fail 'An existing application or CDP endpoint is present.'
+ }
+ return [pscustomobject][ordered]@{
+ filePinsValidated = 12
+ runtimeTreeValidated = $true
+ packageRegistrationValidated = $true
+ currentPgmValidated = $true
+ k3dRegistrationValidatedReadOnly = $true
+ k3dComActivated = $false
+ }
+}
+
+function Assert-Application([Diagnostics.Process] $Process, [string] $Executable, [bool] $RequireReadyWindow = $true) {
+ $Process.Refresh()
+ if ($Process.HasExited -or -not (SamePath $Process.Path $Executable) -or
+ [LegacyPgmPreparePackageIdentity]::Read($Process.Handle) -cne $expectedPackageFullName) {
+ Fail 'The package application identity changed.'
+ }
+ if ($RequireReadyWindow) {
+ if ($Process.MainWindowHandle -eq [IntPtr]::Zero -or
+ [string]$Process.MainWindowTitle -cne $expectedWindowTitle) {
+ Fail 'The package application window is not ready or changed identity.'
+ }
+ }
+}
+
+function Test-DescendsFrom([int] $ProcessId, [int] $AncestorId) {
+ $seen = [Collections.Generic.HashSet[int]]::new(); $current = $ProcessId
+ while ($current -gt 0 -and $seen.Add($current)) {
+ if ($current -eq $AncestorId) { return $true }
+ $row = Get-CimInstance Win32_Process -Filter "ProcessId = $current" -ErrorAction SilentlyContinue
+ if ($null -eq $row) { return $false }; $current = [int]$row.ParentProcessId
+ }
+ return $false
+}
+
+function Assert-Cdp([int] $Port, [int] $AppPid) {
+ $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop)
+ if ($listeners.Count -ne 1 -or [string]$listeners[0].LocalAddress -cne '127.0.0.1') { Fail 'The exact IPv4 loopback CDP listener is unavailable.' }
+ $owner = Get-Process -Id ([int]$listeners[0].OwningProcess) -ErrorAction Stop
+ if ([IO.Path]::GetFileName($owner.Path) -cne 'msedgewebview2.exe' -or -not (Test-DescendsFrom $owner.Id $AppPid)) {
+ Fail 'The CDP listener is not owned by the exact app WebView child.'
+ }
+}
+
+function Wait-ExactTarget([int] $Port, [int] $AppPid) {
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ while ([DateTime]::UtcNow -lt $deadline) {
+ Assert-Application $script:application ([string]$script:plan.package.executable.path)
+ Assert-Pgm $script:plan.pgm
+ try {
+ $readinessListeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
+ if ($readinessListeners.Count -eq 0) {
+ Start-Sleep -Milliseconds 250
+ continue
+ }
+ Assert-Cdp $Port $AppPid
+ $client = [Net.WebClient]::new()
+ try {
+ $client.Proxy = $null
+ $text = $client.DownloadString("http://127.0.0.1:$Port/json/list")
+ $targets = @($text | ConvertFrom-Json -ErrorAction Stop)
+ }
+ finally { $client.Dispose() }
+ $exact = @($targets | Where-Object { [string]$_.type -ceq 'page' -and [string]$_.url -ceq $expectedUrl })
+ if ($exact.Count -gt 1) { Fail 'More than one exact WebView target exists.' }
+ if ($exact.Count -eq 1) {
+ $uri = [Uri]([string]$exact[0].webSocketDebuggerUrl)
+ if ($uri.Scheme -cne 'ws' -or $uri.Host -cne '127.0.0.1' -or $uri.Port -ne $Port -or
+ -not $uri.AbsolutePath.StartsWith('/devtools/page/', [StringComparison]::Ordinal)) { Fail 'The exact target WebSocket URL is unsafe.' }
+ return
+ }
+ }
+ catch [InvalidOperationException] { throw }
+ catch { }
+ Start-Sleep -Milliseconds 250
+ }
+ Fail 'The exact WebView target did not appear before timeout.'
+}
+
+function Write-BytesExclusive([string] $Path, [byte[]] $Bytes) {
+ $stream = [IO.File]::Open($Path,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::Read)
+ try { $stream.Write($Bytes,0,$Bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() }
+}
+
+function New-GateCapability {
+ $script:gateCapabilityGenerations++
+ if ($script:gateCapabilityGenerations -ne 1) {
+ Fail 'The native Gate A capability generation budget was exceeded.'
+ }
+ $bytes = New-Object byte[] 32
+ $rng = [Security.Cryptography.RandomNumberGenerator]::Create()
+ try {
+ $rng.GetBytes($bytes)
+ return ([BitConverter]::ToString($bytes)).Replace('-','')
+ }
+ finally {
+ $rng.Dispose()
+ [Array]::Clear($bytes,0,$bytes.Length)
+ }
+}
+
+function Install-GateConfiguration([object] $Configuration, [string] $RoundId) {
+ $original = Assert-FilePin $Configuration.original 'original config'
+ $gate = Assert-FilePin $Configuration.gate 'Gate config'
+ if (-not (SamePath $original (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\playout.local.json'))) {
+ Fail 'The original config is not the exact application config path.'
+ }
+ $script:backupPath = Join-Path ([IO.Path]::GetDirectoryName($original)) ("playout.local.$RoundId.backup")
+ $temporary = $original + '.' + [Guid]::NewGuid().ToString('N') + '.gate.tmp'
+ if ([IO.File]::Exists($script:backupPath) -or [IO.File]::Exists($temporary)) { Fail 'A stale Gate config artifact exists.' }
+ $bytes = [IO.File]::ReadAllBytes($gate)
+ try { Write-BytesExclusive $temporary $bytes }
+ finally { [Array]::Clear($bytes,0,$bytes.Length) }
+ try {
+ [IO.File]::Replace($temporary,$original,$script:backupPath,$true)
+ $script:gateConfigInstalled = $true
+ }
+ finally { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } }
+ if ((Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.gate.sha256 -or
+ (Get-FileHash $script:backupPath -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256) {
+ Fail 'Atomic Gate config installation could not be verified.'
+ }
+}
+
+function Restore-OriginalConfiguration([object] $Configuration) {
+ if (-not $script:gateConfigInstalled -or $script:configRestored) { return }
+ $script:configRestoreAttempts++
+ if ($script:configRestoreAttempts -ne 1) { Fail 'The exact original config restore one-shot budget was exceeded.' }
+ $original = [string]$Configuration.original.path
+ if (-not [IO.File]::Exists($script:backupPath) -or
+ (Get-FileHash $script:backupPath -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256 -or
+ (Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.gate.sha256) {
+ Fail 'The exact config restore precondition changed.'
+ }
+ [IO.File]::Replace($script:backupPath,$original,$null,$true)
+ if ((Get-FileHash $original -Algorithm SHA256).Hash -cne [string]$Configuration.original.sha256 -or
+ [IO.File]::Exists($script:backupPath)) { Fail 'The original config restore is not exact.' }
+ $script:configRestored = $true
+}
+
+function Assert-PackagePermitClient(
+ [object] $Pipe,
+ [DateTime] $NotBeforeUtc,
+ [string] $ExpectedExecutable) {
+ $clientProcessId = [LegacyPgmPreparePackageIdentity]::ReadNamedPipeClientProcessId(
+ $Pipe.Stream.SafePipeHandle)
+ $process = Get-Process -Id $clientProcessId -ErrorAction Stop
+ $process.Refresh()
+ if ($process.HasExited -or -not (SamePath $process.Path $ExpectedExecutable) -or
+ $process.StartTime.ToUniversalTime() -lt $NotBeforeUtc.AddSeconds(-1) -or
+ [LegacyPgmPreparePackageIdentity]::Read($process.Handle) -cne $expectedPackageFullName) {
+ Fail 'The package launch permit pipe client identity is not exact.'
+ }
+ $script:packagePermitClientProcessId = $clientProcessId
+ return $process
+}
+
+function Stop-PackageWrapperFailStop([Diagnostics.Process] $Process) {
+ # This stops only the short-lived package-context control wrapper. It never
+ # closes, terminates, disconnects, or commands the app, PGM, Tornado, or K3D.
+ if ($null -eq $Process) { return }
+ $exitObserved = $false
+ try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { }
+ if (-not $exitObserved) {
+ try { $Process.Kill() }
+ catch {
+ try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { }
+ if (-not $exitObserved) {
+ Fail 'OUTCOME_UNKNOWN: The package-context wrapper could not be fail-stopped or proven exited.'
+ }
+ }
+ if (-not $exitObserved) {
+ try { $exitObserved = [bool]$Process.WaitForExit(5000) } catch { }
+ }
+ }
+ if (-not $exitObserved) {
+ Fail 'OUTCOME_UNKNOWN: The package-context wrapper was not proven stopped.'
+ }
+}
+
+function Start-Package([object] $Plan) {
+ $command = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue
+ if ($null -eq $command -or [string]$command.Source -cne 'Appx') { Fail 'The Appx package-context launch helper is required.' }
+ if ([string]$script:gateCapability -cnotmatch '^[0-9A-F]{64}$') {
+ Fail 'The native Gate A capability is absent before package launch.'
+ }
+ if ($script:authorizationExpiresAtUtcTicks -isnot [long] -or
+ [long]$script:authorizationExpiresAtUtcTicks -le [DateTime]::UtcNow.Ticks) {
+ Fail 'The native Gate A authorization expiry is absent or expired before package launch.'
+ }
+ $pgmStartUtc = [DateTime]::ParseExact(
+ [string]$Plan.pgm.startTimeUtc,
+ "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'",
+ [Globalization.CultureInfo]::InvariantCulture,
+ [Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime()
+ $environment = [ordered]@{
+ MBN_STOCK_PLAYOUT_MODE='Live'; MBN_STOCK_PLAYOUT_HOST='127.0.0.1'; MBN_STOCK_PLAYOUT_PORT='30001'
+ MBN_STOCK_PLAYOUT_TCP_MODE='1'; MBN_STOCK_PLAYOUT_CLIENT_PORT='0'; MBN_STOCK_PLAYOUT_LAYOUT_INDEX='10'
+ MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION='6'; MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND='None'
+ MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT='2004'; MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE='true'
+ MBN_STOCK_PLAYOUT_SCENE_DIRECTORY=[string]$Plan.configuration.sceneDirectory
+ MBN_STOCK_PLAYOUT_QUEUE_CAPACITY='64'; MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS='5000'
+ MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS='5000'; MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS='3000'
+ MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS='1000'; MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS='1000'
+ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS='0'; MBN_STOCK_PLAYOUT_RECONNECT_ENABLED='false'
+ MBN_STOCK_K3D_NATIVE_SHA256=[string]$Plan.k3d.native.sha256
+ MBN_STOCK_K3D_INTEROP_SHA256=[string]$Plan.k3d.interop.sha256
+ $liveAuthorizationName=$liveAuthorizationValue
+ $capabilityName=[string]$script:gateCapability
+ $pgmProcessIdName=([int]$Plan.pgm.processId).ToString([Globalization.CultureInfo]::InvariantCulture)
+ $pgmStartTimeUtcTicksName=$pgmStartUtc.Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)
+ $gateExpiresAtUtcTicksName=([long]$script:authorizationExpiresAtUtcTicks).ToString([Globalization.CultureInfo]::InvariantCulture)
+ WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS="--remote-debugging-address=127.0.0.1 --remote-debugging-port=$([int]$Plan.webView.port)"
+ }
+ $encodedExe = [Convert]::ToBase64String($utf8NoBom.GetBytes([string]$Plan.package.executable.path))
+ $encodedDir = [Convert]::ToBase64String($utf8NoBom.GetBytes([string]$Plan.package.installLocation))
+ $launchPipe = New-CurrentUserOneShotPipe 'Package'
+ $pipeName = [string]$launchPipe.Name
+ $child = @"
+`$exe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedExe'))
+`$dir=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedDir'))
+`$client=[IO.Pipes.NamedPipeClientStream]::new('.', '$pipeName', [IO.Pipes.PipeDirection]::In, [IO.Pipes.PipeOptions]::None)
+try {
+ `$client.Connect(15000)
+ `$lengthBytes=New-Object byte[] 4
+ `$offset=0
+ while(`$offset -lt 4){`$read=`$client.Read(`$lengthBytes,`$offset,4-`$offset);if(`$read -le 0){exit 92};`$offset+=`$read}
+ `$length=[BitConverter]::ToInt32(`$lengthBytes,0)
+ if(`$length -le 0 -or `$length -gt 65536){exit 93}
+ `$payload=New-Object byte[] `$length
+ `$offset=0
+ while(`$offset -lt `$length){`$read=`$client.Read(`$payload,`$offset,`$length-`$offset);if(`$read -le 0){exit 94};`$offset+=`$read}
+ if(`$client.ReadByte() -ne -1){exit 95}
+ `$strictUtf8=New-Object Text.UTF8Encoding(`$false,`$true)
+ `$map=`$strictUtf8.GetString(`$payload)|ConvertFrom-Json -ErrorAction Stop
+}
+finally { if(`$null-ne`$client){`$client.Dispose()} }
+`$start=New-Object Diagnostics.ProcessStartInfo
+`$start.FileName=`$exe;`$start.WorkingDirectory=`$dir;`$start.UseShellExecute=`$false
+foreach(`$name in @(`$start.EnvironmentVariables.Keys)){if(`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'MBN_STOCK_K3D_*' -or `$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or `$name -like 'MBN_STOCK_ORACLE_*' -or `$name -like 'MBN_STOCK_MARIADB_*' -or `$name -like 'MBN_STOCK_DB_*' -or `$name -like 'WEBVIEW2_*' -or `$name -like 'COREWEBVIEW2_*' -or `$name -like 'DOTNET_*' -or `$name -like 'CORECLR_*' -or `$name -like 'COR_*' -or `$name -like 'COMPlus_*'){`$start.EnvironmentVariables.Remove(`$name)}}
+foreach(`$property in `$map.PSObject.Properties){`$start.EnvironmentVariables[`$property.Name]=[string]`$property.Value}
+try { if(`$null -eq [Diagnostics.Process]::Start(`$start)){exit 91} }
+finally {
+ foreach(`$name in @(`$start.EnvironmentVariables.Keys)){if(`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'MBN_STOCK_K3D_*' -or `$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or `$name -like 'MBN_STOCK_ORACLE_*' -or `$name -like 'MBN_STOCK_MARIADB_*' -or `$name -like 'MBN_STOCK_DB_*' -or `$name -like 'WEBVIEW2_*' -or `$name -like 'COREWEBVIEW2_*' -or `$name -like 'DOTNET_*' -or `$name -like 'CORECLR_*' -or `$name -like 'COR_*' -or `$name -like 'COMPlus_*'){`$start.EnvironmentVariables.Remove(`$name)}}
+ if(`$null-ne`$payload){[Array]::Clear(`$payload,0,`$payload.Length)}
+ if(`$null-ne`$lengthBytes){[Array]::Clear(`$lengthBytes,0,`$lengthBytes.Length)}
+}
+"@
+ $encodedChild = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child))
+ $notBefore = [DateTime]::UtcNow
+ $script:launchAttempted = $true
+ $wrapperExecutable = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
+ $launcher = [Management.Automation.PowerShell]::Create()
+ $wrapper = $null
+ $async = $null
+ try {
+ [void]$launcher.AddCommand('Import-Module')
+ [void]$launcher.AddParameter('Name','Appx')
+ [void]$launcher.AddParameter('ErrorAction','Stop')
+ [void]$launcher.AddStatement()
+ [void]$launcher.AddCommand('Invoke-CommandInDesktopPackage')
+ [void]$launcher.AddParameter('PackageFamilyName',$expectedPackageFamily)
+ [void]$launcher.AddParameter('AppId','LegacyParityApp')
+ [void]$launcher.AddParameter('Command',$wrapperExecutable)
+ [void]$launcher.AddParameter('Args',"-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encodedChild")
+ [void]$launcher.AddParameter('PreventBreakaway')
+ [void]$launcher.AddParameter('ErrorAction','Stop')
+ $async = $launcher.BeginInvoke()
+ $pipeDeadline = [DateTime]::UtcNow.AddSeconds(20)
+ while (-not $launchPipe.Connection.IsCompleted -and [DateTime]::UtcNow -lt $pipeDeadline) {
+ if ($async.IsCompleted) {
+ [void]$launcher.EndInvoke($async)
+ Fail 'The package-context wrapper exited before requesting its one-shot launch permit.'
+ }
+ Assert-Pgm $Plan.pgm
+ Start-Sleep -Milliseconds 50
+ }
+ if (-not $launchPipe.Connection.IsCompleted) { Fail 'The package-context wrapper did not request its launch permit.' }
+ $launchPipe.Connection.GetAwaiter().GetResult()
+ $script:packagePermitRequests++
+ if ($script:packagePermitRequests -ne 1 -or -not $launchPipe.Stream.IsConnected) {
+ Fail 'The package launch permit request budget or connection state is invalid.'
+ }
+ $wrapper = Assert-PackagePermitClient $launchPipe $notBefore $wrapperExecutable
+ [void](Assert-Authorization $script:authorization $Plan)
+ Assert-Pgm $Plan.pgm
+ if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or
+ @(Get-NetTCPConnection -State Listen -LocalPort ([int]$Plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) {
+ Fail 'The app or CDP precondition changed before package launch permit delivery.'
+ }
+ $payload = $utf8NoBom.GetBytes(($environment | ConvertTo-Json -Compress))
+ $lengthBytes = [BitConverter]::GetBytes([int]$payload.Length)
+ try {
+ if ($payload.Length -le 0 -or $payload.Length -gt 65536) { Fail 'The package launch environment payload is unsafe.' }
+ $launchPipe.Stream.Write($lengthBytes,0,$lengthBytes.Length)
+ $launchPipe.Stream.Write($payload,0,$payload.Length)
+ $launchPipe.Stream.Flush()
+ $script:packagePermitDeliveries++
+ }
+ finally {
+ [Array]::Clear($payload,0,$payload.Length)
+ [Array]::Clear($lengthBytes,0,$lengthBytes.Length)
+ Close-OneShotPipe $launchPipe
+ }
+ if ($script:packagePermitDeliveries -ne 1) { Fail 'The package launch permit was not delivered exactly once.' }
+ $wrapperDeadline = [DateTime]::UtcNow.AddSeconds(20)
+ while (-not $async.IsCompleted -and [DateTime]::UtcNow -lt $wrapperDeadline) { Start-Sleep -Milliseconds 50 }
+ if (-not $async.IsCompleted) { Fail 'The package-context wrapper exceeded its one-shot launch timeout.' }
+ [void]$launcher.EndInvoke($async)
+ if ($launcher.HadErrors -or $launcher.Streams.Error.Count -ne 0) { Fail 'The package-context wrapper reported a launch error.' }
+ if ($null -eq $wrapper -or -not $wrapper.WaitForExit(0)) {
+ Fail 'OUTCOME_UNKNOWN: The package-context wrapper pipeline completed without proving the exact wrapper exited.'
+ }
+ }
+ catch {
+ $caught = $_
+ Close-OneShotPipe $launchPipe
+ $failStopFailure = $null
+ try { if ($null -ne $wrapper) { Stop-PackageWrapperFailStop $wrapper } }
+ catch { $failStopFailure = $_ }
+ try { if ($null -ne $async -and -not $async.IsCompleted) { $launcher.Stop() } } catch { }
+ if ($null -ne $failStopFailure) { throw $failStopFailure }
+ throw $caught
+ }
+ finally { $launcher.Dispose() }
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ while ([DateTime]::UtcNow -lt $deadline) {
+ $items = @(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue | Where-Object { -not $_.HasExited })
+ if ($items.Count -gt 1) { Fail 'More than one LegacyParityApp process appeared.' }
+ if ($items.Count -eq 1 -and $items[0].StartTime.ToUniversalTime() -ge $notBefore.AddSeconds(-1)) {
+ $script:application = $items[0]
+ $script:applicationLaunched = $true
+ Assert-Application $items[0] ([string]$Plan.package.executable.path) $false
+ $items[0].Refresh()
+ if ($items[0].MainWindowHandle -ne [IntPtr]::Zero -and
+ [string]$items[0].MainWindowTitle -ceq $expectedWindowTitle) {
+ return $items[0]
+ }
+ }
+ Start-Sleep -Milliseconds 100
+ }
+ Fail 'The exact package application did not start before timeout.'
+}
+
+function Quote-Argument([string] $Value) {
+ if ($Value -notmatch '[\s"]') { return $Value }
+ return '"' + ([regex]::Replace($Value, '(\\*)"', '$1$1\"') -replace '(\\+)$','$1$1') + '"'
+}
+
+function Test-ExactStringSequence([object[]] $Actual, [string[]] $Expected) {
+ if ($Actual.Count -ne $Expected.Count) { return $false }
+ for ($index = 0; $index -lt $Expected.Count; $index++) {
+ if ($Actual[$index] -isnot [string] -or [string]$Actual[$index] -cne $Expected[$index]) {
+ return $false
+ }
+ }
+ return $true
+}
+
+function Assert-NodeScreenshot([object] $Screenshot, [string] $ExpectedPath) {
+ Assert-ExactProperties $Screenshot @('path','bytes','sha256') 'Node evidence.screenshot'
+ if ($Screenshot.path -isnot [string] -or $Screenshot.sha256 -isnot [string] -or
+ ($Screenshot.bytes -isnot [int] -and $Screenshot.bytes -isnot [long])) {
+ Fail 'OUTCOME_UNKNOWN: Node screenshot evidence JSON types are invalid.'
+ }
+ $path = FullPath ([string]$Screenshot.path) 'Node evidence.screenshot.path'
+ if (-not (SamePath $path $ExpectedPath) -or -not [IO.File]::Exists($path)) {
+ Fail 'OUTCOME_UNKNOWN: Node screenshot path is not the exclusive requested PNG path.'
+ }
+ Assert-NoReparse $path 'Node screenshot'
+ Add-ReadLease $path 'Node screenshot'
+ $bytes = [IO.File]::ReadAllBytes($path)
+ try {
+ $signature = [byte[]](0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A)
+ if ($bytes.Length -lt 1024 -or [int64]$Screenshot.bytes -ne [int64]$bytes.LongLength -or
+ [string]$Screenshot.sha256 -cnotmatch '^[0-9A-F]{64}$' -or
+ (Convert-Hash $bytes) -cne [string]$Screenshot.sha256) {
+ Fail 'OUTCOME_UNKNOWN: Node screenshot length or SHA-256 does not match the actual PNG.'
+ }
+ for ($index = 0; $index -lt $signature.Length; $index++) {
+ if ($bytes[$index] -ne $signature[$index]) {
+ Fail 'OUTCOME_UNKNOWN: Node screenshot does not have the exact PNG signature.'
+ }
+ }
+ }
+ finally { [Array]::Clear($bytes,0,$bytes.Length) }
+}
+
+function Assert-NodePreparedFinalState([object] $FinalState) {
+ Assert-ExactProperties $FinalState @(
+ 'revision','isBusy','statusMessage','statusKind','playlist','playout') 'Node evidence.finalState'
+ if (($FinalState.revision -isnot [int] -and $FinalState.revision -isnot [long]) -or
+ [int64]$FinalState.revision -lt 0) {
+ Fail 'OUTCOME_UNKNOWN: Node final revision is invalid.'
+ }
+ Assert-JsonBool $FinalState.isBusy $false 'Node evidence.finalState.isBusy'
+ if ($FinalState.playlist -isnot [Array]) {
+ Fail 'OUTCOME_UNKNOWN: Node final playlist must be a JSON array.'
+ }
+ $playlist = @($FinalState.playlist)
+ if ($playlist.Count -ne 1) { Fail 'OUTCOME_UNKNOWN: Node final playlist is not exactly one row.' }
+ $row = $playlist[0]
+ Assert-ExactProperties $row @(
+ 'rowId','isEnabled','isActive','marketText','stockName','graphicType','subtype','pageText') 'Node evidence.finalState.playlist[0]'
+ if ($row.rowId -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$row.rowId)) {
+ Fail 'OUTCOME_UNKNOWN: Node final playlist row identity is invalid.'
+ }
+ Assert-JsonBool $row.isEnabled $true 'Node evidence.finalState.playlist[0].isEnabled'
+ Assert-JsonBool $row.isActive $true 'Node evidence.finalState.playlist[0].isActive'
+ Assert-JsonString $row.marketText $expectedMarketText 'Node evidence.finalState.playlist[0].marketText'
+ Assert-JsonString $row.stockName $expectedStockName 'Node evidence.finalState.playlist[0].stockName'
+ Assert-JsonString $row.graphicType $expectedGraphicType 'Node evidence.finalState.playlist[0].graphicType'
+ Assert-JsonString $row.subtype $expectedSubtype 'Node evidence.finalState.playlist[0].subtype'
+ Assert-JsonString $row.pageText '1/1' 'Node evidence.finalState.playlist[0].pageText'
+
+ $playout = $FinalState.playout
+ Assert-ExactProperties $playout @(
+ 'mode','connectionState','phase','isConnected','isCommandAvailable','liveTakeInAllowed',
+ 'isPlayCompletionPending','isTakeOutCompletionPending','outcomeUnknown','preparedCode',
+ 'onAirCode','currentEntryId','pageIndexZeroBased','pageCount','pageSize','itemCount',
+ 'currentPageItemCount','isLastPage','nextKind','isBusy','refreshActive',
+ 'refreshCompletedCount','refreshMaximumCount','refreshLimitReached','message') 'Node evidence.finalState.playout'
+ Assert-JsonString $playout.mode 'live' 'Node evidence.finalState.playout.mode'
+ Assert-JsonString $playout.connectionState 'connected' 'Node evidence.finalState.playout.connectionState'
+ Assert-JsonString $playout.phase 'prepared' 'Node evidence.finalState.playout.phase'
+ Assert-JsonBool $playout.isConnected $true 'Node evidence.finalState.playout.isConnected'
+ Assert-JsonBool $playout.isCommandAvailable $true 'Node evidence.finalState.playout.isCommandAvailable'
+ Assert-JsonBool $playout.liveTakeInAllowed $false 'Node evidence.finalState.playout.liveTakeInAllowed'
+ Assert-JsonBool $playout.isPlayCompletionPending $false 'Node evidence.finalState.playout.isPlayCompletionPending'
+ Assert-JsonBool $playout.isTakeOutCompletionPending $false 'Node evidence.finalState.playout.isTakeOutCompletionPending'
+ Assert-JsonBool $playout.outcomeUnknown $false 'Node evidence.finalState.playout.outcomeUnknown'
+ Assert-JsonString $playout.preparedCode '5001' 'Node evidence.finalState.playout.preparedCode'
+ if ($null -ne $playout.onAirCode) { Fail 'OUTCOME_UNKNOWN: Node final onAirCode must be JSON null.' }
+ Assert-JsonString $playout.currentEntryId ([string]$row.rowId) 'Node evidence.finalState.playout.currentEntryId'
+ Assert-JsonInt $playout.pageIndexZeroBased 0 'Node evidence.finalState.playout.pageIndexZeroBased'
+ Assert-JsonInt $playout.pageCount 1 'Node evidence.finalState.playout.pageCount'
+ Assert-JsonInt $playout.itemCount 1 'Node evidence.finalState.playout.itemCount'
+ Assert-JsonInt $playout.currentPageItemCount 1 'Node evidence.finalState.playout.currentPageItemCount'
+ Assert-JsonBool $playout.isLastPage $true 'Node evidence.finalState.playout.isLastPage'
+ Assert-JsonString $playout.nextKind 'endOfPlaylist' 'Node evidence.finalState.playout.nextKind'
+ Assert-JsonBool $playout.isBusy $false 'Node evidence.finalState.playout.isBusy'
+ Assert-JsonBool $playout.refreshActive $false 'Node evidence.finalState.playout.refreshActive'
+}
+
+function Assert-NodeSuccessEvidence([object] $Node, [object] $Plan) {
+ Assert-ExactProperties $Node @(
+ 'schemaVersion','result','startedAtUtc','completedAtUtc','planSha256','roundId','target',
+ 'commandBudget','observed','checkpoints','screenshot','finalState','error','authorizationSha256') 'Node evidence'
+ Assert-JsonInt $Node.schemaVersion 1 'Node evidence.schemaVersion'
+ Assert-JsonString $Node.result 'PASS_PREPARED' 'Node evidence.result'
+ Assert-JsonString $Node.planSha256 $script:planHash 'Node evidence.planSha256'
+ Assert-JsonString $Node.authorizationSha256 $script:authorizationHash 'Node evidence.authorizationSha256'
+ Assert-JsonString $Node.roundId ([string]$Plan.roundId) 'Node evidence.roundId'
+ if ($null -ne $Node.error) { Fail 'OUTCOME_UNKNOWN: Node success evidence contains an error.' }
+ Assert-ExactProperties $Node.target @('expectedUrl','port','id') 'Node evidence.target'
+ Assert-JsonString $Node.target.expectedUrl $expectedUrl 'Node evidence.target.expectedUrl'
+ Assert-JsonInt $Node.target.port ([int]$Plan.webView.port) 'Node evidence.target.port'
+ if ($Node.target.id -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Node.target.id)) {
+ Fail 'OUTCOME_UNKNOWN: Node target identity is missing.'
+ }
+ Assert-ClosedBudgets $Node.commandBudget 'Node evidence.commandBudget'
+ Assert-ExactProperties $Node.observed @(
+ 'prepareDispatchPossible','prepareIssued','busyObserved','jitPermitRequested',
+ 'jitPermitReceived','outboundTypes','blocked') 'Node evidence.observed'
+ Assert-JsonBool $Node.observed.prepareDispatchPossible $true 'Node evidence.observed.prepareDispatchPossible'
+ Assert-JsonBool $Node.observed.prepareIssued $true 'Node evidence.observed.prepareIssued'
+ Assert-JsonBool $Node.observed.busyObserved $true 'Node evidence.observed.busyObserved'
+ Assert-JsonBool $Node.observed.jitPermitRequested $true 'Node evidence.observed.jitPermitRequested'
+ Assert-JsonBool $Node.observed.jitPermitReceived $true 'Node evidence.observed.jitPermitReceived'
+ if ($script:nodePermitRequests -ne 1 -or $script:nodePermitDeliveries -ne 1 -or
+ $script:nodePermitClientProcessId -le 0) {
+ Fail 'OUTCOME_UNKNOWN: The parent-owned JIT permit ledger is not exactly one verified delivery.'
+ }
+ if ($Node.observed.outboundTypes -isnot [Array] -or $Node.observed.blocked -isnot [Array] -or
+ @($Node.observed.blocked).Count -ne 0) {
+ Fail 'OUTCOME_UNKNOWN: Node success ledger is not an unblocked JSON array ledger.'
+ }
+ $actualOutbound = @($Node.observed.outboundTypes)
+ $withoutSelection = @('ready','search-stocks','cut-pointer-down','drop-selected-cuts','gate-a-prepare')
+ $withSelection = @('ready','search-stocks','select-stock','cut-pointer-down','drop-selected-cuts','gate-a-prepare')
+ if (-not (Test-ExactStringSequence $actualOutbound $withoutSelection) -and
+ -not (Test-ExactStringSequence $actualOutbound $withSelection)) {
+ Fail 'OUTCOME_UNKNOWN: Node success outbound ledger order is not exact.'
+ }
+ if ($Node.checkpoints -isnot [Array]) { Fail 'OUTCOME_UNKNOWN: Node checkpoints must be a JSON array.' }
+ Assert-NodePreparedFinalState $Node.finalState
+ Assert-NodeScreenshot $Node.screenshot $script:screenshotPath
+}
+
+function Get-GateCapabilityHash([string] $Capability) {
+ if ($Capability -cnotmatch '^[0-9A-F]{64}$') { Fail 'The native Gate A capability is invalid.' }
+ $bytes = [Text.Encoding]::ASCII.GetBytes($Capability)
+ try { return Convert-Hash $bytes }
+ finally { [Array]::Clear($bytes,0,$bytes.Length) }
+}
+
+function Assert-NodePermitClient(
+ [object] $Pipe,
+ [Diagnostics.Process] $Process,
+ [long] $ExpectedStartTimeUtcTicks,
+ [string] $ExpectedExecutable) {
+ $clientProcessId = [LegacyPgmPreparePackageIdentity]::ReadNamedPipeClientProcessId(
+ $Pipe.Stream.SafePipeHandle)
+ if ($clientProcessId -ne $Process.Id) {
+ Fail 'The JIT permit pipe was claimed by a process other than the spawned Node helper.'
+ }
+ $Process.Refresh()
+ if ($Process.HasExited -or -not (SamePath $Process.Path $ExpectedExecutable) -or
+ $Process.StartTime.ToUniversalTime().Ticks -ne $ExpectedStartTimeUtcTicks) {
+ Fail 'The JIT permit pipe Node process identity changed.'
+ }
+ $row = Get-CimInstance Win32_Process -Filter "ProcessId = $clientProcessId" -ErrorAction Stop
+ if ($null -eq $row -or [int]$row.ParentProcessId -ne $PID -or
+ -not (SamePath ([string]$row.ExecutablePath) $ExpectedExecutable)) {
+ Fail 'The JIT permit pipe client is not the exact direct child Node helper.'
+ }
+ $script:nodePermitClientProcessId = $clientProcessId
+}
+
+function Stop-NodeHelperFailStop([Diagnostics.Process] $Process, [object] $Pipe) {
+ # This revokes and stops only the control-plane Node helper. It never closes,
+ # disconnects, terminates, or otherwise commands the app, PGM, Tornado, or K3D.
+ Close-OneShotPipe $Pipe
+ $script:gateCapability = $null
+ if ($null -eq $Process) { return }
+ $script:nodeStillRunning = $true
+ $exitObserved = $false
+ try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { }
+ if (-not $exitObserved) {
+ try { $Process.Kill() }
+ catch {
+ try { $exitObserved = [bool]$Process.WaitForExit(0) } catch { }
+ if (-not $exitObserved) {
+ Fail 'OUTCOME_UNKNOWN: The Node helper could not be fail-stopped or proven exited after parent revocation.'
+ }
+ }
+ if (-not $exitObserved) {
+ try { $exitObserved = [bool]$Process.WaitForExit(5000) } catch { }
+ }
+ }
+ if (-not $exitObserved) {
+ Fail 'OUTCOME_UNKNOWN: The Node helper was not proven stopped after parent revocation.'
+ }
+ $script:nodeStillRunning = $false
+ try { $script:nodeExitCode = [int]$Process.ExitCode } catch { }
+}
+
+function Start-NodeHelper([object] $Plan) {
+ $script:nodeInvocations++
+ if ($script:nodeInvocations -ne 1) { Fail 'The Node helper one-shot budget was exceeded.' }
+ if ([string]$script:gateCapability -cnotmatch '^[0-9A-F]{64}$' -or
+ [string]$script:gateCapabilitySha256 -cnotmatch '^[0-9A-F]{64}$' -or
+ (Get-GateCapabilityHash ([string]$script:gateCapability)) -cne [string]$script:gateCapabilitySha256) {
+ Fail 'The native Gate A capability is absent before the Node helper starts.'
+ }
+ $permitPipe = New-CurrentUserOneShotPipe 'Node'
+ $args = @(
+ [string]$Plan.automation.helper.path,
+ '--port',[string]$Plan.webView.port,'--plan',(FullPath $PlanPath 'PlanPath'),
+ '--plan-sha256',$script:planHash,'--authorization',(FullPath $AuthorizationPath 'AuthorizationPath'),
+ '--authorization-sha256',$script:authorizationHash,
+ '--output',$script:nodeEvidencePath,'--screenshot',$script:screenshotPath,
+ '--capability-sha256',[string]$script:gateCapabilitySha256,
+ '--permit-pipe',[string]$permitPipe.Name,
+ '--timeout-ms','90000')
+ $info = [Diagnostics.ProcessStartInfo]::new()
+ $info.FileName = [string]$Plan.automation.node.path
+ $info.WorkingDirectory = $repositoryRoot
+ $info.UseShellExecute = $false
+ $info.CreateNoWindow = $true
+ $info.Arguments = (($args | ForEach-Object { Quote-Argument ([string]$_) }) -join ' ')
+ foreach ($name in @($info.EnvironmentVariables.Keys)) {
+ if ([string]$name -like 'NODE_*' -or [string]$name -like 'MBN_LEGACY_PGM_GATE_A_*' -or
+ [string]$name -like 'MBN_STOCK_ORACLE_*' -or [string]$name -like 'MBN_STOCK_MARIADB_*' -or
+ [string]$name -like 'MBN_STOCK_DB_*' -or
+ [string]$name -like 'DOTNET_*' -or [string]$name -like 'CORECLR_*' -or
+ [string]$name -like 'COR_*' -or [string]$name -like 'COMPlus_*' -or
+ [string]$name -in @('OPENSSL_CONF','OPENSSL_MODULES')) {
+ $info.EnvironmentVariables.Remove([string]$name)
+ }
+ }
+ $process = $null
+ try { $process = [Diagnostics.Process]::Start($info) }
+ catch {
+ Close-OneShotPipe $permitPipe
+ $script:gateCapability = $null
+ throw
+ }
+ if ($null -eq $process) {
+ Close-OneShotPipe $permitPipe
+ $script:gateCapability = $null
+ Fail 'The one Node helper invocation did not start.'
+ }
+ try {
+ $process.Refresh()
+ $nodeStartTimeUtcTicks = $process.StartTime.ToUniversalTime().Ticks
+ $deadline = [DateTime]::UtcNow.AddSeconds(105)
+ while (-not $process.HasExited -and [DateTime]::UtcNow -lt $deadline) {
+ Assert-Application $script:application ([string]$Plan.package.executable.path)
+ Assert-Pgm $Plan.pgm
+ Assert-Cdp ([int]$Plan.webView.port) $script:application.Id
+ if ($script:nodePermitDeliveries -eq 0 -and $permitPipe.Connection.IsCompleted) {
+ $permitPipe.Connection.GetAwaiter().GetResult()
+ $script:nodePermitRequests++
+ if ($script:nodePermitRequests -ne 1 -or -not $permitPipe.Stream.IsConnected) {
+ Fail 'The Node JIT permit request budget or connection state is invalid.'
+ }
+ Assert-NodePermitClient `
+ $permitPipe `
+ $process `
+ $nodeStartTimeUtcTicks `
+ ([string]$Plan.automation.node.path)
+ $currentExpiry = Assert-Authorization $script:authorization $Plan
+ if ($currentExpiry.UtcDateTime.Ticks -ne [long]$script:authorizationExpiresAtUtcTicks) {
+ Fail 'The authorization expiry changed at the JIT permit boundary.'
+ }
+ Assert-Application $script:application ([string]$Plan.package.executable.path)
+ Assert-Pgm $Plan.pgm
+ Assert-Cdp ([int]$Plan.webView.port) $script:application.Id
+ $bytes = [Text.Encoding]::ASCII.GetBytes([string]$script:gateCapability)
+ try {
+ if ($bytes.Length -ne 64 -or (Convert-Hash $bytes) -cne [string]$script:gateCapabilitySha256) {
+ Fail 'The JIT permit bearer changed before delivery.'
+ }
+ $permitPipe.Stream.Write($bytes,0,$bytes.Length)
+ $permitPipe.Stream.Flush()
+ $script:nodePermitDeliveries++
+ }
+ finally {
+ [Array]::Clear($bytes,0,$bytes.Length)
+ $script:gateCapability = $null
+ Close-OneShotPipe $permitPipe
+ }
+ if ($script:nodePermitDeliveries -ne 1) {
+ Fail 'The Node JIT permit delivery budget was not exactly consumed.'
+ }
+ }
+ Start-Sleep -Milliseconds 100
+ $process.Refresh()
+ }
+ if (-not $process.HasExited) {
+ Fail 'OUTCOME_UNKNOWN: Node helper exceeded its outer timeout; parent fail-stop is required without any app or playout cleanup command.'
+ }
+ Close-OneShotPipe $permitPipe
+ $script:gateCapability = $null
+ if (-not $process.WaitForExit(0)) {
+ Fail 'OUTCOME_UNKNOWN: Node helper exit could not be positively observed.'
+ }
+ $process.Refresh()
+ $script:nodeExitCode = [int]$process.ExitCode
+ if (-not [IO.File]::Exists($script:nodeEvidencePath)) {
+ Fail 'OUTCOME_UNKNOWN: Node helper produced no exclusive evidence file.'
+ }
+ Add-ReadLease $script:nodeEvidencePath 'Node evidence'
+ if ($script:nodeExitCode -eq 0) {
+ # The last monitor sample can precede Node exit. Re-pin every live identity
+ # immediately before accepting the prepared success evidence.
+ Assert-Pgm $Plan.pgm
+ Assert-Application $script:application ([string]$Plan.package.executable.path)
+ Assert-Cdp ([int]$Plan.webView.port) $script:application.Id
+ $node = Read-SealedJson $script:nodeEvidencePath (Get-FileHash $script:nodeEvidencePath -Algorithm SHA256).Hash 'Node evidence'
+ try { Assert-NodeSuccessEvidence $node.Value $Plan }
+ catch {
+ if ($_.Exception.Message.StartsWith('OUTCOME_UNKNOWN:',[StringComparison]::Ordinal)) { throw }
+ Fail ('OUTCOME_UNKNOWN: Node success evidence is not the exact sealed PREPARED result: ' + $_.Exception.Message)
+ }
+ return
+ }
+ if ($script:nodeExitCode -eq 2) {
+ try {
+ $known = ([IO.File]::ReadAllText($script:nodeEvidencePath,$utf8NoBom) | ConvertFrom-Json -ErrorAction Stop)
+ if ([int]$known.schemaVersion -ne 1 -or [string]$known.result -cne 'KNOWN_FAILURE' -or
+ [string]$known.planSha256 -cne $script:planHash -or
+ [string]$known.authorizationSha256 -cne $script:authorizationHash -or
+ $known.observed.prepareDispatchPossible -isnot [bool] -or
+ $known.observed.prepareIssued -isnot [bool] -or
+ $known.observed.busyObserved -isnot [bool] -or
+ $known.observed.jitPermitRequested -isnot [bool] -or
+ $known.observed.jitPermitReceived -isnot [bool]) {
+ Fail 'OUTCOME_UNKNOWN: Node known-failure evidence is not exact.'
+ }
+ }
+ catch {
+ if ($_.Exception.Message.StartsWith('OUTCOME_UNKNOWN:',[StringComparison]::Ordinal)) { throw }
+ Fail 'OUTCOME_UNKNOWN: Node known-failure evidence could not be validated.'
+ }
+ Fail 'KNOWN_FAILURE: Node helper stopped without a successful PREPARE.'
+ }
+ Fail "OUTCOME_UNKNOWN: Node helper exited with code $($script:nodeExitCode)."
+ }
+ catch {
+ $caught = $_
+ Stop-NodeHelperFailStop $process $permitPipe
+ throw $caught
+ }
+}
+
+function Write-Evidence([string] $Path) {
+ $nodePin = $null
+ $prepareMayHaveBeenDispatched = $script:nodeInvocations -gt 0
+ $nodeObserved = $null
+ $screenshotPin = $null
+ if (-not [string]::IsNullOrWhiteSpace($script:nodeEvidencePath) -and [IO.File]::Exists($script:nodeEvidencePath)) {
+ $item = Get-Item -LiteralPath $script:nodeEvidencePath -Force
+ $nodePin = [ordered]@{ path=$script:nodeEvidencePath; length=[int64]$item.Length; sha256=(Get-FileHash $script:nodeEvidencePath -Algorithm SHA256).Hash }
+ try {
+ $nodeValue = ([IO.File]::ReadAllText($script:nodeEvidencePath,$utf8NoBom) | ConvertFrom-Json -ErrorAction Stop)
+ $trustedNodeIdentity = [int]$nodeValue.schemaVersion -eq 1 -and
+ [string]$nodeValue.planSha256 -ceq $script:planHash -and
+ [string]$nodeValue.authorizationSha256 -ceq $script:authorizationHash
+ $nodeObserved = [ordered]@{
+ trustedIdentity = $trustedNodeIdentity
+ result = [string]$nodeValue.result
+ prepareDispatchPossible = $(if ($nodeValue.observed.prepareDispatchPossible -is [bool]) { [bool]$nodeValue.observed.prepareDispatchPossible } else { $null })
+ prepareIssued = $(if ($nodeValue.observed.prepareIssued -is [bool]) { [bool]$nodeValue.observed.prepareIssued } else { $null })
+ busyObserved = $(if ($nodeValue.observed.busyObserved -is [bool]) { [bool]$nodeValue.observed.busyObserved } else { $null })
+ jitPermitRequested = $(if ($nodeValue.observed.jitPermitRequested -is [bool]) { [bool]$nodeValue.observed.jitPermitRequested } else { $null })
+ jitPermitReceived = $(if ($nodeValue.observed.jitPermitReceived -is [bool]) { [bool]$nodeValue.observed.jitPermitReceived } else { $null })
+ outboundTypes = @($nodeValue.observed.outboundTypes)
+ blocked = @($nodeValue.observed.blocked)
+ finalState = $nodeValue.finalState
+ screenshot = $nodeValue.screenshot
+ }
+ if ($script:nodeExitCode -eq 2 -and $trustedNodeIdentity -and
+ [string]$nodeValue.result -ceq 'KNOWN_FAILURE' -and
+ $nodeValue.observed.prepareDispatchPossible -is [bool] -and
+ [bool]$nodeValue.observed.prepareDispatchPossible -eq $false -and
+ $nodeValue.observed.prepareIssued -is [bool] -and
+ [bool]$nodeValue.observed.prepareIssued -eq $false) {
+ $prepareMayHaveBeenDispatched = $false
+ }
+ }
+ catch {
+ $prepareMayHaveBeenDispatched = $true
+ }
+ }
+ if (-not [string]::IsNullOrWhiteSpace($script:screenshotPath) -and [IO.File]::Exists($script:screenshotPath)) {
+ $shot = Get-Item -LiteralPath $script:screenshotPath -Force
+ $screenshotPin = [ordered]@{ path=$script:screenshotPath; length=[int64]$shot.Length; sha256=(Get-FileHash $script:screenshotPath -Algorithm SHA256).Hash }
+ }
+ $evidence = [ordered]@{
+ schemaVersion=1; kind='LegacyPgmPrepareGateEvidence'; roundId=$(if($null-ne$script:plan){[string]$script:plan.roundId}else{$null})
+ status=$script:status; phase=$script:phase; startedAtUtc=$script:startedAtUtc.ToString('o')
+ completedAtUtc=[DateTime]::UtcNow.ToString('o'); planSha256=$script:planHash
+ authorizationSha256=$script:authorizationHash; appLaunched=$script:applicationLaunched
+ appProcessId=$(if($null-ne$script:application){$script:application.Id}else{$null})
+ launchAttempted=$script:launchAttempted; nodeHelperInvocations=$script:nodeInvocations
+ nodeExitCode=$script:nodeExitCode; nodeStillRunning=$script:nodeStillRunning
+ nodePermitRequests=$script:nodePermitRequests
+ nodePermitDeliveries=$script:nodePermitDeliveries
+ nodePermitClientProcessId=$script:nodePermitClientProcessId
+ capabilitySha256=$script:gateCapabilitySha256
+ packagePermitRequests=$script:packagePermitRequests
+ packagePermitDeliveries=$script:packagePermitDeliveries
+ packagePermitClientProcessId=$script:packagePermitClientProcessId
+ nodeEvidence=$nodePin; screenshot=$screenshotPin; observed=$nodeObserved
+ authorizedBudgets=[ordered]@{connect=1;prepare=1;takeIn=0;next=0;pageNext=0;takeOut=0;databaseWrite=0;appClose=0;retry=0}
+ gateConfigInstalled=$script:gateConfigInstalled
+ originalConfigRestored=$script:configRestored
+ manualConfigRestoreRequired=($script:gateConfigInstalled -and -not $script:configRestored)
+ prepareMayHaveBeenDispatched=$prepareMayHaveBeenDispatched
+ configRestoreAttempts=$script:configRestoreAttempts
+ automaticSessionCleanupAttemptedAfterLaunch=$false; error=$script:failure
+ }
+ $json = ($evidence | ConvertTo-Json -Depth 8) + [Environment]::NewLine
+ $bytes = $utf8NoBom.GetBytes($json)
+ try { Write-BytesExclusive $Path $bytes } finally { [Array]::Clear($bytes,0,$bytes.Length) }
+}
+
+$planCapture = Read-SealedJson $PlanPath $script:planHash 'PlanPath'
+$script:plan = $planCapture.Value
+Assert-Plan $script:plan
+
+if ($Action -ceq 'ValidatePlan') {
+ $script:phase = 'read-only-plan-environment-validation'
+ $validation = Assert-ReadOnlyPlanEnvironment $script:plan
+ [pscustomobject][ordered]@{
+ result='PLAN_VALID'; roundId=[string]$script:plan.roundId; planPath=$planCapture.Path
+ planLength=$planCapture.Length; planSha256=$script:planHash
+ filePinsValidated=$validation.filePinsValidated
+ runtimeTreeValidated=$validation.runtimeTreeValidated
+ packageRegistrationValidated=$validation.packageRegistrationValidated
+ currentPgmValidated=$validation.currentPgmValidated
+ k3dRegistrationValidatedReadOnly=$validation.k3dRegistrationValidatedReadOnly
+ authorizationRead=$false; packageLaunches=0; appCommandsIssued=0; playoutCommandsIssued=0
+ comActivations=0; vendorCommandsIssued=0
+ }
+ return
+}
+
+if ([string]::IsNullOrWhiteSpace($AuthorizationPath) -or
+ [string]::IsNullOrWhiteSpace($ExpectedAuthorizationSha256) -or
+ [string]::IsNullOrWhiteSpace($EvidencePath)) { Fail 'ExecuteAuthorizedGateA requires authorization path/hash and a new evidence path.' }
+$script:authorizationHash = $ExpectedAuthorizationSha256.ToUpperInvariant()
+$evidenceFull = FullPath $EvidencePath 'EvidencePath'
+if ((Test-Within $planCapture.Path $repositoryRoot) -or
+ (Test-Within (FullPath $AuthorizationPath 'AuthorizationPath') $repositoryRoot) -or
+ (Test-Within $evidenceFull $repositoryRoot)) {
+ Fail 'Plan, authorization and live evidence must remain outside the Git repository.'
+}
+if ([IO.File]::Exists($evidenceFull) -or [IO.Directory]::Exists($evidenceFull)) { Fail 'EvidencePath already exists.' }
+$evidenceParent = [IO.Path]::GetDirectoryName($evidenceFull); [void][IO.Directory]::CreateDirectory($evidenceParent); Assert-NoReparse $evidenceParent 'EvidencePath parent'
+$script:nodeEvidencePath = Join-Path $evidenceParent (([IO.Path]::GetFileNameWithoutExtension($evidenceFull)) + '.node.json')
+$script:screenshotPath = Join-Path $evidenceParent (([IO.Path]::GetFileNameWithoutExtension($evidenceFull)) + '.png')
+if ([IO.File]::Exists($script:nodeEvidencePath) -or [IO.Directory]::Exists($script:nodeEvidencePath) -or
+ [IO.File]::Exists($script:screenshotPath) -or [IO.Directory]::Exists($script:screenshotPath)) { Fail 'Node evidence or screenshot path already exists.' }
+
+try {
+ $script:phase = 'authorization'
+ $authorizationCapture = Read-SealedJson $AuthorizationPath $script:authorizationHash 'AuthorizationPath'
+ $script:authorization = $authorizationCapture.Value
+ $expiresAt = Assert-Authorization $script:authorization $script:plan
+ $script:authorizationExpiresAtUtcTicks = [long]$expiresAt.UtcDateTime.Ticks
+ $script:phase = 'native-one-shot-gate-readiness'
+ if (-not $nativeOneShotPrepareGateReady) {
+ Fail 'Execution is disabled until the native one-shot PREPARE capability gate is implemented, tested, committed and pushed.'
+ }
+ $script:phase = 'frozen-preflight'
+ Assert-CleanPushedCommit $script:plan.repository
+ Assert-NoUnsafeEnvironment
+ if (-not (SamePath (Assert-FilePin $script:plan.automation.orchestrator 'Gate orchestrator') $PSCommandPath) -or
+ -not (SamePath (Assert-FilePin $script:plan.automation.helper 'Node helper') (Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'))) { Fail 'Automation file identity changed.' }
+ [void](Assert-FilePin $script:plan.automation.node 'Node runtime')
+ [void](Assert-FilePin $script:plan.package.msix 'Release x64 MSIX')
+ $appExe = Assert-FilePin $script:plan.package.executable 'package executable'
+ $tree = Get-RuntimeTreePin ([string]$script:plan.package.installLocation)
+ if ($tree.fileCount -ne [int]$script:plan.package.runtimeTree.fileCount -or $tree.manifestSha256 -cne [string]$script:plan.package.runtimeTree.manifestSha256) { Fail 'Package runtime tree changed.' }
+ Add-RuntimeTreeReadLeases ([string]$script:plan.package.installLocation)
+ $leasedTree = Get-RuntimeTreePin ([string]$script:plan.package.installLocation)
+ if ($leasedTree.fileCount -ne [int]$script:plan.package.runtimeTree.fileCount -or
+ $leasedTree.manifestSha256 -cne [string]$script:plan.package.runtimeTree.manifestSha256) {
+ Fail 'Package runtime tree changed while its read leases were acquired.'
+ }
+ [void](Assert-FilePin $script:plan.configuration.original 'original config')
+ Assert-OriginalConfigDryRun $script:plan.configuration.original
+ [void](Assert-FilePin $script:plan.configuration.gate 'Gate config')
+ Assert-GateConfigSafety $script:plan.configuration
+ [void](Assert-FilePin $script:plan.configuration.database 'database config')
+ [void](Assert-FilePin $script:plan.cut.file '5001 cut')
+ [void](Assert-FilePin $script:plan.k3d.native 'K3D native binary')
+ [void](Assert-FilePin $script:plan.k3d.interop 'K3D interop assembly')
+ [void](Assert-FilePin $script:plan.pgm.executable 'PGM executable')
+ Assert-K3dRegistration $script:plan.k3d
+ Assert-NoReparse ([string]$script:plan.configuration.sceneDirectory) 'scene directory'
+ Add-ReadLease ([string]$script:plan.automation.helper.path) 'Node helper'
+ Add-ReadLease ([string]$script:plan.automation.node.path) 'Node runtime'
+ Add-ReadLease ([string]$script:plan.package.executable.path) 'package executable'
+ Add-ReadLease ([string]$script:plan.configuration.gate.path) 'Gate config source'
+ Add-ReadLease ([string]$script:plan.configuration.database.path) 'database config'
+ Add-ReadLease ([string]$script:plan.cut.file.path) '5001 cut'
+ Add-ReadLease ([string]$script:plan.k3d.native.path) 'K3D native binary'
+ Add-ReadLease ([string]$script:plan.k3d.interop.path) 'K3D interop assembly'
+ Add-ReadLease ([string]$script:plan.pgm.executable.path) 'PGM executable'
+ Assert-Pgm $script:plan.pgm
+ $packages = @(Get-AppxPackage -Name $expectedPackageName -ErrorAction Stop)
+ if ($packages.Count -ne 1 -or -not $packages[0].IsDevelopmentMode -or [string]$packages[0].SignatureKind -cne 'None' -or
+ [string]$packages[0].Status -cne 'Ok' -or [string]$packages[0].PackageFullName -cne $expectedPackageFullName -or
+ [string]$packages[0].PackageFamilyName -cne $expectedPackageFamily -or [string]$packages[0].Publisher -cne 'CN=Comtrophy' -or
+ [string]$packages[0].Version -cne '0.1.0.0' -or [string]$packages[0].Architecture -cne 'X64' -or
+ -not (SamePath ([string]$packages[0].InstallLocation) ([string]$script:plan.package.installLocation))) { Fail 'Exact Release x64 development package registration is absent.' }
+ if (@(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($expectedExecutableName)) -ErrorAction SilentlyContinue).Count -ne 0 -or
+ @(Get-NetTCPConnection -State Listen -LocalPort ([int]$script:plan.webView.port) -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'An existing application or CDP endpoint is present.' }
+ [void](Assert-Authorization $script:authorization $script:plan)
+ $script:phase = 'gate-config-install'
+ Install-GateConfiguration $script:plan.configuration ([string]$script:plan.roundId)
+ $script:phase = 'native-capability-generation'
+ $script:gateCapability = New-GateCapability
+ $script:gateCapabilitySha256 = Get-GateCapabilityHash ([string]$script:gateCapability)
+ $script:phase = 'package-launch-auto-connect'
+ $script:application = Start-Package $script:plan
+ $script:applicationLaunched = $true
+ $script:phase = 'wait-exact-target'
+ Wait-ExactTarget ([int]$script:plan.webView.port) $script:application.Id
+ $script:phase = 'restore-original-config'
+ Restore-OriginalConfiguration $script:plan.configuration
+ [void](Assert-Authorization $script:authorization $script:plan)
+ Assert-Pgm $script:plan.pgm; Assert-Application $script:application $appExe; Assert-Cdp ([int]$script:plan.webView.port) $script:application.Id
+ $script:phase = 'prepare5001-once'
+ Start-NodeHelper $script:plan
+ $script:status = 'PREPARED_KNOWN_SUCCESS'
+ $script:phase = 'prepared-session-left-open'
+}
+catch {
+ $script:failure = $_.Exception.Message
+ if ($script:failure.StartsWith('KNOWN_FAILURE:',[StringComparison]::Ordinal)) { $script:status='KNOWN_FAILURE' }
+ elseif (-not $script:launchAttempted) { $script:status='PRECONDITION_FAILED' }
+ else { $script:status='OUTCOME_UNKNOWN' }
+ if (-not $script:launchAttempted -and $script:gateConfigInstalled -and
+ -not $script:configRestored -and $script:configRestoreAttempts -eq 0) {
+ try { Restore-OriginalConfiguration $script:plan.configuration } catch { $script:failure += ' Pre-launch one-shot local config restore failed: ' + $_.Exception.Message }
+ }
+}
+
+Write-Evidence $evidenceFull
+$evidenceItem = Get-Item -LiteralPath $evidenceFull -Force
+$evidenceSha256 = (Get-FileHash -LiteralPath $evidenceFull -Algorithm SHA256).Hash
+foreach ($lease in $script:fileLeases) { try { $lease.Dispose() } catch { } }
+$script:fileLeases.Clear()
+if ($script:status -cne 'PREPARED_KNOWN_SUCCESS') { Fail "$($script:status): $($script:failure) Evidence: $evidenceFull SHA-256: $evidenceSha256" }
+[pscustomobject][ordered]@{
+ result=$script:status; roundId=[string]$script:plan.roundId; planSha256=$script:planHash
+ authorizationSha256=$script:authorizationHash; applicationProcessId=$script:application.Id
+ authorizedConnectMaximum=1; prepare5001Page1Observed=1; nodeHelperInvocations=1
+ packagePermitDeliveries=$script:packagePermitDeliveries; nodePermitDeliveries=$script:nodePermitDeliveries
+ takeIn=0; next=0; pageNext=0; takeOut=0; databaseWrite=0; appClose=0; retry=0
+ originalConfigRestored=$script:configRestored; applicationLeftOpen=$true
+ evidence=$evidenceFull; evidenceLength=[int64]$evidenceItem.Length; evidenceSha256=$evidenceSha256
+ nodeEvidence=$script:nodeEvidencePath; screenshot=$script:screenshotPath
+}
diff --git a/scripts/New-LegacyPgmPreparePlan.ps1 b/scripts/New-LegacyPgmPreparePlan.ps1
new file mode 100644
index 0000000..f333933
--- /dev/null
+++ b/scripts/New-LegacyPgmPreparePlan.ps1
@@ -0,0 +1,402 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $SpecificationPath,
+
+ [Parameter(Mandatory = $true)]
+ [string] $OutputPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$utf8NoBom = [Text.UTF8Encoding]::new($false, $true)
+$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1'
+$nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'
+$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
+$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'))
+$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git'
+$expectedUrl = 'https://legacy-parity.mbn.local/index.html'
+$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'
+$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s'
+$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
+$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix'
+$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367'
+$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json'
+$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8'))
+$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q'))
+$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA='))
+$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
+$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA'))
+
+function Fail([string] $Message) {
+ throw [InvalidOperationException]::new($Message)
+}
+
+function FullPath([string] $Path, [string] $Name) {
+ if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) {
+ Fail "$Name must be an absolute path."
+ }
+ return [IO.Path]::GetFullPath($Path)
+}
+
+function SamePath([string] $Left, [string] $Right) {
+ return [string]::Equals(
+ [IO.Path]::GetFullPath($Left).TrimEnd('\'),
+ [IO.Path]::GetFullPath($Right).TrimEnd('\'),
+ [StringComparison]::OrdinalIgnoreCase)
+}
+
+function Assert-NoReparse([string] $Path, [string] $Name) {
+ $current = [IO.Path]::GetFullPath($Path)
+ while (-not [string]::IsNullOrWhiteSpace($current)) {
+ if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) {
+ if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Fail "$Name contains a reparse point."
+ }
+ }
+ $parent = [IO.Directory]::GetParent($current)
+ if ($null -eq $parent) { break }
+ $current = $parent.FullName
+ }
+}
+
+function Read-Json([string] $Path, [string] $Name) {
+ $full = FullPath $Path $Name
+ if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
+ Assert-NoReparse $full $Name
+ $bytes = [IO.File]::ReadAllBytes($full)
+ try {
+ if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." }
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
+ Fail "$Name must be UTF-8 without BOM."
+ }
+ try { $text = $utf8NoBom.GetString($bytes) }
+ catch { Fail "$Name is not strict UTF-8." }
+ try { return $text | ConvertFrom-Json -ErrorAction Stop }
+ catch { Fail "$Name is not valid JSON." }
+ }
+ finally { [Array]::Clear($bytes, 0, $bytes.Length) }
+}
+
+function Require([object] $Object, [string] $Name, [string] $Context) {
+ if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) {
+ Fail "$Context.$Name is required."
+ }
+ return $Object.$Name
+}
+
+function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) {
+ if ($null -eq $Object) { Fail "$Context is required." }
+ $actual = @($Object.PSObject.Properties.Name)
+ if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." }
+ foreach ($name in $Expected) {
+ if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) {
+ Fail "$Context property set is not exact: $name."
+ }
+ }
+}
+
+function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) {
+ if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." }
+}
+
+function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) {
+ if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." }
+}
+
+function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) {
+ if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." }
+}
+
+function New-FilePin([string] $Path, [string] $Name) {
+ $full = FullPath $Path $Name
+ if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
+ Assert-NoReparse $full $Name
+ $item = Get-Item -LiteralPath $full -Force
+ return [ordered]@{
+ path = $full
+ length = [int64]$item.Length
+ sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash
+ }
+}
+
+function Get-RuntimeTreePin([string] $Root) {
+ $fullRoot = FullPath $Root 'package.installLocation'
+ if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' }
+ Assert-NoReparse $fullRoot 'package.installLocation'
+ foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) {
+ Assert-NoReparse $directory 'package runtime directory'
+ }
+ $files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
+ if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' }
+ $relative = New-Object 'Collections.Generic.List[string]'
+ $rootPrefix = $fullRoot.TrimEnd('\') + '\'
+ foreach ($file in $files) {
+ Assert-NoReparse $file 'package runtime file'
+ if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ Fail 'A package runtime file escaped the install root.'
+ }
+ $name = $file.Substring($rootPrefix.Length).Replace('\', '/')
+ if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) {
+ Fail 'A package runtime relative path cannot be represented safely.'
+ }
+ [void]$relative.Add($name)
+ }
+ $names = $relative.ToArray()
+ [Array]::Sort($names, [StringComparer]::Ordinal)
+ $builder = [Text.StringBuilder]::new()
+ foreach ($name in $names) {
+ $file = Join-Path $fullRoot $name.Replace('/', '\')
+ $item = Get-Item -LiteralPath $file -Force
+ $hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
+ [void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n")
+ }
+ $manifestBytes = $utf8NoBom.GetBytes($builder.ToString())
+ try {
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') }
+ finally { $algorithm.Dispose() }
+ }
+ finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) }
+ return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest }
+}
+
+function Assert-GateConfig([string] $Path, [string] $SceneDirectory) {
+ $config = Read-Json $Path 'configuration.gate'
+ $names = @(
+ 'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex',
+ 'legacySceneFadeDuration','legacySceneBackgroundKind',
+ 'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount',
+ 'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory',
+ 'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist',
+ 'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds',
+ 'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds',
+ 'processPollIntervalMilliseconds','reconnectDelayMilliseconds',
+ 'maximumReconnectAttempts','reconnectEnabled',
+ 'maximumAutomaticRefreshesPerTakeIn')
+ Assert-ExactProperties $config $names 'configuration.gate JSON'
+ if ($config.testSceneAllowlist -isnot [Array]) {
+ Fail 'configuration.gate.testSceneAllowlist must be a JSON array.'
+ }
+ $allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON')
+ Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode'
+ Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host'
+ Assert-JsonInt $config.port 30001 'configuration.gate.port'
+ Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode'
+ Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort'
+ Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex'
+ Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration'
+ Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind'
+ Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount'
+ Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite'
+ Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled'
+ Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity'
+ Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds'
+ Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds'
+ Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds'
+ Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds'
+ Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds'
+ Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts'
+ Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled'
+ Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn'
+ if (
+ $null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or
+ $null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or
+ $null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or
+ -not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or
+ $null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or
+ $allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') {
+ Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.'
+ }
+}
+
+$specificationPath = FullPath $SpecificationPath 'SpecificationPath'
+$outputPath = FullPath $OutputPath 'OutputPath'
+if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
+ Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.'
+}
+if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) {
+ Fail 'OutputPath already exists; a frozen plan is never overwritten.'
+}
+if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' }
+if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) {
+ Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.'
+}
+
+$spec = Read-Json $specificationPath 'SpecificationPath'
+Assert-ExactProperties $spec @(
+ 'schemaVersion','roundId','repository','package','automation','configuration',
+ 'cut','k3d','pgm','webView') 'specification'
+Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion'
+$roundId = [string](Require $spec 'roundId' 'specification')
+if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' }
+
+$repo = Require $spec 'repository' 'specification'
+Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository'
+if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or
+ [string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or
+ [string]$repo.remoteUrl -cne $expectedRemote -or
+ [string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' }
+
+$package = Require $spec 'package' 'specification'
+Assert-ExactProperties $package @(
+ 'name','familyName','fullName','publisher','version','architecture',
+ 'applicationId','installLocation','executablePath','msixPath') 'package'
+if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or
+ [string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or
+ [string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or
+ [string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or
+ [string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') {
+ Fail 'package identity is not the exact LegacyParity Release x64 package.'
+}
+$installLocation = FullPath ([string]$package.installLocation) 'package.installLocation'
+$expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
+if (-not (SamePath $installLocation $expectedInstallLocation)) {
+ Fail 'package.installLocation is not the exact Release x64 output.'
+}
+$executablePath = FullPath ([string]$package.executablePath) 'package.executablePath'
+if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) {
+ Fail 'package executable is outside the exact install root.'
+}
+$msixPath = FullPath ([string]$package.msixPath) 'package.msixPath'
+if (-not (SamePath $msixPath $approvedMsixPath)) {
+ Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.'
+}
+if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) {
+ Fail 'The approved Release x64 MSIX SHA-256 changed.'
+}
+
+$automation = Require $spec 'automation' 'specification'
+Assert-ExactProperties $automation @('nodePath') 'automation'
+if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) {
+ Fail 'automation.nodePath is not the approved bundled Node runtime.'
+}
+$configuration = Require $spec 'configuration' 'specification'
+Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration'
+$originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath'
+$gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath'
+$sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory'
+if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) {
+ Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.'
+}
+if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' }
+Assert-GateConfig $gateConfigPath $sceneDirectory
+
+$cut = Require $spec 'cut' 'specification'
+Assert-ExactProperties $cut @('path') 'cut'
+$cutPath = FullPath ([string]$cut.path) 'cut.path'
+if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' }
+if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) {
+ Fail 'The approved original 5001.t2s SHA-256 changed.'
+}
+
+$k3d = Require $spec 'k3d' 'specification'
+Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d'
+$pgm = Require $spec 'pgm' 'specification'
+Assert-ExactProperties $pgm @(
+ 'processId','startTimeUtc','processName','executablePath','windowTitle',
+ 'listenerAddress','host','port') 'pgm'
+if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or
+ [string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or
+ [string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' }
+
+$webView = Require $spec 'webView' 'specification'
+Assert-ExactProperties $webView @('address','port','url') 'webView'
+if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or
+ [int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' }
+
+$plan = [ordered]@{
+ schemaVersion = 1
+ kind = 'LegacyPgmPrepareGatePlan'
+ roundId = $roundId
+ createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture)
+ repository = [ordered]@{
+ path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit
+ upstream = 'origin/main'; remoteUrl = $expectedRemote
+ }
+ package = [ordered]@{
+ name = [string]$package.name; familyName = [string]$package.familyName
+ fullName = [string]$package.fullName; publisher = [string]$package.publisher
+ version = [string]$package.version; architecture = 'X64'
+ applicationId = [string]$package.applicationId; installLocation = $installLocation
+ executable = New-FilePin $executablePath 'package executable'
+ msix = New-FilePin $msixPath 'package MSIX'
+ runtimeTree = Get-RuntimeTreePin $installLocation
+ }
+ automation = [ordered]@{
+ orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator'
+ node = New-FilePin ([string]$automation.nodePath) 'Node runtime'
+ helper = New-FilePin $nodeHelperPath 'Node Gate helper'
+ }
+ configuration = [ordered]@{
+ original = New-FilePin $originalConfigPath 'original DryRun config'
+ gate = New-FilePin $gateConfigPath 'Gate DryRun config'
+ database = New-FilePin $approvedDatabaseConfigPath 'database config'
+ sceneDirectory = $sceneDirectory
+ }
+ cut = [ordered]@{
+ sceneCode = '5001'; pageNumberOneBased = 1
+ file = New-FilePin $cutPath '5001 cut'
+ }
+ selector = [ordered]@{
+ marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName
+ stockCode = '005930'; cutLabel = $expectedCutLabel
+ graphicType = $expectedGraphicType; subtype = $expectedSubtype
+ normalizedSubtype = 'CURRENT'; sceneAlias = '5001'
+ }
+ k3d = [ordered]@{
+ native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary'
+ interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly'
+ }
+ pgm = [ordered]@{
+ processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc
+ processName = [string]$pgm.processName
+ executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable'
+ windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001
+ }
+ webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl }
+ approvalPolicy = [ordered]@{
+ requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5
+ }
+ budgets = [ordered]@{
+ connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0
+ takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0
+ }
+ safety = [ordered]@{
+ automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1
+ nodeHelperFailStopRequired = $true
+ packageWrapperFailStopRequired = $true
+ persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true
+ nativeOneShotPrepareGateRequired = $true
+ leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true
+ automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true
+ }
+}
+
+$parent = [IO.Path]::GetDirectoryName($outputPath)
+if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' }
+[void][IO.Directory]::CreateDirectory($parent)
+Assert-NoReparse $parent 'OutputPath parent'
+$json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine
+$bytes = $utf8NoBom.GetBytes($json)
+try {
+ $stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
+ try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) }
+ finally { $stream.Dispose() }
+}
+finally { [Array]::Clear($bytes, 0, $bytes.Length) }
+
+[pscustomobject][ordered]@{
+ result = 'PLAN_CREATED_COMMAND_FREE'
+ roundId = $roundId
+ planPath = $outputPath
+ planLength = (Get-Item -LiteralPath $outputPath).Length
+ planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash
+ authorizationCreated = $false
+ appCommandsIssued = 0
+ vendorCommandsIssued = 0
+}
diff --git a/scripts/Test-LegacyPgmPrepareGate.mjs b/scripts/Test-LegacyPgmPrepareGate.mjs
new file mode 100644
index 0000000..c4a6f8d
--- /dev/null
+++ b/scripts/Test-LegacyPgmPrepareGate.mjs
@@ -0,0 +1,821 @@
+import fs from "node:fs";
+import net from "node:net";
+import path from "node:path";
+import { createHash } from "node:crypto";
+
+const exactUrl = "https://legacy-parity.mbn.local/index.html";
+const requiredSelector = Object.freeze({
+ searchText: "삼성전자",
+ marketText: "코스피",
+ stockName: "삼성전자",
+ cutLabel: "1열판기본_현재가",
+ graphicType: "1열판기본",
+ subtype: "현재가",
+ preparedCode: "5001",
+ pageNumberOneBased: 1
+});
+const acceptedArguments = new Set([
+ "port", "plan", "plan-sha256", "authorization", "authorization-sha256",
+ "output", "screenshot", "capability-sha256", "permit-pipe", "timeout-ms"
+]);
+const forbiddenCdpMethods = new Set([
+ "Browser.close", "Page.close", "Runtime.terminateExecution", "Target.closeTarget"
+]);
+
+class GateFailure extends Error {
+ constructor(category, message) {
+ super(`${category}: ${message}`);
+ this.name = "GateFailure";
+ this.category = category;
+ }
+}
+
+const known = message => { throw new GateFailure("KNOWN_FAILURE", message); };
+const unknown = message => { throw new GateFailure("OUTCOME_UNKNOWN", message); };
+const sha256 = bytes => createHash("sha256").update(bytes).digest("hex").toUpperCase();
+const isSha256 = value => /^[0-9A-F]{64}$/u.test(String(value || ""));
+const isSafeId = value => /^[A-Za-z0-9._:-]{1,128}$/u.test(String(value || ""));
+
+function parseArguments(argv) {
+ if (argv.length === 0 || argv.length % 2 !== 0) known("Arguments must be --name value pairs.");
+ const values = new Map();
+ for (let index = 0; index < argv.length; index += 2) {
+ const raw = argv[index];
+ const value = argv[index + 1];
+ if (!raw?.startsWith("--") || !value) known("Every argument requires a value.");
+ const name = raw.slice(2);
+ if (!acceptedArguments.has(name) || values.has(name)) known(`Invalid argument --${name}.`);
+ values.set(name, value);
+ }
+ for (const name of [
+ "port", "plan", "plan-sha256", "authorization", "authorization-sha256",
+ "output", "screenshot", "capability-sha256", "permit-pipe"
+ ]) {
+ if (!values.has(name)) known(`--${name} is required.`);
+ }
+ const port = Number(values.get("port"));
+ if (!Number.isInteger(port) || port < 1 || port > 65535) known("--port is invalid.");
+ const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 90000;
+ if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 15000 ||
+ timeoutMilliseconds > 180000) known("--timeout-ms must be 15000 through 180000.");
+ const result = {
+ port,
+ timeoutMilliseconds,
+ planPath: path.resolve(values.get("plan")),
+ planSha256: String(values.get("plan-sha256")).toUpperCase(),
+ authorizationPath: path.resolve(values.get("authorization")),
+ authorizationSha256: String(values.get("authorization-sha256")).toUpperCase(),
+ outputPath: path.resolve(values.get("output")),
+ screenshotPath: path.resolve(values.get("screenshot")),
+ capabilitySha256: String(values.get("capability-sha256")).toUpperCase(),
+ permitPipeName: String(values.get("permit-pipe"))
+ };
+ for (const [name, candidate, extension] of [
+ ["plan", result.planPath, ".json"],
+ ["authorization", result.authorizationPath, ".json"],
+ ["output", result.outputPath, ".json"],
+ ["screenshot", result.screenshotPath, ".png"]
+ ]) {
+ if (!path.isAbsolute(values.get(name)) || path.extname(candidate).toLowerCase() !== extension) {
+ known(`--${name} must be an absolute ${extension} path.`);
+ }
+ }
+ if (!isSha256(result.planSha256) || !isSha256(result.authorizationSha256) ||
+ !isSha256(result.capabilitySha256)) {
+ known("A supplied SHA-256 is invalid.");
+ }
+ if (!/^MBN_STOCK_WEBVIEW\.GateA\.Node\.[0-9A-F]{32}$/u.test(result.permitPipeName)) {
+ known("The parent-owned JIT permit pipe name is invalid.");
+ }
+ if (new Set([result.planPath, result.authorizationPath, result.outputPath, result.screenshotPath]
+ .map(value => value.toLowerCase())).size !== 4) known("Evidence paths must be distinct.");
+ if (fs.existsSync(result.outputPath) || fs.existsSync(result.screenshotPath)) {
+ known("Output evidence is never overwritten.");
+ }
+ return result;
+}
+
+function readFrozenJson(filePath, label, expectedHash = null) {
+ const bytes = fs.readFileSync(filePath);
+ const actualHash = sha256(bytes);
+ if (expectedHash && actualHash !== expectedHash) known(`${label} SHA-256 changed.`);
+ let value;
+ try { value = JSON.parse(bytes.toString("utf8")); }
+ catch { known(`${label} is not strict JSON.`); }
+ return { bytes, hash: actualHash, value };
+}
+
+const configuration = parseArguments(process.argv.slice(2));
+if (Object.keys(process.env).some(name => name.startsWith("MBN_LEGACY_PGM_GATE_A_"))) {
+ known("Gate A bearer material must not be inherited through the Node environment.");
+}
+const startedAtUtc = new Date().toISOString();
+const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
+const evidence = {
+ schemaVersion: 1,
+ result: "RUNNING",
+ startedAtUtc,
+ completedAtUtc: null,
+ planSha256: configuration.planSha256,
+ roundId: null,
+ target: { expectedUrl: exactUrl, port: configuration.port, id: null },
+ commandBudget: { connect: 1, prepare: 1, takeIn: 0, next: 0, pageNext: 0, takeOut: 0, databaseWrite: 0, appClose: 0, retry: 0 },
+ observed: {
+ prepareDispatchPossible: false,
+ prepareIssued: false,
+ busyObserved: false,
+ jitPermitRequested: false,
+ jitPermitReceived: false,
+ outboundTypes: [],
+ blocked: []
+ },
+ checkpoints: [],
+ screenshot: null,
+ finalState: null,
+ error: null
+};
+
+let socket = null;
+let nextRpcId = 1;
+const pending = new Map();
+let prepareDispatchPossible = false;
+let prepareIssued = false;
+
+function assertDeadline(label) {
+ if (Date.now() >= hardDeadline) unknown(`Global Gate A deadline expired ${label}; no retry.`);
+}
+
+async function sleep(milliseconds) {
+ assertDeadline("before wait");
+ await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now())));
+ assertDeadline("after wait");
+}
+
+function validateFrozenContract() {
+ const planFile = readFrozenJson(configuration.planPath, "plan", configuration.planSha256);
+ const plan = planFile.value;
+ if (plan?.schemaVersion !== 1 || plan?.kind !== "LegacyPgmPrepareGatePlan" ||
+ !isSafeId(plan.roundId) || plan?.webView?.url !== exactUrl ||
+ plan?.webView?.address !== "127.0.0.1" ||
+ plan?.webView?.port !== configuration.port ||
+ plan?.safety?.nodeHelperInvocationMaximum !== 1 ||
+ plan?.safety?.nodeHelperFailStopRequired !== true ||
+ plan?.safety?.packageWrapperFailStopRequired !== true ||
+ plan?.safety?.forceCloseForbidden !== true ||
+ plan?.safety?.automaticSessionCleanupForbiddenAfterLaunch !== true) {
+ known("Plan identity or target contract is invalid.");
+ }
+ const databasePin = plan?.configuration?.database;
+ if (typeof databasePin?.path !== "string" || !path.isAbsolute(databasePin.path) ||
+ !Number.isSafeInteger(databasePin?.length) || databasePin.length <= 0 ||
+ !isSha256(databasePin?.sha256)) {
+ known("Plan database configuration pin is invalid.");
+ }
+ const selectorMap = {
+ marketText: requiredSelector.marketText,
+ stockName: requiredSelector.stockName,
+ cutLabel: requiredSelector.cutLabel,
+ graphicType: requiredSelector.graphicType,
+ subtype: requiredSelector.subtype,
+ sceneAlias: requiredSelector.preparedCode
+ };
+ for (const [name, value] of Object.entries(selectorMap)) {
+ if (plan?.selector?.[name] !== value) known(`Plan selector ${name} is not exact.`);
+ }
+ if (plan?.selector?.groupCode !== "KOSPI" || plan?.selector?.stockCode !== "005930" ||
+ plan?.selector?.normalizedSubtype !== "CURRENT" ||
+ plan?.cut?.sceneCode !== "5001" ||
+ plan?.cut?.pageNumberOneBased !== requiredSelector.pageNumberOneBased) {
+ known("Plan selector identity is incomplete.");
+ }
+ const limits = plan?.budgets;
+ if (limits?.connect !== 1 || limits?.prepare !== 1 || limits?.takeIn !== 0 ||
+ limits?.next !== 0 || limits?.pageNext !== 0 || limits?.takeOut !== 0 ||
+ limits?.databaseWrite !== 0 || limits?.appClose !== 0 || limits?.retry !== 0) {
+ known("Plan command budget is not exact Gate A.");
+ }
+ const authorizationFile = readFrozenJson(
+ configuration.authorizationPath,
+ "authorization",
+ configuration.authorizationSha256);
+ const authorization = authorizationFile.value;
+ const approvedAt = Date.parse(authorization?.authorizedAtUtc || "");
+ const expiresAt = Date.parse(authorization?.expiresAtUtc || "");
+ if (authorization?.schemaVersion !== 1 ||
+ authorization?.kind !== "LegacyPgmPrepareGateAuthorization" ||
+ authorization?.roundId !== plan.roundId ||
+ String(authorization?.planSha256 || "").toUpperCase() !== configuration.planSha256 ||
+ authorization?.authorizationStatement !==
+ "I_AUTHORIZE_CURRENT_PGM_LIVE_CONNECT_ONCE_AND_PREPARE_5001_PAGE_1_ONCE" ||
+ authorization?.currentK3dLicenseValid !== true ||
+ authorization?.runningTornadoIsLicensedMainProgram !== true ||
+ authorization?.currentPgmApproved !== true ||
+ !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt) ||
+ expiresAt <= approvedAt || expiresAt - approvedAt > 15 * 60 * 1000 ||
+ Date.now() < approvedAt - 5000 || Date.now() >= expiresAt - 5000) {
+ known("Gate A authorization is invalid, expired, or too near expiry.");
+ }
+ const authorizationBudget = authorization?.budgets;
+ if (authorizationBudget?.connect !== 1 || authorizationBudget?.prepare !== 1 ||
+ authorizationBudget?.takeIn !== 0 || authorizationBudget?.next !== 0 ||
+ authorizationBudget?.pageNext !== 0 || authorizationBudget?.takeOut !== 0 ||
+ authorizationBudget?.databaseWrite !== 0 || authorizationBudget?.appClose !== 0 ||
+ authorizationBudget?.retry !== 0) {
+ known("Authorization command budget is not exact Gate A.");
+ }
+ evidence.roundId = plan.roundId;
+ return { plan, authorization, authorizationHash: authorizationFile.hash, expiresAt };
+}
+
+async function discoverTarget() {
+ let lastError = null;
+ while (Date.now() < hardDeadline) {
+ try {
+ const response = await fetch(`http://127.0.0.1:${configuration.port}/json/list`, {
+ redirect: "error", signal: AbortSignal.timeout(1000)
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const targets = await response.json();
+ const exact = Array.isArray(targets) ? targets.filter(target =>
+ target?.type === "page" && target.url === exactUrl) : [];
+ if (exact.length > 1) known("More than one exact WebView target exists.");
+ if (exact.length === 1) return exact[0];
+ } catch (error) { lastError = String(error?.message || error); }
+ await sleep(100);
+ }
+ known(`Exact WebView target was not found${lastError ? `: ${lastError}` : "."}`);
+}
+
+function validateEndpoint(target) {
+ if (!target?.id || !target.webSocketDebuggerUrl) known("Target has no CDP endpoint.");
+ const endpoint = new URL(target.webSocketDebuggerUrl);
+ if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
+ Number(endpoint.port) !== configuration.port ||
+ endpoint.pathname !== `/devtools/page/${target.id}`) known("CDP endpoint is not exact loopback.");
+ evidence.target.id = target.id;
+ return endpoint;
+}
+
+async function connect(endpoint) {
+ if (typeof WebSocket !== "function") known("Node WebSocket API is unavailable.");
+ socket = new WebSocket(endpoint.href);
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000);
+ socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
+ socket.addEventListener("error", () => {
+ clearTimeout(timer); reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open failed."));
+ }, { once: true });
+ });
+ socket.addEventListener("message", event => {
+ let message;
+ try { message = JSON.parse(String(event.data)); } catch { return; }
+ if (!Object.hasOwn(message, "id")) return;
+ const request = pending.get(message.id);
+ if (!request) return;
+ pending.delete(message.id);
+ clearTimeout(request.timer);
+ if (message.error) request.reject(new GateFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message}`));
+ else request.resolve(message.result);
+ });
+ socket.addEventListener("close", () => {
+ for (const request of pending.values()) {
+ clearTimeout(request.timer);
+ request.reject(new GateFailure("OUTCOME_UNKNOWN", `${request.method} interrupted by CDP close.`));
+ }
+ pending.clear();
+ });
+}
+
+function rpc(method, params = {}, timeoutMilliseconds = 10000) {
+ assertDeadline(`before ${method}`);
+ if (forbiddenCdpMethods.has(method)) known(`Forbidden CDP method ${method}.`);
+ if (!socket || socket.readyState !== WebSocket.OPEN) unknown(`CDP is unavailable for ${method}.`);
+ const id = nextRpcId++;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new GateFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry.`));
+ }, Math.min(timeoutMilliseconds, hardDeadline - Date.now()));
+ pending.set(id, { method, resolve, reject, timer });
+ socket.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+async function evaluate(expression) {
+ const response = await rpc("Runtime.evaluate", {
+ expression, awaitPromise: true, returnByValue: true, userGesture: false
+ });
+ if (response.exceptionDetails) known(response.exceptionDetails.exception?.description ||
+ response.exceptionDetails.text || "Runtime evaluation failed.");
+ return response.result?.value;
+}
+
+async function installGate(planSha256, capabilitySha256) {
+ const token = `legacy-pgm-prepare-${Date.now()}`;
+ const result = await evaluate(`(() => {
+ if (!window.chrome?.webview) throw new Error("WebView bridge unavailable");
+ if (globalThis.__legacyPgmPrepareGate) throw new Error("Gate already installed");
+ const allowedBeforePrepare = new Set([
+ "ready", "search-stocks", "select-stock", "cut-pointer-down", "drop-selected-cuts"
+ ]);
+ const gate = {
+ version: 1,
+ token: ${JSON.stringify(token)},
+ planSha256: ${JSON.stringify(planSha256)},
+ capabilityHash: ${JSON.stringify(capabilitySha256)},
+ original: window.chrome.webview.postMessage,
+ wrapper: null,
+ listener: null,
+ states: [],
+ outbound: [],
+ blocked: [],
+ armed: false,
+ prepareCapability: null,
+ prepareCount: 0,
+ prepareIssuedAtUtc: null,
+ stateCountAtPrepare: null,
+ expectedPreRevision: null,
+ busyObservedAtPreRevision: false
+ };
+ const hashText = async value => {
+ const bytes = new TextEncoder().encode(value);
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0"))
+ .join("").toUpperCase();
+ };
+ gate.listener = event => {
+ if (event.data?.type !== "state" || !event.data.payload) return;
+ const state = structuredClone(event.data.payload);
+ gate.states.push(state);
+ if (gate.states.length > 500) gate.states.shift();
+ if (gate.prepareCount === 1 && state?.isBusy === true &&
+ state?.revision === gate.expectedPreRevision) {
+ gate.busyObservedAtPreRevision = true;
+ }
+ };
+ gate.wrapper = message => {
+ const type = typeof message?.type === "string" ? message.type : null;
+ const payload = message?.payload;
+ const record = { type, atUtc: new Date().toISOString() };
+ gate.outbound.push(record);
+ let allowed = false;
+ if (type === "gate-a-prepare") {
+ const empty = payload && typeof payload === "object" &&
+ !Array.isArray(payload) && Object.keys(payload).length === 1 &&
+ typeof payload.capability === "string" &&
+ payload.capability === gate.prepareCapability;
+ allowed = gate.armed && gate.prepareCount === 0 && empty;
+ if (allowed) {
+ gate.armed = false;
+ gate.prepareCapability = null;
+ gate.prepareCount = 1;
+ gate.prepareIssuedAtUtc = record.atUtc;
+ gate.stateCountAtPrepare = gate.states.length;
+ }
+ } else {
+ allowed = !gate.armed && gate.prepareCount === 0 && allowedBeforePrepare.has(type);
+ }
+ if (!allowed) {
+ if (gate.armed) {
+ gate.armed = false;
+ gate.prepareCapability = null;
+ }
+ gate.blocked.push(record);
+ return;
+ }
+ return gate.original.call(window.chrome.webview, message);
+ };
+ gate.arm = async (supplied, expectedPreRevision) => {
+ if (gate.armed || gate.prepareCount !== 0 || gate.blocked.length !== 0 ||
+ !Number.isSafeInteger(expectedPreRevision) || expectedPreRevision < 0 ||
+ await hashText(String(supplied || "")) !== gate.capabilityHash) {
+ throw new Error("One-shot PREPARE capability rejected");
+ }
+ gate.expectedPreRevision = expectedPreRevision;
+ gate.prepareCapability = String(supplied);
+ gate.armed = true;
+ return { armed: true, stateCount: gate.states.length };
+ };
+ gate.inspect = () => ({
+ version: gate.version,
+ token: gate.token,
+ planSha256: gate.planSha256,
+ wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper,
+ stateCount: gate.states.length,
+ states: gate.states.slice(),
+ outbound: gate.outbound.slice(),
+ blocked: gate.blocked.slice(),
+ armed: gate.armed,
+ prepareCount: gate.prepareCount,
+ prepareIssuedAtUtc: gate.prepareIssuedAtUtc,
+ stateCountAtPrepare: gate.stateCountAtPrepare,
+ expectedPreRevision: gate.expectedPreRevision,
+ busyObservedAtPreRevision: gate.busyObservedAtPreRevision
+ });
+ window.chrome.webview.postMessage = gate.wrapper;
+ if (window.chrome.webview.postMessage !== gate.wrapper) throw new Error("Gate identity failed");
+ window.chrome.webview.addEventListener("message", gate.listener);
+ globalThis.__legacyPgmPrepareGate = Object.freeze({
+ arm: gate.arm,
+ inspect: gate.inspect
+ });
+ window.chrome.webview.postMessage({ type: "ready", payload: {} });
+ return { token: gate.token, wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper };
+ })()`);
+ if (result?.token !== token || result.wrapperCurrent !== true) unknown("Page gate did not install exactly.");
+}
+
+function projectedState(state) {
+ if (!state) return null;
+ const playout = state.playout || {};
+ return {
+ revision: state.revision, isBusy: state.isBusy, statusMessage: state.statusMessage,
+ statusKind: state.statusKind,
+ playlist: (state.playlist || []).map(row => ({
+ rowId: row.rowId, isEnabled: row.isEnabled, isActive: row.isActive,
+ marketText: row.marketText, stockName: row.stockName,
+ graphicType: row.graphicType, subtype: row.subtype, pageText: row.pageText
+ })),
+ playout: {
+ mode: playout.mode, connectionState: playout.connectionState, phase: playout.phase,
+ isConnected: playout.isConnected, isCommandAvailable: playout.isCommandAvailable,
+ liveTakeInAllowed: playout.liveTakeInAllowed,
+ isPlayCompletionPending: playout.isPlayCompletionPending,
+ isTakeOutCompletionPending: playout.isTakeOutCompletionPending,
+ outcomeUnknown: playout.outcomeUnknown, preparedCode: playout.preparedCode,
+ onAirCode: playout.onAirCode, currentEntryId: playout.currentEntryId,
+ pageIndexZeroBased: playout.pageIndexZeroBased, pageCount: playout.pageCount,
+ pageSize: playout.pageSize, itemCount: playout.itemCount,
+ currentPageItemCount: playout.currentPageItemCount, isLastPage: playout.isLastPage,
+ nextKind: playout.nextKind, isBusy: playout.isBusy,
+ refreshActive: playout.refreshActive, refreshCompletedCount: playout.refreshCompletedCount,
+ refreshMaximumCount: playout.refreshMaximumCount,
+ refreshLimitReached: playout.refreshLimitReached, message: playout.message
+ }
+ };
+}
+
+async function snapshot() {
+ return await evaluate(`(() => {
+ const gate = globalThis.__legacyPgmPrepareGate;
+ if (!gate || typeof gate.inspect !== "function") return null;
+ const audit = gate.inspect();
+ const state = audit.states.at(-1) || null;
+ return {
+ audit,
+ state,
+ dom: {
+ readyState: document.readyState,
+ url: location.href,
+ connection: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
+ status: (document.getElementById("playout-status")?.textContent || "").trim(),
+ dialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
+ dialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim()
+ }
+ };
+ })()`);
+}
+
+function assertCommon(value, label) {
+ if (!value?.state?.playout || value.dom?.url !== exactUrl ||
+ value.dom.readyState !== "complete" || value.audit?.wrapperCurrent !== true) {
+ unknown(`Authoritative state or gate missing at ${label}.`);
+ }
+ if (value.audit.blocked.length !== 0) unknown(`An unapproved message was blocked at ${label}.`);
+ if (value.state.playout.outcomeUnknown === true) unknown(`Native outcome is unknown at ${label}.`);
+ if (value.state.playout.onAirCode != null || value.state.playout.phase === "program") {
+ unknown(`PROGRAM output was observed at ${label}.`);
+ }
+ if (value.dom.dialogHidden !== true) known(`Legacy dialog is visible at ${label}: ${value.dom.dialogText}`);
+}
+
+async function waitFor(label, predicate, timeoutMilliseconds = null) {
+ const deadline = Math.min(hardDeadline, Date.now() + (timeoutMilliseconds || configuration.timeoutMilliseconds));
+ let last = null;
+ while (Date.now() < deadline) {
+ last = await snapshot();
+ if (last?.state?.playout) assertCommon(last, label);
+ if (predicate(last)) return last;
+ await sleep(100);
+ }
+ unknown(`Timed out waiting for ${label}; no retry. Last=${JSON.stringify(projectedState(last?.state))}`);
+}
+
+function isConnectedIdle(value) {
+ const p = value?.state?.playout;
+ return p?.mode === "live" && p.connectionState === "connected" && p.phase === "idle" &&
+ p.isConnected === true && p.isCommandAvailable === true && p.liveTakeInAllowed === false &&
+ p.outcomeUnknown !== true && p.preparedCode == null && p.onAirCode == null &&
+ p.isBusy !== true && p.isPlayCompletionPending !== true &&
+ p.isTakeOutCompletionPending !== true && p.refreshActive !== true &&
+ value.state.isBusy !== true;
+}
+
+function exactPlaylist(state) {
+ const rows = state?.playlist || [];
+ return rows.length === 1 && rows[0].isEnabled === true && rows[0].isActive === true &&
+ rows[0].marketText === requiredSelector.marketText &&
+ rows[0].stockName === requiredSelector.stockName &&
+ rows[0].graphicType === requiredSelector.graphicType &&
+ rows[0].subtype === requiredSelector.subtype && rows[0].pageText === "1/1";
+}
+
+function assertExactOutbound(audit, expectPrepare) {
+ const types = audit?.outbound?.map(item => item.type) || [];
+ const count = type => types.filter(value => value === type).length;
+ const selectedCount = count("select-stock");
+ if (count("ready") !== 1 || count("search-stocks") !== 1 ||
+ selectedCount > 1 || count("cut-pointer-down") !== 1 ||
+ count("drop-selected-cuts") !== 1 ||
+ count("gate-a-prepare") !== (expectPrepare ? 1 : 0) ||
+ audit?.blocked?.length !== 0 ||
+ audit?.prepareCount !== (expectPrepare ? 1 : 0)) {
+ unknown("Gate A outbound ledger counts are not exact.");
+ }
+ const expected = ["ready", "search-stocks"];
+ if (selectedCount === 1) expected.push("select-stock");
+ expected.push("cut-pointer-down", "drop-selected-cuts");
+ if (expectPrepare) expected.push("gate-a-prepare");
+ if (types.length !== expected.length ||
+ types.some((type, index) => type !== expected[index])) {
+ unknown(`Gate A outbound ledger order is not exact: ${JSON.stringify(types)}`);
+ }
+}
+
+function isPrepared5001(value) {
+ const p = value?.state?.playout;
+ const row = value?.state?.playlist?.[0];
+ return exactPlaylist(value?.state) && p?.mode === "live" &&
+ p.connectionState === "connected" && p.phase === "prepared" && p.isConnected === true &&
+ p.isCommandAvailable === true && p.liveTakeInAllowed === false && p.outcomeUnknown !== true &&
+ p.preparedCode === "5001" && p.onAirCode == null && p.currentEntryId === row?.rowId &&
+ p.pageIndexZeroBased === 0 && p.pageCount === 1 && p.itemCount === 1 &&
+ p.currentPageItemCount === 1 && p.isLastPage === true &&
+ String(p.nextKind).toLowerCase() === "endofplaylist" &&
+ p.isBusy !== true && p.isPlayCompletionPending !== true &&
+ p.isTakeOutCompletionPending !== true && p.refreshActive !== true &&
+ value.state.isBusy !== true;
+}
+
+async function click(expression, label) {
+ const geometry = await evaluate(`(() => {
+ const element = ${expression};
+ if (!element || element.disabled || element.getAttribute("aria-disabled") === "true") return null;
+ const r = element.getBoundingClientRect();
+ if (r.width <= 0 || r.height <= 0) return null;
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ })()`);
+ if (!geometry) known(`${label} is unavailable.`);
+ await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mousePressed", ...geometry, button: "left", buttons: 1, clickCount: 1
+ });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mouseReleased", ...geometry, button: "left", buttons: 0, clickCount: 1
+ });
+}
+
+async function configurePlaylist() {
+ await evaluate(`(() => {
+ const input = document.getElementById("stock-search");
+ if (!input) throw new Error("stock search unavailable");
+ input.value = ${JSON.stringify(requiredSelector.searchText)};
+ })()`);
+ await click('document.getElementById("stock-search-button")', "stock search button");
+ let current = await waitFor("exact stock search", value => {
+ const state = value?.state;
+ return state?.isBusy === false && state.searchText === requiredSelector.searchText &&
+ (state.searchResults || []).filter(row => row.displayName === requiredSelector.stockName).length === 1;
+ }, 60000);
+ const exact = current.state.searchResults.filter(row => row.displayName === requiredSelector.stockName)[0];
+ if (current.state.selectedStockIndex !== exact.index) {
+ await click(`document.querySelector('#stock-results button[data-result-index="${exact.index}"]')`,
+ "exact stock result");
+ current = await waitFor("exact stock selection", value =>
+ value?.state?.selectedStockIndex === exact.index && value.state.isBusy === false, 15000);
+ }
+ const geometry = await evaluate(`(() => {
+ const state = globalThis.__legacyPgmPrepareGate.inspect().states.at(-1);
+ const cut = (state?.cutRows || []).find(row => row.rawLabel ===
+ ${JSON.stringify(requiredSelector.cutLabel)} && row.isSeparator !== true);
+ if (!cut) return null;
+ const source = document.querySelector('#cut-list .cut-row[data-physical-index="' +
+ String(cut.physicalIndex) + '"]');
+ const target = document.getElementById("playlist-drop-zone");
+ if (!source || !target) return null;
+ const a = source.getBoundingClientRect();
+ const b = target.getBoundingClientRect();
+ return {
+ physicalIndex: cut.physicalIndex,
+ source: { x: a.left + a.width / 2, y: a.top + a.height / 2 },
+ threshold: { x: a.left + a.width / 2 + 20, y: a.top + a.height / 2 + 20 },
+ target: { x: b.left + b.width / 2, y: b.top + Math.min(80, b.height / 2) }
+ };
+ })()`);
+ if (!geometry) known("Exact 5001 cut row or drop target is unavailable.");
+ await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry.source });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mousePressed", ...geometry.source, button: "left", buttons: 1, clickCount: 1
+ });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mouseMoved", ...geometry.threshold, button: "left", buttons: 1
+ });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mouseMoved", ...geometry.target, button: "left", buttons: 1
+ });
+ await rpc("Input.dispatchMouseEvent", {
+ type: "mouseReleased", ...geometry.target, button: "left", buttons: 0, clickCount: 1
+ });
+ current = await waitFor("exact 5001 playlist", value =>
+ value?.state?.isBusy === false && exactPlaylist(value.state), 30000);
+ assertExactOutbound(current.audit, false);
+ evidence.checkpoints.push({ name: "playlist-5001", state: projectedState(current.state) });
+ return current;
+}
+
+async function receiveJitCapability(expiresAt) {
+ if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before JIT permit.");
+ evidence.observed.jitPermitRequested = true;
+ const remaining = Math.min(15000, hardDeadline - Date.now(), expiresAt - 5000 - Date.now());
+ if (remaining <= 0) known("No safe time remains for the JIT permit.");
+ const pipePath = `\\\\.\\pipe\\${configuration.permitPipeName}`;
+ const capability = await new Promise((resolve, reject) => {
+ let settled = false;
+ let total = 0;
+ const chunks = [];
+ const client = net.createConnection(pipePath);
+ const finish = (error, value = null) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ client.destroy();
+ if (error) reject(error);
+ else resolve(value);
+ };
+ const timer = setTimeout(() => finish(new GateFailure(
+ "KNOWN_FAILURE", "The parent did not grant the one-shot JIT permit.")), remaining);
+ client.on("data", chunk => {
+ total += chunk.length;
+ if (total > 64) {
+ finish(new GateFailure("KNOWN_FAILURE", "The JIT permit payload length is invalid."));
+ return;
+ }
+ chunks.push(chunk);
+ });
+ client.on("end", () => {
+ const bytes = Buffer.concat(chunks, total);
+ try {
+ const value = bytes.toString("ascii");
+ if (bytes.length !== 64 || !/^[0-9A-F]{64}$/u.test(value) ||
+ sha256(bytes) !== configuration.capabilitySha256) {
+ finish(new GateFailure("KNOWN_FAILURE", "The JIT permit did not match the sealed capability hash."));
+ return;
+ }
+ finish(null, value);
+ } finally {
+ bytes.fill(0);
+ for (const chunk of chunks) chunk.fill(0);
+ }
+ });
+ client.on("error", () => finish(new GateFailure(
+ "KNOWN_FAILURE", "The parent-owned JIT permit pipe closed without authority.")));
+ });
+ evidence.observed.jitPermitReceived = true;
+ return capability;
+}
+
+async function prepareOnce(expiresAt) {
+ if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before PREPARE.");
+ let before = await snapshot();
+ assertCommon(before, "PREPARE boundary");
+ if (!isConnectedIdle(before) || !exactPlaylist(before.state)) known("PREPARE boundary is not exact IDLE 5001.");
+ const preRevision = before.state.revision;
+ let capability = await receiveJitCapability(expiresAt);
+ before = await snapshot();
+ assertCommon(before, "post-permit PREPARE boundary");
+ if (before.state.revision !== preRevision || !isConnectedIdle(before) ||
+ !exactPlaylist(before.state)) {
+ capability = null;
+ known("The exact PREPARE boundary changed while the parent granted the JIT permit.");
+ }
+ const armed = await evaluate(`globalThis.__legacyPgmPrepareGate.arm(
+ ${JSON.stringify(capability)}, ${JSON.stringify(preRevision)})`);
+ if (armed?.armed !== true) unknown("PREPARE permit did not arm.");
+ if (Date.now() >= expiresAt - 5000) known("Authorization expired at PREPARE boundary.");
+ prepareDispatchPossible = true;
+ evidence.observed.prepareDispatchPossible = true;
+ const dispatched = await evaluate(`(() => {
+ if (!globalThis.__legacyPgmPrepareGate) throw new Error("PREPARE gate unavailable");
+ window.chrome.webview.postMessage({
+ type: "gate-a-prepare",
+ payload: { capability: ${JSON.stringify(capability)} }
+ });
+ return true;
+ })()`);
+ capability = null;
+ if (dispatched !== true) unknown("PREPARE bridge dispatch did not return exactly.");
+ prepareIssued = true;
+ evidence.observed.prepareIssued = true;
+ let current;
+ let busyObserved = false;
+ while (Date.now() < hardDeadline) {
+ current = await snapshot();
+ assertCommon(current, "PREPARE observation");
+ if (current.audit.prepareCount !== 1) unknown("PREPARE one-shot ledger changed.");
+ if (current.audit.busyObservedAtPreRevision === true) {
+ busyObserved = true;
+ evidence.observed.busyObserved = true;
+ }
+ if (busyObserved && isPrepared5001(current) && current.state.revision > preRevision) {
+ evidence.checkpoints.push({ name: "prepared-5001", state: projectedState(current.state) });
+ return current;
+ }
+ const p = current.state.playout;
+ if (busyObserved && current.state.isBusy === false && current.state.revision > preRevision &&
+ p.phase === "idle" && p.preparedCode == null && p.onAirCode == null &&
+ p.outcomeUnknown !== true && String(current.state.statusKind).toLowerCase() === "error" &&
+ String(current.state.statusMessage || "").length > 0) {
+ throw new GateFailure("KNOWN_FAILURE", `PREPARE rejected: ${current.state.statusMessage}`);
+ }
+ await sleep(100);
+ }
+ unknown("PREPARE outcome timed out; no retry or cleanup command was sent.");
+}
+
+async function captureScreenshot() {
+ const response = await rpc("Page.captureScreenshot", {
+ format: "png", fromSurface: true, captureBeyondViewport: false
+ }, 15000);
+ if (!response?.data) known("Screenshot data is unavailable.");
+ const bytes = Buffer.from(response.data, "base64");
+ const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
+ if (bytes.length < 1024 || !bytes.subarray(0, pngSignature.length).equals(pngSignature)) {
+ unknown("Screenshot is not a nontrivial PNG image.");
+ }
+ fs.mkdirSync(path.dirname(configuration.screenshotPath), { recursive: true });
+ fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
+ evidence.screenshot = {
+ path: configuration.screenshotPath, bytes: bytes.length, sha256: sha256(bytes)
+ };
+}
+
+async function execute() {
+ const contract = validateFrozenContract();
+ evidence.authorizationSha256 = contract.authorizationHash;
+ const target = await discoverTarget();
+ await connect(validateEndpoint(target));
+ await rpc("Runtime.enable");
+ await rpc("Page.enable");
+ await installGate(configuration.planSha256, configuration.capabilitySha256);
+ const connected = await waitFor("Live connected IDLE", value => isConnectedIdle(value), 60000);
+ if ((connected.state.playlist || []).length !== 0) known("Gate A requires an empty playlist.");
+ evidence.checkpoints.push({ name: "connected-idle", state: projectedState(connected.state) });
+ await configurePlaylist();
+ const prepared = await prepareOnce(contract.expiresAt);
+ assertExactOutbound(prepared.audit, true);
+ await captureScreenshot();
+ const final = await snapshot();
+ assertCommon(final, "post-screenshot final verification");
+ assertExactOutbound(final.audit, true);
+ if (final.audit.busyObservedAtPreRevision !== true || !isPrepared5001(final)) {
+ unknown("Post-screenshot state is not the exact prepared 5001 result.");
+ }
+ evidence.finalState = projectedState(final.state);
+ evidence.observed.outboundTypes = final.audit.outbound.map(item => item.type);
+ evidence.observed.blocked = final.audit.blocked;
+ evidence.result = "PASS_PREPARED";
+}
+
+try {
+ await execute();
+} catch (error) {
+ let category = error instanceof GateFailure ? error.category : "HARNESS_ERROR";
+ if (socket?.readyState === WebSocket.OPEN) {
+ try {
+ const final = await snapshot();
+ evidence.finalState = projectedState(final?.state);
+ evidence.observed.outboundTypes = final?.audit?.outbound?.map(item => item.type) || [];
+ evidence.observed.blocked = final?.audit?.blocked || [];
+ if (final?.audit?.prepareCount === 1) {
+ prepareIssued = true;
+ evidence.observed.prepareIssued = true;
+ }
+ if (!fs.existsSync(configuration.screenshotPath)) await captureScreenshot();
+ } catch (captureError) {
+ evidence.captureError = String(captureError?.message || captureError);
+ }
+ }
+ const isExactKnownRejection = prepareIssued && category === "KNOWN_FAILURE" &&
+ String(error?.message || "").includes("PREPARE rejected:");
+ if (prepareDispatchPossible && !isExactKnownRejection) category = "OUTCOME_UNKNOWN";
+ evidence.result = category;
+ evidence.error = { name: error?.name || "Error", message: String(error?.message || error) };
+ process.exitCode = category === "KNOWN_FAILURE" ? 2 : 3;
+} finally {
+ evidence.completedAtUtc = new Date().toISOString();
+ fs.mkdirSync(path.dirname(configuration.outputPath), { recursive: true });
+ fs.writeFileSync(configuration.outputPath, JSON.stringify(evidence, null, 2) + "\n", {
+ encoding: "utf8", flag: "wx"
+ });
+ try { socket?.close(); } catch { /* The page and its installed guard stay open. */ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs
new file mode 100644
index 0000000..49f4318
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/DatabaseMutationAuthorization.cs
@@ -0,0 +1,100 @@
+#nullable enable
+
+using MMoneyCoderSharp.Data;
+
+namespace MBN_STOCK_WEBVIEW.Infrastructure;
+
+///
+/// Identifies the native database-write boundary that is requesting
+/// authorization. The request deliberately contains no operator-entered data.
+///
+public enum DatabaseMutationTarget
+{
+ OperatorCatalog,
+ NamedPlaylist,
+ ManualFinancial
+}
+
+///
+/// Describes one validated transaction immediately before its executor can
+/// create a database connection.
+///
+public sealed record DatabaseMutationAuthorizationRequest(
+ DatabaseMutationTarget Target,
+ DataSourceKind Source,
+ Guid OperationId,
+ string MutationKind);
+
+///
+/// Final authorization seam for state-changing database operations. Returning
+/// false denies the transaction before a connection or transaction is created.
+/// Throwing also fails closed and is never converted into an allow decision.
+///
+public interface IDatabaseMutationAuthorization
+{
+ bool IsAuthorized(DatabaseMutationAuthorizationRequest request);
+}
+
+///
+/// Provides the compatibility policy used when existing constructors do not
+/// supply an explicit database-mutation authorization policy.
+///
+public static class DatabaseMutationAuthorization
+{
+ public static IDatabaseMutationAuthorization AllowAll { get; } =
+ new AllowAllDatabaseMutationAuthorization();
+
+ private sealed class AllowAllDatabaseMutationAuthorization
+ : IDatabaseMutationAuthorization
+ {
+ public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return true;
+ }
+ }
+}
+
+///
+/// Raised when the active policy denies a database mutation before any native
+/// database connection is created.
+///
+public sealed class DatabaseMutationAuthorizationException : InvalidOperationException
+{
+ public DatabaseMutationAuthorizationException(
+ DatabaseMutationAuthorizationRequest request,
+ Exception? innerException = null)
+ : base(
+ $"The {request?.Target} database mutation was denied before a connection was opened.",
+ innerException)
+ {
+ Request = request ?? throw new ArgumentNullException(nameof(request));
+ }
+
+ public DatabaseMutationAuthorizationRequest Request { get; }
+}
+
+internal static class DatabaseMutationAuthorizationGuard
+{
+ internal static void Demand(
+ IDatabaseMutationAuthorization authorization,
+ DatabaseMutationAuthorizationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(authorization);
+ ArgumentNullException.ThrowIfNull(request);
+ bool authorized;
+ try
+ {
+ authorized = authorization.IsAuthorized(request);
+ }
+ catch (Exception exception)
+ {
+ throw new DatabaseMutationAuthorizationException(request, exception);
+ }
+
+ if (!authorized)
+ {
+ throw new DatabaseMutationAuthorizationException(request);
+ }
+ }
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs
index 45fb024..54e3245 100644
--- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs
+++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs
@@ -17,6 +17,7 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
+ private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly Action _configureCommand;
@@ -28,7 +29,22 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
- ConfigureProviderCommand)
+ ConfigureProviderCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ public OperatorCatalogMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector? errorDetector,
+ IDatabaseMutationAuthorization mutationAuthorization)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector ?? new TransientDatabaseErrorDetector(),
+ ConfigureProviderCommand,
+ mutationAuthorization)
{
}
@@ -37,6 +53,21 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action configureCommand)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector,
+ configureCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ internal OperatorCatalogMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector errorDetector,
+ Action configureCommand,
+ IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -44,6 +75,8 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
+ _mutationAuthorization = mutationAuthorization ??
+ throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -56,6 +89,23 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
{
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ DatabaseMutationAuthorizationGuard.Demand(
+ _mutationAuthorization,
+ new DatabaseMutationAuthorizationRequest(
+ DatabaseMutationTarget.OperatorCatalog,
+ transaction.Source,
+ transaction.OperationId,
+ transaction.Kind.ToString()));
+ }
+ catch (DatabaseMutationAuthorizationException exception)
+ {
+ throw new OperatorCatalogMutationException(
+ $"The {transaction.Kind} transaction was denied before it started and did not commit.",
+ outcomeUnknown: false,
+ exception);
+ }
var commandTimeoutSeconds = _connectionFactory.GetCommandTimeoutSeconds(transaction.Source);
if (commandTimeoutSeconds is < 1 or > 600)
{
diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs
index 8625afe..f47918f 100644
--- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs
+++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs
@@ -19,6 +19,7 @@ public sealed partial class OracleManualFinancialMutationExecutor
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
+ private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action _configureCommand;
@@ -31,7 +32,22 @@ public sealed partial class OracleManualFinancialMutationExecutor
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
- ConfigureOracleCommand)
+ ConfigureOracleCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ public OracleManualFinancialMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector? errorDetector,
+ IDatabaseMutationAuthorization mutationAuthorization)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector ?? new TransientDatabaseErrorDetector(),
+ ConfigureOracleCommand,
+ mutationAuthorization)
{
}
@@ -40,12 +56,29 @@ public sealed partial class OracleManualFinancialMutationExecutor
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action configureCommand)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector,
+ configureCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ internal OracleManualFinancialMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector errorDetector,
+ Action configureCommand,
+ IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
ArgumentNullException.ThrowIfNull(resilienceOptions);
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ?? throw new ArgumentNullException(nameof(configureCommand));
+ _mutationAuthorization = mutationAuthorization ??
+ throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -73,6 +106,23 @@ public sealed partial class OracleManualFinancialMutationExecutor
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ DatabaseMutationAuthorizationGuard.Demand(
+ _mutationAuthorization,
+ new DatabaseMutationAuthorizationRequest(
+ DatabaseMutationTarget.ManualFinancial,
+ DataSourceKind.Oracle,
+ transaction.OperationId,
+ transaction.Kind.ToString()));
+ }
+ catch (DatabaseMutationAuthorizationException exception)
+ {
+ throw new ManualFinancialMutationException(
+ $"The {transaction.Kind} transaction was denied before it started and did not commit.",
+ outcomeUnknown: false,
+ exception);
+ }
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
var operationToken = timeoutSource.Token;
diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs
index 059320c..ad9bf6d 100644
--- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs
+++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs
@@ -17,6 +17,7 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
{
private readonly IDatabaseConnectionFactory _connectionFactory;
private readonly ITransientDatabaseErrorDetector _errorDetector;
+ private readonly IDatabaseMutationAuthorization _mutationAuthorization;
private readonly TimeSpan _operationTimeout;
private readonly int _commandTimeoutSeconds;
private readonly Action _configureCommand;
@@ -29,7 +30,22 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
connectionFactory,
resilienceOptions,
errorDetector ?? new TransientDatabaseErrorDetector(),
- ConfigureOracleCommand)
+ ConfigureOracleCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ public OracleNamedPlaylistMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector? errorDetector,
+ IDatabaseMutationAuthorization mutationAuthorization)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector ?? new TransientDatabaseErrorDetector(),
+ ConfigureOracleCommand,
+ mutationAuthorization)
{
}
@@ -38,6 +54,21 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
DatabaseResilienceOptions resilienceOptions,
ITransientDatabaseErrorDetector errorDetector,
Action configureCommand)
+ : this(
+ connectionFactory,
+ resilienceOptions,
+ errorDetector,
+ configureCommand,
+ DatabaseMutationAuthorization.AllowAll)
+ {
+ }
+
+ internal OracleNamedPlaylistMutationExecutor(
+ IDatabaseConnectionFactory connectionFactory,
+ DatabaseResilienceOptions resilienceOptions,
+ ITransientDatabaseErrorDetector errorDetector,
+ Action configureCommand,
+ IDatabaseMutationAuthorization mutationAuthorization)
{
_connectionFactory = connectionFactory ??
throw new ArgumentNullException(nameof(connectionFactory));
@@ -45,6 +76,8 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
_errorDetector = errorDetector ?? throw new ArgumentNullException(nameof(errorDetector));
_configureCommand = configureCommand ??
throw new ArgumentNullException(nameof(configureCommand));
+ _mutationAuthorization = mutationAuthorization ??
+ throw new ArgumentNullException(nameof(mutationAuthorization));
var validationOptions = new DatabaseOptions { Resilience = resilienceOptions };
validationOptions.ValidateRuntimeSettings();
@@ -72,6 +105,23 @@ public sealed class OracleNamedPlaylistMutationExecutor : INamedPlaylistMutation
ValidateTransaction(transaction);
cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ DatabaseMutationAuthorizationGuard.Demand(
+ _mutationAuthorization,
+ new DatabaseMutationAuthorizationRequest(
+ DatabaseMutationTarget.NamedPlaylist,
+ DataSourceKind.Oracle,
+ transaction.OperationId,
+ transaction.Kind.ToString()));
+ }
+ catch (DatabaseMutationAuthorizationException exception)
+ {
+ throw new NamedPlaylistMutationException(
+ $"The {transaction.Kind} transaction was denied before it started and did not commit.",
+ outcomeUnknown: false,
+ exception);
+ }
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(_operationTimeout);
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
index b989609..12ddf6f 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs
@@ -6,6 +6,12 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command)
: LegacyUiIntent;
+///
+/// Process-scoped Gate A PREPARE request. The opaque capability is validated and
+/// consumed by the native launch authorization; it is never emitted in state.
+///
+public sealed record LegacyGateAPrepareIntent(string Capability) : LegacyUiIntent;
+
public sealed record LegacySetFadeDurationIntent(int Duration)
: LegacyUiIntent;
@@ -17,6 +23,7 @@ internal static class LegacyPlayoutUiIntentParser
{
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
{
+ "gate-a-prepare" => ParseGateAPrepare(payload),
"prepare-playout" => ParseEmpty(
payload,
static () => new LegacyExecutePlayoutIntent(
@@ -43,6 +50,29 @@ internal static class LegacyPlayoutUiIntentParser
_ => null
};
+ private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)
+ {
+ if (payload.ValueKind != JsonValueKind.Object ||
+ payload.GetRawText().Length > 128 ||
+ payload.EnumerateObject().Count() != 1 ||
+ !payload.TryGetProperty("capability", out var capabilityElement) ||
+ capabilityElement.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ var capability = capabilityElement.GetString();
+ if (capability is null || capability.Length != 64 ||
+ capability.Any(character =>
+ character is not (>= '0' and <= '9') and
+ not (>= 'A' and <= 'F')))
+ {
+ return null;
+ }
+
+ return new LegacyGateAPrepareIntent(capability);
+ }
+
private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)
{
if (payload.ValueKind != JsonValueKind.Object ||
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
index e993889..d5d0b90 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
@@ -5,6 +5,7 @@ using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
+using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
@@ -45,7 +46,9 @@ public sealed partial class MainWindow
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
- _playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
+ _playoutEngine = PlayoutEngineFactory.Create(
+ _playoutOptions,
+ _playoutLaunchAuthorization);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
if (_databaseRuntime is not null)
{
@@ -118,8 +121,19 @@ public sealed partial class MainWindow
private async Task ExecuteOperatorPlayoutAsync(
LegacyOperatorPlayoutCommand command,
- CancellationToken cancellationToken)
+ CancellationToken cancellationToken,
+ bool gateAPrepareClaimed = false,
+ LegacyOperatorPlayoutRequest? gateAPrepareRequest = null)
{
+ if (_playoutLaunchAuthorization.IsGateA &&
+ (!gateAPrepareClaimed ||
+ command != LegacyOperatorPlayoutCommand.Prepare ||
+ gateAPrepareRequest is null))
+ {
+ return _controller.ReportError(
+ "Gate A 검증 회차에서는 승인된 5001 PREPARE 한 번만 실행할 수 있습니다.");
+ }
+
var engine = _playoutEngine;
var workflow = _playoutWorkflow;
if (engine is null || workflow is null)
@@ -163,8 +177,9 @@ public sealed partial class MainWindow
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
- var request = _controller.CreatePlayoutRequest(
- _compositionOptions!.Current.FadeDuration);
+ var request = gateAPrepareRequest ??
+ _controller.CreatePlayoutRequest(
+ _compositionOptions!.Current.FadeDuration);
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
@@ -265,10 +280,112 @@ public sealed partial class MainWindow
}
}
+ private async Task ExecuteGateAPrepareAsync(
+ string capability,
+ CancellationToken cancellationToken)
+ {
+ if (!_playoutLaunchAuthorization.IsGateA ||
+ !_playoutLaunchAuthorization.TryClaimGateAPrepare(capability))
+ {
+ return _controller.ReportError(
+ "Gate A PREPARE 승인이 없거나 이미 사용되었습니다.");
+ }
+
+ try
+ {
+ if (!TryCreateExactGateAPrepareRequest(out var request))
+ {
+ return _controller.ReportError(
+ "Gate A PREPARE 직전 상태가 승인된 삼성전자 5001 page 1 조건과 일치하지 않습니다.");
+ }
+
+ return await ExecuteOperatorPlayoutAsync(
+ LegacyOperatorPlayoutCommand.Prepare,
+ cancellationToken,
+ gateAPrepareClaimed: true,
+ gateAPrepareRequest: request).ConfigureAwait(false);
+ }
+ finally
+ {
+ // Success, rejection, cancellation and unknown outcome all consume the
+ // one process-lifetime capability. There is deliberately no retry path.
+ _playoutLaunchAuthorization.CompleteGateAPrepare();
+ }
+ }
+
+ private bool TryCreateExactGateAPrepareRequest(
+ out LegacyOperatorPlayoutRequest? request)
+ {
+ request = null;
+ var snapshot = _controller.Current;
+ if (snapshot.Playlist.Count != 1 ||
+ _compositionOptions?.Current is not { } composition ||
+ composition.FadeDuration != 6 ||
+ composition.BackgroundKind != LegacySceneBackgroundKind.None)
+ {
+ return false;
+ }
+
+ var row = snapshot.Playlist[0];
+ if (!row.IsEnabled || !row.IsActive ||
+ !string.Equals(row.MarketText, "코스피", StringComparison.Ordinal) ||
+ !string.Equals(row.StockName, "삼성전자", StringComparison.Ordinal) ||
+ !string.Equals(row.GraphicType, "1열판기본", StringComparison.Ordinal) ||
+ !string.Equals(row.Subtype, "현재가", StringComparison.Ordinal) ||
+ !string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
+ !string.Equals(row.StockCode, "005930", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ LegacyOperatorPlayoutRequest candidate;
+ try
+ {
+ candidate = _controller.CreatePlayoutRequest(6);
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+
+ if (candidate.SelectedIndexZeroBased != 0 ||
+ candidate.Playlist.Count != 1)
+ {
+ return false;
+ }
+
+ var entry = candidate.Playlist[0];
+ var selection = entry.Selection;
+ if (!entry.IsEnabled ||
+ !string.Equals(entry.EntryId, row.RowId, StringComparison.Ordinal) ||
+ !string.Equals(entry.CutCode, "5001", StringComparison.Ordinal) ||
+ entry.FadeDuration != 6 ||
+ entry.PageNavigation?.IsEnabled == true ||
+ entry.MovingAverageSelectionSource is not null ||
+ selection is null ||
+ !string.Equals(selection.GroupCode, "코스피", StringComparison.Ordinal) ||
+ !string.Equals(selection.Subject, "삼성전자", StringComparison.Ordinal) ||
+ !string.Equals(selection.GraphicType, "1열판기본", StringComparison.Ordinal) ||
+ !string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
+ !string.Equals(selection.DataCode, "005930", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ request = candidate;
+ return true;
+ }
+
private async Task SetFadeDurationAsync(
int fadeDuration,
CancellationToken cancellationToken)
{
+ if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
+ {
+ return _controller.ReportError(
+ "Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
+ }
+
if (fadeDuration is < 0 or > 19)
{
return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
@@ -308,6 +425,12 @@ public sealed partial class MainWindow
private async Task ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
+ if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
+ {
+ return _controller.ReportError(
+ "Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
+ }
+
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
@@ -349,6 +472,12 @@ public sealed partial class MainWindow
private async Task ChooseBackgroundAsync(
CancellationToken cancellationToken)
{
+ if (!_playoutLaunchAuthorization.AllowsCompositionMutation)
+ {
+ return _controller.ReportError(
+ "Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
+ }
+
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
@@ -534,6 +663,27 @@ public sealed partial class MainWindow
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
+ private static bool IsPersistentWriteIntent(LegacyUiIntent intent) => intent is
+ LegacyDeleteUc4SelectedThemeIntent or
+ LegacyDeleteUc6SelectedExpertIntent or
+ LegacySaveOperatorCatalogIntent or
+ LegacyConfirmNativeDialogIntent or
+ LegacyAddComparisonPairIntent or
+ LegacyMoveComparisonPairIntent or
+ LegacyDeleteSelectedComparisonPairIntent or
+ LegacyDeleteAllComparisonPairsIntent or
+ LegacySaveManualFinancialIntent or
+ LegacyDeleteManualFinancialIntent or
+ LegacyDeleteAllManualFinancialIntent or
+ LegacySaveManualNetSellIntent or
+ LegacySaveManualViIntent or
+ LegacyConfirmManualListIntent or
+ LegacyImportManualListsIntent or
+ LegacyCreateNamedPlaylistIntent or
+ LegacySaveCurrentNamedPlaylistIntent or
+ LegacySaveCurrentNamedPlaylistToIntent or
+ LegacyDeleteSelectedNamedPlaylistIntent;
+
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or
LegacyAddManualViResultIntent or
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
index c44b6f2..11c1900 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
+using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Windowing;
using Microsoft.Web.WebView2.Core;
using MMoneyCoderSharp.Data;
@@ -21,6 +22,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
+ private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
@@ -36,6 +38,10 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
+ // Capture and remove Gate A bearer material before XAML can create WebView2.
+ // Only the native process keeps the capability digest for this launch.
+ _playoutLaunchAuthorization =
+ PlayoutLaunchAuthorization.CaptureFromEnvironment();
_windowSubclassProcedure = OnWindowSubclassMessage;
InitializeComponent();
EnsureCloseConfirmationAttached();
@@ -72,6 +78,8 @@ public sealed partial class MainWindow : Window
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
DataQueryExecutor.Configure(_databaseRuntime.Executor);
+ var databaseMutationAuthorization =
+ new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
industrySelectionService = new LegacyIndustrySelectionService(
_databaseRuntime.Executor);
@@ -91,17 +99,23 @@ public sealed partial class MainWindow : Window
_databaseRuntime.Executor,
new OracleManualFinancialMutationExecutor(
_databaseRuntime.ConnectionFactory,
- _databaseRuntime.Options.Resilience));
+ _databaseRuntime.Options.Resilience,
+ errorDetector: null,
+ mutationAuthorization: databaseMutationAuthorization));
manualFinancialStockSearchService = new LegacyStockSearchService(
_databaseRuntime.Executor);
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
_databaseRuntime.Executor,
new OracleNamedPlaylistMutationExecutor(
_databaseRuntime.ConnectionFactory,
- _databaseRuntime.Options.Resilience));
+ _databaseRuntime.Options.Resilience,
+ errorDetector: null,
+ mutationAuthorization: databaseMutationAuthorization));
var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor(
_databaseRuntime.ConnectionFactory,
- _databaseRuntime.Options.Resilience);
+ _databaseRuntime.Options.Resilience,
+ errorDetector: null,
+ mutationAuthorization: databaseMutationAuthorization);
themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService(
_databaseRuntime.Executor,
operatorCatalogMutationExecutor);
@@ -377,6 +391,15 @@ public sealed partial class MainWindow : Window
return;
}
+ if (_playoutLaunchAuthorization.IsGateA &&
+ IsPersistentWriteIntent(intent))
+ {
+ PostState(ReportIntentFailure(
+ intent,
+ "Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다."));
+ return;
+ }
+
if (_controller.Current.Dialog?.IsConfirmation == true &&
intent is not LegacyReadyIntent and
not LegacyConfirmNativeDialogIntent and
@@ -469,6 +492,7 @@ public sealed partial class MainWindow : Window
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacyExecutePlayoutIntent or
+ LegacyGateAPrepareIntent or
LegacySetFadeDurationIntent or
LegacyToggleBackgroundIntent or
LegacyChooseBackgroundIntent)
@@ -961,6 +985,10 @@ public sealed partial class MainWindow : Window
await _controller.ConfirmManualListsAsync(cancellationToken),
LegacyImportManualListsIntent =>
await _controller.ImportLegacyManualListsAsync(cancellationToken),
+ LegacyGateAPrepareIntent prepare =>
+ await ExecuteGateAPrepareAsync(
+ prepare.Capability,
+ cancellationToken),
LegacyExecutePlayoutIntent playout =>
await ExecuteOperatorPlayoutAsync(
playout.Command,
@@ -1038,6 +1066,25 @@ public sealed partial class MainWindow : Window
outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
+ private sealed class LaunchDatabaseMutationAuthorization
+ : IDatabaseMutationAuthorization
+ {
+ private readonly PlayoutLaunchAuthorization _authorization;
+
+ public LaunchDatabaseMutationAuthorization(
+ PlayoutLaunchAuthorization authorization)
+ {
+ _authorization = authorization ??
+ throw new ArgumentNullException(nameof(authorization));
+ }
+
+ public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return _authorization.AllowsDatabaseWrites;
+ }
+ }
+
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs
index 1d0c998..b59d6fb 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs
@@ -71,12 +71,23 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
private readonly ILateBoundComActivator _activator;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
+ private readonly bool _rollbackOnPrepareFailure;
public DynamicK3dSessionFactory()
: this(
new InstalledK3dInteropComActivator(),
new InstalledK3dInteropMethodInvoker(),
- new InstalledK3dEventHandlerFactory())
+ new InstalledK3dEventHandlerFactory(),
+ rollbackOnPrepareFailure: true)
+ {
+ }
+
+ internal DynamicK3dSessionFactory(bool rollbackOnPrepareFailure)
+ : this(
+ new InstalledK3dInteropComActivator(),
+ new InstalledK3dInteropMethodInvoker(),
+ new InstalledK3dEventHandlerFactory(),
+ rollbackOnPrepareFailure)
{
}
@@ -84,32 +95,40 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
: this(
activator,
new InstalledK3dInteropMethodInvoker(),
- new ActivatorK3dEventHandlerFactory(activator))
+ new ActivatorK3dEventHandlerFactory(activator),
+ rollbackOnPrepareFailure: true)
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker)
- : this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator))
+ : this(
+ activator,
+ invoker,
+ new ActivatorK3dEventHandlerFactory(activator),
+ rollbackOnPrepareFailure: true)
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker,
- IK3dEventHandlerFactory eventHandlerFactory)
+ IK3dEventHandlerFactory eventHandlerFactory,
+ bool rollbackOnPrepareFailure = true)
{
_activator = activator;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
+ _rollbackOnPrepareFailure = rollbackOnPrepareFailure;
}
public IK3dSession Create() => new DynamicK3dSession(
_activator,
new RuntimeComObjectReleaser(),
_invoker,
- _eventHandlerFactory);
+ _eventHandlerFactory,
+ _rollbackOnPrepareFailure);
}
internal interface ILateBoundComActivator
@@ -155,6 +174,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
private readonly ILateBoundComObjectReleaser _releaser;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
+ private readonly bool _rollbackOnPrepareFailure;
private readonly K3dEventQueue _eventQueue = new();
private object? _engine;
private object? _eventHandler;
@@ -177,7 +197,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
activator,
new RuntimeComObjectReleaser(),
new InstalledK3dInteropMethodInvoker(),
- new ActivatorK3dEventHandlerFactory(activator))
+ new ActivatorK3dEventHandlerFactory(activator),
+ rollbackOnPrepareFailure: true)
{
}
@@ -204,12 +225,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
ILateBoundComActivator activator,
ILateBoundComObjectReleaser releaser,
ILateBoundComMethodInvoker invoker,
- IK3dEventHandlerFactory eventHandlerFactory)
+ IK3dEventHandlerFactory eventHandlerFactory,
+ bool rollbackOnPrepareFailure = true)
{
_activator = activator;
_releaser = releaser;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
+ _rollbackOnPrepareFailure = rollbackOnPrepareFailure;
}
public bool IsConnected => _engine is not null && _player is not null;
@@ -484,7 +507,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
catch
{
- if (transactionStarted)
+ if (transactionStarted && _rollbackOnPrepareFailure)
{
try
{
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs
index 67ac22d..7ec51cc 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs
@@ -1,32 +1,135 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
+using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Registration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using MBN_STOCK_WEBVIEW.Playout.Safety;
+using System.Net;
namespace MBN_STOCK_WEBVIEW.Playout;
public static class PlayoutEngineFactory
{
public static IPlayoutEngine CreateDefault() =>
- Create(PlayoutOptionsLoader.Load());
+ Create(
+ PlayoutOptionsLoader.Load(),
+ PlayoutLaunchAuthorization.CaptureFromEnvironment());
- public static IPlayoutEngine Create(PlayoutOptions options)
+ public static IPlayoutEngine Create(PlayoutOptions options) =>
+ Create(options, PlayoutLaunchAuthorization.CaptureFromEnvironment());
+
+ public static IPlayoutEngine Create(
+ PlayoutOptions options,
+ PlayoutLaunchAuthorization launchAuthorization)
{
ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(launchAuthorization);
+ ValidateGateAOptions(options, launchAuthorization);
var validated = ValidatedPlayoutOptions.Create(options);
+ ITornadoProcessProbe processProbe = new TornadoProcessProbe();
+ Func? finalConnectSafetyCheck = null;
+ var abandonOnTargetMismatch = false;
+ var startMonitor = validated.Mode != PlayoutMode.Disabled;
+
+ if (launchAuthorization.IsGateA)
+ {
+ var safetyProbe = new WindowsPgmDiagnosticSafetyProbe();
+ var request = new PgmConnectDiagnosticRequest(
+ validated.Host,
+ validated.Port,
+ "PGM");
+ var parsedHost = IPAddress.Parse(validated.Host);
+ var baseline = safetyProbe.Capture(request, parsedHost);
+ if (!baseline.IsEligible ||
+ baseline.Identity is not { } identity ||
+ identity.ProcessId != launchAuthorization.ExpectedPgmProcessId ||
+ identity.StartTimeUtcTicks !=
+ launchAuthorization.ExpectedPgmStartTimeUtcTicks)
+ {
+ throw new PlayoutConfigurationException(
+ "Gate A could not confirm the exact approved PGM process and listener.");
+ }
+
+ bool HasExactTarget()
+ {
+ try
+ {
+ var current = safetyProbe.Capture(request, parsedHost);
+ return current.IsEligible && baseline.HasSameTarget(current);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ processProbe = new PgmCutsSequenceProcessProbe(
+ safetyProbe,
+ request,
+ parsedHost,
+ baseline);
+ finalConnectSafetyCheck = HasExactTarget;
+ abandonOnTargetMismatch = true;
+ startMonitor = false;
+ }
+
IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live
? new StaDispatcher(validated.QueueCapacity)
: null;
return new TornadoPlayoutEngine(
validated,
- new DynamicK3dSessionFactory(),
+ new DynamicK3dSessionFactory(
+ rollbackOnPrepareFailure: !launchAuthorization.IsGateA),
new K3dRegistrationProbe(),
- new TornadoProcessProbe(),
+ processProbe,
dispatcher,
- new EnvironmentLiveAuthorization(),
- TimeProvider.System);
+ launchAuthorization,
+ TimeProvider.System,
+ startMonitor,
+ finalConnectSafetyCheck,
+ abandonOnTargetMismatch,
+ beforeSdkDispatchClaim: launchAuthorization.IsGateA
+ ? launchAuthorization.EnsureGateASdkDispatchAuthorized
+ : null,
+ launchAuthorization: launchAuthorization);
+ }
+
+ internal static void ValidateGateAOptions(
+ PlayoutOptions options,
+ PlayoutLaunchAuthorization launchAuthorization)
+ {
+ if (!launchAuthorization.IsGateA)
+ {
+ return;
+ }
+
+ var allowlist = (options.TestSceneAllowlist ?? [])
+ .Select(static value => value?.Trim())
+ .ToArray();
+ if (options.Mode != PlayoutMode.Live ||
+ !options.TrustedLiveOutputEnabled ||
+ !string.Equals(options.Host?.Trim(), "127.0.0.1", StringComparison.Ordinal) ||
+ options.Port != 30001 ||
+ options.TcpMode != 1 ||
+ options.ClientPort != 0 ||
+ options.OutputChannel is not null ||
+ options.LayoutIndex != 10 ||
+ !PlayoutLaunchAuthorization.IsExactGateASceneDirectory(
+ options.SceneDirectory) ||
+ allowlist.Length != 1 ||
+ !string.Equals(allowlist[0], "5001", StringComparison.Ordinal) ||
+ options.ReconnectEnabled ||
+ options.MaximumReconnectAttempts != 0 ||
+ options.MaximumAutomaticRefreshesPerTakeIn != 0 ||
+ options.LegacySceneFadeDuration != 6 ||
+ options.LegacySceneBackgroundKind != LegacySceneBackgroundKind.None ||
+ !string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath))
+ {
+ throw new PlayoutConfigurationException(
+ "Gate A requires the exact closed 5001-only Live configuration.");
+ }
}
///
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs b/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs
new file mode 100644
index 0000000..557265b
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Playout/Safety/PlayoutLaunchAuthorization.cs
@@ -0,0 +1,632 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using MBN_STOCK_WEBVIEW.Core.Playout;
+using MBN_STOCK_WEBVIEW.Playout.Configuration;
+using MBN_STOCK_WEBVIEW.Playout.Interop;
+
+namespace MBN_STOCK_WEBVIEW.Playout.Safety;
+
+///
+/// Process-lifetime authorization captured before WebView2 is created. Gate A is
+/// deliberately narrower than normal Live mode: one CONNECT and one claimed
+/// PREPARE of the approved 5001 cut are the only requests that can cross the
+/// native engine boundary.
+///
+public sealed class PlayoutLaunchAuthorization : ILiveAuthorization, IDisposable
+{
+ public const string GateACapabilityEnvironmentVariable =
+ "MBN_LEGACY_PGM_GATE_A_PREPARE_CAPABILITY";
+
+ public const string GateAPgmProcessIdEnvironmentVariable =
+ "MBN_LEGACY_PGM_GATE_A_PGM_PID";
+
+ public const string GateAPgmStartTimeUtcTicksEnvironmentVariable =
+ "MBN_LEGACY_PGM_GATE_A_PGM_START_TIME_UTC_TICKS";
+
+ public const string GateAExpiresAtUtcTicksEnvironmentVariable =
+ "MBN_LEGACY_PGM_GATE_A_EXPIRES_AT_UTC_TICKS";
+
+ public const string GateAApprovedSceneFilePath =
+ @"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts\5001.t2s";
+
+ public const string GateAApprovedSceneSha256 =
+ "99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7";
+
+ private const int GateAArmed = 0;
+ private const int GateAOperatorClaimed = 1;
+ private const int GateAEnginePrepareClaimed = 2;
+ private const int GateATerminal = 3;
+ private const int CapabilityLength = 64;
+ private static readonly long MaximumGateALifetimeTicks = TimeSpan.FromMinutes(15).Ticks;
+ private static readonly object GateACaptureSync = new();
+ private static bool _gateAObservedForProcess;
+
+ private readonly object _gateASync = new();
+ private readonly byte[]? _gateACapabilityHash;
+ private readonly TimeProvider _timeProvider;
+ private readonly long _authorizationCapturedTimestamp;
+ private readonly TimeSpan _authorizedLifetime;
+ private FileStream? _gateASceneLease;
+ private int _gateAState;
+ private bool _connectClaimed;
+ private bool _capabilityHashCleared;
+
+ private PlayoutLaunchAuthorization(
+ bool liveAuthorized,
+ byte[]? gateACapabilityHash,
+ int? expectedPgmProcessId,
+ long? expectedPgmStartTimeUtcTicks,
+ long? expiresAtUtcTicks,
+ TimeProvider timeProvider,
+ long authorizationCapturedTimestamp,
+ TimeSpan authorizedLifetime,
+ FileStream? gateASceneLease)
+ {
+ IsAuthorizedForThisLaunch = liveAuthorized;
+ _gateACapabilityHash = gateACapabilityHash;
+ ExpectedPgmProcessId = expectedPgmProcessId;
+ ExpectedPgmStartTimeUtcTicks = expectedPgmStartTimeUtcTicks;
+ ExpiresAtUtcTicks = expiresAtUtcTicks;
+ _timeProvider = timeProvider;
+ _authorizationCapturedTimestamp = authorizationCapturedTimestamp;
+ _authorizedLifetime = authorizedLifetime;
+ _gateASceneLease = gateASceneLease;
+ }
+
+ public bool IsAuthorizedForThisLaunch { get; }
+
+ public bool IsGateA => _gateACapabilityHash is not null;
+
+ public bool AllowsDatabaseWrites => !IsGateA;
+
+ public bool AllowsCompositionMutation => !IsGateA;
+
+ public int? ExpectedPgmProcessId { get; }
+
+ public long? ExpectedPgmStartTimeUtcTicks { get; }
+
+ public long? ExpiresAtUtcTicks { get; }
+
+ public static string GateAApprovedSceneDirectory =>
+ Path.GetDirectoryName(GateAApprovedSceneFilePath)!;
+
+ internal bool AllowsAutomaticReconnect => !IsGateA;
+
+ internal bool AllowsSdkDisconnect => !IsGateA;
+
+ internal static PlayoutLaunchAuthorization Unrestricted { get; } =
+ new(
+ liveAuthorized: false,
+ gateACapabilityHash: null,
+ expectedPgmProcessId: null,
+ expectedPgmStartTimeUtcTicks: null,
+ expiresAtUtcTicks: null,
+ TimeProvider.System,
+ authorizationCapturedTimestamp: 0,
+ authorizedLifetime: TimeSpan.Zero,
+ gateASceneLease: null);
+
+ ///
+ /// Captures all authorization material once, removes the bearer values from
+ /// the process environment, then acquires and hashes the approved cut through
+ /// a read-only/non-write-sharing handle. The handle remains owned by this
+ /// authorization for the native application lifetime.
+ ///
+ public static PlayoutLaunchAuthorization CaptureFromEnvironment() =>
+ CaptureFromEnvironment(
+ TimeProvider.System,
+ GateAApprovedSceneFilePath,
+ GateAApprovedSceneSha256);
+
+ internal static PlayoutLaunchAuthorization CaptureFromEnvironment(
+ TimeProvider timeProvider,
+ string approvedSceneFilePath,
+ string approvedSceneSha256)
+ {
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneFilePath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(approvedSceneSha256);
+
+ lock (GateACaptureSync)
+ {
+ return CaptureFromEnvironmentLocked(
+ timeProvider,
+ approvedSceneFilePath,
+ approvedSceneSha256);
+ }
+ }
+
+ internal static void ResetGateACaptureLatchForTests()
+ {
+ lock (GateACaptureSync)
+ {
+ _gateAObservedForProcess = false;
+ }
+ }
+
+ private static PlayoutLaunchAuthorization CaptureFromEnvironmentLocked(
+ TimeProvider timeProvider,
+ string approvedSceneFilePath,
+ string approvedSceneSha256)
+ {
+ var liveAuthorized = PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch();
+ var capability = Environment.GetEnvironmentVariable(
+ GateACapabilityEnvironmentVariable,
+ EnvironmentVariableTarget.Process);
+ var processIdText = Environment.GetEnvironmentVariable(
+ GateAPgmProcessIdEnvironmentVariable,
+ EnvironmentVariableTarget.Process);
+ var startTimeText = Environment.GetEnvironmentVariable(
+ GateAPgmStartTimeUtcTicksEnvironmentVariable,
+ EnvironmentVariableTarget.Process);
+ var expiresAtText = Environment.GetEnvironmentVariable(
+ GateAExpiresAtUtcTicksEnvironmentVariable,
+ EnvironmentVariableTarget.Process);
+ var anyGateAValue = capability is not null ||
+ processIdText is not null ||
+ startTimeText is not null ||
+ expiresAtText is not null;
+ var gateAWasAlreadyObserved = _gateAObservedForProcess;
+ if (anyGateAValue)
+ {
+ // Latch before validation, cleanup, or file I/O so even an exception in
+ // those paths cannot make a second process-local capture eligible.
+ _gateAObservedForProcess = true;
+ }
+
+ ClearGateAEnvironment();
+ if (gateAWasAlreadyObserved || anyGateAValue)
+ {
+ ClearLiveAuthorizationEnvironment();
+ }
+
+ if (gateAWasAlreadyObserved)
+ {
+ throw new PlayoutConfigurationException(
+ "Gate A launch authorization was already observed by this process.");
+ }
+
+ if (!anyGateAValue)
+ {
+ return new PlayoutLaunchAuthorization(
+ liveAuthorized,
+ gateACapabilityHash: null,
+ expectedPgmProcessId: null,
+ expectedPgmStartTimeUtcTicks: null,
+ expiresAtUtcTicks: null,
+ timeProvider,
+ authorizationCapturedTimestamp: 0,
+ authorizedLifetime: TimeSpan.Zero,
+ gateASceneLease: null);
+ }
+
+ // Observing any Gate A launch value permanently consumes the process-wide
+ // capture slot before validation or file I/O. A malformed, expired, or
+ // otherwise rejected first attempt must never be corrected or re-injected
+ // within the same native process.
+ // The already-captured Live decision is retained only in the returned native
+ // object. Its static bearer was removed above before validation or file I/O.
+
+ var nowUtcTicks = timeProvider.GetUtcNow().UtcTicks;
+ var capturedTimestamp = timeProvider.GetTimestamp();
+ if (!IsExactCapability(capability) ||
+ !int.TryParse(
+ processIdText,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var processId) ||
+ processId <= 0 ||
+ !long.TryParse(
+ startTimeText,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var startTimeUtcTicks) ||
+ startTimeUtcTicks <= 0 ||
+ !long.TryParse(
+ expiresAtText,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var expiresAtUtcTicks) ||
+ expiresAtUtcTicks <= nowUtcTicks ||
+ expiresAtUtcTicks - nowUtcTicks > MaximumGateALifetimeTicks)
+ {
+ throw new PlayoutConfigurationException(
+ "Gate A launch authorization is incomplete, expired, or invalid.");
+ }
+
+ var sceneLease = AcquireApprovedSceneLease(
+ approvedSceneFilePath,
+ approvedSceneSha256);
+ return new PlayoutLaunchAuthorization(
+ liveAuthorized,
+ HashCapability(capability!),
+ processId,
+ startTimeUtcTicks,
+ expiresAtUtcTicks,
+ timeProvider,
+ capturedTimestamp,
+ TimeSpan.FromTicks(expiresAtUtcTicks - nowUtcTicks),
+ sceneLease);
+ }
+
+ ///
+ /// Atomically consumes the browser-to-native PREPARE capability. Invalid
+ /// values do not consume it. Expiry is checked immediately before the state
+ /// transition and permanently closes the gate.
+ ///
+ public bool TryClaimGateAPrepare(string? capability)
+ {
+ if (!IsGateA || !IsExactCapability(capability))
+ {
+ return false;
+ }
+
+ var candidateHash = HashCapability(capability!);
+ try
+ {
+ lock (_gateASync)
+ {
+ if (TerminateIfExpiredLocked() ||
+ _gateAState != GateAArmed ||
+ !CryptographicOperations.FixedTimeEquals(
+ candidateHash,
+ _gateACapabilityHash!))
+ {
+ return false;
+ }
+
+ // Re-read native time at the exact state boundary. A claim at the
+ // expiry tick is rejected and can never be rearmed.
+ if (TerminateIfExpiredLocked())
+ {
+ return false;
+ }
+
+ _gateAState = GateAOperatorClaimed;
+ return true;
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(candidateHash);
+ }
+ }
+
+ ///
+ /// Permanently closes the PREPARE capability after the native request,
+ /// regardless of success, rejection, cancellation, timeout, or unknown outcome.
+ /// The approved cut lease intentionally remains held until native shutdown.
+ ///
+ public void CompleteGateAPrepare()
+ {
+ if (!IsGateA)
+ {
+ return;
+ }
+
+ lock (_gateASync)
+ {
+ EnterTerminalLocked();
+ }
+ }
+
+ internal bool TryClaimEngineCommand(
+ PlayoutOperation operation,
+ PlayoutCue? cue)
+ {
+ if (!IsGateA)
+ {
+ return true;
+ }
+
+ lock (_gateASync)
+ {
+ if (TerminateIfExpiredLocked())
+ {
+ return false;
+ }
+
+ switch (operation)
+ {
+ case PlayoutOperation.Connect when !_connectClaimed:
+ _connectClaimed = true;
+ return true;
+ case PlayoutOperation.Prepare:
+ return TryClaimEnginePrepareLocked(cue);
+ default:
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Final native guard invoked on the STA immediately before an SDK dispatch
+ /// latch can be claimed. This closes the registration/queue-delay window after
+ /// the outer command claim. Expiry or a terminal gate is reported as a safety
+ /// exception so the engine quarantines without issuing cleanup commands.
+ ///
+ internal void EnsureGateASdkDispatchAuthorized()
+ {
+ if (!IsGateA)
+ {
+ return;
+ }
+
+ lock (_gateASync)
+ {
+ if (TerminateIfExpiredLocked() || _gateAState == GateATerminal)
+ {
+ throw new K3dConnectSafetyException();
+ }
+ }
+ }
+
+ internal bool CanTakeIn => !IsGateA;
+
+ internal string RejectionMessage(PlayoutOperation operation) =>
+ IsGateA
+ ? $"Gate A does not authorize another {operation} command for this process."
+ : "The playout command is not authorized for this launch.";
+
+ internal static PlayoutLaunchAuthorization CreateGateAForTests(
+ string capability,
+ bool liveAuthorized = true,
+ int expectedPgmProcessId = 1,
+ long expectedPgmStartTimeUtcTicks = 1,
+ TimeProvider? timeProvider = null,
+ long? expiresAtUtcTicks = null,
+ FileStream? gateASceneLease = null)
+ {
+ if (!IsExactCapability(capability))
+ {
+ throw new ArgumentException(
+ "A test Gate A capability must be exactly 64 uppercase hexadecimal characters.",
+ nameof(capability));
+ }
+
+ var clock = timeProvider ?? TimeProvider.System;
+ var nowUtcTicks = clock.GetUtcNow().UtcTicks;
+ var expiry = expiresAtUtcTicks ??
+ nowUtcTicks + MaximumGateALifetimeTicks;
+ return new PlayoutLaunchAuthorization(
+ liveAuthorized,
+ HashCapability(capability),
+ expectedPgmProcessId,
+ expectedPgmStartTimeUtcTicks,
+ expiry,
+ clock,
+ clock.GetTimestamp(),
+ TimeSpan.FromTicks(Math.Max(0, expiry - nowUtcTicks)),
+ gateASceneLease);
+ }
+
+ internal static bool IsExactGateASceneDirectory(string? directory)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ return false;
+ }
+
+ try
+ {
+ return string.Equals(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)),
+ Path.TrimEndingDirectorySeparator(
+ Path.GetFullPath(GateAApprovedSceneDirectory)),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch (Exception exception) when (exception is
+ ArgumentException or
+ NotSupportedException or
+ PathTooLongException)
+ {
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (IsGateA)
+ {
+ lock (_gateASync)
+ {
+ EnterTerminalLocked();
+ }
+ }
+
+ Interlocked.Exchange(ref _gateASceneLease, null)?.Dispose();
+ }
+
+ private bool TryClaimEnginePrepareLocked(PlayoutCue? cue)
+ {
+ if (_gateAState != GateAOperatorClaimed)
+ {
+ return false;
+ }
+
+ if (!IsExactGateAPrepareCue(cue))
+ {
+ EnterTerminalLocked();
+ return false;
+ }
+
+ if (TerminateIfExpiredLocked())
+ {
+ return false;
+ }
+
+ _gateAState = GateAEnginePrepareClaimed;
+ return true;
+ }
+
+ private bool TerminateIfExpiredLocked()
+ {
+ if (ExpiresAtUtcTicks is not { } expiry)
+ {
+ return false;
+ }
+
+ var expired = _timeProvider.GetUtcNow().UtcTicks >= expiry;
+ if (!expired)
+ {
+ try
+ {
+ expired = _timeProvider.GetElapsedTime(
+ _authorizationCapturedTimestamp,
+ _timeProvider.GetTimestamp()) >= _authorizedLifetime;
+ }
+ catch
+ {
+ // A broken monotonic clock must never extend live authority.
+ expired = true;
+ }
+ }
+
+ if (!expired)
+ {
+ return false;
+ }
+
+ EnterTerminalLocked();
+ return true;
+ }
+
+ private void EnterTerminalLocked()
+ {
+ _gateAState = GateATerminal;
+ if (!_capabilityHashCleared && _gateACapabilityHash is not null)
+ {
+ CryptographicOperations.ZeroMemory(_gateACapabilityHash);
+ _capabilityHashCleared = true;
+ }
+ }
+
+ private static bool IsExactGateAPrepareCue(PlayoutCue? cue)
+ {
+ if (cue is null ||
+ !string.Equals(cue.SceneName, "5001", StringComparison.Ordinal) ||
+ !string.Equals(cue.SceneFile, "5001.t2s", StringComparison.Ordinal) ||
+ cue.FadeDuration != 6)
+ {
+ return false;
+ }
+
+ var disabledBackgroundCount = 0;
+ foreach (var mutation in cue.Mutations ?? [])
+ {
+ switch (mutation)
+ {
+ case PlayoutUseBackground
+ {
+ IsEnabled: false,
+ Timing: PlayoutMutationTiming.BeforeTransaction
+ }:
+ disabledBackgroundCount++;
+ break;
+ case PlayoutUseBackground or
+ PlayoutSetBackgroundTexture or
+ PlayoutSetBackgroundVideo:
+ return false;
+ }
+ }
+
+ return disabledBackgroundCount == 1;
+ }
+
+ private static bool IsExactCapability(string? value) =>
+ value is { Length: CapabilityLength } && value.All(static character =>
+ character is >= '0' and <= '9' or >= 'A' and <= 'F');
+
+ private static byte[] HashCapability(string capability) =>
+ SHA256.HashData(Encoding.ASCII.GetBytes(capability));
+
+ private static FileStream AcquireApprovedSceneLease(
+ string sceneFilePath,
+ string expectedSha256)
+ {
+ if (!IsExactCapability(expectedSha256))
+ {
+ throw new PlayoutConfigurationException(
+ "The approved Gate A scene hash is invalid.");
+ }
+
+ FileStream? lease = null;
+ byte[]? expectedHash = null;
+ byte[]? actualHash = null;
+ try
+ {
+ lease = new FileStream(
+ Path.GetFullPath(sceneFilePath),
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read,
+ bufferSize: 64 * 1024,
+ FileOptions.SequentialScan);
+ expectedHash = Convert.FromHexString(expectedSha256);
+ actualHash = SHA256.HashData(lease);
+ lease.Position = 0;
+ if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
+ {
+ throw new PlayoutConfigurationException(
+ "The approved Gate A scene hash does not match the leased file.");
+ }
+
+ return lease;
+ }
+ catch (PlayoutConfigurationException)
+ {
+ lease?.Dispose();
+ throw;
+ }
+ catch (Exception exception) when (exception is
+ IOException or
+ UnauthorizedAccessException or
+ ArgumentException or
+ NotSupportedException)
+ {
+ lease?.Dispose();
+ throw new PlayoutConfigurationException(
+ "The approved Gate A scene could not be leased and verified.",
+ exception);
+ }
+ finally
+ {
+ if (expectedHash is not null)
+ {
+ CryptographicOperations.ZeroMemory(expectedHash);
+ }
+
+ if (actualHash is not null)
+ {
+ CryptographicOperations.ZeroMemory(actualHash);
+ }
+ }
+ }
+
+ private static void ClearGateAEnvironment()
+ {
+ Environment.SetEnvironmentVariable(
+ GateACapabilityEnvironmentVariable,
+ null,
+ EnvironmentVariableTarget.Process);
+ Environment.SetEnvironmentVariable(
+ GateAPgmProcessIdEnvironmentVariable,
+ null,
+ EnvironmentVariableTarget.Process);
+ Environment.SetEnvironmentVariable(
+ GateAPgmStartTimeUtcTicksEnvironmentVariable,
+ null,
+ EnvironmentVariableTarget.Process);
+ Environment.SetEnvironmentVariable(
+ GateAExpiresAtUtcTicksEnvironmentVariable,
+ null,
+ EnvironmentVariableTarget.Process);
+ }
+
+ private static void ClearLiveAuthorizationEnvironment() =>
+ Environment.SetEnvironmentVariable(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ null,
+ EnvironmentVariableTarget.Process);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs
index 73342d3..b6d2002 100644
--- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs
+++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs
@@ -18,6 +18,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private readonly ITornadoProcessProbe _processProbe;
private readonly IStaDispatcher? _dispatcher;
private readonly ILiveAuthorization _liveAuthorization;
+ private readonly PlayoutLaunchAuthorization _launchAuthorization;
private readonly TimeProvider _timeProvider;
private readonly Func? _finalConnectSafetyCheck;
private readonly bool _abandonOnTargetMismatch;
@@ -55,7 +56,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
bool startMonitor = true,
Func? finalConnectSafetyCheck = null,
bool abandonOnTargetMismatch = false,
- Action? beforeSdkDispatchClaim = null)
+ Action? beforeSdkDispatchClaim = null,
+ PlayoutLaunchAuthorization? launchAuthorization = null)
{
_options = options;
_sessionFactory = sessionFactory;
@@ -63,6 +65,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_processProbe = processProbe;
_dispatcher = dispatcher;
_liveAuthorization = liveAuthorization;
+ _launchAuthorization = launchAuthorization ??
+ PlayoutLaunchAuthorization.Unrestricted;
_timeProvider = timeProvider;
_finalConnectSafetyCheck = finalConnectSafetyCheck;
_abandonOnTargetMismatch = abandonOnTargetMismatch;
@@ -102,7 +106,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return SerializedAsync(
PlayoutOperation.Prepare,
cancellationToken,
- token => PrepareCoreAsync(cue, token));
+ token => PrepareCoreAsync(cue, token),
+ cue);
}
public Task TakeInAsync(CancellationToken cancellationToken = default) =>
@@ -116,7 +121,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return SerializedAsync(
PlayoutOperation.Next,
cancellationToken,
- token => NextCoreAsync(cue, token));
+ token => NextCoreAsync(cue, token),
+ cue);
}
public Task UpdateOnAirAsync(
@@ -127,7 +133,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return SerializedAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken,
- token => UpdateOnAirCoreAsync(cue, token));
+ token => UpdateOnAirCoreAsync(cue, token),
+ cue);
}
public Task TakeOutAsync(
@@ -337,7 +344,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return;
}
- if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
+ if (_session is not null || !_connectionRequested ||
+ !_launchAuthorization.AllowsAutomaticReconnect ||
+ !_options.ReconnectEnabled ||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
_timeProvider.GetUtcNow() < _nextReconnectAt)
{
@@ -362,7 +371,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private async Task SerializedAsync(
PlayoutOperation operation,
CancellationToken cancellationToken,
- Func> callback)
+ Func> callback,
+ PlayoutCue? cue = null)
{
try
{
@@ -407,6 +417,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return ExistingOutcomeUnknown(operation);
}
+ if (!_launchAuthorization.TryClaimEngineCommand(operation, cue))
+ {
+ return Result(
+ operation,
+ PlayoutResultCode.Rejected,
+ _launchAuthorization.RejectionMessage(operation));
+ }
+
return await callback(cancellationToken).ConfigureAwait(false);
}
finally
@@ -1412,6 +1430,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return false;
}
+ if (_session is not null && !_launchAuthorization.AllowsSdkDisconnect)
+ {
+ await AbandonCurrentSessionAsync().ConfigureAwait(false);
+ return false;
+ }
+
var session = _session;
if (session?.HasPendingLifecycleCallbacks == true)
{
@@ -1526,6 +1550,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
IK3dSession session,
CancellationToken cancellationToken)
{
+ if (!_launchAuthorization.AllowsSdkDisconnect)
+ {
+ throw new K3dConnectSafetyException();
+ }
+
var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim);
return _dispatcher!.InvokeAsync(
() =>
@@ -1759,7 +1788,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private bool CanTakeIn() => _options.Mode switch
{
PlayoutMode.Test => true,
- PlayoutMode.Live => IsLiveAuthorized(),
+ PlayoutMode.Live =>
+ IsLiveAuthorized() && _launchAuthorization.CanTakeIn,
_ => false
};
diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs
new file mode 100644
index 0000000..653c422
--- /dev/null
+++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/DatabaseMutationAuthorizationTestDoubles.cs
@@ -0,0 +1,15 @@
+#nullable enable
+
+namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
+
+internal sealed class DenyAllDatabaseMutationAuthorization
+ : IDatabaseMutationAuthorization
+{
+ internal List Requests { get; } = [];
+
+ public bool IsAuthorized(DatabaseMutationAuthorizationRequest request)
+ {
+ Requests.Add(request);
+ return false;
+ }
+}
diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs
index 2356eaf..fc869c4 100644
--- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs
@@ -290,8 +290,37 @@ public sealed class OperatorCatalogMutationExecutorTests
Assert.Equal(0, plan.BeginCount);
}
+ [Fact]
+ public async Task ExecuteTransactionAsync_DeniedAuthorizationCreatesNoConnectionOrTransaction()
+ {
+ var plan = new CatalogMutationConnectionPlan();
+ var factory = new CatalogMutationConnectionFactory(plan);
+ var authorization = new DenyAllDatabaseMutationAuthorization();
+ var service = new LegacyExpertCatalogPersistenceService(
+ new CatalogNoQueryExecutor(),
+ CreateExecutor(factory, authorization));
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.CreateAsync(new ExpertCatalogDefinition(
+ new ExpertSelectionIdentity("0001", "Expert"),
+ [])));
+
+ Assert.False(exception.OutcomeUnknown);
+ var denied = Assert.IsType(
+ exception.InnerException);
+ Assert.Equal(DatabaseMutationTarget.OperatorCatalog, denied.Request.Target);
+ Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
+ Assert.Equal(nameof(OperatorCatalogMutationKind.CreateExpert), denied.Request.MutationKind);
+ Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
+ Assert.Single(authorization.Requests);
+ Assert.Equal(0, factory.CreateCount);
+ Assert.Equal(0, plan.BeginCount);
+ Assert.Empty(plan.Commands);
+ }
+
private static OperatorCatalogMutationExecutor CreateExecutor(
- IDatabaseConnectionFactory factory) =>
+ IDatabaseConnectionFactory factory,
+ IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -301,7 +330,8 @@ public sealed class OperatorCatalogMutationExecutorTests
InitialRetryDelayMilliseconds = 1
},
new StubErrorDetector(transient: true),
- static (_, _) => { });
+ static (_, _) => { },
+ mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static ObservedCatalogMutationParameter Parameter(
ObservedCatalogMutationCommand command,
diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs
index 28969ac..7462cff 100644
--- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs
@@ -68,6 +68,31 @@ public sealed class OracleManualFinancialMutationExecutorTests
Assert.True(plan.TransactionDisposed);
}
+ [Fact]
+ public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction()
+ {
+ var plan = new Plan();
+ var factory = new Factory(plan);
+ var authorization = new DenyAllDatabaseMutationAuthorization();
+ var service = Service(Executor(factory, mutationAuthorization: authorization));
+
+ var exception = await Assert.ThrowsAsync(() =>
+ service.CreateAsync(Sales("Alpha")));
+
+ Assert.False(exception.OutcomeUnknown);
+ var denied = Assert.IsType(
+ exception.InnerException);
+ Assert.Equal(DatabaseMutationTarget.ManualFinancial, denied.Request.Target);
+ Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
+ Assert.Equal(nameof(ManualFinancialMutationKind.Create), denied.Request.MutationKind);
+ Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
+ Assert.Single(authorization.Requests);
+ Assert.Equal(0, factory.CreateCount);
+ Assert.Equal(0, plan.OpenCount);
+ Assert.Equal(0, plan.BeginCount);
+ Assert.Empty(plan.Commands);
+ }
+
[Fact]
public async Task Zero_affected_create_rolls_back_as_known_duplicate_before_commit()
{
@@ -141,7 +166,8 @@ public sealed class OracleManualFinancialMutationExecutorTests
private static OracleManualFinancialMutationExecutor Executor(
Factory factory,
- int operationTimeoutSeconds = 30) =>
+ int operationTimeoutSeconds = 30,
+ IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -151,7 +177,8 @@ public sealed class OracleManualFinancialMutationExecutorTests
InitialRetryDelayMilliseconds = 30_000
},
new StubErrorDetector(false),
- command => Assert.IsType(command).BindByName = true);
+ command => Assert.IsType(command).BindByName = true,
+ mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static ManualSalesRecord Sales(string stockName) =>
new(
diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs
index 9b056c0..794e376 100644
--- a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs
@@ -283,6 +283,31 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
Assert.Empty(plan.Commands);
}
+ [Fact]
+ public async Task DeniedAuthorizationCreatesNoConnectionOrTransaction()
+ {
+ var plan = new MutationPlan();
+ var factory = new MutationConnectionFactory(plan);
+ var authorization = new DenyAllDatabaseMutationAuthorization();
+ var service = CreateService(CreateExecutor(factory, mutationAuthorization: authorization));
+
+ var exception = await Assert.ThrowsAsync(
+ () => service.CreateDefinitionAsync("00000011", "Morning"));
+
+ Assert.False(exception.OutcomeUnknown);
+ var denied = Assert.IsType(
+ exception.InnerException);
+ Assert.Equal(DatabaseMutationTarget.NamedPlaylist, denied.Request.Target);
+ Assert.Equal(DataSourceKind.Oracle, denied.Request.Source);
+ Assert.Equal(nameof(NamedPlaylistMutationKind.CreateDefinition), denied.Request.MutationKind);
+ Assert.NotEqual(Guid.Empty, denied.Request.OperationId);
+ Assert.Single(authorization.Requests);
+ Assert.Equal(0, factory.CreateCount);
+ Assert.Equal(0, plan.OpenCount);
+ Assert.Equal(0, plan.BeginCount);
+ Assert.Empty(plan.Commands);
+ }
+
[Fact]
public void Constructor_RejectsInvalidCommandTimeout()
{
@@ -296,7 +321,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
private static OracleNamedPlaylistMutationExecutor CreateExecutor(
MutationConnectionFactory factory,
- int operationTimeoutSeconds = 30) =>
+ int operationTimeoutSeconds = 30,
+ IDatabaseMutationAuthorization? mutationAuthorization = null) =>
new(
factory,
new DatabaseResilienceOptions
@@ -306,7 +332,8 @@ public sealed class OracleNamedPlaylistMutationExecutorTests
InitialRetryDelayMilliseconds = 30_000
},
new TransientDatabaseErrorDetector(),
- command => Assert.IsType(command).BindByName = true);
+ command => Assert.IsType(command).BindByName = true,
+ mutationAuthorization ?? DatabaseMutationAuthorization.AllowAll);
private static LegacyNamedPlaylistPersistenceService CreateService(
INamedPlaylistMutationExecutor mutations) =>
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs
index 99269d5..3770101 100644
--- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs
@@ -8,6 +8,47 @@ namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyPlayoutBridgeTests
{
+ private const string GateACapability =
+ "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+
+ [Fact]
+ public void GateAPrepare_ParsesOnlyExactUppercaseCapabilityShape()
+ {
+ Assert.True(LegacyBridgeProtocol.TryParseIntent(
+ "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" +
+ GateACapability + "\"}}",
+ out var intent,
+ out var error), error);
+
+ Assert.Equal(
+ GateACapability,
+ Assert.IsType(intent).Capability);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
+ [InlineData("0123456789ABCDEF")]
+ [InlineData("G123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF")]
+ public void GateAPrepare_RejectsInvalidCapabilities(string capability)
+ {
+ Assert.False(LegacyBridgeProtocol.TryParseIntent(
+ "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" +
+ capability + "\"}}",
+ out _,
+ out _));
+ }
+
+ [Fact]
+ public void GateAPrepare_RejectsExtraProperties()
+ {
+ Assert.False(LegacyBridgeProtocol.TryParseIntent(
+ "{\"type\":\"gate-a-prepare\",\"payload\":{\"capability\":\"" +
+ GateACapability + "\",\"retry\":false}}",
+ out _,
+ out _));
+ }
+
[Theory]
[InlineData("prepare-playout", LegacyOperatorPlayoutCommand.Prepare)]
[InlineData("take-in", LegacyOperatorPlayoutCommand.TakeIn)]
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs
new file mode 100644
index 0000000..02f2ec2
--- /dev/null
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAAppIntegrationContractTests.cs
@@ -0,0 +1,361 @@
+namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
+
+public sealed class LegacyGateAAppIntegrationContractTests
+{
+ private static readonly string RepositoryRoot = FindRepositoryRoot();
+ private static readonly string AppRoot = Path.Combine(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.LegacyParityApp");
+ private static readonly string Window = Read(AppRoot, "MainWindow.xaml.cs");
+ private static readonly string Playout = Read(AppRoot, "MainWindow.Playout.cs");
+ private static readonly string IntentParser = Read(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.LegacyBridge",
+ "LegacyPlayoutUiIntents.cs");
+ private static readonly string Factory = Read(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.Playout",
+ "PlayoutEngineFactory.cs");
+ private static readonly string LaunchAuthorization = Read(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.Playout",
+ "Safety",
+ "PlayoutLaunchAuthorization.cs");
+ private static readonly string InfrastructureRoot = Path.Combine(
+ RepositoryRoot,
+ "src",
+ "MBN_STOCK_WEBVIEW.Infrastructure",
+ "Execution");
+
+ [Fact]
+ public void Launch_authorization_is_captured_before_WebView_creation_and_forwarded_unchanged()
+ {
+ var constructor = Slice(
+ Window,
+ " public MainWindow()",
+ " private async void OnRootLoaded(");
+ var capture = RequiredIndex(
+ constructor,
+ "PlayoutLaunchAuthorization.CaptureFromEnvironment()");
+ var initializeComponent = RequiredIndex(constructor, "InitializeComponent();");
+
+ Assert.True(capture < initializeComponent);
+ Assert.Contains(
+ "_playoutLaunchAuthorization =\n" +
+ " PlayoutLaunchAuthorization.CaptureFromEnvironment();",
+ Normalize(constructor),
+ StringComparison.Ordinal);
+
+ var initialization = Normalize(Slice(
+ Playout,
+ " private void InitializePlayoutRuntime()",
+ " private async Task ConnectPlayoutAsync("));
+ Assert.Contains(
+ "_playoutEngine = PlayoutEngineFactory.Create(\n" +
+ " _playoutOptions,\n" +
+ " _playoutLaunchAuthorization);",
+ initialization,
+ StringComparison.Ordinal);
+
+ var factoryCreate = Slice(
+ Normalize(Factory),
+ " public static IPlayoutEngine Create(\n" +
+ " PlayoutOptions options,\n" +
+ " PlayoutLaunchAuthorization launchAuthorization)",
+ " internal static void ValidateGateAOptions(");
+ Assert.Contains("dispatcher,\n launchAuthorization,", factoryCreate,
+ StringComparison.Ordinal);
+ Assert.Contains("launchAuthorization: launchAuthorization", factoryCreate,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Gate_A_has_a_dedicated_capability_intent_and_exact_Samsung_5001_prepare_handler()
+ {
+ Assert.Contains(
+ "public sealed record LegacyGateAPrepareIntent(string Capability)",
+ IntentParser,
+ StringComparison.Ordinal);
+ Assert.Contains("\"gate-a-prepare\" => ParseGateAPrepare(payload)", IntentParser,
+ StringComparison.Ordinal);
+ var parser = Slice(
+ IntentParser,
+ " private static LegacyUiIntent? ParseGateAPrepare(JsonElement payload)",
+ " private static LegacyUiIntent? ParseFadeDuration(JsonElement payload)");
+ Assert.Contains("payload.EnumerateObject().Count() != 1", parser,
+ StringComparison.Ordinal);
+ Assert.Contains("capability.Length != 64", parser, StringComparison.Ordinal);
+ Assert.Contains("not (>= 'A' and <= 'F')", parser, StringComparison.Ordinal);
+
+ var dispatch = Normalize(Slice(
+ Window,
+ " private async Task HandleIntentAsync(",
+ " private async Task RefreshNamedPlaylistsForDispatchAsync("));
+ Assert.Contains(
+ "LegacyGateAPrepareIntent prepare =>\n" +
+ " await ExecuteGateAPrepareAsync(\n" +
+ " prepare.Capability,",
+ dispatch,
+ StringComparison.Ordinal);
+
+ var handler = Slice(
+ Playout,
+ " private async Task ExecuteGateAPrepareAsync(",
+ " private bool TryCreateExactGateAPrepareRequest(");
+ Assert.Contains("TryClaimGateAPrepare(capability)", handler,
+ StringComparison.Ordinal);
+ Assert.Contains("TryCreateExactGateAPrepareRequest(out var request)", handler,
+ StringComparison.Ordinal);
+ Assert.Contains("gateAPrepareClaimed: true", handler, StringComparison.Ordinal);
+ Assert.Contains("gateAPrepareRequest: request", handler, StringComparison.Ordinal);
+ Assert.Contains("CompleteGateAPrepare()", handler, StringComparison.Ordinal);
+
+ var exact = Slice(
+ Playout,
+ " private bool TryCreateExactGateAPrepareRequest(",
+ " private async Task SetFadeDurationAsync(");
+ foreach (var required in new[]
+ {
+ "snapshot.Playlist.Count != 1",
+ "composition.FadeDuration != 6",
+ "composition.BackgroundKind != LegacySceneBackgroundKind.None",
+ "row.MarketText, \"코스피\"",
+ "row.StockName, \"삼성전자\"",
+ "row.PageText, \"1/1\"",
+ "row.StockCode, \"005930\"",
+ "_controller.CreatePlayoutRequest(6)",
+ "candidate.SelectedIndexZeroBased != 0",
+ "entry.CutCode, \"5001\"",
+ "entry.FadeDuration != 6",
+ "entry.PageNavigation?.IsEnabled == true",
+ "selection.GroupCode, \"코스피\"",
+ "selection.Subject, \"삼성전자\"",
+ "selection.DataCode, \"005930\""
+ })
+ {
+ Assert.Contains(required, exact, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void Ordinary_playout_intents_are_rejected_in_Gate_A_before_workflow_access()
+ {
+ var execute = Slice(
+ Playout,
+ " private async Task ExecuteOperatorPlayoutAsync(",
+ " private async Task ExecuteGateAPrepareAsync(");
+ var workflowRead = RequiredIndex(execute, "var workflow = _playoutWorkflow;");
+ var preWorkflowGuard = execute[..workflowRead];
+
+ Assert.Contains("_playoutLaunchAuthorization.IsGateA", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.Contains("!gateAPrepareClaimed", preWorkflowGuard, StringComparison.Ordinal);
+ Assert.Contains("command != LegacyOperatorPlayoutCommand.Prepare", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.Contains("gateAPrepareRequest is null", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.Contains("return _controller.ReportError(", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("_playoutWorkflow", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("CreatePlayoutRequest", preWorkflowGuard,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("_playoutCommandGate.WaitAsync", preWorkflowGuard,
+ StringComparison.Ordinal);
+
+ var dispatch = Slice(
+ Window,
+ " private async Task HandleIntentAsync(",
+ " private async Task RefreshNamedPlaylistsForDispatchAsync(");
+ Assert.Contains("LegacyExecutePlayoutIntent playout =>", dispatch,
+ StringComparison.Ordinal);
+ Assert.Contains("ExecuteOperatorPlayoutAsync(\n playout.Command,",
+ Normalize(dispatch), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void All_database_mutation_executors_receive_the_same_launch_denial_policy()
+ {
+ var constructor = Slice(
+ Window,
+ " public MainWindow()",
+ " private async void OnRootLoaded(");
+ Assert.Contains(
+ "new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization)",
+ constructor,
+ StringComparison.Ordinal);
+ Assert.True(
+ CountOccurrences(
+ constructor,
+ "mutationAuthorization: databaseMutationAuthorization") == 3,
+ "The same launch authorization must be injected into all three DB mutation executors.");
+
+ var adapter = Slice(
+ Window,
+ " private sealed class LaunchDatabaseMutationAuthorization",
+ " private void PostState(");
+ Assert.Contains(": IDatabaseMutationAuthorization", adapter,
+ StringComparison.Ordinal);
+ Assert.Contains("return _authorization.AllowsDatabaseWrites;", adapter,
+ StringComparison.Ordinal);
+ Assert.Contains("_authorization = authorization ??", adapter,
+ StringComparison.Ordinal);
+
+ foreach (var executorFile in new[]
+ {
+ "OracleManualFinancialMutationExecutor.cs",
+ "OracleNamedPlaylistMutationExecutor.cs",
+ "OperatorCatalogMutationExecutor.cs"
+ })
+ {
+ var executor = File.ReadAllText(Path.Combine(InfrastructureRoot, executorFile));
+ var demand = RequiredIndex(executor, "DatabaseMutationAuthorizationGuard.Demand(");
+ var open = RequiredIndex(executor, "await connection.OpenAsync(");
+ Assert.True(demand < open, $"{executorFile} must deny before opening a DB connection.");
+ Assert.Contains("_mutationAuthorization", executor, StringComparison.Ordinal);
+ Assert.Contains("IDatabaseMutationAuthorization mutationAuthorization", executor,
+ StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void Gate_A_blocks_persistent_write_intents_before_dispatch()
+ {
+ var process = Normalize(Slice(
+ Window,
+ " private async Task ProcessIntentAsync(LegacyUiIntent intent)",
+ " private static bool IsNamedPlaylistModalIntent("));
+ var guard = RequiredIndex(
+ process,
+ "_playoutLaunchAuthorization.IsGateA &&\n IsPersistentWriteIntent(intent)");
+ var dispatchStarted = RequiredIndex(process, "dispatchStarted = true;");
+ var dispatch = RequiredIndex(process, "HandleIntentAsync(intent");
+ Assert.True(guard < dispatchStarted);
+ Assert.True(guard < dispatch);
+
+ var guardEnd = process.IndexOf("return;", guard, StringComparison.Ordinal);
+ Assert.True(guardEnd > guard);
+ Assert.Contains("ReportIntentFailure(", process[guard..guardEnd],
+ StringComparison.Ordinal);
+
+ var persistent = Slice(
+ Playout,
+ " private static bool IsPersistentWriteIntent(LegacyUiIntent intent)",
+ " private static bool IsFixedSectionBatchMutationIntent(");
+ foreach (var intent in new[]
+ {
+ "LegacyDeleteUc4SelectedThemeIntent",
+ "LegacyDeleteUc6SelectedExpertIntent",
+ "LegacySaveOperatorCatalogIntent",
+ "LegacyConfirmNativeDialogIntent",
+ "LegacyAddComparisonPairIntent",
+ "LegacySaveManualFinancialIntent",
+ "LegacySaveManualNetSellIntent",
+ "LegacySaveManualViIntent",
+ "LegacyImportManualListsIntent",
+ "LegacyCreateNamedPlaylistIntent",
+ "LegacySaveCurrentNamedPlaylistIntent",
+ "LegacyDeleteSelectedNamedPlaylistIntent"
+ })
+ {
+ Assert.Contains(intent, persistent, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void Gate_A_blocks_fade_and_background_mutation_before_any_command_gate()
+ {
+ Assert.Contains("public bool AllowsCompositionMutation => !IsGateA;",
+ LaunchAuthorization, StringComparison.Ordinal);
+
+ var fade = Slice(
+ Playout,
+ " private async Task SetFadeDurationAsync(",
+ " private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(");
+ AssertCompositionMutationGuard(
+ fade,
+ "Gate A 검증 회차에서는 Fade 설정을 변경할 수 없습니다.");
+
+ var toggle = Slice(
+ Playout,
+ " private async Task ToggleBackgroundAsync(",
+ " private async Task ChooseBackgroundAsync(");
+ AssertCompositionMutationGuard(
+ toggle,
+ "Gate A 검증 회차에서는 배경 설정을 변경할 수 없습니다.");
+
+ var choose = Slice(
+ Playout,
+ " private async Task ChooseBackgroundAsync(",
+ " private bool CanChangeComposition()");
+ AssertCompositionMutationGuard(
+ choose,
+ "Gate A 검증 회차에서는 배경 파일을 선택할 수 없습니다.");
+ }
+
+ private static void AssertCompositionMutationGuard(string method, string message)
+ {
+ var authorization = RequiredIndex(
+ method,
+ "if (!_playoutLaunchAuthorization.AllowsCompositionMutation)");
+ var commandGate = RequiredIndex(method, "_playoutCommandGate.WaitAsync");
+ Assert.True(authorization < commandGate);
+ Assert.Contains(message, method[authorization..commandGate],
+ StringComparison.Ordinal);
+ Assert.Contains("return _controller.ReportError(", method[authorization..commandGate],
+ StringComparison.Ordinal);
+ }
+
+ private static int CountOccurrences(string source, string marker)
+ {
+ var count = 0;
+ var index = 0;
+ while ((index = source.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += marker.Length;
+ }
+
+ return count;
+ }
+
+ private static string Slice(string source, string startMarker, string endMarker)
+ {
+ var start = RequiredIndex(source, startMarker);
+ var end = RequiredIndex(source, endMarker, start + startMarker.Length);
+ return source[start..end];
+ }
+
+ private static int RequiredIndex(string source, string marker, int startIndex = 0)
+ {
+ var index = source.IndexOf(marker, startIndex, StringComparison.Ordinal);
+ Assert.True(index >= 0, $"Required source marker was not found: {marker}");
+ return index;
+ }
+
+ private static string Normalize(string source) =>
+ source.Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ private static string Read(params string[] parts) =>
+ File.ReadAllText(Path.Combine(parts));
+
+ private static string FindRepositoryRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
+ {
+ return current.FullName;
+ }
+
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Repository root was not found.");
+ }
+}
diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs
new file mode 100644
index 0000000..13a50b7
--- /dev/null
+++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyGateAHarnessContractTests.cs
@@ -0,0 +1,318 @@
+namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
+
+public sealed class LegacyGateAHarnessContractTests
+{
+ private const string CurrentMsixSha256 =
+ "0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367";
+
+ private static readonly string RepositoryRoot = FindRepositoryRoot();
+ private static readonly string Runner = ReadScript("Invoke-LegacyPgmPrepareGate.ps1");
+ private static readonly string PlanBuilder = ReadScript("New-LegacyPgmPreparePlan.ps1");
+ private static readonly string NodeHelper = ReadScript("Test-LegacyPgmPrepareGate.mjs");
+
+ [Fact]
+ public void Node_receives_the_bearer_only_from_the_parent_owned_JIT_pipe()
+ {
+ Assert.Contains("import net from \"node:net\";", NodeHelper, StringComparison.Ordinal);
+ Assert.Contains("\"capability-sha256\", \"permit-pipe\"", NodeHelper,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "name.startsWith(\"MBN_LEGACY_PGM_GATE_A_\")",
+ NodeHelper,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "process.env[capabilityName]",
+ NodeHelper,
+ StringComparison.Ordinal);
+
+ var receive = Slice(
+ NodeHelper,
+ "async function receiveJitCapability(expiresAt)",
+ "async function prepareOnce(expiresAt)");
+ Assert.Contains("net.createConnection(pipePath)", receive, StringComparison.Ordinal);
+ Assert.Contains("bytes.length !== 64", receive, StringComparison.Ordinal);
+ Assert.Contains("sha256(bytes) !== configuration.capabilitySha256", receive,
+ StringComparison.Ordinal);
+ Assert.Contains("jitPermitReceived = true", receive, StringComparison.Ordinal);
+
+ var prepare = Slice(
+ NodeHelper,
+ "async function prepareOnce(expiresAt)",
+ "async function captureScreenshot()");
+ var boundary = RequiredIndex(prepare, "const preRevision = before.state.revision;");
+ var permit = RequiredIndex(prepare, "await receiveJitCapability(expiresAt)");
+ var arm = RequiredIndex(prepare, "__legacyPgmPrepareGate.arm(");
+ var dispatchPossible = RequiredIndex(prepare, "prepareDispatchPossible = true;");
+ Assert.True(boundary < permit && permit < arm && arm < dispatchPossible);
+
+ var execute = Slice(NodeHelper, "async function execute()", "try {");
+ Assert.Contains(
+ "installGate(configuration.planSha256, configuration.capabilitySha256)",
+ execute,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Runner_authenticates_the_exact_Node_client_before_one_JIT_delivery()
+ {
+ var start = Slice(
+ Runner,
+ "function Start-NodeHelper([object] $Plan)",
+ "function Write-Evidence([string] $Path)");
+ Assert.Contains("New-CurrentUserOneShotPipe 'Node'", start, StringComparison.Ordinal);
+ Assert.Contains("'--capability-sha256'", start, StringComparison.Ordinal);
+ Assert.Contains("'--permit-pipe'", start, StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "$info.EnvironmentVariables[$capabilityName]",
+ start,
+ StringComparison.Ordinal);
+ Assert.Contains("MBN_LEGACY_PGM_GATE_A_*", start, StringComparison.Ordinal);
+ Assert.Contains("Assert-NodePermitClient", start, StringComparison.Ordinal);
+
+ var client = Slice(
+ Runner,
+ "function Assert-NodePermitClient(",
+ "function Stop-NodeHelperFailStop(");
+ Assert.Contains("ReadNamedPipeClientProcessId", client, StringComparison.Ordinal);
+ Assert.Contains("$clientProcessId -ne $Process.Id", client, StringComparison.Ordinal);
+ Assert.Contains("$Process.StartTime.ToUniversalTime().Ticks", client,
+ StringComparison.Ordinal);
+ Assert.Contains("[int]$row.ParentProcessId -ne $PID", client,
+ StringComparison.Ordinal);
+ Assert.Contains("SamePath ([string]$row.ExecutablePath) $ExpectedExecutable", client,
+ StringComparison.Ordinal);
+
+ var delivery = RequiredIndex(start, "$permitPipe.Stream.Write($bytes,0,$bytes.Length)");
+ foreach (var marker in new[]
+ {
+ "Assert-NodePermitClient",
+ "Assert-Authorization $script:authorization $Plan",
+ "Assert-Application $script:application",
+ "Assert-Pgm $Plan.pgm",
+ "Assert-Cdp ([int]$Plan.webView.port)"
+ })
+ {
+ Assert.True(RequiredIndex(start, marker) < delivery, marker);
+ }
+
+ var stop = Slice(
+ Runner,
+ "function Stop-NodeHelperFailStop(",
+ "function Start-NodeHelper([object] $Plan)");
+ Assert.Contains("Close-OneShotPipe $Pipe", stop, StringComparison.Ordinal);
+ Assert.Contains("$Process.Kill()", stop, StringComparison.Ordinal);
+ Assert.Contains("$Process.WaitForExit(0)", stop, StringComparison.Ordinal);
+ Assert.Contains("$Process.WaitForExit(5000)", stop, StringComparison.Ordinal);
+ Assert.Contains("was not proven stopped", stop, StringComparison.Ordinal);
+ Assert.Contains("only the control-plane Node helper", stop, StringComparison.Ordinal);
+ Assert.DoesNotContain("$script:application", stop, StringComparison.Ordinal);
+ Assert.DoesNotContain("Assert-Pgm", stop, StringComparison.Ordinal);
+
+ var catchBlock = start[RequiredIndex(start, " catch {")..];
+ Assert.Contains("Stop-NodeHelperFailStop $process $permitPipe", catchBlock,
+ StringComparison.Ordinal);
+ Assert.Contains("exceeded its outer timeout; parent fail-stop is required", start,
+ StringComparison.Ordinal);
+ var monitorTry = Slice(start, " try {\n $process.Refresh()", " catch {");
+ Assert.True(
+ RequiredIndex(monitorTry, "$process.StartTime.ToUniversalTime().Ticks") <
+ RequiredIndex(monitorTry, "while (-not $process.HasExited"));
+ Assert.Contains("if (-not $process.HasExited)", monitorTry, StringComparison.Ordinal);
+ Assert.Contains("$process.WaitForExit(0)", monitorTry, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Package_wrapper_receives_launch_environment_over_an_authenticated_pipe_not_its_command_line()
+ {
+ var package = Slice(
+ Runner,
+ "function Start-Package([object] $Plan)",
+ "function Quote-Argument([string] $Value)");
+ Assert.DoesNotContain("$encodedEnvironment", package, StringComparison.Ordinal);
+ Assert.Contains("New-CurrentUserOneShotPipe 'Package'", package,
+ StringComparison.Ordinal);
+ Assert.Contains("Assert-PackagePermitClient", package, StringComparison.Ordinal);
+ Assert.Contains("[LegacyPgmPreparePackageIdentity]::Read($process.Handle)",
+ Runner,
+ StringComparison.Ordinal);
+ Assert.Contains("$payload = $utf8NoBom.GetBytes(($environment | ConvertTo-Json -Compress))",
+ package,
+ StringComparison.Ordinal);
+ Assert.Contains("$launchPipe.Stream.Write($payload,0,$payload.Length)", package,
+ StringComparison.Ordinal);
+ Assert.Contains("$client.ReadByte() -ne -1", package, StringComparison.Ordinal);
+ var wrapperStop = Slice(
+ Runner,
+ "function Stop-PackageWrapperFailStop(",
+ "function Start-Package([object] $Plan)");
+ Assert.Contains("$Process.Kill()", wrapperStop, StringComparison.Ordinal);
+ Assert.Contains("$Process.WaitForExit(0)", wrapperStop, StringComparison.Ordinal);
+ Assert.Contains("$Process.WaitForExit(5000)", wrapperStop, StringComparison.Ordinal);
+ Assert.Contains("was not proven stopped", wrapperStop, StringComparison.Ordinal);
+ Assert.DoesNotContain("catch { return }", wrapperStop, StringComparison.Ordinal);
+ Assert.Contains("$wrapper.WaitForExit(0)", package, StringComparison.Ordinal);
+ Assert.Contains("without proving the exact wrapper exited", package,
+ StringComparison.Ordinal);
+ var child = Slice(package, " $child = @\"", "\"@\n $encodedChild");
+ Assert.DoesNotContain("$encodedEnvironment", child, StringComparison.Ordinal);
+ Assert.DoesNotContain("$script:gateCapability", child, StringComparison.Ordinal);
+ Assert.DoesNotContain("$capabilityName", child, StringComparison.Ordinal);
+ Assert.DoesNotContain("$environment", child, StringComparison.Ordinal);
+
+ var pipes = Slice(
+ Runner,
+ "function New-CurrentUserOneShotPipe([string] $Purpose)",
+ "function Close-OneShotPipe([object] $Pipe)");
+ Assert.Contains("[IO.Pipes.PipeDirection]::InOut", pipes, StringComparison.Ordinal);
+ Assert.Contains("[IO.Pipes.PipeSecurity]::new()", pipes, StringComparison.Ordinal);
+ Assert.Contains("$identity.User", pipes, StringComparison.Ordinal);
+ Assert.Contains("SetAccessRuleProtection($true, $false)", pipes,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Managed_runtime_injection_environment_is_rejected_and_removed_from_children()
+ {
+ var preflight = Slice(
+ Runner,
+ "function Assert-NoUnsafeEnvironment",
+ "function Assert-Pgm([object] $Pgm)");
+ var package = Slice(
+ Runner,
+ "function Start-Package([object] $Plan)",
+ "function Quote-Argument([string] $Value)");
+ var child = Slice(package, " $child = @\"", "\"@\n $encodedChild");
+ var node = Slice(
+ Runner,
+ "function Start-NodeHelper([object] $Plan)",
+ "function Write-Evidence([string] $Path)");
+
+ foreach (var prefix in new[] { "DOTNET_*", "CORECLR_*", "COR_*", "COMPlus_*" })
+ {
+ Assert.Contains(prefix, preflight, StringComparison.Ordinal);
+ Assert.True(Count(child, prefix) >= 2, $"{prefix} must be removed before and after app launch.");
+ Assert.Contains(prefix, node, StringComparison.Ordinal);
+ }
+
+ foreach (var scope in new[]
+ {
+ "[EnvironmentVariableTarget]::Process",
+ "[EnvironmentVariableTarget]::User",
+ "[EnvironmentVariableTarget]::Machine"
+ })
+ {
+ Assert.Contains(scope, preflight, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void Database_source_is_pinned_leased_and_cannot_be_overridden_by_environment()
+ {
+ Assert.Contains("approvedDatabaseConfigPath", PlanBuilder, StringComparison.Ordinal);
+ Assert.Contains(
+ "database = New-FilePin $approvedDatabaseConfigPath 'database config'",
+ PlanBuilder,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "Assert-FilePin $script:plan.configuration.database 'database config'",
+ Runner,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "Add-ReadLease ([string]$script:plan.configuration.database.path) 'database config'",
+ Runner,
+ StringComparison.Ordinal);
+ Assert.Contains("filePinsValidated = 12", Runner, StringComparison.Ordinal);
+ Assert.Contains("const databasePin = plan?.configuration?.database;", NodeHelper,
+ StringComparison.Ordinal);
+ Assert.Contains("!isSha256(databasePin?.sha256)", NodeHelper,
+ StringComparison.Ordinal);
+
+ var preflight = Slice(
+ Runner,
+ "function Assert-NoUnsafeEnvironment",
+ "function Assert-Pgm([object] $Pgm)");
+ var package = Slice(
+ Runner,
+ "function Start-Package([object] $Plan)",
+ "function Quote-Argument([string] $Value)");
+ var child = Slice(package, " $child = @\"", "\"@\n $encodedChild");
+ foreach (var prefix in new[]
+ {
+ "MBN_STOCK_ORACLE_*", "MBN_STOCK_MARIADB_*", "MBN_STOCK_DB_*"
+ })
+ {
+ Assert.Contains(prefix, preflight, StringComparison.Ordinal);
+ Assert.True(Count(child, prefix) >= 2, $"{prefix} must be removed before and after app launch.");
+ }
+ }
+
+ [Fact]
+ public void Plan_contract_requires_helper_fail_stop_and_the_current_package_pin()
+ {
+ foreach (var source in new[] { Runner, PlanBuilder })
+ {
+ Assert.Contains(CurrentMsixSha256, source, StringComparison.Ordinal);
+ }
+
+ Assert.Contains("nodeHelperFailStopRequired = $true", PlanBuilder,
+ StringComparison.Ordinal);
+ Assert.Contains("packageWrapperFailStopRequired = $true", PlanBuilder,
+ StringComparison.Ordinal);
+ Assert.Contains("'nodeHelperFailStopRequired'", Runner, StringComparison.Ordinal);
+ Assert.Contains("'packageWrapperFailStopRequired'", Runner, StringComparison.Ordinal);
+ Assert.Contains("plan?.safety?.nodeHelperFailStopRequired !== true", NodeHelper,
+ StringComparison.Ordinal);
+ Assert.Contains("plan?.safety?.packageWrapperFailStopRequired !== true", NodeHelper,
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "Assert-JsonInt $Plan.safety.nodeHelperInvocationMaximum 1",
+ Runner,
+ StringComparison.Ordinal);
+ }
+
+ private static string Slice(string source, string startMarker, string endMarker)
+ {
+ var start = RequiredIndex(source, startMarker);
+ var end = RequiredIndex(source, endMarker, start + startMarker.Length);
+ return source[start..end];
+ }
+
+ private static int RequiredIndex(string source, string marker, int startIndex = 0)
+ {
+ var index = source.IndexOf(marker, startIndex, StringComparison.Ordinal);
+ Assert.True(index >= 0, $"Required source marker was not found: {marker}");
+ return index;
+ }
+
+ private static int Count(string source, string marker)
+ {
+ var count = 0;
+ var index = 0;
+ while ((index = source.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += marker.Length;
+ }
+
+ return count;
+ }
+
+ private static string ReadScript(string fileName) =>
+ File.ReadAllText(Path.Combine(RepositoryRoot, "scripts", fileName));
+
+ private static string FindRepositoryRoot()
+ {
+ var current = new DirectoryInfo(AppContext.BaseDirectory);
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
+ {
+ return current.FullName;
+ }
+
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Repository root was not found.");
+ }
+}
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
index 55f4503..cd50e12 100644
--- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
@@ -4,6 +4,7 @@ using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
+using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
@@ -1678,6 +1679,128 @@ public sealed class DynamicK3dSessionTests
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
}
+ [Theory]
+ [InlineData("mutation")]
+ [InlineData("query")]
+ [InlineData("end")]
+ public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries(
+ string failureStage)
+ {
+ const string capability =
+ "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var rawOptions = new PlayoutOptions
+ {
+ Mode = PlayoutMode.Live,
+ Host = "127.0.0.1",
+ Port = 30001,
+ TcpMode = 1,
+ LayoutIndex = 10,
+ SceneDirectory = scenes.Path,
+ TestSceneAllowlist = ["5001"],
+ TrustedLiveOutputEnabled = true,
+ ReconnectEnabled = false,
+ MaximumReconnectAttempts = 0,
+ MaximumAutomaticRefreshesPerTakeIn = 0,
+ LegacySceneFadeDuration = 6,
+ ConnectTimeoutMilliseconds = 500,
+ OperationTimeoutMilliseconds = 500,
+ DisconnectTimeoutMilliseconds = 500,
+ ProcessPollIntervalMilliseconds = 100
+ };
+ var options = ValidatedPlayoutOptions.Create(rawOptions);
+ var failure = new COMException($"fake Gate A {failureStage} failure");
+ var log = new FakeComLog();
+ var scene = new FakeScene(log, "5001");
+ var player = new FakePlayer(log);
+ var vendorEngine = new FakeEngine(log, player, scene);
+ switch (failureStage)
+ {
+ case "mutation":
+ scene.GetObjectFailure = failure;
+ break;
+ case "query":
+ scene.QueryVariablesFailure = failure;
+ break;
+ case "end":
+ vendorEngine.EndTransactionFailure = failure;
+ break;
+ default:
+ throw new InvalidOperationException("Unknown test failure stage.");
+ }
+
+ var activator = new FakeActivator(log, vendorEngine);
+ var releaser = new FakeReleaser(
+ log,
+ (scene, "5001"),
+ (player, "Player"),
+ (vendorEngine, "Engine"),
+ (activator.EventHandler, "EventHandler"));
+ var sessionFactory = new DelegateK3dSessionFactory(() =>
+ new DynamicK3dSession(
+ activator,
+ releaser,
+ new InstalledK3dInteropMethodInvoker(),
+ new ActivatorK3dEventHandlerFactory(activator),
+ rollbackOnPrepareFailure: false));
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ capability);
+ var dispatcher = new StaDispatcher(options.QueueCapacity);
+ var engine = new TornadoPlayoutEngine(
+ options,
+ sessionFactory,
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(
+ ProcessSnapshot("approved-pgm"),
+ ProcessSnapshot("approved-pgm")),
+ dispatcher,
+ authorization,
+ TimeProvider.System,
+ startMonitor: false,
+ finalConnectSafetyCheck: () => true,
+ abandonOnTargetMismatch: true,
+ beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
+ launchAuthorization: authorization);
+
+ try
+ {
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+ Assert.True(authorization.TryClaimGateAPrepare(capability));
+ var cue = new PlayoutCue(
+ "5001.t2s",
+ "5001",
+ [new PlayoutField("headline", "test", true)],
+ FadeDuration: 6,
+ Mutations: [new PlayoutUseBackground(false)]);
+
+ var result = await engine.PrepareAsync(cue, CancellationToken.None);
+ var retry = await engine.PrepareAsync(cue, CancellationToken.None);
+
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ }
+
+ Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction"));
+ Assert.DoesNotContain("RollbackTransaction", log.Names);
+ Assert.DoesNotContain("Disconnect", log.Names);
+ Assert.DoesNotContain("Prepare:10", log.Names);
+ Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
+ Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
+ Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
+ Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
+ }
+
+ private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
+ new(1, 1, 0)
+ {
+ EligibleProcessGeneration = new TornadoProcessGeneration(generation)
+ };
+
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
{
Mode = PlayoutMode.Test,
@@ -1715,6 +1838,12 @@ public sealed class DynamicK3dSessionTests
name.StartsWith("Unload:", StringComparison.Ordinal) ||
name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();
+ private sealed class DelegateK3dSessionFactory(Func create)
+ : IK3dSessionFactory
+ {
+ public IK3dSession Create() => create();
+ }
+
public sealed class FakeComLog
{
private readonly ConcurrentQueue _names = new();
@@ -1865,6 +1994,8 @@ public sealed class DynamicK3dSessionTests
public Func? SceneResolver { get; init; }
+ public Exception? EndTransactionFailure { get; set; }
+
public int KTAPConnect(
int tcpMode,
string host,
@@ -1913,10 +2044,23 @@ public sealed class DynamicK3dSessionTests
public void BeginTransaction() => _log.Add("BeginTransaction");
- public void EndTransaction() => _log.Add("EndTransaction");
+ public void EndTransaction()
+ {
+ _log.Add("EndTransaction");
+ if (EndTransactionFailure is not null)
+ {
+ throw EndTransactionFailure;
+ }
+ }
- public void EndTransactionOnChannel(int channel) =>
+ public void EndTransactionOnChannel(int channel)
+ {
_log.Add($"EndTransactionOnChannel:{channel}");
+ if (EndTransactionFailure is not null)
+ {
+ throw EndTransactionFailure;
+ }
+ }
public void RollbackTransaction() => _log.Add("RollbackTransaction");
@@ -2026,6 +2170,11 @@ public sealed class DynamicK3dSessionTests
public object GetObject(string objectName)
{
_log.Add($"GetObject:{objectName}");
+ if (GetObjectFailure is not null)
+ {
+ throw GetObjectFailure;
+ }
+
return new FakeSceneObject(_log, objectName);
}
@@ -2044,6 +2193,8 @@ public sealed class DynamicK3dSessionTests
set => _queryVariablesFailure = value;
}
+ public Exception? GetObjectFailure { get; set; }
+
public void Unload()
{
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs
new file mode 100644
index 0000000..3d4efe0
--- /dev/null
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutLaunchAuthorizationTests.cs
@@ -0,0 +1,747 @@
+using System.Security.Cryptography;
+using MBN_STOCK_WEBVIEW.Core.Playout;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+using MBN_STOCK_WEBVIEW.Playout.Safety;
+
+namespace MBN_STOCK_WEBVIEW.Playout.Tests;
+
+public sealed class PlayoutLaunchAuthorizationTests
+{
+ private const string Capability =
+ "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+
+ [Fact]
+ public void Missing_gate_environment_preserves_the_standard_launch_profile()
+ {
+ using var environment = ClearGateEnvironment();
+
+ var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
+
+ Assert.False(authorization.IsGateA);
+ Assert.True(authorization.AllowsDatabaseWrites);
+ Assert.True(authorization.AllowsCompositionMutation);
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ }
+
+ [Fact]
+ public void Non_gate_capture_preserves_existing_live_authorization_semantics()
+ {
+ using var environment = ClearGateEnvironment()
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+
+ using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
+
+ Assert.False(authorization.IsGateA);
+ Assert.True(authorization.IsAuthorizedForThisLaunch);
+ Assert.Equal(
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
+ Environment.GetEnvironmentVariable(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
+ }
+
+ [Fact]
+ public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+
+ using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath));
+ PlayoutLaunchAuthorization? second = null;
+ Assert.Throws(() =>
+ second = PlayoutLaunchAuthorization.CaptureFromEnvironment());
+
+ Assert.True(first.IsGateA);
+ Assert.True(first.IsAuthorizedForThisLaunch);
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
+ Assert.Null(second);
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
+ }
+
+ [Fact]
+ public void Gate_environment_is_captured_once_and_cleared_before_browser_startup()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath));
+
+ Assert.True(authorization.IsGateA);
+ Assert.Equal(41308, authorization.ExpectedPgmProcessId);
+ Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks);
+ Assert.Equal(expiry, authorization.ExpiresAtUtcTicks);
+ Assert.False(authorization.AllowsDatabaseWrites);
+ Assert.False(authorization.AllowsCompositionMutation);
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
+
+ environment.Set(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
+ new string('F', 64));
+
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
+ }
+
+ [Theory]
+ [InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")]
+ [InlineData("ABCDEF")]
+ [InlineData(null)]
+ public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared(
+ string? capability)
+ {
+ using var environment = ClearGateEnvironment()
+ .Set(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
+ capability)
+ .Set(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
+ "41308");
+
+ Assert.Throws(
+ PlayoutLaunchAuthorization.CaptureFromEnvironment);
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
+ }
+
+ [Fact]
+ public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = ClearGateEnvironment()
+ .Set(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
+ "ABCDEF")
+ .Set(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
+ "41308")
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+
+ Assert.Throws(() =>
+ PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath)));
+
+ environment
+ .Set(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
+ Capability)
+ .Set(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
+ "41308")
+ .Set(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable,
+ "638878000000000000")
+ .Set(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable,
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+
+ PlayoutLaunchAuthorization? second = null;
+ Assert.Throws(() =>
+ second = PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath)));
+
+ Assert.Null(second);
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
+ }
+
+ [Fact]
+ public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
+ .Set(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
+ PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
+ using var start = new ManualResetEventSlim();
+ var tasks = Enumerable.Range(0, 8)
+ .Select(_ => Task.Run(() =>
+ {
+ start.Wait();
+ try
+ {
+ return (
+ Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath)),
+ Error: (Exception?)null);
+ }
+ catch (Exception exception)
+ {
+ return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception);
+ }
+ }))
+ .ToArray();
+
+ start.Set();
+ var results = await Task.WhenAll(tasks);
+
+ var successful = Assert.Single(results, static result => result.Authorization is not null);
+ Assert.True(successful.Authorization!.IsGateA);
+ foreach (var failed in results.Where(static result => result.Authorization is null))
+ {
+ Assert.IsType(failed.Error);
+ }
+
+ successful.Authorization.Dispose();
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
+ }
+
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(-1L)]
+ [InlineData(9000000001L)]
+ public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value(
+ long expiryOffsetTicks)
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ Assert.Throws(() =>
+ PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath)));
+
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
+ Assert.Null(Environment.GetEnvironmentVariable(
+ PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
+ }
+
+ [Fact]
+ public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
+
+ Assert.Throws(() =>
+ PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ new string('A', 64)));
+
+ using var write = File.Open(
+ scenePath,
+ FileMode.Open,
+ FileAccess.Write,
+ FileShare.ReadWrite | FileShare.Delete);
+ Assert.True(write.CanWrite);
+ }
+
+ [Fact]
+ public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var scenePath = Path.Combine(scenes.Path, "5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
+ using var environment = GateEnvironment(
+ Capability,
+ "41308",
+ "638878000000000000",
+ expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
+ clock,
+ scenePath,
+ SceneHash(scenePath));
+
+ using (var secondReader = File.Open(
+ scenePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.Read))
+ {
+ Assert.True(secondReader.CanRead);
+ }
+
+ Assert.ThrowsAny(() => File.Open(
+ scenePath,
+ FileMode.Open,
+ FileAccess.Write,
+ FileShare.ReadWrite | FileShare.Delete));
+ Assert.ThrowsAny(() => File.Delete(scenePath));
+
+ authorization.CompleteGateAPrepare();
+ Assert.ThrowsAny(() => File.Delete(scenePath));
+
+ authorization.Dispose();
+ File.Delete(scenePath);
+ Assert.False(File.Exists(scenePath));
+ }
+
+ [Fact]
+ public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed()
+ {
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ Capability,
+ timeProvider: clock,
+ expiresAtUtcTicks: expiry);
+
+ clock.Advance(TimeSpan.FromMinutes(1));
+
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Connect,
+ cue: null));
+ }
+
+ [Fact]
+ public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary()
+ {
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ Capability,
+ timeProvider: clock,
+ expiresAtUtcTicks: expiry);
+
+ clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+ clock.Advance(TimeSpan.FromTicks(1));
+
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue()));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue()));
+ }
+
+ [Fact]
+ public void Connect_just_before_expiry_does_not_extend_later_prepare_authority()
+ {
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ Capability,
+ timeProvider: clock,
+ expiresAtUtcTicks: expiry);
+
+ clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
+ Assert.True(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Connect,
+ cue: null));
+ clock.Advance(TimeSpan.FromTicks(1));
+
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue()));
+ }
+
+ [Fact]
+ public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate()
+ {
+ var clock = new ManualGateTimeProvider();
+ var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ Capability,
+ timeProvider: clock,
+ expiresAtUtcTicks: expiry);
+
+ clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
+ clock.AdvanceMonotonic(TimeSpan.FromMinutes(1));
+
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Connect,
+ cue: null));
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ }
+
+ [Fact]
+ public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm()
+ {
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+ var cue = GateACue();
+
+ Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
+ Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
+ Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
+
+ authorization.CompleteGateAPrepare();
+
+ Assert.False(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
+ }
+
+ [Fact]
+ public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction()
+ {
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" }));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue()));
+ }
+
+ [Fact]
+ public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch()
+ {
+ var blockedCues = new[]
+ {
+ GateACue() with { FadeDuration = 5 },
+ GateACue() with { Mutations = [] },
+ GateACue() with
+ {
+ Mutations = [new PlayoutUseBackground(true)]
+ },
+ GateACue() with
+ {
+ Mutations =
+ [
+ new PlayoutUseBackground(false),
+ new PlayoutUseBackground(false)
+ ]
+ },
+ GateACue() with
+ {
+ Mutations = [new PlayoutSetBackgroundTexture("background.png")]
+ },
+ GateACue() with
+ {
+ Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)]
+ }
+ };
+
+ foreach (var cue in blockedCues)
+ {
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ cue));
+ Assert.False(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ GateACue()));
+ }
+ }
+
+ [Fact]
+ public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare()
+ {
+ var provider = new RegisteredLegacySceneCueProvider(
+ new SinglePageDataSource(new LegacySceneDataPage(
+ "s5001",
+ new S5001DomesticStockSceneData(
+ LegacyDomesticEquityMarket.Kospi,
+ S5001DomesticStockMode.Current,
+ S5001NxtBadge.None,
+ "Samsung Electronics",
+ ScenePriceDirection.Up,
+ 70_000,
+ 500,
+ 0.72m),
+ 1)));
+ var page = await provider.CreatePageAsync(
+ new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6),
+ pageIndexZeroBased: 0);
+ using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+
+ Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]);
+ Assert.True(authorization.TryClaimGateAPrepare(Capability));
+ Assert.True(authorization.TryClaimEngineCommand(
+ PlayoutOperation.Prepare,
+ page.Cue));
+ }
+
+ [Fact]
+ public async Task Concurrent_operator_claims_have_exactly_one_winner()
+ {
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+ using var start = new ManualResetEventSlim();
+ var tasks = Enumerable.Range(0, 16)
+ .Select(_ => Task.Run(() =>
+ {
+ start.Wait();
+ return authorization.TryClaimGateAPrepare(Capability);
+ }))
+ .ToArray();
+
+ start.Set();
+ var results = await Task.WhenAll(tasks);
+
+ Assert.Single(results, static result => result);
+ }
+
+ [Fact]
+ public void GateA_options_require_the_exact_closed_profile()
+ {
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
+ var valid = GateAOptions();
+
+ PlayoutEngineFactory.ValidateGateAOptions(valid, authorization);
+
+ foreach (var invalid in new[]
+ {
+ GateAOptions().WithMode(PlayoutMode.DryRun),
+ GateAOptions().WithAllowlist("5001", "5006"),
+ GateAOptions().WithReconnect(true, 0),
+ GateAOptions().WithReconnect(false, 1),
+ GateAOptions().WithRefresh(null),
+ GateAOptions().WithFade(5),
+ GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture),
+ GateAOptions().WithSceneDirectory(Path.GetTempPath()),
+ GateAOptions().WithClientPort(1),
+ GateAOptions().WithOutputChannel(0)
+ })
+ {
+ Assert.Throws(() =>
+ PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization));
+ }
+ }
+
+ private static PlayoutCue GateACue() => new(
+ "5001.t2s",
+ "5001",
+ FadeDuration: 6,
+ Mutations: [new PlayoutUseBackground(false)]);
+
+ private static PlayoutOptions GateAOptions() => new()
+ {
+ Mode = PlayoutMode.Live,
+ Host = "127.0.0.1",
+ Port = 30001,
+ TcpMode = 1,
+ LayoutIndex = 10,
+ TestSceneAllowlist = ["5001"],
+ TrustedLiveOutputEnabled = true,
+ ReconnectEnabled = false,
+ MaximumReconnectAttempts = 0,
+ MaximumAutomaticRefreshesPerTakeIn = 0,
+ LegacySceneFadeDuration = 6,
+ LegacySceneBackgroundKind = LegacySceneBackgroundKind.None,
+ SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory
+ };
+
+ private static string SceneHash(string path) =>
+ Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
+
+ private sealed class SinglePageDataSource(LegacySceneDataPage page)
+ : ILegacySceneDataSource
+ {
+ public Task LoadPageAsync(
+ LegacyPlaylistEntry entry,
+ int pageIndexZeroBased,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(page);
+ }
+
+ private static EnvironmentScope ClearGateEnvironment()
+ {
+ PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests();
+ return new EnvironmentScope()
+ .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null)
+ .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null)
+ .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null)
+ .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null)
+ .Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
+ }
+
+ private static EnvironmentScope GateEnvironment(
+ string capability,
+ string processId,
+ string startTimeUtcTicks,
+ string expiresAtUtcTicks) =>
+ ClearGateEnvironment()
+ .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability)
+ .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId)
+ .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks)
+ .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks);
+}
+
+internal sealed class ManualGateTimeProvider : TimeProvider
+{
+ private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero);
+ private long _timestamp;
+
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+
+ public override DateTimeOffset GetUtcNow() => _utcNow;
+
+ public override long GetTimestamp() => _timestamp;
+
+ public void Advance(TimeSpan duration)
+ {
+ _utcNow = _utcNow.Add(duration);
+ _timestamp += duration.Ticks;
+ }
+
+ public void AdvanceMonotonic(TimeSpan duration) =>
+ _timestamp += duration.Ticks;
+
+ public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
+}
+
+internal static class GateAOptionTestExtensions
+{
+ public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode)
+ {
+ options.Mode = mode;
+ return options;
+ }
+
+ public static PlayoutOptions WithAllowlist(
+ this PlayoutOptions options,
+ params string[] scenes)
+ {
+ options.TestSceneAllowlist = [.. scenes];
+ return options;
+ }
+
+ public static PlayoutOptions WithReconnect(
+ this PlayoutOptions options,
+ bool enabled,
+ int attempts)
+ {
+ options.ReconnectEnabled = enabled;
+ options.MaximumReconnectAttempts = attempts;
+ return options;
+ }
+
+ public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum)
+ {
+ options.MaximumAutomaticRefreshesPerTakeIn = maximum;
+ return options;
+ }
+
+ public static PlayoutOptions WithFade(this PlayoutOptions options, int fade)
+ {
+ options.LegacySceneFadeDuration = fade;
+ return options;
+ }
+
+ public static PlayoutOptions WithBackground(
+ this PlayoutOptions options,
+ LegacySceneBackgroundKind kind)
+ {
+ options.LegacySceneBackgroundKind = kind;
+ return options;
+ }
+
+ public static PlayoutOptions WithSceneDirectory(
+ this PlayoutOptions options,
+ string sceneDirectory)
+ {
+ options.SceneDirectory = sceneDirectory;
+ return options;
+ }
+
+ public static PlayoutOptions WithClientPort(
+ this PlayoutOptions options,
+ int clientPort)
+ {
+ options.ClientPort = clientPort;
+ return options;
+ }
+
+ public static PlayoutOptions WithOutputChannel(
+ this PlayoutOptions options,
+ int outputChannel)
+ {
+ options.OutputChannel = outputChannel;
+ return options;
+ }
+}
diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs
index 9986008..da45f98 100644
--- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs
+++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs
@@ -3,11 +3,15 @@ using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
+using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class TornadoPlayoutEngineTests
{
+ private const string GateACapability =
+ "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
+
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
@@ -2568,6 +2572,339 @@ public sealed class TornadoPlayoutEngineTests
Assert.Contains("Disconnect", session.Calls);
}
+ [Fact]
+ public async Task GateA_AllowsExactlyOneConnectAndClaimed5001Prepare_ThenAbandonsWithoutBye()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var session = new FakeK3dSession();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability);
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ new FakeK3dSessionFactory(() => session),
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
+ liveAuthorized: true,
+ launchAuthorization: authorization);
+
+ try
+ {
+ var cue = GateACue();
+
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.ConnectAsync(CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.PrepareAsync(cue, CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.TakeInAsync(CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.NextAsync(cue, CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.UpdateOnAirAsync(cue, CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.TakeOutAsync(
+ PlayoutTakeOutScope.All,
+ CancellationToken.None)).Code);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.DisconnectAsync(CancellationToken.None)).Code);
+ Assert.False(engine.Status.LiveTakeInAllowed);
+
+ Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
+ Assert.True((await engine.PrepareAsync(cue, CancellationToken.None)).IsSuccess);
+ Assert.Equal(
+ PlayoutResultCode.Rejected,
+ (await engine.PrepareAsync(cue, CancellationToken.None)).Code);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ }
+
+ Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
+ Assert.Equal(1, session.KtapDispatchCount);
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ Assert.DoesNotContain("Dispose", session.Calls);
+ }
+
+ [Fact]
+ public async Task GateA_WrongPrepareTargetPermanentlyConsumesPrepareWithoutSdkMutation()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var session = new FakeK3dSession();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability);
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ new FakeK3dSessionFactory(() => session),
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
+ liveAuthorized: true,
+ launchAuthorization: authorization);
+
+ try
+ {
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+ Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
+
+ var wrong = await engine.PrepareAsync(
+ new PlayoutCue("5006.t2s", "5006", FadeDuration: 6),
+ CancellationToken.None);
+ var correction = await engine.PrepareAsync(
+ GateACue(),
+ CancellationToken.None);
+
+ Assert.Equal(PlayoutResultCode.Rejected, wrong.Code);
+ Assert.Equal(PlayoutResultCode.Rejected, correction.Code);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ }
+
+ Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
+ Assert.DoesNotContain(
+ session.Calls,
+ static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ }
+
+ [Fact]
+ public async Task GateA_PgmGenerationChangeAbandonsWithoutReconnectOrBye()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var sessionFactory = new FakeK3dSessionFactory();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability);
+ var options = GateAOptions(scenes.Path);
+ options.ReconnectEnabled = true;
+ options.MaximumReconnectAttempts = 3;
+ var engine = CreateEngine(
+ options,
+ sessionFactory,
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(
+ ProcessSnapshot("approved-pgm"),
+ ProcessSnapshot("approved-pgm"),
+ ProcessSnapshot("replacement-pgm")),
+ liveAuthorized: true,
+ launchAuthorization: authorization);
+
+ try
+ {
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+
+ await engine.PollProcessOnceAsync(CancellationToken.None);
+
+ Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
+ Assert.Equal(1, sessionFactory.CreateCount);
+ var session = Assert.Single(sessionFactory.Sessions);
+ Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ }
+
+ Assert.Equal(1, sessionFactory.CreateCount);
+ }
+
+ [Fact]
+ public async Task GateA_ExpiredConnectIsRejectedBeforeProcessRegistrationOrVendorBoundary()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability,
+ timeProvider: clock,
+ expiresAtUtcTicks:
+ clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
+ clock.Advance(TimeSpan.FromMinutes(1));
+ var sessionFactory = new FakeK3dSessionFactory();
+ var registration = new FakeRegistrationProbe();
+ var process = new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm"));
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ sessionFactory,
+ registration,
+ process,
+ liveAuthorized: true,
+ launchAuthorization: authorization);
+
+ try
+ {
+ var result = await engine.ConnectAsync(CancellationToken.None);
+
+ Assert.Equal(PlayoutResultCode.Rejected, result.Code);
+ Assert.Equal(0, process.CaptureCount);
+ Assert.Equal(0, registration.ProbeCount);
+ Assert.Equal(0, sessionFactory.CreateCount);
+ }
+ finally
+ {
+ await engine.DisposeAsync();
+ authorization.Dispose();
+ }
+ }
+
+ [Fact]
+ public async Task GateA_PrepareFailureBecomesOutcomeUnknownAndCannotRetryOrDisconnect()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var session = new FakeK3dSession
+ {
+ PrepareAction = (_, _) => throw new COMException(
+ "fake ambiguous Gate A prepare failure")
+ };
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability);
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ new FakeK3dSessionFactory(() => session),
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
+ liveAuthorized: true,
+ abandonOnTargetMismatch: true,
+ launchAuthorization: authorization);
+
+ try
+ {
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+ Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
+
+ var failed = await engine.PrepareAsync(GateACue(), CancellationToken.None);
+ var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
+
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code);
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ authorization.Dispose();
+ }
+
+ Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
+ Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5001"));
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ Assert.DoesNotContain("Dispose", session.Calls);
+ }
+
+ [Fact]
+ public async Task GateA_ConnectExpiryAtSdkBoundaryPreventsKtapDispatch()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability,
+ timeProvider: clock,
+ expiresAtUtcTicks:
+ clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
+ var session = new FakeK3dSession
+ {
+ BeforeKtapDispatchAction = () => clock.Advance(TimeSpan.FromMinutes(1))
+ };
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ new FakeK3dSessionFactory(() => session),
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
+ liveAuthorized: true,
+ finalConnectSafetyCheck: () => true,
+ abandonOnTargetMismatch: true,
+ beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
+ launchAuthorization: authorization);
+
+ try
+ {
+ var result = await engine.ConnectAsync(CancellationToken.None);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(0, session.KtapDispatchCount);
+ }
+ finally
+ {
+ await engine.DisposeAsync();
+ authorization.Dispose();
+ }
+
+ Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ Assert.DoesNotContain("Dispose", session.Calls);
+ }
+
+ [Fact]
+ public async Task GateA_PrepareExpiryAtSdkBoundaryPreventsPrepareDispatchAndQuarantines()
+ {
+ using var scenes = TemporarySceneDirectory.Create("5001.t2s");
+ var clock = new ManualGateTimeProvider();
+ var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
+ GateACapability,
+ timeProvider: clock,
+ expiresAtUtcTicks:
+ clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
+ var dispatchBoundaryCount = 0;
+ void DelayedDispatchGuard()
+ {
+ if (Interlocked.Increment(ref dispatchBoundaryCount) == 2)
+ {
+ clock.Advance(TimeSpan.FromMinutes(1));
+ }
+
+ authorization.EnsureGateASdkDispatchAuthorized();
+ }
+
+ var session = new FakeK3dSession();
+ var engine = CreateEngine(
+ GateAOptions(scenes.Path),
+ new FakeK3dSessionFactory(() => session),
+ new FakeRegistrationProbe(),
+ new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
+ liveAuthorized: true,
+ finalConnectSafetyCheck: () => true,
+ abandonOnTargetMismatch: true,
+ beforeSdkDispatchClaim: DelayedDispatchGuard,
+ launchAuthorization: authorization);
+
+ try
+ {
+ Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
+ Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
+
+ var result = await engine.PrepareAsync(GateACue(), CancellationToken.None);
+ var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
+
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
+ Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
+ Assert.Equal(2, dispatchBoundaryCount);
+ }
+ finally
+ {
+ authorization.CompleteGateAPrepare();
+ await engine.DisposeAsync();
+ authorization.Dispose();
+ }
+
+ Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
+ Assert.DoesNotContain(
+ session.Calls,
+ static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
+ Assert.DoesNotContain("Disconnect", session.Calls);
+ Assert.DoesNotContain("Dispose", session.Calls);
+ }
+
private static TornadoPlayoutEngine CreateEngine(
PlayoutOptions rawOptions,
FakeK3dSessionFactory sessionFactory,
@@ -2577,7 +2914,8 @@ public sealed class TornadoPlayoutEngineTests
Func? finalConnectSafetyCheck = null,
bool abandonOnTargetMismatch = false,
Action? beforeSdkDispatchClaim = null,
- FakeLiveAuthorization? liveAuthorization = null)
+ FakeLiveAuthorization? liveAuthorization = null,
+ PlayoutLaunchAuthorization? launchAuthorization = null)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
@@ -2594,7 +2932,8 @@ public sealed class TornadoPlayoutEngineTests
startMonitor: false,
finalConnectSafetyCheck: finalConnectSafetyCheck,
abandonOnTargetMismatch: abandonOnTargetMismatch,
- beforeSdkDispatchClaim: beforeSdkDispatchClaim);
+ beforeSdkDispatchClaim: beforeSdkDispatchClaim,
+ launchAuthorization: launchAuthorization);
}
private static PlayoutOptions TestOptions(
@@ -2630,6 +2969,33 @@ public sealed class TornadoPlayoutEngineTests
MaximumReconnectAttempts = 3
};
+ private static PlayoutOptions GateAOptions(string sceneDirectory) => new()
+ {
+ Mode = PlayoutMode.Live,
+ Host = "127.0.0.1",
+ Port = 30001,
+ TcpMode = 1,
+ LayoutIndex = 10,
+ SceneDirectory = sceneDirectory,
+ TestSceneAllowlist = ["5001"],
+ TrustedLiveOutputEnabled = true,
+ ReconnectEnabled = false,
+ MaximumReconnectAttempts = 0,
+ MaximumAutomaticRefreshesPerTakeIn = 0,
+ LegacySceneFadeDuration = 6,
+ ConnectTimeoutMilliseconds = 500,
+ OperationTimeoutMilliseconds = 500,
+ DisconnectTimeoutMilliseconds = 500,
+ ProcessPollIntervalMilliseconds = 100,
+ ReconnectDelayMilliseconds = 0
+ };
+
+ private static PlayoutCue GateACue() => new(
+ "5001.t2s",
+ "5001",
+ FadeDuration: 6,
+ Mutations: [new PlayoutUseBackground(false)]);
+
private static PlayoutCue Cue(string sceneName) => new(
$"{sceneName}.t2s",
sceneName,