feat(admin): plugin write plane — admin.kick/ban/unban/broadcast

Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the
website can moderate the live shard.

- BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite),
  admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at
  or above AdminAccessFloor (default CoOwner — Owner-only shield), replies
  admin.ok/admin.error with the reqId echoed, and emits an admin.audit
  (origin=web) broadcast. Attribution is web:<actor> in the console log and
  the ban BanDealer tag. Kicking enumerates NetState.Instances so a
  character-select session is caught too.
- BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in),
  AdminAccessFloor, broadcast/reason length caps, ban duration clamp.
- tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored.

Verified: compiles clean against ServUO (0 err/warn); live run on the seeded
shard confirms all four verbs, the audit stream, timed-ban fields, and the
Owner-floor refusal, with no exceptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-13 01:55:11 -05:00
parent 8e400daf26
commit 5968007882
4 changed files with 488 additions and 2 deletions

View File

@@ -0,0 +1,78 @@
param(
[int] $Port = 7788,
[string] $Log = "$PSScriptRoot\sc_admin.log"
)
# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
# fires admin.* commands covering the happy paths and every guard, logs the replies.
# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
function Say($msg) {
for ($i = 0; $i -lt 5; $i++) {
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
catch { Start-Sleep -Milliseconds 100 }
}
}
"" | Out-File -FilePath $Log -Encoding utf8
Say "[admin] starting on 127.0.0.1:$Port"
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
$bound = $false
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
try { $listener.Start(); $bound = $true }
catch { Start-Sleep -Seconds 1 }
}
if (-not $bound) { Say "[admin] could not bind"; exit 1 }
Say "[admin] listening"
$client = $listener.AcceptTcpClient()
Say "[admin] === shard connected ==="
$stream = $client.GetStream()
$reader = New-Object System.IO.StreamReader($stream)
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 500
$requests = @(
# happy path, no target needed
'{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
# ban an offline seed account (timed), then unban
'{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
'{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
# kick an offline account -> should succeed with sessions:0
'{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
# floor: whitlocktech is Owner -> must be refused
'{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
# unknown target
'{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
# missing actor -> refused by the shared gate
'{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
)
foreach ($r in $requests) {
$writer.WriteLine($r)
Say "[admin] -> $r"
Start-Sleep -Milliseconds 400
}
# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
$stream.ReadTimeout = 2500
try {
while ($true) {
$line = $reader.ReadLine()
if ($null -eq $line) { break }
Say "[admin] <- $line"
}
} catch {
Say "[admin] read window closed (idle)"
}
Say "[admin] done"
$client.Close()
$listener.Stop()