Compare commits

...
Author SHA1 Message Date
QiuSW 009dc3cca0 feat: 建立三项目机器身份与安全传输 v1 (#151) 2026-08-31 10:53:51 +08:00
ila 573113eb3b Merge pull request '#161' from docs/150-contract-acceptance
docs: 同步 #150 标准事件与证据 v1 契约
2026-08-31 10:05:00 +08:00
QiuSW b548b05874 docs: 记录标准事件与证据 v1 契约 (#150) 2026-08-31 10:04:39 +08:00
ila 23a85278cb Merge pull request '#158' from feature/150-event-evidence-contract into dev
冻结 Sense/Brain→Bell 标准事件与证据引用契约 v1(#150)。
2026-08-31 09:55:35 +08:00
ila 96777a948f Merge pull request '#160' from docs/148-149-contract-acceptance into dev
同步 #148、#149 冻结契约的 Wiki 核心镜像。
2026-08-31 09:41:48 +08:00
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW a22d3ce0f1 fix: 对齐多配置运行状态语义 (#149) 2026-08-31 08:59:48 +08:00
QiuSW 359c553452 fix: align evidence success state (#150) 2026-08-31 08:57:25 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 54c58551ae fix: 统一运行状态契约版本标识 (#149) 2026-08-31 08:49:49 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
QiuSW 2a395aa126 feat: freeze event evidence contract v1 (#150) 2026-08-31 08:45:57 +08:00
QiuSW e4fed702c4 feat: 冻结 Brain 运行状态契约 v1 (#149) 2026-08-31 08:44:36 +08:00
ila 49aa79f3b9 Merge pull request '#147' from docs/8-mvp-acceptance into dev
同步 MVP #8 验收后的核心 Wiki 镜像。
2026-08-29 20:49:44 +08:00
QiuSW 64e20e6aed docs: 记录 MVP #8 验收状态 (#8) 2026-08-29 20:49:25 +08:00
ila 19c0868c5d Merge PR #146: Sense 独立验收脚本隔离与诊断修复 (#145)
用户于 2026-08-29 验收通过 #145。
2026-08-29 20:15:35 +08:00
QiuSW 1c6b30fac0 fix: 修复 Sense 独立验收脚本隔离与诊断 (#145) 2026-08-29 20:00:04 +08:00
ila 6702b8a5b9 Merge PR #144: Bell 独立纵切与 Windows 交付验证 (#134)
用户于 2026-08-29 验收通过 #134。
2026-08-29 17:34:07 +08:00
QiuSW a35f1d6770 test: 建立 Bell 独立纵切与 Windows 交付验证 (#134) 2026-08-29 17:30:03 +08:00
ila 6194b664ee Merge PR #143: 修复 Bell 生产外壳白屏 (#142)
用户已于 2026-08-29 明确验收通过 #142。
2026-08-29 17:22:33 +08:00
110 changed files with 6022 additions and 36 deletions
+59
View File
@@ -0,0 +1,59 @@
# Bell 独立纵切验收
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
## 固定工具链
```powershell
$env:GOTOOLCHAIN='go1.26.5'
go version
node --version
corepack pnpm@9.15.1 --version
```
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
## 源码验证
```powershell
Set-Location Bell\server
$env:GOTOOLCHAIN='go1.26.5'
go test ./... -count=1
go vet ./...
go build ./...
Set-Location ..\ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit --runInBand
corepack pnpm@9.15.1 build:prod
```
## Windows 包和隔离 E2E
```powershell
Set-Location <仓库根目录>
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
```
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
浏览器验收打开脚本输出的临时 `base_url`,检查:
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
- 不显示开发工具、定时任务、系统监控等无关入口。
## 仓库闭环
```powershell
python dev_scripts/harness.py check --strict
git diff --check
git status --short --branch
```
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
+59
View File
@@ -0,0 +1,59 @@
# Bell Windows 运行说明
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
## 配置
编辑 `config\bell.env`:
```text
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
```
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
## 启动、检查和停止
```bat
check-bell.bat
start-bell.bat
check-bell.bat -Running
stop-bell.bat
```
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
## 构建和包审计
从仓库根目录运行:
```powershell
Bell\scripts\build\build-windows.bat
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
```
输出:
- `Bell\dist\bell-windows-amd64\`
- `Bell\dist\bell-windows-amd64.zip`
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
## 常见错误
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
+9
View File
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
```
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
## Windows 交付与独立验收
- Windows 构建:`Bell\scripts\build\build-windows.bat`
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
+11
View File
@@ -0,0 +1,11 @@
# Bell production environment. Copy values into process environment or this file.
BELL_HOST=127.0.0.1
BELL_PORT=18090
BELL_WEB_HOST=127.0.0.1
BELL_WEB_PORT=18091
BELL_DATABASE_URL=
BELL_JWT_SECRET=
BELL_BOOTSTRAP_USERNAME=
BELL_BOOTSTRAP_PASSWORD=
BELL_AUTO_MIGRATE=true
BELL_SYNTHETIC_EVENTS_ENABLED=false
+13
View File
@@ -0,0 +1,13 @@
param([Parameter(Mandatory = $true)][string]$WebRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot)
$index = Join-Path $root 'index.html'
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
foreach ($relative in $references | Sort-Object -Unique) {
if ($relative -match '^https?:') { continue }
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
}
Write-Host "Bell web asset check passed: $root"
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
exit /b %errorlevel%
+88
View File
@@ -0,0 +1,88 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$repositoryRoot = Split-Path $bellRoot -Parent
$serverRoot = Join-Path $bellRoot 'server'
$uiRoot = Join-Path $bellRoot 'ui'
$distRoot = Join-Path $bellRoot 'dist'
$target = Join-Path $distRoot 'bell-windows-amd64'
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
$staging = Join-Path $distRoot ('.bell-windows-amd64.staging-' + $PID)
function Assert-ChildPath([string]$Parent,[string]$Child) {
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
$childPath = [IO.Path]::GetFullPath($Child)
if (-not $childPath.StartsWith($parentPath,[StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe build path outside $Parent`: $Child" }
}
function Get-FileSha256([string]$Path) {
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
}
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
$savedToolchain = $env:GOTOOLCHAIN
$env:GOTOOLCHAIN = 'go1.26.5'
try {
Push-Location $serverRoot
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
$nodeVersion = (& node --version).Trim().TrimStart('v')
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; found $goVersion." }
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; found $pnpmVersion." }
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
New-Item -ItemType Directory -Path $staging | Out-Null
Push-Location $uiRoot
try {
& corepack pnpm@9.15.1 install --frozen-lockfile
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
& corepack pnpm@9.15.1 run build:prod
if ($LASTEXITCODE -ne 0) { throw 'Bell UI production build failed.' }
# The frozen Vue CLI differential build references a module runtime
# that ScriptExt removes from disk. The complete legacy bundle is
# present, so make that reproducible bundle the package entry point.
$builtIndex = Join-Path $uiRoot 'dist\index.html'
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
$html = $html.Replace(' nomodule', '')
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
} finally { Pop-Location }
$oldGOOS,$oldGOARCH,$oldCGO = $env:GOOS,$env:GOARCH,$env:CGO_ENABLED
try {
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
Push-Location $serverRoot
try { & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell server Windows build failed.' } } finally { Pop-Location }
} finally { $env:GOOS,$env:GOARCH,$env:CGO_ENABLED=$oldGOOS,$oldGOARCH,$oldCGO }
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'),(Join-Path $staging 'config'),(Join-Path $staging 'LICENSES') | Out-Null
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.yml')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
Copy-Item -LiteralPath (Join-Path $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
$commit = (& git -C $repositoryRoot rev-parse HEAD).Trim()
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'),@("source_commit=$commit",'go=1.26.5','node=22.22.1','pnpm=9.15.1'),(New-Object Text.UTF8Encoding($false)))
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
if ($LASTEXITCODE -ne 0) { throw 'Bell package audit failed.' }
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'),$manifest,(New-Object Text.UTF8Encoding($false)))
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
Move-Item -LiteralPath $staging -Destination $target
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
Write-Host "Bell Windows package: $target"
Write-Host "Bell Windows archive: $archive"
} finally {
$env:GOTOOLCHAIN = $savedToolchain
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
}
+34
View File
@@ -0,0 +1,34 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($PackageRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
$required = @(
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
'VERSION.txt'
)
foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } }
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules','.git','dist','.cache') }
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db','.sqlite','.sqlite3','.dump','.bak') }
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
}
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
}
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
$content = [string](Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue)
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" }
}
Write-Host "Bell package audit passed: $root"
+117
View File
@@ -0,0 +1,117 @@
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$script:BellAllowedEnvironment = @(
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
'BELL_SYNTHETIC_EVENTS_ENABLED'
)
function Get-BellPackageRoot {
param([string]$ScriptDirectory = $PSScriptRoot)
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
}
function Import-BellEnvironment {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
$lineNumber = 0
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
$lineNumber++
$line = $rawLine.Trim()
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
$separator = $line.IndexOf('=')
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
$name = $line.Substring(0, $separator).Trim()
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
$value = $line.Substring($separator + 1)
if ($value.Length -ge 2) {
$first, $last = $value[0], $value[$value.Length - 1]
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
}
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Get-BellEnvironmentValue {
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
return $value
}
function Test-BellTcpEndpoint {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
$client = [Net.Sockets.TcpClient]::new()
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
}
function Test-BellListenPortAvailable {
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
}
function Get-BellDatabaseEndpoint {
param([Parameter(Mandatory = $true)][string]$Connection)
if ($Connection -match '^postgres(?:ql)?://') {
$uri = [Uri]$Connection
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
}
$values = @{}
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
$value = $match.Groups['value'].Value.Trim("'", '"')
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
}
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
}
function Get-BellPort {
param([string]$Name, [int]$Default)
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
$port = 0
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
return $port
}
function Initialize-BellRuntime {
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
if (-not $AllowOccupiedPorts) {
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
}
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
$webRoot = Join-Path $PackageRoot 'web'
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
return [pscustomobject]@{
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
}
}
function Wait-BellHealth {
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
Start-Sleep -Milliseconds 300
}
throw "Bell health check timed out: $BaseUrl/healthz"
}
+72
View File
@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][string]$WebRoot,
[Parameter(Mandatory = $true)][string]$ListenHost,
[Parameter(Mandatory = $true)][int]$ListenPort,
[Parameter(Mandatory = $true)][string]$BackendUrl
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
$handler = [Net.Http.HttpClientHandler]::new()
$handler.UseProxy = $false
$client = [Net.Http.HttpClient]::new($handler)
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
try {
$listener.Start()
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
$request = $context.Request
$response = $context.Response
$path = $request.Url.AbsolutePath
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
$target = "$BackendUrl$($request.Url.PathAndQuery)"
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
if ($request.HasEntityBody) {
$memory = [IO.MemoryStream]::new()
$request.InputStream.CopyTo($memory)
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
$memory.Dispose()
}
foreach ($key in $request.Headers.AllKeys) {
if ($key -in @('Host','Content-Length')) { continue }
$values = $request.Headers.GetValues($key)
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
}
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
$response.StatusCode = [int]$upstream.StatusCode
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
$message.Dispose(); $upstream.Dispose()
} else {
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
$file = Join-Path $root 'index.html'
}
if ($response.StatusCode -ne 403) {
$bytes = [IO.File]::ReadAllBytes($file)
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
$response.ContentLength64 = $bytes.Length
$response.OutputStream.Write($bytes, 0, $bytes.Length)
}
}
} catch {
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
} finally {
try { $context.Response.OutputStream.Close() } catch {}
}
}
} finally {
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
exit /b %errorlevel%
+12
View File
@@ -0,0 +1,12 @@
param([switch]$Running)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
if ($Running) {
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
}
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
exit /b %errorlevel%
+41
View File
@@ -0,0 +1,41 @@
param([switch]$SkipMigration)
. (Join-Path $PSScriptRoot 'bell-common.ps1')
$backend = $null
$pidFile = $null
try {
$root = Get-BellPackageRoot
$state = Initialize-BellRuntime -PackageRoot $root
$bell = Join-Path $root 'bell.exe'
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
$runtime = Join-Path $root 'runtime'
$logs = Join-Path $runtime 'logs'
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
$pidFile = Join-Path $runtime 'bell.pid'
if (Test-Path -LiteralPath $pidFile) {
$oldPid = 0
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
Remove-Item -LiteralPath $pidFile -Force
}
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
Push-Location $root
try {
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
Write-Host 'Applying pending Bell database migrations...'
& $bell migrate -c $state.SettingsPath
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
}
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
Wait-BellHealth -BaseUrl $state.BackendUrl
Write-Host "Bell is available at $($state.WebUrl)/"
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
} finally { Pop-Location }
} catch {
Write-Error $_.Exception.Message
exit 1
} finally {
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
}
+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
where pwsh.exe >nul 2>nul
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
exit /b %errorlevel%
+17
View File
@@ -0,0 +1,17 @@
. (Join-Path $PSScriptRoot 'bell-common.ps1')
try {
$root = Get-BellPackageRoot
$pidFile = Join-Path $root 'runtime\bell.pid'
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
$processId = 0
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
$rootPattern = [regex]::Escape($root)
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
& taskkill.exe /PID $processId /T /F | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Write-Host 'Bell backend and web process tree stopped.'
exit 0
} catch { Write-Error $_.Exception.Message; exit 1 }
+7
View File
@@ -0,0 +1,7 @@
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
if($KeepTemporary){$arguments+='-KeepTemporary'}
if($BrowserHold){$arguments+='-BrowserHold'}
& pwsh.exe @arguments
exit $LASTEXITCODE
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
package machine_identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
type crossLanguageVector struct {
PublicKey string `json:"public_key_base64url"`
Token string `json:"token"`
Now int64 `json:"now"`
Audience string `json:"audience"`
Scope string `json:"required_scope"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body_base64"`
}
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0).UTC()
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
}
func errorCode(t *testing.T, err error) string {
t.Helper()
var coded *Error
if !errors.As(err, &coded) {
t.Fatalf("expected coded error, got %v", err)
}
return coded.Code
}
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
t.Fatal(err)
}
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
t.Fatalf("accepted %q", value)
}
}
}
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
mint := func() string {
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
return token
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
token := mint()
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
t.Fatal(err)
}
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
t.Fatal(err)
}
}
func TestExpiryRevocationAndRotation(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte("{}")
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
t.Fatal(err)
}
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
rotation, err := NewRegistry(
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if !rotation.Revoke("brain-old-0001") {
t.Fatal("old key was not revoked")
}
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
t.Fatal(err)
}
}
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
if err := safe.Validate(); err != nil {
t.Fatal(err)
}
unsafe := safe
unsafe.VerifyHostname = false
if err := unsafe.Validate(); err == nil {
t.Fatal("unsafe hostname policy accepted")
}
unsafe = safe
unsafe.TLSMinVersion = tls.VersionTLS11
if err := unsafe.Validate(); err == nil {
t.Fatal("TLS 1.1 accepted")
}
}
func TestVerifiesCrossLanguageVector(t *testing.T) {
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
raw, err := os.ReadFile(vectorPath)
if err != nil {
t.Fatal(err)
}
var vector crossLanguageVector
if err = json.Unmarshal(raw, &vector); err != nil {
t.Fatal(err)
}
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
if err != nil {
t.Fatal(err)
}
body, err := base64.StdEncoding.DecodeString(vector.Body)
if err != nil {
t.Fatal(err)
}
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != "yv:brain:vector" {
t.Fatalf("unexpected issuer: %s", claims.Issuer)
}
}
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
}}}},
}
raw, _ := json.Marshal(document)
file := filepath.Join(t.TempDir(), "principals.json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
registry, err := LoadRegistry(file, "yovision-bell")
if err != nil {
t.Fatal(err)
}
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
t.Fatal("registry record missing")
}
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
t.Fatal("wrong registry audience accepted")
}
}
@@ -0,0 +1,39 @@
package machine_identity
import (
"crypto/tls"
"errors"
"net/http"
"time"
)
type TransportPolicy struct {
TLSMinVersion uint16
VerifyCertificate bool
VerifyHostname bool
ConnectTimeout time.Duration
ResponseHeaderTimeout time.Duration
RequestTimeout time.Duration
MaxRequestBytes int64
}
func (p TransportPolicy) Validate() error {
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
return errors.New("machine transport policy is unsafe")
}
return nil
}
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
if err := p.Validate(); err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
TLSHandshakeTimeout: p.ConnectTimeout,
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
}
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
}
@@ -0,0 +1,8 @@
param([Parameter(Mandatory = $true)][string]$PackageRoot)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$web = Join-Path ([IO.Path]::GetFullPath($PackageRoot)) 'web'
& (Join-Path $PSScriptRoot '..\..\scripts\build\assert-web-assets.ps1') -WebRoot $web
$index = Get-Content -LiteralPath (Join-Path $web 'index.html') -Raw -Encoding UTF8
if ($index -notmatch 'id=["'']app["'']') { throw 'Bell package does not contain the GoAdmin Vue application mount.' }
Write-Host 'Bell GoAdmin shell compatibility check passed.'
+204
View File
@@ -0,0 +1,204 @@
param(
[string]$PostgresBin = 'D:\pgsql17\bin',
[string]$PreparedPackageRoot = '',
[switch]$KeepTemporary,
[switch]$BrowserHold
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$bellRoot = Join-Path $repositoryRoot 'Bell'
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $temporary 'postgres'
$pgLog = Join-Path $temporary 'postgres.log'
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
$launcher = $null
$pgStarted = $false
$savedEnvironment = @{}
function Get-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
}
function Get-UniqueFreePorts([int]$Count) {
$ports = [Collections.Generic.List[int]]::new()
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
return $ports.ToArray()
}
function New-RandomText([int]$Bytes=32) {
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
}
function Set-TestEnvironment([string]$Name,[string]$Value) {
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
}
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
for($i=0;$i -lt $Attempts;$i++) {
$client=[Net.Sockets.TcpClient]::new()
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
}
throw "TCP port $Port did not reach open=$Open"
}
function Wait-Health([string]$BaseUrl) {
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
throw "Bell health endpoint did not become ready: $BaseUrl"
}
function Invoke-BellJson {
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
return $response
}
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
foreach($menu in @($Menus)) {
if($null-eq$menu){continue}
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
if($menu.PSObject.Properties.Name-contains'children'){
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
}
}
}
function Start-Package([string]$Root){
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
Wait-Health $script:baseUrl
}
function Stop-Package([string]$Root){
& (Join-Path $Root 'stop-bell.bat') | Out-Host
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
$script:launcher=$null
}
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
New-Item -ItemType Directory -Path $temporary | Out-Null
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
$packageRoot=Join-Path $temporary 'package'
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
# Production captcha behavior is covered by #138. The isolated business
# E2E uses a disposable package copy in dev mode so it never needs to
# expose or OCR a captcha answer.
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
$script:baseUrl="http://127.0.0.1:$script:webPort"
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
$environment=@{
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
}
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
Start-Package $packageRoot
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
$adminToken=Login $adminName $adminPassword
$psql=Join-Path $PostgresBin 'psql.exe'
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
if($operatorRole-lt 1){throw 'operator role was not migrated'}
$operators=@(
@{username='bell_e2e_operator_a';nickName='处置员A'},
@{username='bell_e2e_operator_b';nickName='处置员B'}
)
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
$eventBody.eventType=$eventType
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
$eventId=[string]$created.data.event.id
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
$alert=@($alerts.data.list)[0]
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
$alertId=[string]$alert.id
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
$acks=$requests|ForEach-Object -Parallel {
$headers=@{Authorization="Bearer $($_.Token)"}
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
[pscustomobject]@{Token=$_.Token;Response=$response}
} -ThrottleLimit 20
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
$winnerToken=[string]$winners[0].Token
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
Stop-Package $packageRoot
Start-Package $packageRoot
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
if($BrowserHold){
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
Write-Host "Bell browser session ready: $browserSession"
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
}
Stop-Package $packageRoot
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
} finally {
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
}
+12
View File
@@ -0,0 +1,12 @@
{
"sourceEventId": "bell-e2e-danger-001",
"eventType": "danger_area_entered",
"occurredAt": "2026-08-29T00:00:00Z",
"location": "东门危险区域",
"severity": "high",
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
"attributes": {
"target": "anonymous",
"fixture": true
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.1.0"
description = "Headless inference delivery unit for YoVision"
readme = "README.md"
requires-python = "==3.11.*"
dependencies = []
dependencies = ["cryptography==50.0.1"]
[project.optional-dependencies]
# The wheel backend is selected by the official PyTorch index documented in
@@ -0,0 +1,37 @@
"""Independent service-to-service machine identity for Brain connectors."""
from .token import (
ALLOWED_SKEW_SECONDS,
MAX_KEY_OVERLAP_SECONDS,
MAX_LIFETIME_SECONDS,
VERSION,
Claims,
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
Verifier,
load_private_key,
load_registry,
bearer_token,
)
from .transport import TransportPolicy
__all__ = [
"ALLOWED_SKEW_SECONDS",
"MAX_KEY_OVERLAP_SECONDS",
"MAX_LIFETIME_SECONDS",
"VERSION",
"Claims",
"KeyRecord",
"MachineIdentityError",
"Registry",
"ReplayStore",
"Signer",
"TransportPolicy",
"Verifier",
"load_private_key",
"load_registry",
"bearer_token",
]
@@ -0,0 +1,336 @@
"""Ed25519 request-bound machine tokens.
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
shared secrets. HTTP adapters must obtain the compact token exclusively from
the Authorization bearer header and pass the request body unchanged.
"""
from __future__ import annotations
import base64
import hashlib
import json
import posixpath
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Protocol
from urllib.parse import urlsplit
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
VERSION = "yovision.machine-identity/v1"
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
MAX_LIFETIME_SECONDS = 300
ALLOWED_SKEW_SECONDS = 30
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
class MachineIdentityError(ValueError):
"""A stable, non-secret authentication failure."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def bearer_token(authorization: str) -> str:
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
raise MachineIdentityError("machine_token_missing")
return parts[1]
@dataclass(frozen=True)
class Claims:
ver: str
iss: str
sub: str
aud: str
scope: tuple[str, ...]
iat: int
nbf: int
exp: int
jti: str
htm: str
htu: str
body_sha256: str
@dataclass(frozen=True)
class KeyRecord:
principal: str
key_id: str
public_key: Ed25519PublicKey
audience: str
scopes: frozenset[str]
enabled: bool = True
revoked: bool = False
class Registry:
def __init__(self, records: Iterable[KeyRecord]) -> None:
self._lock = threading.RLock()
self._records: dict[str, KeyRecord] = {}
for record in records:
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
raise ValueError("invalid machine key record")
if record.key_id in self._records:
raise ValueError("duplicate machine key id")
self._records[record.key_id] = record
def lookup(self, key_id: str) -> KeyRecord | None:
with self._lock:
return self._records.get(key_id)
def revoke(self, key_id: str) -> bool:
with self._lock:
record = self._records.get(key_id)
if record is None:
return False
self._records[key_id] = KeyRecord(
principal=record.principal,
key_id=record.key_id,
public_key=record.public_key,
audience=record.audience,
scopes=record.scopes,
enabled=record.enabled,
revoked=True,
)
return True
class ReplayStore:
"""Process-local replay cache for tests or one uninterrupted process."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._used: dict[tuple[str, str], int] = {}
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
with self._lock:
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
key = (principal, token_id)
if key in self._used:
return False
self._used[key] = expires_at
return True
class ReplayCache(Protocol):
"""Connector implementations provide an atomic durable implementation."""
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
if not str(path).strip():
raise ValueError("machine private key path is required")
try:
raw = Path(path).read_bytes()
key = serialization.load_pem_private_key(raw, password=None)
except (OSError, ValueError, TypeError) as exc:
raise ValueError("invalid machine private key file") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("machine private key is not Ed25519")
return key
def load_registry(path: str | Path, expected_audience: str) -> Registry:
if not str(path).strip() or expected_audience not in _AUDIENCES:
raise ValueError("machine principal registry path and audience are required")
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid machine principal registry") from exc
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
raise ValueError("invalid machine principal registry")
records: list[KeyRecord] = []
try:
for principal in document["principals"]:
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
raise ValueError
for key in principal["keys"]:
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
raise ValueError
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
except (KeyError, TypeError, ValueError):
raise ValueError("invalid machine principal registry") from None
return Registry(records)
class Signer:
def __init__(
self,
principal: str,
key_id: str,
private_key: Ed25519PrivateKey,
*,
clock: Callable[[], int] | None = None,
) -> None:
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("incomplete machine signer configuration")
self._principal = principal
self._key_id = key_id
self._private_key = private_key
self._clock = clock or (lambda: int(time.time()))
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
normalized_path = _normalize_path(request_path)
normalized_method = method.upper()
scope_values = tuple(scopes)
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
raise ValueError("invalid machine token request")
now = int(self._clock())
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
claims = {
"ver": VERSION,
"iss": self._principal,
"sub": self._principal,
"aud": audience,
"scope": list(scope_values),
"iat": now,
"nbf": now,
"exp": now + MAX_LIFETIME_SECONDS,
"jti": secrets.token_urlsafe(16),
"htm": normalized_method,
"htu": normalized_path,
"body_sha256": hashlib.sha256(body).hexdigest(),
}
encoded_header = _encode_json(header)
encoded_claims = _encode_json(claims)
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
signature = self._private_key.sign(signing_input)
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
class Verifier:
def __init__(
self,
registry: Registry,
replay_store: ReplayCache,
*,
clock: Callable[[], int] | None = None,
) -> None:
self._registry = registry
self._replay_store = replay_store
self._clock = clock or (lambda: int(time.time()))
def verify(
self,
token: str,
audience: str,
required_scope: str,
method: str,
request_path: str,
body: bytes,
) -> Claims:
parts = token.split(".")
if len(parts) != 3 or "=" in token:
raise MachineIdentityError("machine_token_invalid")
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
raise MachineIdentityError("machine_token_invalid")
record = self._registry.lookup(header["kid"])
if record is None:
raise MachineIdentityError("machine_token_invalid")
try:
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
except (InvalidSignature, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
if not record.enabled or record.revoked:
raise MachineIdentityError("machine_identity_revoked")
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
claims = _claims_from_object(raw)
if claims.iss != record.principal or claims.sub != record.principal:
raise MachineIdentityError("machine_token_invalid")
now = int(self._clock())
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_invalid")
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_expired")
if claims.aud != audience or record.audience != audience:
raise MachineIdentityError("machine_audience_denied")
if required_scope not in claims.scope or required_scope not in record.scopes:
raise MachineIdentityError("machine_scope_denied")
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
raise MachineIdentityError("machine_token_invalid")
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
raise MachineIdentityError("machine_token_replayed")
return claims
def _claims_from_object(value: dict[str, object]) -> Claims:
try:
scope = value["scope"]
if not isinstance(scope, list) or not _valid_scopes(scope):
raise ValueError
integer_fields = ("iat", "nbf", "exp")
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
raise ValueError
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
if any(not isinstance(value[field], str) for field in string_fields):
raise ValueError
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
raise ValueError
bytes.fromhex(claims.body_sha256)
_normalize_path(claims.htu)
return claims
except (KeyError, TypeError, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
def _normalize_path(value: str) -> str:
split = urlsplit(value)
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
raise ValueError("machine request path must be normalized and contain no query or fragment")
return split.path
def _valid_scopes(scopes: Iterable[str]) -> bool:
values = tuple(scopes)
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
def _encode_json(value: dict[str, object]) -> str:
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
try:
decoded = json.loads(_b64decode(value).decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
raise MachineIdentityError("machine_token_invalid") from None
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
raise MachineIdentityError("machine_token_invalid")
return decoded
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
if not value or "=" in value:
raise ValueError("invalid base64url")
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
if _b64encode(decoded) != value:
raise ValueError("non-canonical base64url")
return decoded
@@ -0,0 +1,37 @@
"""Fail-closed HTTPS transport policy for Brain connectors."""
from __future__ import annotations
import ssl
from dataclasses import dataclass
@dataclass(frozen=True)
class TransportPolicy:
tls_min_version: str
verify_certificate: bool
verify_hostname: bool
connect_timeout_ms: int
response_header_timeout_ms: int
request_timeout_ms: int
max_request_bytes: int
def validate(self) -> None:
if (
self.tls_min_version not in {"1.2", "1.3"}
or not self.verify_certificate
or not self.verify_hostname
or not 100 <= self.connect_timeout_ms <= 30_000
or not 100 <= self.response_header_timeout_ms <= 30_000
or not 100 <= self.request_timeout_ms <= 60_000
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
):
raise ValueError("machine transport policy is unsafe")
def ssl_context(self) -> ssl.SSLContext:
self.validate()
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
return context
+8
View File
@@ -15,6 +15,14 @@
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
默认入口只复制 Git 已跟踪的 `Sense/` 源码到系统临时目录,因此正常开发工作区中已有的 `node_modules`、`dist`、本地配置、日志和其他未跟踪文件不会进入验收副本。`-PreparedPackageRoot` 也会先把指定包复制到本次临时目录,运行时配置、浏览器脚本、截图和日志不会写回原包或源码树。
Sense 进程提前退出或 HTTP 就绪超时时,脚本返回非零并输出阶段、退出状态、临时日志位置和经过过滤、截断的日志摘要;数据库连接、密码、token、Cookie、JWT 和 credential key 不得出现在诊断中。默认无论成功或失败都会清理所属进程和临时目录;`-KeepTemporary` 仅用于排错,仍会停止进程,但保留目录可能包含随机运行时秘密,必须限制访问并在排错后安全删除。
为避免 Windows 首次扫描临时复制的 MediaMTX 二进制占用产品固定的就绪窗口,E2E 会先在同一动态端口和临时配置上启动一次包内 MediaMTX,确认 Control API 可用并完全停止,再由 Sense 以 managed 模式启动并完成生命周期验收。预检失败会单独报告 `MediaMTX preflight` 阶段,不会被误报为 Sense HTTP 超时。
HTTP、RTSP、HLS、Control API 和 ONVIF 动态端口使用 TCP 绑定探测;WebRTC 本地 UDP 端口必须使用 UDP socket 实际绑定探测,不得用 TCP 空闲结果代替,避免落入 Windows 的 UDP 排除或占用范围。
## 回归矩阵
| 范围 | 自动化证据 | 判定 |
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
package machine_identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
type crossLanguageVector struct {
PublicKey string `json:"public_key_base64url"`
Token string `json:"token"`
Now int64 `json:"now"`
Audience string `json:"audience"`
Scope string `json:"required_scope"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body_base64"`
}
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0).UTC()
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
}
func errorCode(t *testing.T, err error) string {
t.Helper()
var coded *Error
if !errors.As(err, &coded) {
t.Fatalf("expected coded error, got %v", err)
}
return coded.Code
}
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
t.Fatal(err)
}
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
t.Fatalf("accepted %q", value)
}
}
}
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
mint := func() string {
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
return token
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
token := mint()
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
t.Fatal(err)
}
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
t.Fatal(err)
}
}
func TestExpiryRevocationAndRotation(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte("{}")
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
t.Fatal(err)
}
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
rotation, err := NewRegistry(
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if !rotation.Revoke("brain-old-0001") {
t.Fatal("old key was not revoked")
}
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
t.Fatal(err)
}
}
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
if err := safe.Validate(); err != nil {
t.Fatal(err)
}
unsafe := safe
unsafe.VerifyHostname = false
if err := unsafe.Validate(); err == nil {
t.Fatal("unsafe hostname policy accepted")
}
unsafe = safe
unsafe.TLSMinVersion = tls.VersionTLS11
if err := unsafe.Validate(); err == nil {
t.Fatal("TLS 1.1 accepted")
}
}
func TestVerifiesCrossLanguageVector(t *testing.T) {
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
raw, err := os.ReadFile(vectorPath)
if err != nil {
t.Fatal(err)
}
var vector crossLanguageVector
if err = json.Unmarshal(raw, &vector); err != nil {
t.Fatal(err)
}
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
if err != nil {
t.Fatal(err)
}
body, err := base64.StdEncoding.DecodeString(vector.Body)
if err != nil {
t.Fatal(err)
}
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != "yv:brain:vector" {
t.Fatalf("unexpected issuer: %s", claims.Issuer)
}
}
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
}}}},
}
raw, _ := json.Marshal(document)
file := filepath.Join(t.TempDir(), "principals.json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
registry, err := LoadRegistry(file, "yovision-bell")
if err != nil {
t.Fatal(err)
}
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
t.Fatal("registry record missing")
}
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
t.Fatal("wrong registry audience accepted")
}
}
@@ -0,0 +1,39 @@
package machine_identity
import (
"crypto/tls"
"errors"
"net/http"
"time"
)
type TransportPolicy struct {
TLSMinVersion uint16
VerifyCertificate bool
VerifyHostname bool
ConnectTimeout time.Duration
ResponseHeaderTimeout time.Duration
RequestTimeout time.Duration
MaxRequestBytes int64
}
func (p TransportPolicy) Validate() error {
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
return errors.New("machine transport policy is unsafe")
}
return nil
}
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
if err := p.Validate(); err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
TLSHandshakeTimeout: p.ConnectTimeout,
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
}
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
}
@@ -49,4 +49,18 @@ foreach ($file in $fixtureFiles) {
if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ }
}
$e2eScript = Read-Utf8 (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1')
Assert-True ($e2eScript.Contains('Copy-TrackedSenseSource $repositoryRoot $senseCopy')) 'Sense E2E no longer copies only tracked source'
Assert-True (-not $e2eScript.Contains('Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse')) 'Sense E2E regressed to recursive source-tree copying'
Assert-True ($e2eScript.Contains("`$browserScript = Join-Path `$PSScriptRoot 'browser-smoke.cjs'")) 'browser smoke script no longer runs from its tracked location'
Assert-True ($e2eScript.Contains("`$packageRoot = Join-Path `$temporary 'prepared-package'")) 'prepared package no longer runs from an isolated temporary copy'
Assert-True ($e2eScript.Contains("-Process `$process -Stage 'Sense HTTP'")) 'Sense HTTP readiness no longer observes the package process'
Assert-True ($e2eScript.Contains('Get-SafeLogSummary')) 'Sense readiness diagnostics no longer use log redaction'
Assert-True ($e2eScript.Contains("-Stage 'MediaMTX preflight'")) 'Sense E2E no longer preflights the copied MediaMTX package and config'
Assert-True ($e2eScript.Contains('function Get-FreeUdpPort')) 'Sense E2E no longer probes WebRTC UDP ports with the UDP protocol'
Assert-True ($e2eScript.Contains('do { $webrtcUDPort = Get-FreeUdpPort }')) 'Sense E2E WebRTC UDP port regressed to TCP-only discovery'
& powershell.exe -NoProfile -File (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1') -HarnessSelfTest
if ($LASTEXITCODE -ne 0) { throw 'Sense E2E harness self-test failed' }
Write-Host "Sense compatibility regression passed: $passed assertions."
+141 -16
View File
@@ -3,11 +3,13 @@ param(
[string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe',
[string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe',
[string]$PreparedPackageRoot = '',
[switch]$HarnessSelfTest,
[switch]$KeepTemporary
)
if ($PSVersionTable.PSEdition -eq 'Core') {
$legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser)
if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) }
if ($HarnessSelfTest) { $legacyArguments += '-HarnessSelfTest' }
if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' }
& powershell.exe @legacyArguments
exit $LASTEXITCODE
@@ -34,8 +36,10 @@ $fixtureStatus = Join-Path $temporary 'fixture-status.json'
$server = $null
$fixture = $null
$publisher = $null
$preflightMedia = $null
$pgStarted = $false
$savedEnvironment = @{}
$sensitiveValues = @()
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
@@ -61,15 +65,68 @@ function Set-TestEnvironment([string]$Name, [string]$Value) {
}
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
}
function Wait-Http([string]$Uri, [int]$Attempts = 120) {
function Get-FreeUdpPort {
$client = [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, 0))
try { return ([Net.IPEndPoint]$client.Client.LocalEndPoint).Port } finally { $client.Dispose() }
}
function Copy-TrackedSenseSource([string]$RepositoryRoot, [string]$Destination) {
$tracked = @(& git -C $RepositoryRoot ls-files -- 'Sense')
if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'Could not enumerate tracked Sense source files' }
$sensePrefix = 'Sense\'
foreach ($relative in $tracked) {
$normalized = ([string]$relative).Replace('/', '\')
if (-not $normalized.StartsWith($sensePrefix, [StringComparison]::Ordinal)) {
throw "Unexpected tracked path outside Sense: $relative"
}
$source = Join-Path $RepositoryRoot $normalized
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Tracked Sense source is missing: $relative" }
$target = Join-Path $Destination $normalized.Substring($sensePrefix.Length)
$parent = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $parent)) { [void](New-Item -ItemType Directory -Path $parent -Force) }
Copy-Item -LiteralPath $source -Destination $target
}
}
function Get-SafeLogSummary([string[]]$Paths, [int]$MaximumCharacters = 2000) {
$parts = New-Object System.Collections.Generic.List[string]
foreach ($path in @($Paths)) {
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
$text = [string](@(Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue) -join ' | ')
foreach ($secret in @($script:sensitiveValues)) {
if (-not [string]::IsNullOrWhiteSpace($secret) -and $secret.Length -ge 4) { $text = $text.Replace($secret, '<redacted>') }
}
$text = $text -replace '(?i)((?:password|token|secret|credential(?:_key)?|database(?:_url)?|cookie|authorization)["'']?\s*[:=]\s*["'']?)[^\s,;"'']+', '$1<redacted>'
$text = $text -replace '(?i)(postgres(?:ql)?://)[^\s]+', '$1<redacted>'
if ($text.Length -gt $MaximumCharacters) { $text = $text.Substring($text.Length - $MaximumCharacters) }
if (-not [string]::IsNullOrWhiteSpace($text)) { $parts.Add("$([IO.Path]::GetFileName($path)): $text") }
}
if ($parts.Count -eq 0) { return '<no log output>' }
return ($parts -join ' || ')
}
function Wait-Http {
param(
[string]$Uri,
[int]$Attempts = 120,
$Process = $null,
[string]$Stage = 'HTTP endpoint',
[string[]]$LogPaths = @()
)
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
if ($null -ne $Process -and $Process.HasExited) {
try { $Process.WaitForExit(); $Process.Refresh() } catch {}
$exitCode = try { [string]$Process.ExitCode } catch { 'unknown' }
if ([string]::IsNullOrWhiteSpace($exitCode)) { $exitCode = 'unknown' }
$summary = Get-SafeLogSummary $LogPaths
throw "$Stage process exited before readiness: exit_code=$exitCode; log_files=$($LogPaths -join ','); summary=$summary"
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
if ($response.StatusCode -eq 200) { return }
} catch {}
Start-Sleep -Milliseconds 500
}
throw "HTTP endpoint did not become ready: $Uri"
$processState = if ($null -eq $Process) { 'not-observed' } elseif ($Process.HasExited) { "exited:$($Process.ExitCode)" } else { 'running' }
$summary = Get-SafeLogSummary $LogPaths
throw "$Stage did not become ready: uri=$Uri; process_state=$processState; log_files=$($LogPaths -join ','); summary=$summary"
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
@@ -104,13 +161,62 @@ function Invoke-SenseJson {
function Start-SensePackage([string]$PackageRoot) {
$launcher = Join-Path $PackageRoot 'start-sense.bat'
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru
Wait-Http "$script:baseUrl/"
Wait-Http -Uri "$script:baseUrl/" -Process $process -Stage 'Sense HTTP' -LogPaths @($runtimeLog, $runtimeError)
return $process
}
function Stop-ProcessTree($Process) {
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
}
function Invoke-HarnessSelfTest {
$root = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-selftest-' + [guid]::NewGuid().ToString('N'))
$originalSensitiveValues = @($script:sensitiveValues)
try {
$fixtureRepository = Join-Path $root 'repository'
$trackedSource = Join-Path $fixtureRepository 'Sense\tracked.txt'
$ignoredSource = Join-Path $fixtureRepository 'Sense\ui\node_modules\ignored.txt'
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $trackedSource) -Force)
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredSource) -Force)
[IO.File]::WriteAllText($trackedSource, 'tracked', (New-Object Text.UTF8Encoding($false)))
[IO.File]::WriteAllText($ignoredSource, 'ignored', (New-Object Text.UTF8Encoding($false)))
& git -C $fixtureRepository init --quiet
& git -C $fixtureRepository add -- 'Sense/tracked.txt'
if ($LASTEXITCODE -ne 0) { throw 'Harness self-test could not prepare tracked source' }
$copy = Join-Path $root 'copy'
Copy-TrackedSenseSource $fixtureRepository $copy
if (-not (Test-Path -LiteralPath (Join-Path $copy 'tracked.txt'))) { throw 'Harness self-test did not copy tracked source' }
if (Test-Path -LiteralPath (Join-Path $copy 'ui\node_modules\ignored.txt')) { throw 'Harness self-test copied ignored node_modules content' }
$udpPort = Get-FreeUdpPort
$udpProbe = [Net.Sockets.UdpClient]::new()
try { $udpProbe.Client.Bind([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, $udpPort)) } finally { $udpProbe.Dispose() }
$diagnosticLog = Join-Path $root 'sense.err.log'
[IO.File]::WriteAllText($diagnosticLog, 'SENSE_JWT_SECRET=unit-secret-value', (New-Object Text.UTF8Encoding($false)))
$script:sensitiveValues = @('unit-secret-value')
$exited = [pscustomobject]@{ HasExited = $true; ExitCode = 23 }
$earlyFailure = ''
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 3 -Process $exited -Stage 'Self-test early exit' -LogPaths @($diagnosticLog) } catch { $earlyFailure = $_.Exception.Message }
if ($earlyFailure -notmatch 'exit_code=23' -or $earlyFailure.Contains('unit-secret-value') -or $earlyFailure -notmatch '<redacted>') {
throw "Harness self-test early-exit diagnostic was unsafe or incomplete: $earlyFailure"
}
$timeoutFailure = ''
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 1 -Stage 'Self-test timeout' -LogPaths @($diagnosticLog) } catch { $timeoutFailure = $_.Exception.Message }
if ($timeoutFailure -notmatch 'process_state=not-observed' -or $timeoutFailure.Contains('unit-secret-value') -or $timeoutFailure -notmatch '<redacted>') {
throw "Harness self-test timeout diagnostic was unsafe or incomplete: $timeoutFailure"
}
Write-Host 'Sense E2E harness self-test passed: tracked copy, UDP bind, early exit, timeout and redaction.'
} finally {
$script:sensitiveValues = $originalSensitiveValues
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
}
if ($HarnessSelfTest) {
Invoke-HarnessSelfTest
exit 0
}
try {
foreach ($required in @(
(Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'),
@@ -121,25 +227,32 @@ try {
}
$ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) {
New-Item -ItemType Directory -Path $repoCopy | Out-Null
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
New-Item -ItemType Directory -Path $senseCopy -Force | Out-Null
Copy-TrackedSenseSource $repositoryRoot $senseCopy
& git -C $repoCopy init --quiet
& git -C $repoCopy config user.name 'Sense E2E'
& git -C $repoCopy config user.email 'sense-e2e@invalid.local'
& git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source'
& git -C $repoCopy config core.autocrlf false
& git -C $repoCopy add -- Sense
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source staging failed' }
& git -C $repoCopy commit --quiet -m 'temporary acceptance source'
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source commit failed' }
Write-Host 'Building Sense Windows package in an isolated temporary copy...'
& pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX
if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' }
$packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64'
} else {
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
Write-Host "Using prepared isolated package: $packageRoot"
$preparedInput = [IO.Path]::GetFullPath($PreparedPackageRoot)
if (-not (Test-Path -LiteralPath (Join-Path $preparedInput 'sense.exe'))) { throw 'prepared Sense package is invalid' }
$packageRoot = Join-Path $temporary 'prepared-package'
Copy-Item -LiteralPath $preparedInput -Destination $packageRoot -Recurse
Write-Host "Using temporary copy of prepared package: $packageRoot"
}
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
$tcpPorts = @(Get-UniqueFreePorts 7)
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $mediaAPIPort, $onvifPort = $tcpPorts
do { $webrtcUDPort = Get-FreeUdpPort } while ($tcpPorts -contains $webrtcUDPort)
$script:baseUrl = "http://127.0.0.1:$sensePort"
Write-Host "Initializing isolated PostgreSQL on port $pgPort..."
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null
@@ -162,6 +275,16 @@ try {
'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:'
) -join "`n"
[IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false)))
$preflightOut = Join-Path $temporary 'mediamtx-preflight.out.log'
$preflightError = Join-Path $temporary 'mediamtx-preflight.err.log'
$preflightBinary = Join-Path $packageRoot 'bin\mediamtx.exe'
$preflightConfig = Join-Path $packageRoot 'config\mediamtx.yml'
$preflightMedia = Start-Process -FilePath $preflightBinary -ArgumentList $preflightConfig -WorkingDirectory (Split-Path -Parent $preflightConfig) -RedirectStandardOutput $preflightOut -RedirectStandardError $preflightError -WindowStyle Hidden -PassThru
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $preflightMedia -Stage 'MediaMTX preflight' -LogPaths @($preflightOut, $preflightError) -Attempts 60
Stop-ProcessTree $preflightMedia
Wait-Tcp -Port $mediaAPIPort -Open $false -Attempts 40
$preflightMedia = $null
Write-Host 'MediaMTX package/config preflight passed before managed Sense startup.'
$jwt = New-RandomText 48
$bootstrap = New-RandomText 48
@@ -173,6 +296,7 @@ try {
$cameraUser = 'fixture_' + (New-RandomText 8)
$cameraPassword = New-RandomText 24
$database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable"
$script:sensitiveValues = @($jwt, $bootstrap, $adminPassword, $credentialKey, $cameraUser, $cameraPassword, $database)
$environment = @{
SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database;
SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap;
@@ -198,7 +322,7 @@ try {
if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' }
$server = Start-SensePackage $packageRoot
Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $server -Stage 'MediaMTX API' -LogPaths @($runtimeLog, $runtimeError)
$publisherArguments = @(
'-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp',
@@ -214,6 +338,7 @@ try {
if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' }
$login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword }
$token = [string]$login.token
$script:sensitiveValues += $token
if ($token.Length -lt 20) { throw 'login did not return a usable token' }
$unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401
@@ -265,13 +390,12 @@ try {
$area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0]
if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' }
$browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs'
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
$browserScript = Join-Path $PSScriptRoot 'browser-smoke.cjs'
foreach ($item in @{
SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser;
SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png')
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
Push-Location (Join-Path $senseCopy 'ui')
Push-Location $temporary
try { & node.exe $browserScript } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' }
@@ -299,7 +423,7 @@ try {
$plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim()
if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' }
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) {
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError, $preflightOut, $preflightError)) {
if (Test-Path $log) {
$text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue)
if ($null -eq $text) { $text = '' }
@@ -311,6 +435,7 @@ try {
Stop-ProcessTree $publisher
Stop-ProcessTree $fixture
Stop-ProcessTree $server
Stop-ProcessTree $preflightMedia
if ($pgStarted) {
$pgStopArguments = "-D `"$pgData`" -m fast stop"
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru)
+58
View File
@@ -0,0 +1,58 @@
# Standard event contract v1
`yovision.event/v1` is the only shared representation of an anonymous safety event. It is an immutable fact, not a Bell Alert. Bell owns all rule matching, Alert, acknowledgement, close, notification and user/audit state.
## Identity and idempotency
The permanent idempotency key is the exact UTF-8 pair `(producer_id, source_event_id)`. `producer_id` always names the original producer. A Sense gateway/relay sends its own authenticated transport identity and optional `X-YoVision-Relay-ID`, but it must forward both key fields and the business payload unchanged. A retry is not a new event.
After schema validation, calculate `payload_sha256` from the RFC 8785 JSON Canonicalization Scheme representation of the complete Event. The checked-in vector fixes the expected digest for supported implementations. Bell stores key, digest and Bell `event_id` permanently:
- absent key: atomically create Event/Receipt and return `201` with `disposition=created`;
- same key and digest: return the original `event_id` and digest with `200`, `disposition=duplicate`;
- same key but another digest: return `409 idempotency_conflict`, append an audit fact, and mutate neither Event nor Alert;
- identity lookup and insert must share a transaction/unique constraint so concurrent duplicates have the same result.
Canonical timestamps in Event v1 are UTC RFC 3339 with exactly three fractional digits and `Z`. Optional members are omitted, never sent as `null`. Producers must reject non-finite numbers before canonicalization.
## Mapper responsibilities
| Role | Required responsibility | Must not do |
|---|---|---|
| Brain producer mapper | Convert `brain.internal.event-candidate/v1` into stable original identity, logical site/device/profile/rule/region refs, model version and anonymous observation; generate one `source_event_id` once and persist/reuse it across retries. | Expose internal candidate fields, face/person identity, camera credentials, file paths, Alert state, or regenerate identity during retry. |
| Sense producer/evidence mapper | When Sense originates an event, apply the same original-identity rule; map its internal evidence record to a logical evidence reference and own later status resolution. | Put local path, RTSP URL, signed URL, credential or Outbox attempt ID into Event. |
| Sense relay | Authenticate as a transport hop, preserve original `producer_id`, `source_event_id` and payload, retain retry/audit state outside the Event, and return Bell's response unchanged enough for deterministic retry handling. | Replace producer identity, create a new source ID, enrich/reorder semantics, or treat `409`/`422` as a transient retry. |
| Bell consumer mapper | Validate before persistence; canonicalize; enforce permanent idempotency; map the immutable shared Event into Bell's private Event/Receipt and then independently evaluate rules to create an Alert. Unknown evidence becomes degraded evidence, not a rejected Event. | Persist arbitrary extension fields, import producer internals, or accept shared ack/close/notification/user state. |
Field ownership is deliberately narrow:
| Contract fields | Authoritative writer | Relay/Bell responsibility |
|---|---|---|
| `schema_version`, `producer_id`, `source_event_id` | Original Brain or Sense producer mapper | Relay preserves; Bell uses version gate and permanent idempotency key. |
| `site_ref`, `device_ref`, `profile_ref` | Producer mapper from versioned logical configuration | Relay preserves; Bell treats as opaque external refs. |
| `event_type`, `occurred_at`, `severity`, `rule`, `model`, `observation`, `region` | Brain/Sense mapper at the detection decision | Relay preserves; Bell validates and stores the immutable snapshot. |
| `evidence[]` identity and initial status | Evidence-owning producer, normally Sense | Relay preserves; Bell stores the Event snapshot and resolves current metadata separately. |
| `X-YoVision-Relay-ID` | Authenticated Sense transport hop | Bell audits transport metadata outside the immutable Event. |
| `event_id`, `disposition`, `payload_sha256` | Bell ingest boundary | Producer/relay retain the receipt for deterministic retries. |
## Errors, compatibility and fallback
- `400 invalid_event`: schema, canonical form, or sensitive/unknown member violation. Terminal until the producer fixes the payload.
- `409 idempotency_conflict`: same permanent key with a different payload. Terminal and audited; never overwrite the first Event.
- `422 unsupported_schema_version`: unknown major/revision. Terminal for that payload.
- Evidence `pending`, `processing`, `success` and `failed` are valid Event states. Bell keeps the Event and resolves/degrades evidence independently.
v1 is closed (`additionalProperties=false`). Producers may enable a compatible revision only after all relays and Bell validate it. Any removed/renamed required field, changed meaning, enum narrowing, identity/canonicalization change, or new required member publishes a new major path such as `/v2`. During the compatibility window Bell keeps the previous version endpoint. Rollback disables the new producer version and resumes the last accepted version; it does not delete Event, Receipt, Outbox or audit facts.
Unknown-version fallback is explicit: Bell returns `422`; relay records the terminal rejection without rewriting the payload; producer may remap the same internal candidate into a supported v1 payload only if it has not previously assigned that `(producer_id, source_event_id)` to a different canonical payload. Otherwise it must stop and require operator reconciliation.
## Reproducible verification
No third-party package is needed:
```powershell
python contracts/tests/events-v1/test_contract.py
python contracts/tests/evidence-v1/test_contract.py
```
The tests validate Schema/OpenAPI references, mapper fixtures, RFC 8785-compatible canonical vectors used by v1 examples, duplicate/conflict behavior, unknown versions and sensitive-field rejection.
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/event.schema.json",
"title": "YoVision anonymous safety event v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "producer_id", "source_event_id", "site_ref", "device_ref",
"profile_ref", "event_type", "occurred_at", "severity", "rule", "model",
"observation", "region", "evidence"
],
"properties": {
"schema_version": {"const": "yovision.event/v1"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"site_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"device_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"profile_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"event_type": {"enum": ["dangerous_area_entered", "directional_line_crossed"]},
"occurred_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"},
"severity": {"enum": ["low", "medium", "high", "critical"]},
"rule": {
"type": "object", "additionalProperties": false, "required": ["rule_id", "version"],
"properties": {
"rule_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"model": {
"type": "object", "additionalProperties": false, "required": ["name", "version"],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 128},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"observation": {
"type": "object", "additionalProperties": false,
"required": ["track_id", "category", "confidence"],
"properties": {
"track_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"category": {"enum": ["person", "vehicle", "other"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"bbox_normalized": {
"type": "array", "minItems": 4, "maxItems": 4,
"items": {"type": "number", "minimum": 0, "maximum": 1}
}
}
},
"region": {
"type": "object", "additionalProperties": false,
"required": ["region_id", "kind"],
"properties": {
"region_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"kind": {"enum": ["area", "line"]},
"crossing_direction": {"enum": ["a_to_b", "b_to_a"]}
},
"allOf": [
{"if": {"properties": {"kind": {"const": "line"}}, "required": ["kind"]}, "then": {"required": ["crossing_direction"]}},
{"if": {"properties": {"kind": {"const": "area"}}, "required": ["kind"]}, "then": {"not": {"required": ["crossing_direction"]}}}
]
},
"evidence": {
"type": "array", "maxItems": 8, "uniqueItems": true,
"items": {"$ref": "../../evidence/v1/evidence-reference.schema.json"}
}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "dangerous_area_entered"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "area"}}}}}
},
{
"if": {"properties": {"event_type": {"const": "directional_line_crossed"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "line"}}}}}
}
]
}
@@ -0,0 +1,26 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "bbox_normalized": [0.12, 0.2, 0.31, 0.74]},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
]
}
@@ -0,0 +1,27 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-line-20260831-0002",
"site_ref": "site-school-a",
"device_ref": "camera-north-corridor",
"profile_ref": "profile-main-stream",
"event_type": "directional_line_crossed",
"occurred_at": "2026-08-31T00:03:10.000Z",
"severity": "medium",
"rule": {"rule_id": "rule-north-one-way", "version": "1"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0088", "category": "person", "confidence": 0.88},
"region": {"region_id": "line-north-one-way", "kind": "line", "crossing_direction": "b_to_a"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
]
}
@@ -0,0 +1,7 @@
{
"event_id": "bell-event-00000042",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"disposition": "duplicate",
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
}
@@ -0,0 +1,5 @@
{
"code": "idempotency_conflict",
"message": "idempotency key already belongs to another canonical payload",
"existing_event_id": "bell-event-00000042"
}
@@ -0,0 +1,5 @@
{
"code": "unsupported_schema_version",
"message": "schema_version yovision.event/v2 is not accepted",
"field": "schema_version"
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/ingest-result.schema.json",
"title": "YoVision Bell event ingest result v1",
"type": "object",
"additionalProperties": false,
"required": ["event_id", "producer_id", "source_event_id", "disposition", "payload_sha256"],
"properties": {
"event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"disposition": {"enum": ["created", "duplicate"]},
"payload_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision standard event ingest API", "version": "1.0.0"},
"paths": {
"/v1/events": {
"post": {
"summary": "Ingest one immutable anonymous safety event",
"parameters": [
{"name": "X-YoVision-Relay-ID", "in": "header", "required": false, "description": "Audited transport hop. A relay must not change producer_id or source_event_id.", "schema": {"type": "string", "maxLength": 128}}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "./event.schema.json"}}}},
"responses": {
"201": {"description": "Created", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"200": {"description": "Exact duplicate; returns the original Bell Event identity", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"400": {"description": "Invalid or sensitive payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"409": {"description": "Same idempotency key with a different canonical payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"422": {"description": "Unsupported schema major version", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/problem.schema.json",
"title": "YoVision contract problem v1",
"type": "object",
"additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_event", "unsupported_schema_version", "idempotency_conflict", "evidence_not_found", "evidence_expired"]},
"message": {"type": "string", "minLength": 1, "maxLength": 512},
"field": {"type": "string", "pattern": "^[A-Za-z0-9_.\\[\\]-]{1,128}$"},
"existing_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}
}
}
+30
View File
@@ -0,0 +1,30 @@
# Evidence reference contract v1
This contract shares metadata about a logical evidence object. It never grants object access. `owner_id` identifies the service that owns resolution; `evidence_id` is opaque to every consumer. Neither field may be interpreted as a URL or local path.
## State and degradation
- `pending`: capture was accepted but no processing started.
- `processing`: capture or encoding is in progress.
- `success`: capture completed; `content_type` and SHA-256 `integrity` are required. Access authorization is negotiated outside this payload by the machine-identity/connector work.
- `failed`: `failure.code` and `retryable` are required. Bell keeps the immutable Event and renders evidence unavailable; it must not reject or close the Alert because evidence failed.
- HTTP `404` means an unknown logical reference. `410` means expired evidence. Both degrade evidence only, not the Event.
The payload forbids arbitrary properties, so filesystem paths, camera credentials, bearer/user tokens, signed URLs, face templates and notification/Alert state fail schema validation. Do not add access URLs to v1. A short-lived download grant, if later required, needs a separately reviewed endpoint and security contract.
## Ownership
- Brain may request evidence but maps only logical metadata it actually knows.
- Sense is the default evidence owner and advances the status monotonically for a given capture attempt: `pending -> processing -> success|failed`. It must retain the same `evidence_id` while status changes.
- A relay transports the reference unchanged and must not resolve it into a path or URL.
- Bell stores the latest evidence metadata separately from its immutable Event. Evidence failure/expiry never changes Alert ack/close state.
## Compatibility and rollback
v1 consumers ignore no unknown fields because the v1 schema is closed. Additive fields therefore require a new schema revision that producers enable only after consumers accept it. Changed meaning, removed fields, or new required fields require `/v2`. Rollback disables the new producer and continues resolving stored v1 references; it never deletes Event, Receipt, Outbox, or evidence audit facts.
Run the standalone contract check from the repository root:
```powershell
python contracts/tests/evidence-v1/test_contract.py
```
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/evidence/v1/evidence-reference.schema.json",
"title": "YoVision evidence logical reference v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at"
],
"properties": {
"schema_version": {"const": "yovision.evidence-reference/v1"},
"evidence_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"owner_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"type": {"enum": ["snapshot", "clip"]},
"status": {"enum": ["pending", "processing", "success", "failed"]},
"captured_at": {"type": "string", "format": "date-time"},
"status_updated_at": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"},
"content_type": {"enum": ["image/jpeg", "image/png", "video/mp4"]},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "digest", "size_bytes"],
"properties": {
"algorithm": {"const": "sha256"},
"digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"size_bytes": {"type": "integer", "minimum": 0}
}
},
"failure": {
"type": "object",
"additionalProperties": false,
"required": ["code", "retryable"],
"properties": {
"code": {"enum": ["capture_failed", "processing_failed", "expired", "unavailable"]},
"retryable": {"type": "boolean"}
}
}
},
"allOf": [
{
"if": {"properties": {"status": {"const": "success"}}, "required": ["status"]},
"then": {"required": ["content_type", "integrity"], "not": {"required": ["failure"]}}
},
{
"if": {"properties": {"status": {"const": "failed"}}, "required": ["status"]},
"then": {"required": ["failure"], "not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}]}}
},
{
"if": {"properties": {"status": {"enum": ["pending", "processing"]}}, "required": ["status"]},
"then": {"not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}, {"required": ["failure"]}]}}
}
]
}
@@ -0,0 +1,10 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
@@ -0,0 +1,9 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
@@ -0,0 +1,16 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"expires_at": "2026-09-07T00:00:01.125Z",
"content_type": "image/jpeg",
"integrity": {
"algorithm": "sha256",
"digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75",
"size_bytes": 48215
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision evidence reference API", "version": "1.0.0"},
"paths": {
"/v1/evidence/{evidence_id}": {
"get": {
"summary": "Resolve current metadata for a logical evidence reference",
"parameters": [
{"name": "evidence_id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Current metadata, including pending, processing, success or failed states", "content": {"application/json": {"schema": {"$ref": "./evidence-reference.schema.json"}}}},
"404": {"description": "Unknown logical reference", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}},
"410": {"description": "Evidence expired; event remains valid", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}}
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
# Machine identity contract v1
`yovision.machine-identity/v1` defines service-to-service identity for YoVision connectors. It is deliberately separate from Sense and Bell users, GoAdmin JWT/Cookie state, database roles and operating-system accounts.
## Authentication mechanism
Every request uses HTTPS and one compact Ed25519 JWS in `Authorization: Bearer <token>`. The protected header is closed and contains `alg=EdDSA`, `typ=YOVISION-MACHINE+JWT`, `kid` and `ver=yovision.machine-identity/v1`. The closed claims object contains:
mTLS is not the primary v1 identity mechanism. A customer PKI may add mTLS later as transport hardening, but it cannot replace or weaken the v1 principal, audience, scope, request binding, replay and revocation checks.
- one instance-specific `iss`/`sub` principal;
- one exact service `aud`;
- the minimum required `scope` values;
- `iat`, `nbf`, `exp` and a single-use random `jti`;
- uppercase HTTP method `htm`, normalized absolute-path reference `htu`, and lowercase SHA-256 `body_sha256`.
Tokens live for at most 300 seconds. Consumers allow at most 30 seconds of clock skew, verify the signature and active key/principal before authorization, then atomically consume `jti` until `exp + skew`. Retrying transport creates a new token and `jti`; business idempotency keys remain unchanged.
Production consumers persist the replay key `(principal, jti)` in their own durable store so a process restart cannot reopen the replay window. The checked-in process-local replay stores are adapter test/default primitives only; connector tasks must inject an atomic durable implementation and test restart behavior without sharing a database across products.
The v1 scopes are:
| Caller | Audience | Scope |
|---|---|---|
| Sense | `yovision-brain` | `source-config:write` |
| Brain | `yovision-sense` | `runtime-status:write` |
| Brain or Sense | `yovision-bell` | `events:ingest` |
| Bell | `yovision-sense` | `evidence:read` |
No wildcard audience or scope exists. A relay authenticates as its own transport principal and never replaces the original event producer identity.
## Key lifecycle
Private Ed25519 keys are generated per product instance and stored outside the repository in an OS-protected file or secret store. Runtime configuration contains only the private-key path. Public registries are local consumer configuration, not a shared database.
Rotation first registers a new `kid`, switches the caller, and removes the old key after an overlap no longer than 24 hours. A disabled principal or revoked `kid` is rejected on every request, including tokens that have not expired. Emergency rollback disables the connector; it never enables a shared password, browser token, query token, plaintext transport or signature bypass.
## Threat boundary
v1 protects against token modification, wrong audience/scope, expired or premature tokens, captured-token replay, key/principal revocation and accidental credential mixing. It does not protect a host after administrator/root compromise, a stolen usable private key before revocation, compromised TLS trust roots, endpoint implementation flaws or denial of service. Rate and body-size limits remain consumer responsibilities.
See `../transport/v1/README.md` for HTTPS and request policy. Stable failures are defined in `errors.md`; callers and logs must expose only the stable code, principal/kid when already authenticated, and correlation ID—never the token, signature, private/public key material or complete Authorization header.
## Compatibility
v1 is closed. New optional claims require all consumers to accept them before producers emit them. Any change to signing input, algorithm, claim meaning, replay semantics, maximum lifetime, audience or scope meaning publishes a new major version. Consumers keep the last accepted major during a controlled migration; rollback disables the new producer version without weakening verification.
## Reproducible verification
From the repository root:
```powershell
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
cd Sense/server
go test -race ./app/sense/integration/machine_identity
cd ../../Bell/server
go test -race ./app/bell/integration/machine_identity
```
The isolated contract test validates both JSON Schemas, the fixed Go/Python Ed25519 vector, request binding, exact audience/scope, expiry, replay, rotation overlap, revocation, bearer-only extraction and verified TLS policy. Product connector tasks remain responsible for injecting a durable replay store and testing restart recovery.
+14
View File
@@ -0,0 +1,14 @@
# Machine identity v1 stable errors
| Code | Meaning | Retry |
|---|---|---|
| `machine_token_missing` | Authorization bearer token is absent or malformed | No, fix request |
| `machine_token_invalid` | Header, claims, signature, request binding or key is invalid | No |
| `machine_token_expired` | Token is outside its accepted time window | Mint a new token |
| `machine_audience_denied` | Exact audience does not match | No |
| `machine_scope_denied` | Required scope is absent or not granted to the key | No |
| `machine_identity_revoked` | Principal or key is disabled/revoked | No; operator action |
| `machine_token_replayed` | The same principal/jti was already accepted | Retry with a new token and the same business idempotency key |
| `machine_transport_required` | HTTPS policy is not satisfied | No; fix deployment |
Responses and audit facts never include the token, signature, key material or Authorization header.
@@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/machine-identity/v1/machine-token.schema.json",
"title": "YoVision machine token claims v1",
"type": "object",
"additionalProperties": false,
"required": ["ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"],
"properties": {
"ver": {"const": "yovision.machine-identity/v1"},
"iss": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"sub": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"aud": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
"scope": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"uniqueItems": true,
"items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}
},
"iat": {"type": "integer", "minimum": 0},
"nbf": {"type": "integer", "minimum": 0},
"exp": {"type": "integer", "minimum": 0},
"jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22,64}$"},
"htm": {"type": "string", "pattern": "^(GET|POST|PUT|PATCH|DELETE)$"},
"htu": {"type": "string", "pattern": "^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"},
"body_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
}
}
@@ -0,0 +1,38 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/machine-identity/v1/principal-registry.schema.json",
"title": "YoVision machine principal registry v1",
"type": "object",
"additionalProperties": false,
"required": ["version", "audience", "principals"],
"properties": {
"version": {"const": "yovision.machine-principal-registry/v1"},
"audience": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
"principals": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["principal_id", "enabled", "keys"],
"properties": {
"principal_id": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
"enabled": {"type": "boolean"},
"keys": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["kid", "public_key_base64url", "status", "scopes"],
"properties": {
"kid": {"type": "string", "pattern": "^[A-Za-z0-9._-]{8,64}$"},
"public_key_base64url": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"},
"status": {"enum": ["active", "revoked"]},
"scopes": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}}
}
}
}
}
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Brain → Sense 运行与健康状态契约 v1
本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。
## 消息与时间语义
- `schema_version` 固定为 `yovision.runtime-status/v1`。生产者必须先通过 `runtime-status.schema.json` 再发布。
- `status_id` 是消息幂等键;`sequence` 在单个 `brain_instance_ref` 内单调递增。重复消息可忽略;小于当前已保存 sequence 的消息不得覆盖投影。
- `observed_at` 是 Brain 完成该次观测的 UTC RFC 3339 时间,不是 Sense 的接收时间。允许最大 30 秒未来时钟偏差;超过时拒绝该消息,并保留最后已知投影。
- Brain 的推荐发布周期是 30 秒。Sense 以 `evaluation_time - observed_at > 90 秒` 推导 `stale`;恰好 90 秒仍为 fresh。`stale` 和 `offline` 都是 Sense 的传输/时间投影,不是 Brain 写入的运行状态。
- 未收到任何有效状态时显示 `not_received`;传输断开但最后状态未过期时显示 `offline_fresh`;传输断开或无新消息且超过 90 秒时显示 `offline_stale` / `stale`,同时保留最后已知状态及其观测时间。
## 状态机
Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举:
| 状态 | 含义 | 允许的下一状态 |
|---|---|---|
| `unconfigured` | 尚无可运行配置 | `starting`, `stopped` |
| `starting` | 已接受启动,资源准备中 | `running`, `degraded`, `failed`, `stopped` |
| `running` | 正常提供推理 | `degraded`, `failed`, `stopped` |
| `degraded` | 仍提供有限服务 | `running`, `failed`, `stopped` |
| `failed` | 无法继续提供服务 | `starting`, `stopped` |
| `stopped` | 已有序停止 | `starting`, `unconfigured` |
首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。
## 配置流与 revision
`configurations` 按 #148 的配置流报告,可以为空,也可以包含多个配置。每项 `config_id` 必须唯一,并与 `yovision.source-config/v1` 的 `config_id` 一致;重复 ID 使整条状态无效,不能覆盖最后已知投影。`applied_revision` 是 Brain 对该配置流已实际应用的 integer revision。Sense 必须逐个 `config_id` 与自己已投递的期望 revision 比较:相等为 synchronized,不相等为 mismatch;Sense 的期望 revision 不进入本消息,避免产生第二事实源。
- `not_configured`:尚未应用该配置,revision 必须为 null。
- `applying`:正在应用;revision 为 null 或仍在运行的上一个 revision。
- `applied`:应用成功,revision 必须是大于等于 1 的整数。
- `rejected`:本次应用被拒绝;revision 为 null 或最后成功 revision,且必须带稳定错误码。
## 兼容与回退
- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。
- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。
- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。
## 安全边界
只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。
错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.md`。
@@ -0,0 +1,16 @@
# v1 稳定错误码
生产者可以发布以下稳定错误码。消费者遇到符合格式但尚未认识的 v1 错误码时显示“未识别的远端错误”,保留原始代码用于排障,不把它转换成业务 Alert。
| 错误码 | 责任域 | 含义 |
|---|---|---|
| `CONFIG_INVALID` | 配置 | 配置结构或值无效 |
| `CONFIG_REVISION_UNAVAILABLE` | 配置 | 指定 revision 无法取得 |
| `INPUT_UNREACHABLE` | 输入 | 逻辑输入暂时不可达 |
| `INPUT_DECODE_FAILED` | 输入 | 输入解码失败 |
| `MODEL_LOAD_FAILED` | 模型 | 模型载入失败 |
| `INFERENCE_FAILED` | 推理 | 推理管线失败 |
| `RESOURCE_PRESSURE` | 运行 | 资源压力导致降级 |
| `INTERNAL_COMPONENT_FAILED` | 运行 | 内部组件失败;不随消息暴露组件路径或堆栈 |
`UNSUPPORTED_SCHEMA_VERSION`、`FUTURE_OBSERVATION`、`OUT_OF_ORDER_STATUS` 与 `INVALID_STATUS_TRANSITION` 是 Sense adapter 的本地摄取错误,不由 Brain 发布。
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d205",
"brain_instance_ref": "brain-east-01",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "people-detection", "version": "1.0.0" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
],
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
"inputs": [],
"alert": { "kind": "intrusion" }
}
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d202",
"brain_instance_ref": "brain-east-01",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "people-detection", "version": "1.0.0" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
],
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
"inputs": [],
"access_token": "forbidden-example"
}
@@ -0,0 +1,19 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d206",
"brain_instance_ref": "brain-east-01",
"sequence": 47,
"observed_at": "2026-08-31T00:05:00Z",
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
{ "config_id": "gate-primary", "apply_state": "rejected", "applied_revision": 20, "error_code": "CONFIG_INVALID" }
],
"health": {
"overall": "degraded",
"error_codes": ["CONFIG_INVALID"],
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
},
"inputs": []
}
@@ -0,0 +1,14 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d203",
"brain_instance_ref": "brain-east-01",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "C:\\models\\private.pt", "version": "1.0.0" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
],
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
"inputs": []
}
@@ -0,0 +1,14 @@
{
"schema_version": "yovision.runtime-status/v2",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d201",
"brain_instance_ref": "brain-east-01",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "people-detection", "version": "1.0.0" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
],
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
"inputs": []
}
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d204",
"brain_instance_ref": "brain-east-01",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "people-detection", "version": "1.0.0" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
],
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
"inputs": [],
"user_session": { "user": "forbidden" }
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d103",
"brain_instance_ref": "brain-east-01",
"sequence": 43,
"observed_at": "2026-08-31T00:01:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null }
],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 }
},
"inputs": []
}
@@ -0,0 +1,21 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d107",
"brain_instance_ref": "brain-east-01",
"sequence": 46,
"observed_at": "2026-08-31T00:04:30Z",
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "new-stream", "apply_state": "not_configured", "applied_revision": null, "error_code": null },
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null },
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
{ "config_id": "warehouse", "apply_state": "rejected", "applied_revision": 3, "error_code": "CONFIG_INVALID" }
],
"health": {
"overall": "degraded",
"error_codes": ["CONFIG_INVALID"],
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
},
"inputs": []
}
@@ -0,0 +1,26 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d102",
"brain_instance_ref": "brain-east-01",
"sequence": 42,
"observed_at": "2026-08-31T00:00:30Z",
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null }
],
"health": {
"overall": "degraded",
"error_codes": ["RESOURCE_PRESSURE"],
"metrics": { "load_percent": 91.5, "queue_depth": 7, "latency_ms": 115.0 }
},
"inputs": [
{
"input_ref": "camera-gate-01",
"state": "degraded",
"error_codes": ["INPUT_DECODE_FAILED"],
"metrics": { "load_percent": 5.2, "queue_depth": 3, "latency_ms": 92.0 }
}
]
}
@@ -0,0 +1,16 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d106",
"brain_instance_ref": "brain-east-02",
"sequence": 1,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "unconfigured", "version": "1.0.0", "started_at": null },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 0, "queue_depth": 0, "latency_ms": 0 }
},
"inputs": []
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d104",
"brain_instance_ref": "brain-east-01",
"sequence": 44,
"observed_at": "2026-08-31T00:01:30Z",
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
],
"health": {
"overall": "degraded",
"error_codes": ["INPUT_UNREACHABLE"],
"metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 }
},
"inputs": []
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d105",
"brain_instance_ref": "brain-east-01",
"sequence": 45,
"observed_at": "2026-08-31T00:04:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 }
},
"inputs": []
}
@@ -0,0 +1,25 @@
{
"schema_version": "yovision.runtime-status/v1",
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d101",
"brain_instance_ref": "brain-east-01",
"sequence": 41,
"observed_at": "2026-08-31T00:00:00Z",
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 38.5, "queue_depth": 0, "latency_ms": 21.4 }
},
"inputs": [
{
"input_ref": "camera-gate-01",
"state": "running",
"error_codes": [],
"metrics": { "load_percent": 5.2, "queue_depth": 0, "latency_ms": 18.1 }
}
]
}
+20
View File
@@ -0,0 +1,20 @@
# Brain → Sense mapper 字段责任
| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 |
|---|---|---|
| `schema_version` | 固定发布 `yovision.runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 |
| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 |
| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 |
| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 |
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 |
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
## 契约测试责任
- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。
- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。
- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。
@@ -0,0 +1,172 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/runtime-status/v1/runtime-status.schema.json",
"title": "YoVision Brain runtime status v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"status_id",
"brain_instance_ref",
"sequence",
"observed_at",
"runtime",
"model",
"configurations",
"health",
"inputs"
],
"properties": {
"schema_version": { "const": "yovision.runtime-status/v1" },
"status_id": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
},
"brain_instance_ref": { "$ref": "#/$defs/logicalRef" },
"sequence": { "type": "integer", "minimum": 0 },
"observed_at": { "type": "string", "format": "date-time" },
"runtime": {
"type": "object",
"additionalProperties": false,
"required": ["state", "version"],
"properties": {
"state": { "$ref": "#/$defs/runtimeState" },
"version": { "$ref": "#/$defs/version" },
"started_at": { "type": ["string", "null"], "format": "date-time" }
}
},
"model": {
"type": "object",
"additionalProperties": false,
"required": ["model_ref", "version"],
"properties": {
"model_ref": { "$ref": "#/$defs/logicalRef" },
"version": { "$ref": "#/$defs/version" }
}
},
"configurations": {
"type": "array",
"maxItems": 4096,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["config_id", "apply_state", "applied_revision", "error_code"],
"properties": {
"config_id": { "$ref": "#/$defs/configId" },
"apply_state": {
"type": "string",
"enum": ["not_configured", "applying", "applied", "rejected"]
},
"applied_revision": {
"type": ["integer", "null"],
"minimum": 1
},
"error_code": { "$ref": "#/$defs/nullableErrorCode" }
},
"allOf": [
{
"if": {
"required": ["apply_state"],
"properties": { "apply_state": { "const": "not_configured" } }
},
"then": { "properties": { "applied_revision": { "type": "null" } } }
},
{
"if": {
"required": ["apply_state"],
"properties": { "apply_state": { "const": "applied" } }
},
"then": { "properties": { "applied_revision": { "type": "integer", "minimum": 1 } } }
},
{
"if": {
"required": ["apply_state"],
"properties": { "apply_state": { "const": "rejected" } }
},
"then": { "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } }
}
]
}
},
"health": {
"type": "object",
"additionalProperties": false,
"required": ["overall", "error_codes", "metrics"],
"properties": {
"overall": {
"type": "string",
"enum": ["healthy", "degraded", "unhealthy"]
},
"error_codes": {
"type": "array",
"uniqueItems": true,
"maxItems": 32,
"items": { "$ref": "#/$defs/errorCode" }
},
"metrics": { "$ref": "#/$defs/metrics" }
}
},
"inputs": {
"type": "array",
"maxItems": 4096,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["input_ref", "state", "error_codes", "metrics"],
"properties": {
"input_ref": { "$ref": "#/$defs/logicalRef" },
"state": { "$ref": "#/$defs/runtimeState" },
"error_codes": {
"type": "array",
"uniqueItems": true,
"maxItems": 16,
"items": { "$ref": "#/$defs/errorCode" }
},
"metrics": { "$ref": "#/$defs/metrics" }
}
}
}
},
"$defs": {
"configId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"logicalRef": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"
},
"runtimeState": {
"type": "string",
"enum": ["unconfigured", "starting", "running", "degraded", "failed", "stopped"]
},
"errorCode": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
},
"nullableErrorCode": {
"type": ["string", "null"],
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
},
"metrics": {
"type": "object",
"additionalProperties": false,
"required": ["load_percent", "queue_depth", "latency_ms"],
"properties": {
"load_percent": { "type": "number", "minimum": 0, "maximum": 100 },
"queue_depth": { "type": "integer", "minimum": 0 },
"latency_ms": { "type": "number", "minimum": 0 }
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Sense → Brain 媒体源与区域规则配置契约 v1
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
## 文件
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
- `examples/valid/`:可接受的 active 与待重校准快照。
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
## 消费规则
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
6. `effective_at` 不得早于 `published_at`。
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
## 可复制验证
从仓库根目录运行:
```powershell
& contracts\tests\source-config-v1\run.ps1
```
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
@@ -0,0 +1,29 @@
# v1 兼容、迁移与回退
## 兼容规则
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
## revision 与生效
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
## 支持周期
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
## 回退
1. 停止分发有问题的新主版本或新 revision。
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,8 @@
{
"coordinate-out-of-range.json": "schema",
"credential-field.json": "secret",
"internal-path.json": "internal path",
"profile-binding-mismatch.json": "profile binding",
"query-token.json": "secret",
"unknown-major-version.json": "unknown schema"
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v2",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,62 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream",
"width": 1920,
"height": 1080,
"encoding": "H264",
"frame_rate": 25
},
"media": {
"ref": "media:site-east/entry-01/main",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-7",
"state": "active",
"profile_binding": {
"profile_id": "main-stream",
"width": 1920,
"height": 1080
},
"areas": [
{
"id": "danger-yard",
"version": 3,
"kind": "danger_area",
"enabled": true,
"points": [
{"x": 0.12, "y": 0.18},
{"x": 0.82, "y": 0.18},
{"x": 0.76, "y": 0.78},
{"x": 0.18, "y": 0.72}
]
}
],
"directional_lines": [
{
"id": "entry-line",
"version": 2,
"kind": "directional_line",
"enabled": true,
"start": {"x": 0.2, "y": 0.5},
"end": {"x": 0.8, "y": 0.5},
"trigger_direction": "left_to_right"
}
]
},
"integrity": {
"algorithm": "sha256",
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
}
}
@@ -0,0 +1,39 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 8,
"published_at": "2026-08-31T01:00:00Z",
"effective_at": "2026-08-31T01:00:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream-v2",
"width": 1280,
"height": 720,
"encoding": "H265",
"frame_rate": 20
},
"media": {
"ref": "media:site-east/entry-01/main-v2",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-8",
"state": "recalibration_required",
"profile_binding": {
"profile_id": "main-stream-v2",
"width": 1280,
"height": 720
},
"areas": [],
"directional_lines": []
},
"integrity": {
"algorithm": "sha256",
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
}
}
@@ -0,0 +1,35 @@
# 生产者与消费者 mapper 字段表
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|---|---|---|
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
## 测试责任
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -0,0 +1,273 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
"title": "YoVision Sense to Brain source configuration snapshot v1",
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"config_id",
"revision",
"published_at",
"effective_at",
"site",
"logical_device",
"profile",
"media",
"rule_set",
"integrity"
],
"properties": {
"schema_version": {
"const": "yovision.source-config/v1"
},
"config_id": {
"$ref": "#/$defs/stable_id"
},
"revision": {
"type": "integer",
"minimum": 1
},
"published_at": {
"type": "string",
"format": "date-time"
},
"effective_at": {
"type": "string",
"format": "date-time"
},
"site": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"logical_device": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"profile": {
"$ref": "#/$defs/profile"
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["ref", "transport"],
"properties": {
"ref": {
"type": "string",
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
},
"transport": {
"enum": ["rtsp"]
}
}
},
"rule_set": {
"type": "object",
"additionalProperties": false,
"required": [
"version",
"state",
"profile_binding",
"areas",
"directional_lines"
],
"properties": {
"version": {
"$ref": "#/$defs/stable_id"
},
"state": {
"enum": ["active", "disabled", "recalibration_required"]
},
"profile_binding": {
"$ref": "#/$defs/profile_binding"
},
"areas": {
"type": "array",
"items": {
"$ref": "#/$defs/area_rule"
},
"maxItems": 1024
},
"directional_lines": {
"type": "array",
"items": {
"$ref": "#/$defs/directional_line_rule"
},
"maxItems": 1024
}
}
},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "value"],
"properties": {
"algorithm": {
"const": "sha256"
},
"value": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"extensions": {
"type": "object",
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
"propertyNames": {
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
},
"additionalProperties": {
"type": "object"
}
}
},
"$defs": {
"stable_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"positive_integer": {
"type": "integer",
"minimum": 1
},
"positive_number": {
"type": "number",
"exclusiveMinimum": 0
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": ["id", "width", "height", "encoding", "frame_rate"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
},
"encoding": {
"enum": ["H264", "H265", "MJPEG"]
},
"frame_rate": {
"$ref": "#/$defs/positive_number"
}
}
},
"profile_binding": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "width", "height"],
"properties": {
"profile_id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
}
}
},
"point": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"y": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
},
"area_rule": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "kind", "enabled", "points"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "danger_area"
},
"enabled": {
"type": "boolean"
},
"points": {
"type": "array",
"items": {
"$ref": "#/$defs/point"
},
"minItems": 3,
"maxItems": 256
}
}
},
"directional_line_rule": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"kind",
"enabled",
"start",
"end",
"trigger_direction"
],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "directional_line"
},
"enabled": {
"type": "boolean"
},
"start": {
"$ref": "#/$defs/point"
},
"end": {
"$ref": "#/$defs/point"
},
"trigger_direction": {
"enum": ["left_to_right", "right_to_left"]
}
}
}
}
}
@@ -0,0 +1,91 @@
"""Small dependency-free validator for the JSON Schema keywords used by v1 contracts."""
from __future__ import annotations
import json
import math
import re
from datetime import datetime
from pathlib import Path
from typing import Any
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def canonical_bytes(value: Any) -> bytes:
"""Canonical bytes for checked-in JCS vectors (all vector numbers are JCS-safe)."""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def validate(instance: Any, schema: dict[str, Any], schema_path: Path, location: str = "$") -> list[str]:
if "$ref" in schema:
ref = schema["$ref"]
if ref.startswith("#"):
return [f"{location}: local fragments are not supported by the contract checker"]
target = (schema_path.parent / ref).resolve()
return validate(instance, load_json(target), target, location)
errors: list[str] = []
for subschema in schema.get("allOf", []):
errors.extend(validate(instance, subschema, schema_path, location))
if "anyOf" in schema and not any(not validate(instance, item, schema_path, location) for item in schema["anyOf"]):
errors.append(f"{location}: does not match anyOf")
if "not" in schema and not validate(instance, schema["not"], schema_path, location):
errors.append(f"{location}: matches forbidden schema")
if "if" in schema and not validate(instance, schema["if"], schema_path, location):
errors.extend(validate(instance, schema.get("then", {}), schema_path, location))
expected = schema.get("type")
type_ok = {
"object": lambda x: isinstance(x, dict),
"array": lambda x: isinstance(x, list),
"string": lambda x: isinstance(x, str),
"integer": lambda x: isinstance(x, int) and not isinstance(x, bool),
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x),
"boolean": lambda x: isinstance(x, bool),
}
if expected and (expected not in type_ok or not type_ok[expected](instance)):
return errors + [f"{location}: expected {expected}"]
if "const" in schema and instance != schema["const"]:
errors.append(f"{location}: expected constant {schema['const']!r}")
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{location}: value not in enum")
if isinstance(instance, dict):
required = schema.get("required", [])
errors.extend(f"{location}: missing {name}" for name in required if name not in instance)
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
errors.extend(f"{location}: unknown property {name}" for name in instance if name not in properties)
for name, value in instance.items():
if name in properties:
errors.extend(validate(value, properties[name], schema_path, f"{location}.{name}"))
elif isinstance(instance, list):
if len(instance) < schema.get("minItems", 0):
errors.append(f"{location}: too few items")
if "maxItems" in schema and len(instance) > schema["maxItems"]:
errors.append(f"{location}: too many items")
if schema.get("uniqueItems") and len({canonical_bytes(item) for item in instance}) != len(instance):
errors.append(f"{location}: duplicate items")
for index, value in enumerate(instance):
errors.extend(validate(value, schema.get("items", {}), schema_path, f"{location}[{index}]"))
elif isinstance(instance, str):
if len(instance) < schema.get("minLength", 0):
errors.append(f"{location}: string too short")
if "maxLength" in schema and len(instance) > schema["maxLength"]:
errors.append(f"{location}: string too long")
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
errors.append(f"{location}: pattern mismatch")
if schema.get("format") == "date-time":
try:
datetime.fromisoformat(instance.replace("Z", "+00:00"))
except ValueError:
errors.append(f"{location}: invalid date-time")
elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
if "minimum" in schema and instance < schema["minimum"]:
errors.append(f"{location}: below minimum")
if "maximum" in schema and instance > schema["maximum"]:
errors.append(f"{location}: above maximum")
return errors
@@ -0,0 +1,17 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-sensitive-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "face_feature": "forbidden"},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [],
"camera_password": "forbidden"
}
@@ -0,0 +1,16 @@
{
"schema_version": "yovision.event/v2",
"producer_id": "brain-school-a",
"source_event_id": "evt-unknown-version-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": []
}
@@ -0,0 +1,13 @@
{
"algorithm": "RFC8785-JCS+SHA-256",
"vectors": [
{
"name": "dangerous-area-original-and-reordered-duplicate",
"fixture": "../../events/v1/examples/dangerous-area.json",
"idempotency_key": ["brain-school-a", "evt-area-20260831-0001"],
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1",
"conflict_patch": {"severity": "critical"},
"conflict_payload_sha256": "7076771f7827d97ef45831ae221046b8cb347f152dd222c2edd6f14a58e173b2"
}
]
}
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import copy
import hashlib
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
sys.path.insert(0, str(HERE))
from contract_support import canonical_bytes, load_json, validate # noqa: E402
class EventV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "events" / "v1" / "event.schema.json"
schema = load_json(schema_path)
def assert_valid(self, payload: object) -> None:
self.assertEqual([], validate(payload, self.schema, self.schema_path))
def test_anonymous_area_and_line_examples_are_valid(self) -> None:
for name in ("dangerous-area.json", "directional-line-crossed.json"):
with self.subTest(name=name):
self.assert_valid(load_json(CONTRACTS / "events" / "v1" / "examples" / name))
def test_idempotency_vector_duplicate_and_conflict(self) -> None:
vectors = load_json(HERE / "idempotency-vectors.json")["vectors"]
for vector in vectors:
payload = load_json((HERE / vector["fixture"]).resolve())
self.assertEqual(vector["idempotency_key"], [payload["producer_id"], payload["source_event_id"]])
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
self.assertEqual(vector["payload_sha256"], digest)
reordered = dict(reversed(list(payload.items())))
self.assertEqual(digest, hashlib.sha256(canonical_bytes(reordered)).hexdigest())
conflict = copy.deepcopy(payload)
conflict.update(vector["conflict_patch"])
conflict_digest = hashlib.sha256(canonical_bytes(conflict)).hexdigest()
self.assertEqual(vector["conflict_payload_sha256"], conflict_digest)
self.assertNotEqual(digest, conflict_digest)
def test_unknown_version_is_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "unknown-version.json")
self.assertTrue(validate(payload, self.schema, self.schema_path))
def test_brain_producer_sense_relay_and_bell_consumer_fixture(self) -> None:
produced = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
self.assert_valid(produced)
relayed = copy.deepcopy(produced)
self.assertEqual(
(produced["producer_id"], produced["source_event_id"]),
(relayed["producer_id"], relayed["source_event_id"]),
)
self.assertEqual(canonical_bytes(produced), canonical_bytes(relayed))
bell_allowed = set(self.schema["properties"])
self.assertEqual(set(produced), bell_allowed)
self.assertNotIn("alert", produced)
self.assertNotIn("receipt", produced)
def test_sensitive_and_internal_fields_are_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "sensitive-field.json")
errors = validate(payload, self.schema, self.schema_path)
self.assertTrue(any("camera_password" in error for error in errors))
self.assertTrue(any("face_feature" in error for error in errors))
base = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
for forbidden, value in {
"user_token": "forbidden", "ack_state": "acked", "local_path": "C:/forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(validate(candidate, self.schema, self.schema_path))
def test_openapi_references_exist_and_responses_are_explicit(self) -> None:
path = CONTRACTS / "events" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/events"]["post"]
self.assertEqual({"200", "201", "400", "409", "422"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
self.assertTrue(refs)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
def test_duplicate_conflict_and_unknown_version_response_examples(self) -> None:
directory = CONTRACTS / "events" / "v1"
cases = (
("duplicate-result.json", "ingest-result.schema.json"),
("idempotency-conflict-problem.json", "problem.schema.json"),
("unsupported-version-problem.json", "problem.schema.json"),
)
for fixture_name, schema_name in cases:
with self.subTest(fixture=fixture_name):
schema_path = directory / schema_name
errors = validate(load_json(directory / "examples" / fixture_name), load_json(schema_path), schema_path)
self.assertEqual([], errors)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,12 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-sensitive-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"content_type": "image/jpeg",
"integrity": {"algorithm": "sha256", "digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75", "size_bytes": 48215},
"local_path": "forbidden"
}
@@ -0,0 +1,88 @@
from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
EVENT_SUPPORT = HERE.parent / "events-v1"
sys.path.insert(0, str(EVENT_SUPPORT))
from contract_support import load_json, validate # noqa: E402
class EvidenceV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "evidence" / "v1" / "evidence-reference.schema.json"
schema = load_json(schema_path)
def errors_for(self, payload: object) -> list[str]:
return validate(payload, self.schema, self.schema_path)
def test_pending_success_and_failed_examples_are_valid(self) -> None:
for name in ("pending.json", "success.json", "failed.json"):
with self.subTest(name=name):
payload = load_json(CONTRACTS / "evidence" / "v1" / "examples" / name)
self.assertEqual([], self.errors_for(payload))
def test_state_specific_metadata_is_enforced(self) -> None:
success = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json")
for required in ("content_type", "integrity"):
with self.subTest(success_requires=required):
candidate = copy.deepcopy(success)
del candidate[required]
self.assertTrue(self.errors_for(candidate))
legacy_available = copy.deepcopy(success)
legacy_available["status"] = "available"
self.assertTrue(self.errors_for(legacy_available))
failed = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "failed.json")
del failed["failure"]
self.assertTrue(self.errors_for(failed))
pending = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
pending["content_type"] = "image/jpeg"
self.assertTrue(self.errors_for(pending))
def test_sensitive_access_material_and_unknown_version_are_rejected(self) -> None:
fixture = load_json(HERE / "fixtures" / "sensitive-field.json")
self.assertTrue(any("local_path" in error for error in self.errors_for(fixture)))
base = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
for forbidden, value in {
"camera_password": "forbidden",
"user_token": "forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden",
"face_feature": "forbidden",
"alert_state": "acked"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(self.errors_for(candidate))
unknown = copy.deepcopy(base)
unknown["schema_version"] = "yovision.evidence-reference/v2"
self.assertTrue(self.errors_for(unknown))
def test_openapi_refs_and_degradation_responses(self) -> None:
path = CONTRACTS / "evidence" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/evidence/{evidence_id}"]["get"]
self.assertEqual({"200", "404", "410"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,11 @@
{
"version": "yovision.machine-token-vector/v1",
"public_key_base64url": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ",
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImJyYWluLXZlY3Rvci0wMDAxIiwidHlwIjoiWU9WSVNJT04tTUFDSElORStKV1QiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.eyJhdWQiOiJ5b3Zpc2lvbi1zZW5zZSIsImJvZHlfc2hhMjU2IjoiNDA5NDQzYTZlZTVhYTI5NmRjY2Q2YzBkMTkzZTIxNDU2OGRhYTAwNTNiNjYxNTVmYmE4YWRjYTk5NWI3ODIzZCIsImV4cCI6MTgwMDAwMDMwMCwiaHRtIjoiUE9TVCIsImh0dSI6Ii9tYWNoaW5lL3YxL3J1bnRpbWUtc3RhdHVzIiwiaWF0IjoxODAwMDAwMDAwLCJpc3MiOiJ5djpicmFpbjp2ZWN0b3IiLCJqdGkiOiJBUUlEQkFVR0J3Z0pDZ3NNRFE0UEVBIiwibmJmIjoxODAwMDAwMDAwLCJzY29wZSI6WyJydW50aW1lLXN0YXR1czp3cml0ZSJdLCJzdWIiOiJ5djpicmFpbjp2ZWN0b3IiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.zrvo_7lRiDX1D6po6OlfQg4hDg6XXlyUEmNMYgpuRl3ArXSjvGuOLivDousIyLtRO4bYJu9xMAWX1cea7MdVBQ",
"now": 1800000000,
"audience": "yovision-sense",
"required_scope": "runtime-status:write",
"method": "POST",
"path": "/machine/v1/runtime-status",
"body_base64": "eyJzdGF0dXMiOiJydW5uaW5nIn0="
}
@@ -0,0 +1,2 @@
cryptography==50.0.1
jsonschema==4.25.1
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-machine-identity-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned machine-identity test dependencies.' }
& $python $testDirectory\test_contract.py
if ($LASTEXITCODE -ne 0) { throw 'Machine-identity v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
import base64
import ssl
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "Brain" / "src"))
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from jsonschema import Draft202012Validator
from yovision_brain.integration.machine_identity import (
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
TransportPolicy,
Verifier,
load_registry,
bearer_token,
)
class ContractFilesTest(unittest.TestCase):
def test_closed_claim_and_registry_schemas(self) -> None:
claims = json.loads((ROOT / "contracts/machine-identity/v1/machine-token.schema.json").read_text(encoding="utf-8"))
registry = json.loads((ROOT / "contracts/machine-identity/v1/principal-registry.schema.json").read_text(encoding="utf-8"))
transport = json.loads((ROOT / "contracts/transport/v1/transport-policy.schema.json").read_text(encoding="utf-8"))
Draft202012Validator.check_schema(claims)
Draft202012Validator.check_schema(registry)
Draft202012Validator.check_schema(transport)
self.assertFalse(claims["additionalProperties"])
self.assertEqual(claims["properties"]["ver"]["const"], "yovision.machine-identity/v1")
self.assertEqual(claims["properties"]["scope"]["items"]["enum"], [
"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"
])
self.assertFalse(registry["additionalProperties"])
self.assertEqual(transport["properties"]["verify_certificate"]["const"], True)
self.assertEqual(transport["properties"]["verify_hostname"]["const"], True)
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
claims_object = json.loads(base64.urlsafe_b64decode(vector["token"].split(".")[1] + "=="))
Draft202012Validator(claims).validate(claims_object)
Draft202012Validator(transport).validate({
"version": "yovision.transport/v1", "tls_min_version": "1.2", "verify_certificate": True,
"verify_hostname": True, "connect_timeout_ms": 1000, "response_header_timeout_ms": 1000,
"request_timeout_ms": 5000, "max_request_bytes": 1048576,
})
def test_brain_dependency_is_frozen(self) -> None:
pyproject = (ROOT / "Brain/pyproject.toml").read_text(encoding="utf-8")
self.assertIn('dependencies = ["cryptography==50.0.1"]', pyproject)
def test_contract_documents_fail_closed(self) -> None:
identity = (ROOT / "contracts/machine-identity/v1/README.md").read_text(encoding="utf-8")
transport = (ROOT / "contracts/transport/v1/README.md").read_text(encoding="utf-8")
for required in ("300 seconds", "30 seconds", "24 hours", "jti", "revoked", "browser token"):
self.assertIn(required, identity)
for required in ("TLS 1.2", "hostname verification", "query-string credentials", "disables the connector"):
self.assertIn(required, transport)
def test_python_verifies_cross_language_vector(self) -> None:
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
raw_key = base64.urlsafe_b64decode(vector["public_key_base64url"] + "=")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
registry = Registry([KeyRecord("yv:brain:vector", "brain-vector-0001", Ed25519PublicKey.from_public_bytes(raw_key), vector["audience"], frozenset({vector["required_scope"]}))])
verifier = Verifier(registry, ReplayStore(), clock=lambda: vector["now"])
claims = verifier.verify(vector["token"], vector["audience"], vector["required_scope"], vector["method"], vector["path"], base64.b64decode(vector["body_base64"]))
self.assertEqual(claims.iss, "yv:brain:vector")
class BrainAdapterTest(unittest.TestCase):
def setUp(self) -> None:
self.now = 1_800_000_000
self.private = Ed25519PrivateKey.generate()
self.record = KeyRecord(
principal="yv:brain:node-a",
key_id="brain-key-0001",
public_key=self.private.public_key(),
audience="yovision-sense",
scopes=frozenset({"runtime-status:write"}),
)
self.registry = Registry([self.record])
self.signer = Signer("yv:brain:node-a", "brain-key-0001", self.private, clock=lambda: self.now)
def mint(self, body: bytes = b"{}") -> str:
return self.signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", body)
def verify(self, token: str, body: bytes = b"{}", **changes: str):
verifier = Verifier(self.registry, changes.pop("replay", ReplayStore()), clock=lambda: int(changes.pop("now", self.now)))
return verifier.verify(
token,
changes.pop("audience", "yovision-sense"),
changes.pop("scope", "runtime-status:write"),
changes.pop("method", "POST"),
changes.pop("path", "/machine/v1/runtime-status"),
body,
)
def assert_code(self, code: str, callback) -> None:
with self.assertRaises(MachineIdentityError) as caught:
callback()
self.assertEqual(caught.exception.code, code)
self.assertEqual(str(caught.exception), code)
def test_valid_token_and_replay_rejection(self) -> None:
token = self.mint()
replay = ReplayStore()
first = Verifier(self.registry, replay, clock=lambda: self.now)
claims = first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
self.assertEqual(claims.iss, "yv:brain:node-a")
self.assert_code("machine_token_replayed", lambda: first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}"))
def test_bearer_token_has_no_cookie_or_query_fallback(self) -> None:
self.assertEqual(bearer_token("Bearer compact.token.value"), "compact.token.value")
for value in ("", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"):
self.assert_code("machine_token_missing", lambda value=value: bearer_token(value))
def test_wrong_audience_scope_body_and_expiry(self) -> None:
self.assert_code("machine_audience_denied", lambda: self.verify(self.mint(), audience="yovision-bell"))
self.assert_code("machine_scope_denied", lambda: self.verify(self.mint(), scope="events:ingest"))
self.assert_code("machine_token_invalid", lambda: self.verify(self.mint(), body=b"changed"))
self.assert_code("machine_token_expired", lambda: self.verify(self.mint(), now=str(self.now + 361)))
def test_tampering_revocation_and_rotation(self) -> None:
token = self.mint()
parts = token.split(".")
tampered = f"{parts[0]}.{parts[1][:-1]}A.{parts[2]}"
self.assert_code("machine_token_invalid", lambda: self.verify(tampered))
self.assertTrue(self.registry.revoke("brain-key-0001"))
self.assert_code("machine_identity_revoked", lambda: self.verify(self.mint()))
new_private = Ed25519PrivateKey.generate()
overlap = Registry([
self.record,
KeyRecord("yv:brain:node-a", "brain-key-0002", new_private.public_key(), "yovision-sense", frozenset({"runtime-status:write"})),
])
new_signer = Signer("yv:brain:node-a", "brain-key-0002", new_private, clock=lambda: self.now)
verifier = Verifier(overlap, ReplayStore(), clock=lambda: self.now)
verifier.verify(self.mint(), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
verifier.verify(new_signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", b"{}"), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
def test_transport_policy_requires_verified_tls(self) -> None:
policy = TransportPolicy("1.2", True, True, 1000, 1000, 2000, 1024)
context = policy.ssl_context()
self.assertGreaterEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2)
self.assertTrue(context.check_hostname)
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
with self.assertRaises(ValueError):
TransportPolicy("1.1", True, True, 1000, 1000, 2000, 1024).validate()
with self.assertRaises(ValueError):
TransportPolicy("1.2", True, False, 1000, 1000, 2000, 1024).validate()
def test_loads_external_public_registry_and_rejects_wrong_audience(self) -> None:
from cryptography.hazmat.primitives import serialization
raw_public = self.private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
document = {
"version": "yovision.machine-principal-registry/v1",
"audience": "yovision-sense",
"principals": [{
"principal_id": "yv:brain:node-a", "enabled": True,
"keys": [{"kid": "brain-key-0001", "public_key_base64url": base64.urlsafe_b64encode(raw_public).rstrip(b"=").decode(), "status": "active", "scopes": ["runtime-status:write"]}],
}],
}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "principals.json"
path.write_text(json.dumps(document), encoding="utf-8")
registry = load_registry(path, "yovision-sense")
self.assertIsNotNone(registry.lookup("brain-key-0001"))
with self.assertRaises(ValueError):
load_registry(path, "yovision-bell")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,9 @@
# yovision.runtime-status/v1 契约测试
从仓库根目录运行:
```powershell
python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v
```
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、未知主版本、倒序消息、回退保留和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
@@ -0,0 +1,318 @@
import copy
import json
import re
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
TEST_DIR = Path(__file__).resolve().parent
CONTRACT_DIR = TEST_DIR.parents[1] / "runtime-status" / "v1"
SCHEMA = json.loads((CONTRACT_DIR / "runtime-status.schema.json").read_text(encoding="utf-8"))
VALID_DIR = CONTRACT_DIR / "examples" / "valid"
INVALID_DIR = CONTRACT_DIR / "examples" / "invalid"
def parse_datetime(value):
if not isinstance(value, str):
raise ValueError("not a string")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timezone is required")
return parsed.astimezone(timezone.utc)
def matches_type(value, expected):
if expected == "null":
return value is None
if expected == "object":
return isinstance(value, dict)
if expected == "array":
return isinstance(value, list)
if expected == "string":
return isinstance(value, str)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "boolean":
return isinstance(value, bool)
raise AssertionError(f"unsupported schema type in test validator: {expected}")
def resolve_ref(ref):
if not ref.startswith("#/"):
raise AssertionError(f"external refs are not supported: {ref}")
node = SCHEMA
for part in ref[2:].split("/"):
node = node[part.replace("~1", "/").replace("~0", "~")]
return node
def validate(instance, schema=None, path="$", errors=None):
schema = SCHEMA if schema is None else schema
errors = [] if errors is None else errors
if "$ref" in schema:
return validate(instance, resolve_ref(schema["$ref"]), path, errors)
for subschema in schema.get("allOf", []):
validate(instance, subschema, path, errors)
if "if" in schema:
condition_errors = validate(instance, schema["if"], path, [])
branch = schema.get("then") if not condition_errors else schema.get("else")
if branch is not None:
validate(instance, branch, path, errors)
if "type" in schema:
allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
if not any(matches_type(instance, expected) for expected in allowed):
errors.append(f"{path}: expected {allowed}")
return errors
if "const" in schema and instance != schema["const"]:
errors.append(f"{path}: expected constant {schema['const']!r}")
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{path}: value is not in enum")
if isinstance(instance, dict):
required = schema.get("required", [])
for name in required:
if name not in instance:
errors.append(f"{path}: missing required property {name}")
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
for name in instance:
if name not in properties:
errors.append(f"{path}: additional property {name}")
for name, value in instance.items():
if name in properties:
validate(value, properties[name], f"{path}.{name}", errors)
if isinstance(instance, list):
if "maxItems" in schema and len(instance) > schema["maxItems"]:
errors.append(f"{path}: too many items")
if schema.get("uniqueItems"):
encoded = [json.dumps(item, sort_keys=True) for item in instance]
if len(encoded) != len(set(encoded)):
errors.append(f"{path}: duplicate items")
if "items" in schema:
for index, value in enumerate(instance):
validate(value, schema["items"], f"{path}[{index}]", errors)
if isinstance(instance, str):
if "minLength" in schema and len(instance) < schema["minLength"]:
errors.append(f"{path}: string is too short")
if "maxLength" in schema and len(instance) > schema["maxLength"]:
errors.append(f"{path}: string is too long")
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
errors.append(f"{path}: pattern mismatch")
if schema.get("format") == "date-time":
try:
parse_datetime(instance)
except (TypeError, ValueError):
errors.append(f"{path}: invalid date-time")
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
if "minimum" in schema and instance < schema["minimum"]:
errors.append(f"{path}: below minimum")
if "maximum" in schema and instance > schema["maximum"]:
errors.append(f"{path}: above maximum")
return errors
def load(path):
return json.loads(path.read_text(encoding="utf-8"))
def validate_contract(instance):
errors = validate(instance)
configurations = instance.get("configurations") if isinstance(instance, dict) else None
if isinstance(configurations, list):
config_ids = [item.get("config_id") for item in configurations if isinstance(item, dict)]
duplicates = {config_id for config_id in config_ids if config_ids.count(config_id) > 1}
if duplicates:
errors.append(f"$.configurations: duplicate config_id {sorted(duplicates)!r}")
return errors
def freshness(observed_at, evaluation_time):
age = evaluation_time - parse_datetime(observed_at)
if age < timedelta(seconds=-30):
return "future_rejected"
return "stale" if age > timedelta(seconds=90) else "fresh"
ALLOWED_TRANSITIONS = {
"unconfigured": {"starting", "stopped"},
"starting": {"running", "degraded", "failed", "stopped"},
"running": {"degraded", "failed", "stopped"},
"degraded": {"running", "failed", "stopped"},
"failed": {"starting", "stopped"},
"stopped": {"starting", "unconfigured"},
}
def may_transition(previous, current):
return previous == current or current in ALLOWED_TRANSITIONS[previous]
def may_replace(previous, candidate, evaluation_time):
if candidate["schema_version"] != "yovision.runtime-status/v1":
return False
if validate_contract(candidate):
return False
if freshness(candidate["observed_at"], evaluation_time) == "future_rejected":
return False
if candidate["brain_instance_ref"] != previous["brain_instance_ref"]:
return False
if candidate["sequence"] <= previous["sequence"]:
return False
return may_transition(previous["runtime"]["state"], candidate["runtime"]["state"])
class RuntimeStatusV1ContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.running = load(VALID_DIR / "running.json")
def test_schema_declares_frozen_version_and_closed_objects(self):
self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema")
self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "yovision.runtime-status/v1")
self.assertFalse(SCHEMA["additionalProperties"])
for name in ("runtime", "model", "health"):
self.assertFalse(SCHEMA["properties"][name]["additionalProperties"])
self.assertFalse(SCHEMA["properties"]["configurations"]["items"]["additionalProperties"])
def test_all_valid_examples_satisfy_schema(self):
paths = sorted(VALID_DIR.glob("*.json"))
self.assertGreaterEqual(len(paths), 7)
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(validate_contract(load(path)), [])
def test_all_invalid_examples_are_rejected(self):
paths = sorted(INVALID_DIR.glob("*.json"))
self.assertGreaterEqual(len(paths), 6)
for path in paths:
with self.subTest(path=path.name):
self.assertNotEqual(validate_contract(load(path)), [])
def test_every_runtime_state_is_schema_valid(self):
for state in ALLOWED_TRANSITIONS:
message = copy.deepcopy(self.running)
message["runtime"]["state"] = state
with self.subTest(state=state):
self.assertEqual(validate_contract(message), [])
def test_state_transition_matrix(self):
self.assertTrue(may_transition("unconfigured", "starting"))
self.assertTrue(may_transition("starting", "running"))
self.assertTrue(may_transition("running", "degraded"))
self.assertTrue(may_transition("degraded", "running"))
self.assertTrue(may_transition("running", "failed"))
self.assertTrue(may_transition("failed", "stopped"))
self.assertFalse(may_transition("unconfigured", "running"))
self.assertFalse(may_transition("stopped", "running"))
def test_stale_and_future_boundaries(self):
observed = parse_datetime(self.running["observed_at"])
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=90)), "fresh")
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=91)), "stale")
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=30)), "fresh")
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=31)), "future_rejected")
def test_offline_keeps_last_known_and_recovery_replaces_it(self):
last_known = load(VALID_DIR / "offline-last-known.json")
evaluation = parse_datetime(last_known["observed_at"]) + timedelta(seconds=180)
self.assertEqual(freshness(last_known["observed_at"], evaluation), "stale")
self.assertEqual(last_known["runtime"]["state"], "degraded")
recovered = load(VALID_DIR / "recovered.json")
self.assertTrue(may_replace(last_known, recovered, parse_datetime(recovered["observed_at"])))
def test_unknown_version_and_out_of_order_do_not_replace_projection(self):
unknown = load(INVALID_DIR / "unknown-major.json")
evaluation = parse_datetime(self.running["observed_at"])
self.assertFalse(may_replace(self.running, unknown, evaluation))
older = copy.deepcopy(self.running)
older["sequence"] = self.running["sequence"] - 1
self.assertFalse(may_replace(self.running, older, evaluation))
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
self.assertFalse(may_replace(self.running, duplicate, parse_datetime(duplicate["observed_at"])))
def test_configuration_revision_mismatch_is_consumer_derived(self):
message = load(VALID_DIR / "config-mismatch.json")
desired_revisions = {"gate-primary": 21}
self.assertEqual(validate_contract(message), [])
report = message["configurations"][0]
self.assertNotEqual(report["applied_revision"], desired_revisions[report["config_id"]])
self.assertNotIn("desired_revision", report)
def test_configuration_apply_state_invariants(self):
valid_cases = [
("not_configured", None, None),
("applying", None, None),
("applying", 1, None),
("applied", 1, None),
("rejected", None, "CONFIG_INVALID"),
("rejected", 1, "CONFIG_INVALID"),
]
for apply_state, revision, error_code in valid_cases:
message = copy.deepcopy(self.running)
message["configurations"] = [{
"config_id": "gate-primary",
"apply_state": apply_state,
"applied_revision": revision,
"error_code": error_code,
}]
with self.subTest(apply_state=apply_state):
self.assertEqual(validate_contract(message), [])
invalid_cases = [
("not_configured", 1, None),
("applied", None, None),
("rejected", 1, None),
]
for apply_state, revision, error_code in invalid_cases:
message = copy.deepcopy(self.running)
message["configurations"] = [{
"config_id": "gate-primary",
"apply_state": apply_state,
"applied_revision": revision,
"error_code": error_code,
}]
with self.subTest(invalid_apply_state=apply_state):
self.assertNotEqual(validate_contract(message), [])
def test_empty_multiple_and_duplicate_configuration_streams(self):
empty = load(VALID_DIR / "empty-configurations.json")
multiple = load(VALID_DIR / "configuration-states.json")
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
self.assertEqual(validate_contract(empty), [])
self.assertEqual(validate_contract(multiple), [])
self.assertEqual(len(multiple["configurations"]), 4)
self.assertTrue(any("duplicate config_id" in error for error in validate_contract(duplicate)))
def test_sensitive_and_business_fields_are_rejected_by_name(self):
for forbidden in ("access_token", "password", "credential", "internal_path", "stack", "user_session", "video", "face", "alert"):
message = copy.deepcopy(self.running)
message[forbidden] = "forbidden"
with self.subTest(forbidden=forbidden):
self.assertTrue(any("additional property" in error for error in validate_contract(message)))
def test_logical_references_reject_paths(self):
for value in ("C:\\models\\private.pt", "/srv/models/private.pt", "../private.pt"):
message = copy.deepcopy(self.running)
message["model"]["model_ref"] = value
with self.subTest(value=value):
self.assertNotEqual(validate_contract(message), [])
def test_mapper_responsibilities_are_documented(self):
mapping = (CONTRACT_DIR / "mapping.md").read_text(encoding="utf-8")
for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configurations[]", "health.*", "inputs[]"):
self.assertIn(f"`{field}`", mapping)
self.assertIn("Brain", mapping)
self.assertIn("Sense", mapping)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,2 @@
jsonschema==4.23.0
rfc8785==0.1.4
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}

Some files were not shown because too many files have changed in this diff Show More