From 7df4239c6983a0951c2108876dbafc5d32aa3c48 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 27 Oct 2026 23:34:55 -0400 Subject: [PATCH 1/7] feat(admin): add inert read-only mobile status projection --- .github/workflows/admin-console.yml | 41 ++++++ .../AdminConsole/AdminServerProjection.php | 134 ++++++++++++++++++ tests/admin-console/projection.php | 101 +++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 .github/workflows/admin-console.yml create mode 100644 src/Civ13/AdminConsole/AdminServerProjection.php create mode 100644 tests/admin-console/projection.php diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml new file mode 100644 index 00000000..f2bdffbe --- /dev/null +++ b/.github/workflows/admin-console.yml @@ -0,0 +1,41 @@ +name: Read-only admin console contracts + +on: + push: + paths: + - 'src/Civ13/AdminConsole/**' + - 'tests/admin-console/**' + - '.github/workflows/admin-console.yml' + pull_request: + paths: + - 'src/Civ13/AdminConsole/**' + - 'tests/admin-console/**' + - '.github/workflows/admin-console.yml' + +permissions: + contents: read + +jobs: + projection: + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + php: ['8.3', '8.4', '8.5'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Lint display contract + run: php -l src/Civ13/AdminConsole/AdminServerProjection.php + - name: Lint offline test + run: php -l tests/admin-console/projection.php + - name: Dependency-free projection contracts + timeout-minutes: 2 + run: php tests/admin-console/projection.php diff --git a/src/Civ13/AdminConsole/AdminServerProjection.php b/src/Civ13/AdminConsole/AdminServerProjection.php new file mode 100644 index 00000000..93fbbbf0 --- /dev/null +++ b/src/Civ13/AdminConsole/AdminServerProjection.php @@ -0,0 +1,134 @@ +authorize)($actor, $target) === true; + } catch (\Throwable) { + $allowed = false; + } + if (! $allowed) { + return ['schema' => 'civ13.admin-status.v1', 'access' => 'denied']; + } + + $view = [ + 'schema' => 'civ13.admin-status.v1', + 'access' => 'allowed', + 'server_id' => $target, + 'state' => 'unknown', + 'reason' => 'observation_unavailable', + 'age_seconds' => null, + 'ready' => null, + 'metrics' => ['players' => null, 'map' => null, 'mode' => null, + 'round_id' => null, 'round_duration_seconds' => null, + 'generation' => null, 'save_load_phase' => null], + 'capabilities' => ['view_status' => true, 'save' => false, + 'restart' => false, 'verbs' => false, 'control_reason' => 'read_only'], + ]; + try { + $sample = ($this->observations)($target); + $now = ($this->clock)(); + } catch (\Throwable) { + return $view; + } + if (! is_array($sample) || ($sample['server_id'] ?? null) !== $target + || ($sample['clock_scope'] ?? null) !== $this->clockScope) { + return $view; + } + $observed = $sample['observed_at'] ?? null; + $expires = $sample['expires_at'] ?? null; + if (! self::number($now) || ! self::number($observed) || ! self::number($expires) + || $observed > $now || $expires <= $observed + || $expires - $observed > self::MAX_AGE_SECONDS) { + $view['reason'] = 'invalid_observation_time'; + return $view; + } + $view['age_seconds'] = (float) ($now - $observed); + if ($now >= $expires) { + $view['state'] = 'stale'; + $view['reason'] = 'observation_expired'; + return $view; + } + $state = $sample['state'] ?? null; + if (! in_array($state, ['unknown', 'stale', 'offline', 'online', 'busy', 'recovery_hold'], true)) { + $view['reason'] = 'invalid_observation_state'; + return $view; + } + $view['state'] = $state; + $view['reason'] = match ($state) { + 'unknown' => 'source_unknown', + 'stale' => 'source_stale', + // Offline means observed unreachable; it does NOT establish stopped. + 'offline' => 'source_unreachable', + 'busy' => 'operation_in_progress', + 'recovery_hold' => 'recovery_hold', + default => 'fresh_observation', + }; + if (in_array($state, ['unknown', 'stale', 'offline'], true)) { + return $view; + } + $view['ready'] = in_array($state, ['busy', 'recovery_hold'], true) + ? false : (is_bool($sample['ready'] ?? null) ? $sample['ready'] : null); + $data = is_array($sample['metrics'] ?? null) ? $sample['metrics'] : []; + foreach (['players' => 10000, 'round_duration_seconds' => 2147483647] as $key => $maximum) { + $value = $data[$key] ?? null; + if (is_int($value) && $value >= 0 && $value <= $maximum) { + $view['metrics'][$key] = $value; + } + } + foreach (['map' => 128, 'mode' => 80, 'round_id' => 80, 'generation' => 160] as $key => $limit) { + $value = $data[$key] ?? null; + if (is_string($value) && strlen($value) <= $limit + && preg_match('//u', $value) === 1 + && preg_match('/[\p{C}\p{Zl}\p{Zp}]/u', $value) === 0) { + $view['metrics'][$key] = $value; + } + } + $phase = $data['save_load_phase'] ?? null; + if (in_array($phase, ['idle', 'saving', 'loading', 'restoring_players'], true)) { + $view['metrics']['save_load_phase'] = $phase; + } + return $view; + } + + private static function number(mixed $value): bool + { + return (is_int($value) || is_float($value)) && is_finite((float) $value); + } +} diff --git a/tests/admin-console/projection.php b/tests/admin-console/projection.php new file mode 100644 index 00000000..0b343078 --- /dev/null +++ b/tests/admin-console/projection.php @@ -0,0 +1,101 @@ + 'fixture', 'clock_scope' => 'collector-1', + 'observed_at' => 10.0, 'expires_at' => 40.0, 'state' => 'online', + 'metrics' => ['players' => 0, 'map' => 'Gaia', 'mode' => 'Nomads', + 'round_duration_seconds' => 0, 'generation' => 'round-v2:fixture']]; +$sample = $fixture; +$projection = new AdminServerProjection( + static function (string $actor, string $target) use (&$allowed): bool { + return $allowed && $actor === 'reader' && $target === 'fixture'; + }, + static function (string $target) use (&$sample, &$reads): ?array { ++$reads; return $sample; }, + static function () use (&$now): float { return $now; }, + 'collector-1', +); +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'online' && $view['metrics']['players'] === 0, 'Zero players remains online'); +check($view['ready'] === null, 'Connectivity alone cannot establish readiness'); +check($view['metrics']['round_duration_seconds'] === 0, 'Zero duration is valid'); +check($view['capabilities']['restart'] === false && $view['capabilities']['save'] === false + && $view['capabilities']['verbs'] === false, 'No controls become available'); +check(! array_key_exists('cpu', $view['metrics']), 'Unsupported metrics are not invented'); + +$allowed = false; +$before = $reads; +check($projection->project('reader', 'fixture') === ['schema' => 'civ13.admin-status.v1', 'access' => 'denied'], 'Deny without data'); +check($reads === $before, 'Authorization precedes source read'); +$allowed = true; +check($projection->project('other', 'fixture')['access'] === 'denied', 'Actor binding'); +check($projection->project('reader', 'other')['access'] === 'denied', 'Target authorization'); + +foreach (['unknown', 'stale', 'offline', 'busy', 'recovery_hold'] as $state) { + $sample = $fixture; + $sample['state'] = $state; + $sample['ready'] = true; + $view = $projection->project('reader', 'fixture'); + check($view['state'] === $state, 'Preserve explicit source state '.$state); + if (in_array($state, ['busy', 'recovery_hold'], true)) { + check($view['ready'] === false, 'Busy/hold cannot be ready'); + } else { + check($view['metrics']['players'] === null && $view['ready'] === null, 'Suppress unverified current metrics'); + } +} +$sample = $fixture; +$now = 39.0; +check($projection->project('reader', 'fixture')['age_seconds'] === 29.0, 'Observation age'); +$now = 40.0; +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'stale' && $view['metrics']['players'] === null, 'Exact expiry cannot be refreshed by rendering'); +$now = 20.0; +foreach ([['server_id', 'another'], ['clock_scope', 'old-collector'], ['observed_at', 21.0], + ['observed_at', NAN], ['expires_at', INF], ['expires_at', 10.0], ['expires_at', 131.0], + ['state', 'invented']] as [$key, $value]) { + $sample = $fixture; + $sample[$key] = $value; + check($projection->project('reader', 'fixture')['state'] === 'unknown', 'Reject invalid identity/time/state: '.$key); +} +$sample = null; +check($projection->project('reader', 'fixture')['state'] === 'unknown', 'Missing is not offline'); +$sample = $fixture; +$sample['metrics'] = ['players' => '0', 'map' => "bad\nmap", 'mode' => "\xff", 'round_id' => str_repeat('x', 81), + 'round_duration_seconds' => -1, 'save_load_phase' => '99%', 'token' => 'never-project', 'cpu' => 99]; +$view = $projection->project('reader', 'fixture'); +check(count(array_filter($view['metrics'], static fn ($v) => $v !== null)) === 0, 'Invalid and unknown metrics suppressed'); +check(! str_contains(json_encode($view, JSON_THROW_ON_ERROR), 'never-project'), 'No raw payload leakage'); +$sample = $fixture; +$sample['ready'] = true; +$sample['capabilities'] = ['save' => true, 'restart' => true, 'verbs' => true]; +$sample['metrics']['save_load_phase'] = 'idle'; +check($projection->project('reader', 'fixture')['capabilities']['save'] === false, 'Source cannot grant capabilities'); +check($projection->project('reader', 'fixture')['ready'] === true, 'Explicit fresh readiness retained for display only'); + +$denied = new AdminServerProjection(static function (): bool { throw new RuntimeException('private'); }, + static function (): array { throw new RuntimeException('must not read'); }, static fn (): float => 20.0, 'scope'); +check($denied->project('reader', 'fixture')['access'] === 'denied', 'Authorization failure denies'); +$missing = new AdminServerProjection(static fn (): bool => true, + static function (): array { throw new RuntimeException('private'); }, static fn (): float => 20.0, 'scope'); +check($missing->project('reader', 'fixture')['state'] === 'unknown', 'Source failure hides details'); +$clockFailure = new AdminServerProjection(static fn (): bool => true, static fn (): array => $fixture, + static function (): float { throw new RuntimeException('private'); }, 'collector-1'); +check($clockFailure->project('reader', 'fixture')['state'] === 'unknown', 'Clock failure is unknown'); +echo "Admin console projection offline contracts passed ($checks checks)\n"; From be16312cfb19f0edd345004beae76047b1573194 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 27 Oct 2026 23:44:29 -0400 Subject: [PATCH 2/7] feat(admin): add non-routed phone overview with fixture-only adapter --- .github/workflows/admin-console.yml | 18 +++++ tests/admin-console/ui-dom.mjs | 44 +++++++++++ tests/admin-console/ui.html | 4 + tests/admin-console/ui.js | 61 +++++++++++++++ tests/admin-console/ui.mjs | 16 ++++ web/admin-console/README.md | 62 +++++++++++++++ web/admin-console/console.css | 1 + web/admin-console/console.js | 114 ++++++++++++++++++++++++++++ web/admin-console/demo.js | 2 + web/admin-console/fixtures.js | 21 +++++ web/admin-console/index.html | 28 +++++++ 11 files changed, 371 insertions(+) create mode 100644 tests/admin-console/ui-dom.mjs create mode 100644 tests/admin-console/ui.html create mode 100644 tests/admin-console/ui.js create mode 100644 tests/admin-console/ui.mjs create mode 100644 web/admin-console/README.md create mode 100644 web/admin-console/console.css create mode 100644 web/admin-console/console.js create mode 100644 web/admin-console/demo.js create mode 100644 web/admin-console/fixtures.js create mode 100644 web/admin-console/index.html diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index f2bdffbe..1fe713ac 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -5,11 +5,13 @@ on: paths: - 'src/Civ13/AdminConsole/**' - 'tests/admin-console/**' + - 'web/admin-console/**' - '.github/workflows/admin-console.yml' pull_request: paths: - 'src/Civ13/AdminConsole/**' - 'tests/admin-console/**' + - 'web/admin-console/**' - '.github/workflows/admin-console.yml' permissions: @@ -39,3 +41,19 @@ jobs: - name: Dependency-free projection contracts timeout-minutes: 2 run: php tests/admin-console/projection.php + + ui-contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node --check web/admin-console/console.js + - run: node --check web/admin-console/fixtures.js + - run: node --check web/admin-console/demo.js + - name: Dependency-free DOM contracts (not browser/layout acceptance) + run: node tests/admin-console/ui-dom.mjs diff --git a/tests/admin-console/ui-dom.mjs b/tests/admin-console/ui-dom.mjs new file mode 100644 index 00000000..bd0e4817 --- /dev/null +++ b/tests/admin-console/ui-dom.mjs @@ -0,0 +1,44 @@ +// Minimal DOM contract double, NOT browser/layout/accessibility acceptance. +// Executes the same renderer/interaction assertions as ui.html without packages. +import {readFileSync} from 'node:fs'; +import vm from 'node:vm'; +class Node { + constructor(tag = '', text = '') { this.tagName = tag; this.data = text; this.children = []; this.attributes = {}; this.dataset = {}; this.listeners = {}; this.className = ''; this.value = ''; this.disabled = false; } + get textContent() { return this.data + this.children.map(node => node.textContent).join(''); } + set textContent(value) { this.data = String(value); this.children = []; } + append(...nodes) { for (const node of nodes) { if (node.tagName === '#fragment') this.append(...node.children); else this.children.push(node); } } + replaceChildren(...nodes) { this.data = ''; this.children = []; this.append(...nodes); } + setAttribute(key, value) { this.attributes[key] = value; } + getAttribute(key) { return this.attributes[key]; } + get classList() { return {add: name => { this.className += ' '+name; }, remove: name => { this.className = this.className.split(' ').filter(v => v !== name).join(' '); }}; } + addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } + dispatchEvent(event) { for (const callback of this.listeners[event.type] || []) callback(event); } + matches(selector) { + if (selector.startsWith('.')) return this.className.split(' ').includes(selector.slice(1)); + if (selector.startsWith('[data-state=')) return this.dataset.state === selector.slice(13, -2); + return this.tagName === selector; + } + querySelectorAll(selector) { + if (selector.includes(',')) return selector.split(',').flatMap(part => this.querySelectorAll(part)); + const parts = selector.split(' '); + const result = []; + const visit = node => { for (const child of node.children) { if (child.matches(parts[0])) { if (parts.length > 1) result.push(...child.querySelectorAll(parts.slice(1).join(' '))); else result.push(child); } visit(child); } }; + visit(this); return [...new Set(result)]; + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } +} +const body = new Node('body'); +const document = {body, createElement: tag => new Node(tag), createTextNode: text => new Node('#text', text), + createDocumentFragment: () => new Node('#fragment'), + getElementById: id => { const find = node => node.id === id ? node : node.children.map(find).find(Boolean); return find(body) || null; }}; +for (const [tag, id] of [['select', 'state-filter'], ['button', 'reload'], ['p', 'summary'], ['section', 'servers'], ['pre', 'results']]) { + const node = new Node(tag); node.id = id; if (tag === 'select') node.value = 'all'; body.append(node); +} +const context = vm.createContext({document, console, structuredClone, Event: class {constructor(type) {this.type = type;}}}); +for (const file of ['../../web/admin-console/console.js', '../../web/admin-console/fixtures.js', './ui.js']) { + vm.runInContext(readFileSync(new URL(file, import.meta.url), 'utf8'), context, {filename: file}); +} +await new Promise(resolve => setImmediate(resolve)); +const result = document.getElementById('results').textContent; +console.log(result+' (DOM double; browser/layout unverified)'); +if (body.dataset.result !== 'pass') process.exitCode = 1; diff --git a/tests/admin-console/ui.html b/tests/admin-console/ui.html new file mode 100644 index 00000000..a6ba144f --- /dev/null +++ b/tests/admin-console/ui.html @@ -0,0 +1,4 @@ +Offline admin UI tests +

RUNNING
+ + diff --git a/tests/admin-console/ui.js b/tests/admin-console/ui.js new file mode 100644 index 00000000..a7bb9f8a --- /dev/null +++ b/tests/admin-console/ui.js @@ -0,0 +1,61 @@ +'use strict'; +(async () => { + let checks = 0; + function check(condition, message) { checks++; if (!condition) throw new Error(message); } + try { + const root = document.getElementById('servers'); + const rows = await Civ13FixtureAdapter.read(); + const render = (data, filter = 'all') => Civ13AdminConsole.render(document, root, data, filter); + check(render(rows).count === 6, 'All six fixture states render'); + check(root.querySelectorAll('article').length === 6, 'Semantic server cards'); + check([...root.querySelectorAll('button')].every(button => button.disabled), 'All actions disabled'); + check(root.querySelectorAll('button').length === 18, 'Three unavailable actions per card'); + check(root.querySelector('.metric-primary dd').textContent === '0', 'Zero player count preserved'); + check(root.querySelector('.badge').textContent === 'Online', 'Zero player world online'); + check(root.querySelector('time').dateTime === rows[0].observed_at, 'Fixture timestamp preserved'); + check(root.textContent.includes('Unverified'), 'Readiness not inferred'); + for (const state of ['online', 'unknown', 'stale', 'offline', 'busy', 'recovery_hold']) { + check(root.querySelector(`[data-state="${state}"]`) !== null, 'State '+state); + } + check(render(rows, 'online').count === 1, 'Filter'); + const hostile = structuredClone(rows[0]); + hostile.name = ''; + hostile.projection.metrics.map = ''; + hostile.projection.capabilities = {save: true, restart: true, verbs: true}; + render([hostile]); + check(root.querySelector('img,script') === null && !globalThis.injected, 'No HTML injection'); + check(root.textContent.includes(' button.disabled), 'Capabilities cannot enable demo actions'); + check([...root.querySelectorAll('button')].every(button => document.getElementById(button.getAttribute('aria-describedby'))), 'Action reason association'); + const stale = structuredClone(hostile); stale.projection.state = 'stale'; + render([stale]); check(!root.textContent.includes(' + + + + + +
+
CIV13OPERATIONSFIXTURE DEMO
+
+

ADMINISTRATION / OVERVIEW

Your worlds,
at a glance.

Read-only server health. Know what is happening before you act.

+ +
+

+
+
READ-ONLY BY DESIGN · No commands are sent. No actions are queued.
+
+
+ + From 6a8c149ff9cec48ec25766dc4580beaf23c3dcd0 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 28 Oct 2026 00:07:35 -0400 Subject: [PATCH 3/7] feat(admin): normalize cached directory and heartbeat evidence --- .github/workflows/admin-console.yml | 7 + docs/admin-console-cached-observations.md | 130 ++++++++++++++++ .../AdminConsole/AdminServerProjection.php | 38 ++++- .../AdminConsole/CachedObservationAdapter.php | 120 ++++++++++++++ tests/admin-console/cached-observation.php | 146 ++++++++++++++++++ 5 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 docs/admin-console-cached-observations.md create mode 100644 src/Civ13/AdminConsole/CachedObservationAdapter.php create mode 100644 tests/admin-console/cached-observation.php diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index 1fe713ac..6242bc3a 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -38,6 +38,13 @@ jobs: run: php -l src/Civ13/AdminConsole/AdminServerProjection.php - name: Lint offline test run: php -l tests/admin-console/projection.php + - name: Lint cached observation adapter + run: php -l src/Civ13/AdminConsole/CachedObservationAdapter.php + - name: Lint cached observation tests + run: php -l tests/admin-console/cached-observation.php + - name: Cached observation contracts + timeout-minutes: 2 + run: php tests/admin-console/cached-observation.php - name: Dependency-free projection contracts timeout-minutes: 2 run: php tests/admin-console/projection.php diff --git a/docs/admin-console-cached-observations.md b/docs/admin-console-cached-observations.md new file mode 100644 index 00000000..49d4ae3a --- /dev/null +++ b/docs/admin-console-cached-observations.md @@ -0,0 +1,130 @@ +# Read-only admin console: cached observation adapter + +`AdminConsole/CachedObservationAdapter` normalizes injected captures. It does not +call the directory, heartbeat poller, file reader, network, bot or game. No service +registration, collector integration or endpoint is included. In particular, do not +call `Directory::refresh()` or `ServerHeartbeat::poll()` from an HTTP/UI request. + +## Actual source contracts + +Inspected Civilizationbot main `ff94f6dd6643054336411ebdddaeb120b1d11bae`: + +- `ServerDirectory/Directory::snapshot(provider)` produces schema 1 with UTC + `generated_at`, `expires_at` and `servers`. Each online-only row has id, host, + engine, online=true, checked_at, players, map, mode and round{id,duration_seconds}. + `generated_at` is the time snapshot() renders, **not** when a server was checked. + Expiry is at most 60 seconds after the underlying observation. Null means + unconfigured, warming up or stale; missing rows can reflect failed probes or + omission. Neither null nor missing establishes stopped/offline. +- `ServerHeartbeat::poll()` produces state (advancing/stale/unknown), reason, age + and data. Age measures time since last observed writer progress, not time since + the poll. At 120 seconds without progress the source is stale. The raw data and + reason must not enter the admin DTO: data can contain player identifiers and + other fields not appropriate to expose. + +## Injected capture envelope (new, internal-only) + +The future trusted collector supplies an immutable directory capture: + +``` +{clock_scope, captured_at, wall_time, snapshot} +``` + +`captured_at` is monotonic seconds and `wall_time` is UTC Unix seconds from the +same capture; `snapshot` is the existing parsed directory result, or null. Read +the wall/monotonic pair immediately after snapshot() returns (not before it), so +a UTC second boundary cannot make generated_at newer than captured wall_time. Capture +this clock pair when collecting, not during a later render. A collector incarnation +must use a new `clock_scope` after restart. This is an application-owned adapter +contract, not an HTTP upload format or a credential/authority mechanism. Do not +reconstruct fresh capture envelopes from persisted/public status. + +Heartbeat capture: + +``` +{server_id, clock_scope, sampled_at, observation} +``` + +`sampled_at` is the original poll time in the same monotonic scope; `observation` +is its retained result. Never substitute read/render time. Keep captures private +and pass only the adapter's allowlisted result onward. + +`normalize(target, directoryCapture, heartbeatCapture, now)` uses a target from +trusted inventory: `{id, provider}`. The target must already be authorized by the +caller/registry boundary; accepting a syntactically valid ID here does not grant +access or registration. Providers are the directory's existing civ13.com and +valzargaming.com values. `now` is an injected monotonic time, not an ambient read. + +Checked_at and expiry convert through the fixed clock pair. The adapter retains +original collection times as well as observation times, so a clock rollback +before capture cannot make an older-looking progress timestamp seem trustworthy. +No raw address, port, name, token, bearer credential, reason, session, process +identity or heartbeat data is copied. Duplicate target rows, wrong provider/scope, +future timestamps and malformed/oversized lifetimes fail to unknown. + +## Display contract + +Pass normalized output to the existing `AdminServerProjection`. It retains the +existing authorization gate, metrics and permanently disabled control capabilities, +with additive `evidence` fields: + +- `reachability`: reachable / stale / unknown, age_seconds. +- `heartbeat`: advancing / stale / unknown, age_seconds. +- `readiness`: unknown for these two source adapters. Existing explicit boolean + readiness inputs in other projection fixtures remain separately reported. + +The projection rechecks each evidence expiry on every read. A directory row can +expire while heartbeat progress remains fresh; a reachable server can have a stale +heartbeat. Neither situation is collapsed into readiness. Unknown/stale states +still suppress current player/map metrics. An absent directory row with advancing +heartbeat remains overall unknown, not online or stopped. No offline/busy/recovery +state is invented from these sources; their future authoritative producers remain +separate. Current fixture UI does not yet present the new evidence fields. + +Directory metadata supplies player count, map/mode, round ID/duration only. +Save generation, save/load phase, CPU/RAM and lifecycle readiness remain unavailable. +The unknown fallback carries no observation claim; re-normalizing an old capture +cannot renew reachability/progress evidence. All output is display-only, never an +execution lease or action authorization. + +## Verification + +Offline, dependency-free checks (no bot bootstrap or real captures): + +``` +php -l src/Civ13/AdminConsole/CachedObservationAdapter.php +php -l src/Civ13/AdminConsole/AdminServerProjection.php +php -l tests/admin-console/cached-observation.php +php tests/admin-console/projection.php +php tests/admin-console/cached-observation.php +``` + +The existing admin-console six-job PHP matrix includes the new lints/contracts. +Fixtures cover zero players, source expiry, independent evidence, absent rows, +wrong target/provider/scope, clock rollback, duplicate rows, malformed timestamps, +raw-data exclusion and permanently unavailable controls. Local PHP is unavailable +in the preparation environment; these new PHP checks have not yet executed. The +previous head's passing tests do not establish this patch's runtime correctness. + +## Producer cross-check pointers + +At the inspected source pin, Directory.php lines 132–151 require each included +checked_at <= generated_at < expires_at, with expires_at <= checked_at + 60. +Lines 168–170 emit the exact provider host, uppercase BYOND and online=true; +line 151 emits integer schema 1. metadata() permits an absent game_id as an empty +round.id, also explicitly asserted in tests/server-directory.php:180. + +Existing tests/server-directory.php:147 and :164 assert a 60-second initial +lifetime and one second remaining after 59 seconds; :220 and :225 compare the +managed/unicode fixtures to actual production Directory output. The remote +suite's :69–72 checks an early result is not renewed by a slow peer, and :150 +compares the VZG fixture to production output. The adapter contracts now load +all three existing fixture files and check admission, metadata and 59/60-second +boundaries. Those added checks are prepared, not locally executed. + +Text validation in both projection and adapter uses the producer's exact rejected +Unicode categories: Cc/Cf/Cs and Zl/Zp. Private-use and unassigned characters are +not treated as controls. Explicit contracts accept a private-use map label through +both layers and reject newline, bidi-formatting and malformed UTF-8 independently +in each layer. The existing UI still uses textContent/createTextNode, not HTML; +its hostile-label DOM contracts and fixture-only control boundary are unchanged. diff --git a/src/Civ13/AdminConsole/AdminServerProjection.php b/src/Civ13/AdminConsole/AdminServerProjection.php index 93fbbbf0..253de9c6 100644 --- a/src/Civ13/AdminConsole/AdminServerProjection.php +++ b/src/Civ13/AdminConsole/AdminServerProjection.php @@ -55,6 +55,9 @@ public function project(string $actor, string $target): array 'reason' => 'observation_unavailable', 'age_seconds' => null, 'ready' => null, + 'evidence' => ['reachability' => ['state' => 'unknown', 'age_seconds' => null], + 'heartbeat' => ['state' => 'unknown', 'age_seconds' => null], + 'readiness' => ['state' => 'unknown', 'age_seconds' => null]], 'metrics' => ['players' => null, 'map' => null, 'mode' => null, 'round_id' => null, 'round_duration_seconds' => null, 'generation' => null, 'save_load_phase' => null], @@ -71,9 +74,15 @@ public function project(string $actor, string $target): array || ($sample['clock_scope'] ?? null) !== $this->clockScope) { return $view; } + $view['evidence'] = self::evidence($sample['evidence'] ?? null, $now); $observed = $sample['observed_at'] ?? null; $expires = $sample['expires_at'] ?? null; + $collected = $sample['collected_at'] ?? $observed; + if (($sample['state'] ?? null) === 'unknown' && $observed === null && $expires === null) { + return $view; // Never observed is unknown, not an artificially expiring sample. + } if (! self::number($now) || ! self::number($observed) || ! self::number($expires) + || !self::number($collected) || $collected < $observed || $collected > $now || $observed > $now || $expires <= $observed || $expires - $observed > self::MAX_AGE_SECONDS) { $view['reason'] = 'invalid_observation_time'; @@ -116,7 +125,7 @@ public function project(string $actor, string $target): array $value = $data[$key] ?? null; if (is_string($value) && strlen($value) <= $limit && preg_match('//u', $value) === 1 - && preg_match('/[\p{C}\p{Zl}\p{Zp}]/u', $value) === 0) { + && preg_match('/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u', $value) === 0) { $view['metrics'][$key] = $value; } } @@ -124,9 +133,36 @@ public function project(string $actor, string $target): array if (in_array($phase, ['idle', 'saving', 'loading', 'restoring_players'], true)) { $view['metrics']['save_load_phase'] = $phase; } + if (is_bool($view['ready'])) { + $view['evidence']['readiness'] = ['state' => $view['ready'] ? 'ready' : 'not_ready', + 'age_seconds' => $view['age_seconds']]; + } return $view; } + /** Independent source freshness; this display evidence cannot grant controls. */ + private static function evidence(mixed $sources, mixed $now): array + { + $result = ['reachability' => ['state' => 'unknown', 'age_seconds' => null], + 'heartbeat' => ['state' => 'unknown', 'age_seconds' => null], + 'readiness' => ['state' => 'unknown', 'age_seconds' => null]]; + if (!is_array($sources) || !self::number($now)) { return $result; } + foreach (['reachability' => ['reachable', 'unknown'], 'heartbeat' => ['advancing', 'stale', 'unknown']] as $key => $states) { + $source = $sources[$key] ?? null; + if (!is_array($source) || !in_array($source['state'] ?? null, $states, true)) { continue; } + $observed = $source['observed_at'] ?? null; + $expires = $source['expires_at'] ?? null; + $collected = $source['collected_at'] ?? $observed; + if (!self::number($observed) || !self::number($expires) || !self::number($collected) + || $collected < $observed || $collected > $now || $observed > $now + || $expires <= $observed || $expires - $observed > self::MAX_AGE_SECONDS) { continue; } + $result[$key] = ['state' => $now >= $expires ? 'stale' : $source['state'], + 'age_seconds' => (float) ($now - $observed)]; + } + // Directory and passive heartbeat never prove application readiness. + return $result; + } + private static function number(mixed $value): bool { return (is_int($value) || is_float($value)) && is_finite((float) $value); diff --git a/src/Civ13/AdminConsole/CachedObservationAdapter.php b/src/Civ13/AdminConsole/CachedObservationAdapter.php new file mode 100644 index 00000000..b5d83946 --- /dev/null +++ b/src/Civ13/AdminConsole/CachedObservationAdapter.php @@ -0,0 +1,120 @@ + $target['id'], 'clock_scope' => $this->clockScope, + 'observed_at' => null, 'collected_at' => null, 'expires_at' => null, + 'state' => 'unknown', 'ready' => null, 'metrics' => [], 'evidence' => []]; + $heartbeatEvidence = $this->heartbeat($target['id'], $heartbeat, $now); + if ($heartbeatEvidence !== null) { $view['evidence']['heartbeat'] = $heartbeatEvidence; } + $row = $this->directory($target, $directory, $now); + if ($row === null) { return $view; } + [$entry, $observed, $expires] = $row; + $view['observed_at'] = $observed; + $view['collected_at'] = $directory['captured_at']; + $view['expires_at'] = $expires; + $view['state'] = 'online'; // Only a complete directory row proves reachability. + $view['evidence']['reachability'] = ['state' => 'reachable', 'observed_at' => $observed, 'collected_at' => $directory['captured_at'], 'expires_at' => $expires]; + $view['metrics'] = ['players' => $entry['players'], 'map' => $entry['map'], 'mode' => $entry['mode'], + 'round_id' => $entry['round']['id'], 'round_duration_seconds' => $entry['round']['duration_seconds']]; + // No generation, save phase, lifecycle readiness, identity secrets or raw data. + return $view; + } + + private function directory(array $target, ?array $capture, float $now): ?array + { + if ($capture === null || ($capture['clock_scope'] ?? null) !== $this->clockScope + || !self::number($capture['captured_at'] ?? null) || $capture['captured_at'] > $now + || !is_int($capture['wall_time'] ?? null)) { return null; } + $snapshot = $capture['snapshot'] ?? null; + if (!is_array($snapshot) || ($snapshot['schema'] ?? null) !== 1 || !is_array($snapshot['servers'] ?? null) + || !array_is_list($snapshot['servers']) || count($snapshot['servers']) > 32) { return null; } + $generated = self::timestamp($snapshot['generated_at'] ?? null); + $expires = self::timestamp($snapshot['expires_at'] ?? null); + if ($generated === null || $expires === null || $generated > $capture['wall_time'] + || $expires <= $generated || $expires - $generated > 60) { return null; } + $entry = null; + foreach ($snapshot['servers'] as $candidate) { + if (!is_array($candidate)) { return null; } + if (($candidate['id'] ?? null) === $target['id']) { + if ($entry !== null) { return null; } + $entry = $candidate; + } + } + if ($entry === null || ($entry['host'] ?? null) !== $target['provider'] + || ($entry['online'] ?? null) !== true || ($entry['engine'] ?? null) !== 'BYOND' + || !is_int($entry['players'] ?? null) || $entry['players'] < 0 || $entry['players'] > 10000 + || !self::text($entry['map'] ?? null, 128) || !self::text($entry['mode'] ?? null, 80) + || !is_array($entry['round'] ?? null) || !self::text($entry['round']['id'] ?? null, 80, true) + || !is_int($entry['round']['duration_seconds'] ?? null) + || $entry['round']['duration_seconds'] < 0 || $entry['round']['duration_seconds'] > 2147483647) { return null; } + $checked = self::timestamp($entry['checked_at'] ?? null); + if ($checked === null || $checked > $generated || $expires <= $checked || $expires - $checked > 60) { return null; } + // Fixed clock-pair conversion; generated_at is never used as observation time. + $observedMono = $capture['captured_at'] - ($capture['wall_time'] - $checked); + $expiresMono = $capture['captured_at'] - ($capture['wall_time'] - $expires); + return [$entry, $observedMono, $expiresMono]; + } + + private function heartbeat(string $target, ?array $capture, float $now): ?array + { + if ($capture === null || ($capture['server_id'] ?? null) !== $target + || ($capture['clock_scope'] ?? null) !== $this->clockScope + || !self::number($capture['sampled_at'] ?? null) || $capture['sampled_at'] > $now) { return null; } + $source = $capture['observation'] ?? null; + if (!is_array($source) || !in_array($source['state'] ?? null, ['advancing', 'stale', 'unknown'], true)) { return null; } + $sampled = (float) $capture['sampled_at']; + if ($source['state'] === 'advancing') { + $age = $source['age'] ?? null; + if (!self::number($age) || $age < 0 || $age >= 120) { return null; } + // Last observed writer progress, not file mtime or the latest read time. + return ['state' => 'advancing', 'observed_at' => $sampled - $age, 'collected_at' => $sampled, 'expires_at' => $sampled - $age + 120.0]; + } + // Stale/unknown are observations, not evidence of current writer progress. + return ['state' => $source['state'], 'observed_at' => $sampled, 'collected_at' => $sampled, 'expires_at' => $sampled + 120.0]; + } + + private static function number(mixed $value): bool + { + return (is_int($value) || is_float($value)) && is_finite((float) $value); + } + + private static function text(mixed $value, int $limit, bool $empty = false): bool + { + return is_string($value) && ($empty || trim($value) !== '') && strlen($value) <= $limit + && preg_match('//u', $value) === 1 && preg_match('/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u', $value) === 0; + } + + private static function timestamp(mixed $value): ?int + { + if (!is_string($value) || preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\z/D', $value) !== 1) { return null; } + $time = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:s\Z', $value, new \DateTimeZone('UTC')); + return $time !== false && $time->format('Y-m-d\TH:i:s\Z') === $value ? $time->getTimestamp() : null; + } +} diff --git a/tests/admin-console/cached-observation.php b/tests/admin-console/cached-observation.php new file mode 100644 index 00000000..a5041ee1 --- /dev/null +++ b/tests/admin-console/cached-observation.php @@ -0,0 +1,146 @@ + 'fixture', 'provider' => 'civ13.com']; +$now = 110.0; +$entry = ['id' => 'fixture', 'host' => 'civ13.com', 'online' => true, 'engine' => 'BYOND', + 'checked_at' => stamp($wall - 10), 'players' => 0, 'map' => 'Gaia', 'mode' => 'Nomads', + 'round' => ['id' => '', 'duration_seconds' => 0], 'token' => 'directory-secret']; +$directory = ['clock_scope' => 'clock-1', 'captured_at' => 100.0, 'wall_time' => $wall, + 'snapshot' => ['schema' => 1, 'generated_at' => stamp($wall), + 'expires_at' => stamp($wall + 50), 'servers' => [$entry]]]; +$heartbeat = ['server_id' => 'fixture', 'clock_scope' => 'clock-1', 'sampled_at' => 100.0, + 'observation' => ['state' => 'advancing', 'age' => 20.0, 'reason' => 'private-path', 'data' => 'ckeys-and-secrets']]; +$adapter = new CachedObservationAdapter('clock-1'); +$sample = $adapter->normalize($target, $directory, $heartbeat, $now); +$projection = new AdminServerProjection(static fn (): bool => true, + static function () use (&$sample): array { return $sample; }, + static function () use (&$now): float { return $now; }, 'clock-1'); +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'online' && $view['metrics']['players'] === 0, 'Zero player online row'); +check($view['metrics']['round_duration_seconds'] === 0, 'Zero round time'); +check($view['ready'] === null && $view['evidence']['readiness']['state'] === 'unknown', 'Neither source establishes readiness'); +check($view['age_seconds'] === 20.0, 'Row checked_at, not generated_at, determines age'); +check($view['evidence']['reachability']['state'] === 'reachable', 'Explicit reachable evidence'); +check($view['evidence']['heartbeat']['age_seconds'] === 30.0, 'Heartbeat age refers to last progress'); +check($view['evidence']['heartbeat']['state'] === 'advancing', 'Independent heartbeat'); +check($view['metrics']['generation'] === null && $view['metrics']['save_load_phase'] === null, 'Unavailable lifecycle telemetry not invented'); +check(!str_contains(json_encode($sample), 'secret') && !str_contains(json_encode($sample), 'private-path'), 'No raw secret/reason/data copied'); +check($view['capabilities']['restart'] === false, 'Still read-only'); + +$now = 150.0; +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'stale' && $view['metrics']['players'] === null, 'Directory exact expiry'); +check($view['evidence']['reachability']['state'] === 'stale', 'Reachability expires independently'); +check($view['evidence']['heartbeat']['state'] === 'advancing', 'Directory expiry does not discard fresh heartbeat'); +$now = 200.0; +check($projection->project('reader', 'fixture')['evidence']['heartbeat']['state'] === 'stale', 'Progress expires without any new sample'); +$sample = $adapter->normalize($target, $directory, $heartbeat, $now); +check($projection->project('reader', 'fixture')['state'] === 'stale', 'Renormalization cannot renew expired row'); +$now = 110.0; +$missing = $directory; $missing['snapshot']['servers'] = []; +$sample = $adapter->normalize($target, $missing, $heartbeat, $now); +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'unknown', 'Missing directory row is not offline/stopped'); +check($view['evidence']['reachability']['state'] === 'unknown' && $view['evidence']['heartbeat']['state'] === 'advancing', 'Heartbeat alone does not imply reachability'); +$sample = $adapter->normalize($target, null, null, $now); +check($projection->project('reader', 'fixture')['state'] === 'unknown', 'All missing remains unknown'); +$now = 1000.0; +check($projection->project('reader', 'fixture')['state'] === 'unknown', 'Never-observed does not become a fabricated stale sample'); +$now = 110.0; + +$staleHeartbeat = $heartbeat; $staleHeartbeat['observation']['state'] = 'stale'; +$sample = $adapter->normalize($target, $directory, $staleHeartbeat, $now); +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'online' && $view['evidence']['heartbeat']['state'] === 'stale', 'Reachable Topic and stale heartbeat remain distinct'); +foreach ([['server_id', 'other'], ['clock_scope', 'old'], ['sampled_at', 111.0]] as [$key, $value]) { + $bad = $heartbeat; $bad[$key] = $value; + $sample = $adapter->normalize($target, $directory, $bad, $now); + check($projection->project('reader', 'fixture')['evidence']['heartbeat']['state'] === 'unknown', 'Reject heartbeat binding/time '.$key); +} +foreach ([-1.0, 120.0, NAN, '20'] as $age) { + $bad = $heartbeat; $bad['observation']['age'] = $age; + $sample = $adapter->normalize($target, $directory, $bad, $now); + check($projection->project('reader', 'fixture')['evidence']['heartbeat']['state'] === 'unknown', 'Reject invalid heartbeat progress age'); +} +foreach (['other', 'old-scope', 'future-capture', 'duplicate', 'wrong-provider', 'future-generated', 'future-checked', 'invalid-date', 'long-expiry', 'wrong-schema', 'false-online'] as $case) { + $bad = $directory; + switch ($case) { + case 'other': $bad['snapshot']['servers'][0]['id'] = 'other'; break; + case 'old-scope': $bad['clock_scope'] = 'old'; break; + case 'future-capture': $bad['captured_at'] = 111.0; break; + case 'duplicate': $bad['snapshot']['servers'][] = $entry; break; + case 'wrong-provider': $bad['snapshot']['servers'][0]['host'] = 'valzargaming.com'; break; + case 'future-generated': $bad['snapshot']['generated_at'] = stamp($wall + 1); break; + case 'future-checked': $bad['snapshot']['servers'][0]['checked_at'] = stamp($wall + 1); break; + case 'invalid-date': $bad['snapshot']['servers'][0]['checked_at'] = "2026-02-30T00:00:00Z\0"; break; + case 'long-expiry': $bad['snapshot']['expires_at'] = stamp($wall + 51); break; + case 'wrong-schema': $bad['snapshot']['schema'] = 2; break; + case 'false-online': $bad['snapshot']['servers'][0]['online'] = false; break; + } + $sample = $adapter->normalize($target, $bad, null, $now); + check($projection->project('reader', 'fixture')['state'] === 'unknown', 'Directory invalid case '.$case); +} +$sample = $adapter->normalize($target, $directory, $heartbeat, $now); +$now = 95.0; +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'unknown' && $view['evidence']['heartbeat']['state'] === 'unknown', 'Clock rollback before capture rejects old-looking progress'); +$now = 89.0; +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'unknown' && $view['evidence']['reachability']['state'] === 'unknown', 'Clock rollback rejects future row'); +$now = 110.0; +$sample['evidence']['readiness'] = ['state' => 'ready', 'observed_at' => 100, 'expires_at' => 120, 'secret' => 'never']; +check($projection->project('reader', 'fixture')['evidence']['readiness']['state'] === 'unknown', 'No readiness passthrough'); +$sample['evidence']['heartbeat']['token'] = 'never'; +check(!str_contains(json_encode($projection->project('reader', 'fixture')), 'never'), 'Evidence allowlist'); +// Match the established producer Unicode policy without treating private-use as control. +$privateUse = $directory; +$privateUse['snapshot']['servers'][0]['map'] = "Gaia \u{E000}"; +$sample = $adapter->normalize($target, $privateUse, null, $now); +$view = $projection->project('reader', 'fixture'); +check($view['state'] === 'online' && $view['metrics']['map'] === "Gaia \u{E000}", 'Private-use map passes both adapter and projection'); +foreach (["bad\nmap", "bad\u{202E}map", "bad\xffmap"] as $badText) { + $invalidText = $directory; + $invalidText['snapshot']['servers'][0]['map'] = $badText; + $sample = $adapter->normalize($target, $invalidText, null, $now); + check($projection->project('reader', 'fixture')['state'] === 'unknown', 'Adapter rejects control/bidi/invalid UTF-8'); + $sample = $adapter->normalize($target, $directory, null, $now); + $sample['metrics']['map'] = $badText; + check($projection->project('reader', 'fixture')['metrics']['map'] === null, 'Projection independently rejects control/bidi/invalid UTF-8'); +} + +// Existing producer-checked cross-language fixtures, not new adapter-only vectors. +foreach (['server-directory.json', 'server-directory-vzg.json', 'server-directory-unicode.json'] as $filename) { + $existing = json_decode(file_get_contents(__DIR__.'/../fixtures/'.$filename), true, 16, JSON_THROW_ON_ERROR); + $row = $existing['servers'][0]; + $registryTarget = ['id' => $row['id'], 'provider' => $row['host']]; + $now = 100.0; + $capture = ['clock_scope' => 'clock-1', 'captured_at' => $now, + 'wall_time' => strtotime($existing['generated_at']), 'snapshot' => $existing]; + $sample = $adapter->normalize($registryTarget, $capture, null, $now); + $view = $projection->project('reader', $row['id']); + check($view['state'] === 'online' && $view['metrics']['players'] === 0, 'Existing producer fixture admitted: '.$filename); + check($view['metrics']['map'] === $row['map'] && $view['metrics']['round_id'] === $row['round']['id'], 'Existing producer metadata retained'); + check($sample['expires_at'] - $sample['observed_at'] === 60.0, 'Exact producer 60-second lifetime accepted'); + $now = 159.0; + check($projection->project('reader', $row['id'])['state'] === 'online', 'Producer fixture remains fresh at 59 seconds'); + $now = 160.0; + check($projection->project('reader', $row['id'])['state'] === 'stale', 'Producer fixture expires exactly at 60 seconds'); +} +echo "Cached observation contracts passed ($checks checks)\n"; From 579ad81fa009bb38fbeb8ff6b43dd6d039334bdf Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 28 Oct 2026 00:29:59 -0400 Subject: [PATCH 4/7] feat(admin): prepare unregistered read-only status service boundary --- .github/workflows/admin-console.yml | 29 +++++ docs/admin-console-status-service.md | 108 ++++++++++++++++++ .../AdminConsole/ReadOnlyStatusService.php | 101 ++++++++++++++++ src/Civ13/AdminConsole/StatusEndpoint.php | 49 ++++++++ tests/admin-console/status-http.php | 62 ++++++++++ tests/admin-console/status-service.php | 80 +++++++++++++ 6 files changed, 429 insertions(+) create mode 100644 docs/admin-console-status-service.md create mode 100644 src/Civ13/AdminConsole/ReadOnlyStatusService.php create mode 100644 src/Civ13/AdminConsole/StatusEndpoint.php create mode 100644 tests/admin-console/status-http.php create mode 100644 tests/admin-console/status-service.php diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index 6242bc3a..79a7ab3f 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -45,6 +45,17 @@ jobs: - name: Cached observation contracts timeout-minutes: 2 run: php tests/admin-console/cached-observation.php + - name: Lint status service + run: php -l src/Civ13/AdminConsole/ReadOnlyStatusService.php + - name: Lint unregistered HTTP endpoint + run: php -l src/Civ13/AdminConsole/StatusEndpoint.php + - name: Lint status service tests + run: php -l tests/admin-console/status-service.php + - name: Lint HTTP boundary tests + run: php -l tests/admin-console/status-http.php + - name: Dependency-free status service contracts + timeout-minutes: 2 + run: php tests/admin-console/status-service.php - name: Dependency-free projection contracts timeout-minutes: 2 run: php tests/admin-console/projection.php @@ -64,3 +75,21 @@ jobs: - run: node --check web/admin-console/demo.js - name: Dependency-free DOM contracts (not browser/layout acceptance) run: node tests/admin-console/ui-dom.mjs + + http-boundary: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, openssl, curl + coverage: none + - name: Install existing application dependencies without scripts/plugins + run: composer install --no-interaction --no-plugins --no-scripts --no-dev + - name: Actual PSR and React HTTP boundary contracts (offline) + timeout-minutes: 2 + run: php tests/admin-console/status-http.php diff --git a/docs/admin-console-status-service.md b/docs/admin-console-status-service.md new file mode 100644 index 00000000..ba1be846 --- /dev/null +++ b/docs/admin-console-status-service.md @@ -0,0 +1,108 @@ +# Read-only status service and unregistered HTTP boundary + +This source connects trusted inventory, injected per-request authorization, +retained observation captures, normalization and projection. It does not register +an endpoint, start a listener, collect observations, authenticate a real session, +request Discord members, install OAuth grants or change runtime configuration. +The UI is still a disconnected fixture demo. No commands exist at this boundary. + +## Existing app conventions + +`HttpHandler::__processCallback` expects a synchronous React HTTP Response. The +prepared `AdminConsole/StatusEndpoint::handle(ServerRequestInterface)` follows +that convention, like `ServerDirectory/Endpoint`. It is not added to +HttpServiceManager or HttpHandler. Generic router IP whitelisting is not identity; +`/dwa` must not be used to bypass the endpoint's independent session/role policy. + +## Three injected application-owned dependencies + +1. `session(request): ?string` returns a Discord snowflake **only after** a real + server-side session provider has authenticated it, returns null for an absent/ + expired session, or throws when unavailable. No provider is installed here; + omission fails 503. The endpoint does not parse Authorization, Cookie, query, + request attributes or IP as identity, and never falls back to a bot identity. +2. `authorize(discordId, targetId): bool|null` reads the existing maintained guild + member cache through its application readiness/epoch barrier, using configured + guild and role IDs. True grants this read capability only; false denies, null + or an exception means unavailable. Configure policy for each target; do not + assume every Discord administrator may read every server. Omission fails 503. + Reuse the maintained member cache and its invalidation/readiness rules (the + admin-sync work's renderCached boundary illustrates the epoch checks); do not + create another member fetch/pagination/TTL poller or call admin-sync writers. + Raw cache presence or the generic bot-ready flag alone is not authority. + The thin binding to this existing cache policy is intentionally not installed + by this patch and does not change PR #34/#35 ownership or files. +3. `captures(targetId): {directory:?array,heartbeat:?array}` returns existing + original capture envelopes documented in admin-console-cached-observations.md. + This must be an in-memory retained-value accessor, never refresh(), poll(), + file reads, Topic, HTTP or async work. Null source values mean unknown; + a malformed accessor result or thrown exception is unavailable, not stale data + silently reused. No collector is wired here. + +The service freezes one injected monotonic now per response. Inventory is an +explicit bounded list (maximum 32) of exactly `{id,name,provider}`; duplicate IDs, +extra fields and invalid names/providers reject at construction. Browser input +cannot select inventory, addresses, paths or target ports. Only permitted targets +are read; denied targets and names never appear in the response. Every request +reauthorizes every configured target, then rechecks permitted targets immediately +before releasing the response. Grants are not cached across requests. A role +change while assembling the snapshot drops the prepared response. Synchronous +callbacks must not yield or conceal a network refresh. + +## Proposed HTTP contract (not activated) + +Exact path: `/admin/v1/servers`. GET and HEAD only, no query string. No mutation +routes or target-selection input. Prepared statuses: + +| Situation | Status | +| --- | --- | +| Authenticated, role-authorized inventory | 200 | +| Any nonempty query | 400 | +| No valid authenticated session | 401 | +| No authorized target / role revoked before response | 403 | +| Other path | 404 | +| Other method (`Allow: GET, HEAD`) | 405 | +| Session/authorization provider absent, not-ready cache, unavailable capture or callback failure | 503 | + +Every response, including errors and HEAD, has: +`Content-Type: application/json; charset=utf-8`, +`Cache-Control: private, no-store, max-age=0`, `Pragma: no-cache`, +`Vary: Cookie, Authorization`, and `X-Content-Type-Options: nosniff`. +No CORS access is granted. HEAD runs the same session/policy checks but returns +an empty body. Query/path/method rejection runs before session resolution. +Errors contain fixed codes, not exception details, names or credentials. + +Success envelope: + +``` +{"schema":"civ13.admin-overview.v1","servers":[ + {"name":"Configured display name","projection":{"schema":"civ13.admin-status.v1", "...":"existing projection fields"}} +]} +``` + +This does not invent the fixture UI's observed_at metadata or relabel its demo +as live. All command capabilities remain false, and no raw capture fields enter +the envelope. The per-target projection still preserves independent reachability, +heartbeat and readiness evidence. + +## Verification and remaining integration + +`php tests/admin-console/status-service.php` is dependency-free and uses only +injected values. `php tests/admin-console/status-http.php [explicit-autoload]` +uses the application's actual PSR/React HTTP dependencies and synthetic requests, +not a fake HTTP implementation or listening server. It does not bootstrap bot.php, +load .env, connect to Discord or read real membership. The six-version/platform +PHP matrix gains service tests; a separate PHP 8.3 job installs **existing** +Composer dependencies with scripts/plugins disabled and runs actual HTTP tests. +No dependency declarations/lock files are edited. + +PHP is unavailable in the preparation executor. New PHP tests and lints are +prepared, not passed; prior head CI does not verify this new patch. + +Still missing before a live mobile app: a reviewed real session provider and +session lifecycle (including expiration/revocation and cookie/origin handling), +the read-only configured-role binding to the maintained member cache barrier, +trusted target inventory and cached-observation wiring, supported-browser QA, +and separately approved HTTPS/routing/runtime activation. Do not register the +endpoint merely because offline tests pass. None of these integrations or +credentials is created by this patch. diff --git a/src/Civ13/AdminConsole/ReadOnlyStatusService.php b/src/Civ13/AdminConsole/ReadOnlyStatusService.php new file mode 100644 index 00000000..3946adbe --- /dev/null +++ b/src/Civ13/AdminConsole/ReadOnlyStatusService.php @@ -0,0 +1,101 @@ +, not a request. + * authorize(discordId,targetId): bool, or null/throw when cache policy unavailable. + * It must read the maintained member cache/readiness barrier; never fetch members. + * captures(targetId): {directory:?array,heartbeat:?array} retained original captures. + * Callbacks are synchronous and application-owned. No ambient auth fallback. + */ + public function __construct( + array $inventory, + private string $clockScope, + private \Closure $clock, + private \Closure $captures, + private ?\Closure $authorize = null, + ) { + if ($clockScope === '' || !array_is_list($inventory) || count($inventory) > 32) { + throw new \InvalidArgumentException('Bounded explicit inventory required'); + } + foreach ($inventory as $target) { + if (!is_array($target) || count($target) !== 3 || array_diff(array_keys($target), ['id', 'name', 'provider']) + || !is_string($target['id'] ?? null) || preg_match('/\A[A-Za-z0-9_-]{1,64}\z/D', $target['id']) !== 1 + || isset($this->targets['server:'.$target['id']]) || !in_array($target['provider'] ?? null, ['civ13.com', 'valzargaming.com'], true) + || !is_string($target['name'] ?? null) || trim($target['name']) === '' || strlen($target['name']) > 120 + || preg_match('//u', $target['name']) !== 1 || preg_match('/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u', $target['name']) !== 0) { + throw new \InvalidArgumentException('Invalid trusted target inventory'); + } + $this->targets['server:'.$target['id']] = $target; + } + } + + /** Actor comes only from an independently authenticated server-side session. */ + public function read(string $actor): array + { + if (!self::actor($actor)) { return self::error(401, 'authentication_required'); } + if ($this->authorize === null) { return self::error(503, 'authorization_unavailable'); } + try { + $allowed = []; + foreach ($this->targets as $target) { + $id = $target['id']; + $decision = ($this->authorize)($actor, $id); + if (!is_bool($decision)) { return self::error(503, 'authorization_unavailable'); } + if ($decision) { $allowed[] = $target; } + } + // Do not disclose an empty inventory to an actor with no authorized target. + if ($allowed === []) { return self::error(403, 'access_denied'); } + $now = ($this->clock)(); + if ((!is_int($now) && !is_float($now)) || !is_finite((float) $now)) { + return self::error(503, 'observations_unavailable'); + } + $normalizer = new CachedObservationAdapter($this->clockScope); + $servers = []; + foreach ($allowed as $target) { + $id = $target['id']; + $capture = ($this->captures)($id); + if (!is_array($capture) || !array_key_exists('directory', $capture) || !array_key_exists('heartbeat', $capture) + || ($capture['directory'] !== null && !is_array($capture['directory'])) + || ($capture['heartbeat'] !== null && !is_array($capture['heartbeat']))) { + return self::error(503, 'observations_unavailable'); + } + $observation = $normalizer->normalize($target, $capture['directory'], $capture['heartbeat'], (float) $now); + $projection = new AdminServerProjection( + static fn (string $candidate, string $targetId): bool => $candidate === $actor && $targetId === $id, + static fn (): array => $observation, + static fn (): float => (float) $now, + $this->clockScope, + ); + $servers[] = ['name' => $target['name'], 'projection' => $projection->project($actor, $id)]; + } + // Re-read policy immediately before releasing any snapshot. Never cache a grant. + foreach ($allowed as $target) { + $id = $target['id']; + $decision = ($this->authorize)($actor, $id); + if (!is_bool($decision)) { return self::error(503, 'authorization_unavailable'); } + if (!$decision) { return self::error(403, 'access_denied'); } + } + return ['status' => 200, 'body' => ['schema' => 'civ13.admin-overview.v1', 'servers' => $servers]]; + } catch (\Throwable) { + return self::error(503, 'status_unavailable'); + } + } + + public static function actor(mixed $id): bool + { + return is_string($id) && preg_match('/\A[1-9][0-9]{16,19}\z/D', $id) === 1; + } + + private static function error(int $status, string $code): array + { + return ['status' => $status, 'body' => ['error' => $code]]; + } +} diff --git a/src/Civ13/AdminConsole/StatusEndpoint.php b/src/Civ13/AdminConsole/StatusEndpoint.php new file mode 100644 index 00000000..15890638 --- /dev/null +++ b/src/Civ13/AdminConsole/StatusEndpoint.php @@ -0,0 +1,49 @@ + 'application/json; charset=utf-8', + 'Cache-Control' => 'private, no-store, max-age=0', 'Pragma' => 'no-cache', + 'Vary' => 'Cookie, Authorization', 'X-Content-Type-Options' => 'nosniff']; + $respond = static fn (int $status, array $body, array $extra = []): Response => new Response( + $status, $headers + $extra, + $request->getMethod() === 'HEAD' ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + ); + if ($request->getUri()->getPath() !== self::PATH) { return $respond(404, ['error' => 'not_found']); } + if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) { + return $respond(405, ['error' => 'method_not_allowed'], ['Allow' => 'GET, HEAD']); + } + if ($request->getUri()->getQuery() !== '') { return $respond(400, ['error' => 'query_not_supported']); } + if ($this->session === null) { return $respond(503, ['error' => 'authentication_unavailable']); } + try { + $actor = ($this->session)($request); + if ($actor === null) { return $respond(401, ['error' => 'authentication_required']); } + if (!ReadOnlyStatusService::actor($actor)) { return $respond(503, ['error' => 'authentication_unavailable']); } + $result = $this->service->read($actor); + return $respond($result['status'], $result['body']); + } catch (\Throwable) { + return $respond(503, ['error' => 'status_unavailable']); + } + } +} diff --git a/tests/admin-console/status-http.php b/tests/admin-console/status-http.php new file mode 100644 index 00000000..d2a13a8e --- /dev/null +++ b/tests/admin-console/status-http.php @@ -0,0 +1,62 @@ + 'fixture', 'name' => 'literal fixture', 'provider' => 'civ13.com']], 'clock-1', + static fn (): float => 100.0, + static function () use (&$captureReads): array { ++$captureReads; return ['directory' => null, 'heartbeat' => null]; }, + static function () use (&$allowed): bool { return $allowed; }); +$session = static function (ServerRequest $request) use (&$sessionReads, &$loggedIn, $actor): ?string { + ++$sessionReads; return $loggedIn ? $actor : null; +}; +$endpoint = new StatusEndpoint($service, $session); +$request = static fn (string $method = 'GET', string $suffix = ''): ServerRequest => new ServerRequest($method, 'https://fixture.invalid'.StatusEndpoint::PATH.$suffix); +$response = $endpoint->handle($request()); +check($response->getStatusCode() === 200, 'Actual React HTTP success response'); +check($response->getHeaderLine('Content-Type') === 'application/json; charset=utf-8', 'JSON content type'); +check($response->getHeaderLine('Cache-Control') === 'private, no-store, max-age=0', 'No shared/authenticated caching'); +check($response->getHeaderLine('Pragma') === 'no-cache' && $response->getHeaderLine('Vary') === 'Cookie, Authorization', 'Cache header contract'); +check($response->getHeaderLine('X-Content-Type-Options') === 'nosniff' && !$response->hasHeader('Access-Control-Allow-Origin'), 'No sniffing or CORS grant'); +$body = json_decode((string) $response->getBody(), true, 16, JSON_THROW_ON_ERROR); +check($body['schema'] === 'civ13.admin-overview.v1' && $body['servers'][0]['projection']['capabilities']['save'] === false, 'Real serialized service envelope'); +$response = $endpoint->handle($request('HEAD')); +check($response->getStatusCode() === 200 && (string) $response->getBody() === '', 'HEAD authenticates but has no body'); +check($sessionReads === 2, 'Session resolved on every request'); +$before = $sessionReads; +foreach ([['POST', '', 405], ['DELETE', '', 405], ['GET', '/extra', 404], ['GET', '?actor='.$actor, 400], ['GET', '?host=evil.invalid', 400]] as [$method, $suffix, $status]) { + $response = $endpoint->handle($request($method, $suffix)); + check($response->getStatusCode() === $status && $response->getHeaderLine('Cache-Control') === 'private, no-store, max-age=0', 'Method/path/query rejection with cache headers'); + if ($status === 405) { check($response->getHeaderLine('Allow') === 'GET, HEAD', 'Allow header'); } +} +check($sessionReads === $before, 'Rejected route does not consult session or inventory'); +$loggedIn = false; $before = $captureReads; +$response = $endpoint->handle($request()->withHeader('Authorization', 'Bearer synthetic-untrusted')->withHeader('Cookie', 'discord_id='.$actor)->withAttribute('discord_id', $actor)); +check($response->getStatusCode() === 401 && $captureReads === $before, 'Headers/attributes never bypass absent authenticated session'); +$response = $endpoint->handle($request('HEAD')); +check($response->getStatusCode() === 401 && (string) $response->getBody() === '', 'HEAD error body empty'); +$loggedIn = true; $allowed = false; +check($endpoint->handle($request())->getStatusCode() === 403 && $captureReads === $before, 'Role denial before capture'); +$allowed = true; +check((new StatusEndpoint($service))->handle($request())->getStatusCode() === 503, 'No installed session provider unavailable'); +check((new StatusEndpoint($service, static fn (): string => 'invalid'))->handle($request())->getStatusCode() === 503, 'Malformed provider result fails closed'); +$failed = new StatusEndpoint($service, static function (): string { throw new RuntimeException('private-session-secret'); }); +$response = $failed->handle($request()); +check($response->getStatusCode() === 503 && !str_contains((string) $response->getBody(), 'private-session-secret'), 'Provider failure sanitized'); +$unready = new ReadOnlyStatusService([['id' => 'fixture', 'name' => 'Fixture', 'provider' => 'civ13.com']], 'clock-1', static fn (): float => 100.0, + static function (): array { throw new RuntimeException('must not collect'); }); +check((new StatusEndpoint($unready, $session))->handle($request())->getStatusCode() === 503, 'Missing role policy refuses HTTP release'); +echo "Read-only HTTP boundary contracts passed ($checks checks)\n"; diff --git a/tests/admin-console/status-service.php b/tests/admin-console/status-service.php new file mode 100644 index 00000000..be12d426 --- /dev/null +++ b/tests/admin-console/status-service.php @@ -0,0 +1,80 @@ + 'visible', 'name' => 'Visible fixture', 'provider' => 'civ13.com'], + ['id' => 'hidden', 'name' => 'PRIVATE HIDDEN NAME', 'provider' => 'valzargaming.com']]; +$now = 100.0; $reads = []; $decisions = []; $access = true; $available = true; +$clock = static function () use (&$now): float { return $now; }; +$captures = static function (string $id) use (&$reads): array { + $reads[] = $id; + return ['directory' => null, 'heartbeat' => null, 'token' => 'never-copy']; +}; +$authorize = static function (string $user, string $id) use (&$decisions, &$access, &$available, $actor): ?bool { + $decisions[] = [$user, $id]; + return !$available ? null : $access && $user === $actor && $id === 'visible'; +}; +$service = new ReadOnlyStatusService($inventory, 'clock-1', $clock, $captures, $authorize); +$result = $service->read($actor); +check($result['status'] === 200, 'Authenticated authorized inventory'); +check(count($result['body']['servers']) === 1 && $reads === ['visible'], 'Unauthorized target never read'); +check($result['body']['servers'][0]['projection']['state'] === 'unknown', 'Missing captures remain unknown'); +check(!str_contains(json_encode($result), 'PRIVATE') && !str_contains(json_encode($result), 'never-copy'), 'Hidden names and raw input excluded'); +check(count($decisions) === 3, 'Per-target admission and pre-response recheck'); +check($result['body']['servers'][0]['projection']['capabilities']['restart'] === false, 'Controls unavailable'); +$before = count($reads); $access = false; +check($service->read($actor)['status'] === 403 && count($reads) === $before, 'Revocation on next request; no observation reads'); +$access = true; $available = false; +check($service->read($actor)['status'] === 503 && count($reads) === $before, 'Cache readiness unavailable fails closed'); +$available = true; +check($service->read('request-supplied-name')['status'] === 401 && count($reads) === $before, 'Only validated session actor shape'); +$unconfigured = new ReadOnlyStatusService($inventory, 'clock-1', $clock, $captures); +check($unconfigured->read($actor)['status'] === 503 && count($reads) === $before, 'No installed authorizer fails closed'); +$empty = new ReadOnlyStatusService([], 'clock-1', $clock, $captures, $authorize); +check($empty->read($actor)['status'] === 403, 'Empty inventory is not an authorization grant'); +$throws = new ReadOnlyStatusService($inventory, 'clock-1', $clock, $captures, + static function (): bool { throw new RuntimeException('private-policy-details'); }); +$result = $throws->read($actor); +check($result['status'] === 503 && !str_contains(json_encode($result), 'private-policy'), 'Policy exception hidden'); +$revoked = new ReadOnlyStatusService($inventory, 'clock-1', $clock, + static function () use (&$access): array { $access = false; return ['directory' => null, 'heartbeat' => null]; }, $authorize); +check($revoked->read($actor)['status'] === 403, 'Revocation before response drops all prepared output'); +$access = true; +$badCaptures = new ReadOnlyStatusService($inventory, 'clock-1', $clock, static fn (): array => ['directory' => 'bad'], $authorize); +check($badCaptures->read($actor)['status'] === 503, 'Malformed capture boundary is unavailable'); +$now = NAN; +check($service->read($actor)['status'] === 503, 'Invalid clock refuses snapshot'); +$now = 100.0; +$numeric = new ReadOnlyStatusService([['id' => '123', 'name' => 'Numeric ID', 'provider' => 'civ13.com']], 'clock-1', $clock, + static fn (string $id): array => ['directory' => null, 'heartbeat' => null], + static fn (string $user, string $id): bool => $id === '123'); +check($numeric->read($actor)['body']['servers'][0]['projection']['server_id'] === '123', 'Numeric registry ID stays a string'); +foreach ([[$inventory[0], $inventory[0]], [array_merge($inventory[0], ['token' => 'forbidden'])], + [array_replace($inventory[0], ['provider' => 'evil.invalid'])], [array_replace($inventory[0], ['name' => "bad\nname"])], + array_fill(0, 33, $inventory[0]), ['named' => $inventory[0]]] as $invalid) { + $rejected = false; + try { new ReadOnlyStatusService($invalid, 'clock-1', $clock, $captures, $authorize); } + catch (InvalidArgumentException) { $rejected = true; } + check($rejected, 'Invalid/duplicate/unbounded registry rejected'); +} +// One real normalized cached heartbeat through the complete service pipeline. +$withHeartbeat = new ReadOnlyStatusService([$inventory[0]], 'clock-1', $clock, + static fn (): array => ['directory' => null, 'heartbeat' => ['server_id' => 'visible', 'clock_scope' => 'clock-1', + 'sampled_at' => 90.0, 'observation' => ['state' => 'advancing', 'age' => 10.0, 'data' => 'private']]], $authorize); +$view = $withHeartbeat->read($actor)['body']['servers'][0]['projection']; +check($view['state'] === 'unknown' && $view['evidence']['heartbeat']['state'] === 'advancing', 'Independent heartbeat survives service normalization'); +check($view['ready'] === null && $view['evidence']['readiness']['state'] === 'unknown', 'No readiness inferred by service'); +echo "Read-only status service contracts passed ($checks checks)\n"; From 423ede3a68e52057bf6780b9775d72108429eb7f Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 28 Oct 2026 00:46:54 -0400 Subject: [PATCH 5/7] test(admin): validate fixture UI in bounded installed Chrome --- .github/workflows/admin-console.yml | 26 +++++ tests/admin-console/layout-checks.mjs | 52 ++++++++++ tests/admin-console/ui.mjs | 131 +++++++++++++++++++++++--- web/admin-console/README.md | 30 ++++-- 4 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 tests/admin-console/layout-checks.mjs diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index 79a7ab3f..647c7cf6 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -93,3 +93,29 @@ jobs: - name: Actual PSR and React HTTP boundary contracts (offline) timeout-minutes: 2 run: php tests/admin-console/status-http.php + + browser-contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Require the existing vendor browser (no installation) + run: test -x /usr/bin/google-chrome + - name: Real DOM and phone/desktop layout on local fixtures + timeout-minutes: 1 + env: + CHROMIUM_BIN: /usr/bin/google-chrome + run: node tests/admin-console/ui.mjs + - name: Retain internal fixture QA evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: admin-console-browser-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/admin-console-browser/ + if-no-files-found: warn + retention-days: 7 diff --git a/tests/admin-console/layout-checks.mjs b/tests/admin-console/layout-checks.mjs new file mode 100644 index 00000000..7fc5735a --- /dev/null +++ b/tests/admin-console/layout-checks.mjs @@ -0,0 +1,52 @@ +// Evaluated inside the actual index.html document through the owned browser pipe. +// No network, credentials or live endpoint access. Not a full accessibility audit. +export async function checkLayout(expectedWidth) { + let checks = 0; + function check(value, message) { checks++; if (!value) throw new Error(message); } + for (let i = 0; i < 50 && document.querySelectorAll('#servers article').length !== 6; i++) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + check(window.innerWidth === expectedWidth, `Viewport ${window.innerWidth} differs from ${expectedWidth}`); + check(document.documentElement.lang === 'en', 'Document language'); + check(document.title.includes('demo'), 'Fixture-only document title'); + check(document.querySelector('meta[name="viewport"]') !== null, 'Mobile viewport metadata'); + check(document.querySelectorAll('main').length === 1 && document.querySelectorAll('h1').length === 1, 'Main landmark and heading'); + check(document.querySelector('.notice').textContent.includes('No live connection'), 'No misleading live claim'); + check(document.querySelectorAll('#servers article').length === 6, 'Actual page CSP permits local demo assets'); + check(getComputedStyle(document.querySelector('.servers')).display === 'grid', 'Actual stylesheet loaded'); + check(document.documentElement.scrollWidth <= window.innerWidth + 1, 'No horizontal page overflow'); + const cards = [...document.querySelectorAll('#servers article')]; + for (const card of cards) { + const rect = card.getBoundingClientRect(); + check(rect.width > 0 && rect.left >= -1 && rect.right <= innerWidth + 1, 'Card inside viewport'); + check(card.scrollWidth <= card.clientWidth + 1, 'No horizontal card overflow'); + check(document.getElementById(card.getAttribute('aria-labelledby'))?.tagName === 'H2', 'Card heading label'); + } + const a = cards[0].getBoundingClientRect(), b = cards[1].getBoundingClientRect(); + check(expectedWidth <= 650 ? b.top > a.top && Math.abs(b.left - a.left) <= 1 : Math.abs(b.top - a.top) <= 1 && b.left > a.left, + 'Expected one-column phone/two-column desktop layout'); + check(cards[0].querySelector('.metric-primary dd').textContent === '0' && cards[0].querySelector('.badge').textContent === 'Online', 'Zero-player world stays online'); + check(document.querySelectorAll('time[datetime]').length === 5, 'Fixture observation timestamps'); + const actions = [...document.querySelectorAll('.actions button')]; + check(actions.length === 18 && actions.every(button => button.disabled), 'All real DOM action buttons disabled'); + check(actions.every(button => document.getElementById(button.getAttribute('aria-describedby'))), 'Unavailable action reasons linked'); + for (const control of [document.getElementById('reload'), document.getElementById('state-filter'), ...actions]) { + check(control.getBoundingClientRect().height >= 44 && control.getBoundingClientRect().width >= 44, '44px control target dimensions'); + } + const filter = document.getElementById('state-filter'); + check(document.querySelector('label[for="state-filter"]') !== null, 'Select has an explicit label'); + check(document.getElementById('summary').getAttribute('aria-live') === 'polite', 'Updates have polite live region'); + filter.focus(); + check(document.activeElement === filter, 'Filter is focusable'); + const skip = document.querySelector('.skip'); skip.focus(); + check(document.activeElement === skip && skip.getBoundingClientRect().top >= 0, 'Skip link visible on focus'); + filter.value = 'online'; filter.dispatchEvent(new Event('change')); + check(document.querySelectorAll('#servers article').length === 1, 'Real page filter event'); + filter.value = 'all'; filter.dispatchEvent(new Event('change')); + document.getElementById('reload').click(); + for (let i = 0; i < 50 && document.getElementById('reload').disabled; i++) await new Promise(resolve => setTimeout(resolve, 20)); + check(document.querySelectorAll('#servers article').length === 6 && !document.getElementById('reload').disabled, 'Real page reload restores six fixtures'); + check(!performance.getEntriesByType('resource').some(entry => /^https?:/i.test(entry.name)), 'Page requested no HTTP(S) resources'); + window.scrollTo(0, 0); document.activeElement?.blur(); + return {width: innerWidth, height: innerHeight, checks, cards: cards.length, browserLayout: 'passed'}; +} diff --git a/tests/admin-console/ui.mjs b/tests/admin-console/ui.mjs index 8534d405..12a6dbf7 100644 --- a/tests/admin-console/ui.mjs +++ b/tests/admin-console/ui.mjs @@ -1,16 +1,121 @@ -// Offline real-browser contracts: no server, npm packages or external services. -import {spawnSync} from 'node:child_process'; -import {mkdtempSync, rmSync} from 'node:fs'; +// Existing vendor Chrome only; real DOM + layout over local file URLs, no listener. +// Linux runner process-group ownership permits complete bounded cleanup. +import {spawn} from 'node:child_process'; +import {accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -const browser = process.env.CHROMIUM_BIN || 'chromium'; -const profile = mkdtempSync(join(tmpdir(), 'civ13-admin-ui-')); +import {join, resolve} from 'node:path'; +import {checkLayout} from './layout-checks.mjs'; + +const browser = process.env.CHROMIUM_BIN || process.env.CHROME_BIN || '/usr/bin/google-chrome'; +const artifacts = resolve('artifacts/admin-console-browser'); +mkdirSync(artifacts, {recursive: true}); +const evidence = {browser, sandbox: 'enabled (no bypass flags)', transport: 'DevTools pipe', results: []}; +let child, profile, timeout, stderr = '', protocolError = null, buffer = '', nextId = 0, timedOut = false; +const loaded = new Set(), loadWaiters = new Map(); +const pending = new Map(); +function rejectAll(error) { + protocolError ??= error; + for (const request of pending.values()) request.reject(error); + pending.clear(); + for (const waiter of loadWaiters.values()) waiter.reject(error); + loadWaiters.clear(); +} +function stopOwnedBrowser() { + if (!child?.pid) return; + try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } +} +function send(method, params = {}, sessionId) { + if (protocolError) return Promise.reject(protocolError); + return new Promise((resolve, reject) => { + const id = ++nextId; pending.set(id, {resolve, reject}); + child.stdio[3].write(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})})+'\0'); + }); +} +async function navigate(session, url) { + const response = await send('Page.navigate', {url}, session); + if (response.errorText) throw new Error(response.errorText); + if (!response.loaderId) throw new Error('Navigation has no loader identity'); + const key = `${session}:${response.loaderId}`; + if (protocolError) throw protocolError; + if (!loaded.has(key)) await new Promise((resolve, reject) => loadWaiters.set(key, {resolve, reject})); +} +async function evaluate(session, expression) { + const response = await send('Runtime.evaluate', {expression, awaitPromise: true, returnByValue: true}, session); + if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text); + return response.result.value; +} try { - const result = spawnSync(browser, ['--headless', '--disable-gpu', '--disable-background-networking', + if (process.platform !== 'linux') throw new Error('This bounded browser runner requires Linux process-group ownership'); + accessSync(browser, constants.X_OK); // Missing browser fails; never downloads or substitutes. + profile = mkdtempSync(join(tmpdir(), 'civ13-admin-browser-')); + child = spawn(browser, ['--headless=new', '--remote-debugging-pipe', '--disable-background-networking', '--no-first-run', '--disable-default-apps', '--disable-sync', '--metrics-recording-only', - `--user-data-dir=${profile}`, '--dump-dom', '--virtual-time-budget=2000', - new URL('./ui.html', import.meta.url).href], {encoding: 'utf8', timeout: 20000, maxBuffer: 2 * 1024 * 1024}); - if (result.error || result.status !== 0 || !result.stdout.includes('data-result="pass"')) { - console.error(result.error?.message || result.stdout || result.stderr); process.exitCode = 1; - } else console.log(result.stdout.match(/PASS: \d+ checks/)[0]); -} finally { rmSync(profile, {recursive: true, force: true}); } + `--user-data-dir=${profile}`, 'about:blank'], {detached: true, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe']}); + const exited = new Promise(resolve => { + child.once('exit', (code, signal) => resolve({code, signal})); + child.once('error', error => resolve({error: error.message})); + }); + child.on('error', error => rejectAll(error)); + child.on('exit', (code, signal) => rejectAll(new Error(`Owned browser exited: ${code ?? signal}`))); + child.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-256000); }); + child.stdio[3].on('error', error => rejectAll(error)); + child.stdio[4].on('data', data => { + buffer += data.toString(); + if (buffer.length > 8 * 1024 * 1024) { rejectAll(new Error('Browser protocol output bound exceeded')); stopOwnedBrowser(); return; } + let boundary; + while ((boundary = buffer.indexOf('\0')) !== -1) { + const chunk = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1); + if (!chunk) continue; + try { + const message = JSON.parse(chunk); + if (message.method === 'Page.lifecycleEvent' && message.params.name === 'load') { + const key = `${message.sessionId}:${message.params.loaderId}`; + loaded.add(key); loadWaiters.get(key)?.resolve(); loadWaiters.delete(key); + } + const request = pending.get(message.id); + if (!request) continue; + pending.delete(message.id); + message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result); + } catch (error) { rejectAll(error); } + } + }); + timeout = setTimeout(() => { timedOut = true; rejectAll(new Error('Owned browser exceeded the 20-second deadline')); stopOwnedBrowser(); }, 20000); + evidence.version = await send('Browser.getVersion'); + const {targetId} = await send('Target.createTarget', {url: 'about:blank'}); + const {sessionId} = await send('Target.attachToTarget', {targetId, flatten: true}); + await send('Page.enable', {}, sessionId); + await send('Page.setLifecycleEventsEnabled', {enabled: true}, sessionId); + await send('Network.enable', {}, sessionId); + await send('Network.setBlockedURLs', {urls: ['http://*', 'https://*', 'ws://*', 'wss://*']}, sessionId); + // Preserve the reviewed renderer/interaction assertions, now on a real browser DOM. + await navigate(sessionId, new URL('./ui.html', import.meta.url).href); + const contract = await evaluate(sessionId, `(async()=>{for(let i=0;i<75;i++){if(document.body?.dataset.result)return {status:document.body.dataset.result,text:document.getElementById('results').textContent};await new Promise(r=>setTimeout(r,20));}throw new Error('Real DOM contracts did not finish');})()`); + if (contract.status !== 'pass') throw new Error(contract.text); + evidence.results.push({suite: 'existing-ui-dom', ...contract}); + // Navigate the actual reviewed index, with its own CSP/assets unchanged. + for (const [width, height] of [[320, 844], [390, 844], [1280, 900]]) { + await send('Emulation.setDeviceMetricsOverride', {width, height, deviceScaleFactor: 1, mobile: false}, sessionId); + await navigate(sessionId, new URL('../../web/admin-console/index.html', import.meta.url).href); + const result = await evaluate(sessionId, `(${checkLayout.toString()})(${width})`); + evidence.results.push({suite: 'actual-index-layout', ...result}); + if (width !== 320) { + const screenshot = await send('Page.captureScreenshot', {format: 'png', captureBeyondViewport: false}, sessionId); + writeFileSync(join(artifacts, `fixture-${width}.png`), Buffer.from(screenshot.data, 'base64')); + } + } + await send('Browser.close').catch(() => {}); + const exit = await exited; + if (timedOut) throw new Error('Owned browser exceeded the 20-second deadline during cleanup'); + if (exit.code !== 0) throw new Error(`Browser did not close cleanly: ${exit.error ?? exit.signal ?? exit.code}`); + evidence.status = 'passed'; + console.log(JSON.stringify(evidence, null, 2)); +} catch (error) { + evidence.status = 'failed'; evidence.error = error.message; + console.error(error.message); process.exitCode = 1; +} finally { + clearTimeout(timeout); + stopOwnedBrowser(); + if (profile) rmSync(profile, {recursive: true, force: true}); + writeFileSync(join(artifacts, 'results.json'), JSON.stringify(evidence, null, 2)+'\n'); + writeFileSync(join(artifacts, 'browser-stderr.txt'), stderr); +} diff --git a/web/admin-console/README.md b/web/admin-console/README.md index fb840fd9..c78163a1 100644 --- a/web/admin-console/README.md +++ b/web/admin-console/README.md @@ -43,14 +43,28 @@ script as `tests/admin-console/ui.html`. It is **not** a browser engine and does not validate CSS, CSP loading, real DOM compatibility or accessibility behavior. The admin-console workflow includes these Node 22 checks without npm installation. -On a supported executor with an existing Chromium installation, optional browser -execution is `CHROMIUM_BIN=/path/to/chromium node tests/admin-console/ui.mjs` (set -the equivalent environment variable on Windows), or open `tests/admin-console/ui.html` -in a local browser. It uses local files and a temporary profile; no server or -browser download is required. The runner does not disable Chromium's sandbox. -Browser availability/environment errors are failures, never reported as a pass. -Also inspect index.html at 320/390/768px, keyboard navigation, text zoom and denied, -stale, failed/recovered and repeated reload flows before claiming visual acceptance. +The separate `browser-contracts` CI job uses an existing vendor Google Chrome on +Ubuntu 24.04. It explicitly fails if `/usr/bin/google-chrome` is absent; no browser +or driver is installed/downloaded. It retains the browser sandbox and opens no +debug listener: the dependency-free Node runner communicates over a private +DevTools pipe to its own child process. Run on a supported Linux executor with +`CHROMIUM_BIN=/path/to/existing/chrome node tests/admin-console/ui.mjs`. + +The runner executes the existing ui.html tests on a real DOM, then navigates the +unchanged index.html with its real CSP/styles/scripts at 320/390/1280px. Checks +cover overflow, grid columns, labels/landmarks/focus, 44px control targets, +zero-player state, unavailable actions, timestamps, filtering and reloading. +Each navigation waits for its matching loader's load event. These are basic +checks, not a screen-reader audit or proof of a live app. Content HTTP(S)/WebSocket +requests are blocked, browser background networking is disabled, and all test +pages/resources are local fixture files. No real credentials or server data. + +One owned browser/process group has a 20-second total deadline and is cleaned up +on success/failure. A missing browser or timeout fails explicitly. Results, bounded +stderr and 390/1280px fixture screenshots are retained as internal CI QA artifacts +for seven days, not published as a Site or delivered to the user. Browser/visual +acceptance remains unverified until the new job actually executes successfully; +manual screenshot/accessibility review is separate from passing DOM assertions. Preparation verification: Node syntax and the 32 DOM-double assertions passed. Real Chromium did not run successfully: local process-socket creation was denied; From f104515f8a7774f3863a8338f01e785ecefa35e2 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 28 Oct 2026 01:14:26 -0400 Subject: [PATCH 6/7] feat(admin): prepare inert typed restart intents --- .github/workflows/admin-console.yml | 8 +++ docs/admin-console-command-intents.md | 44 ++++++++++++ .../AdminConsole/CommandIntentPreparer.php | 62 ++++++++++++++++ .../AdminConsole/PreparedCommandIntent.php | 37 ++++++++++ tests/admin-console/command-intent.php | 72 +++++++++++++++++++ 5 files changed, 223 insertions(+) create mode 100644 docs/admin-console-command-intents.md create mode 100644 src/Civ13/AdminConsole/CommandIntentPreparer.php create mode 100644 src/Civ13/AdminConsole/PreparedCommandIntent.php create mode 100644 tests/admin-console/command-intent.php diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index 647c7cf6..1147b4f6 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -56,6 +56,14 @@ jobs: - name: Dependency-free status service contracts timeout-minutes: 2 run: php tests/admin-console/status-service.php + - name: Lint inert command intent types + run: | + php -l src/Civ13/AdminConsole/PreparedCommandIntent.php + php -l src/Civ13/AdminConsole/CommandIntentPreparer.php + php -l tests/admin-console/command-intent.php + - name: Dependency-free command preparation contracts + timeout-minutes: 2 + run: php tests/admin-console/command-intent.php - name: Dependency-free projection contracts timeout-minutes: 2 run: php tests/admin-console/projection.php diff --git a/docs/admin-console-command-intents.md b/docs/admin-console-command-intents.md new file mode 100644 index 00000000..57355d3d --- /dev/null +++ b/docs/admin-console-command-intents.md @@ -0,0 +1,44 @@ +# Inert command preparation + +`CommandIntentPreparer` is not an endpoint or execution adapter. It accepts a +trusted list of target IDs and an injected action-specific role-cache policy. +The actor is supplied only by a future independently authenticated session +boundary. No request header/IP/DTO grants identity. A policy may not reuse the +read-status grant as permission to restart. + +The only supported intent is `restart`, corresponding to the existing fixed +Local/ControlEndpoint action name. This does not connect to that endpoint or +enable native/loopback controls. Save-only and arbitrary verbs are unsupported; +the existing save_shutdown lifecycle operation must not be misrepresented as +save-only. Future verbs require an explicit typed verb/argument allowlist. + +Input has exactly `target_id`, `action`, `request_id`. Request IDs are canonical +lowercase UUIDv4 values generated once by a future caller per logical intent. +Unknown/unauthorized targets yield the same denial. Missing/unready policy fails +closed. Every preparation, including repeated request IDs, rechecks policy. + +The readonly value validates shape, not authorization. Its output explicitly says +`prepared_not_executable` and `execution_enabled=false`; anyone constructing or +copying it gains no authority. It contains no URL, bearer, role list or raw actor. +The actor-scoped request hash is stable across changed targets; the separate +payload hash binds schema, actor, target and fixed action. These are deterministic +comparison keys, NOT secrets or signed grants. + +**No deduplication is implemented yet.** A future durable ledger must atomically +reserve the request key, reject changed fingerprints as conflicts, and preserve +one operation identity/results across retries and crashes. Hashes alone cannot +prevent replay or concurrent execution. No queued/accepted/completed receipt is +returned. HTTP200 here means only that an in-memory value was prepared. + +Before any dispatch: independent session/action-policy recheck; trusted current +target/process generation; observation freshness/readiness; explicit confirmation +and CSRF protections at the real session boundary; atomic operation admission; +and durable audit/history are still required. Never dispatch from this DTO alone. +Save/restart completion must rely on authoritative operation-bound save/process +receipts, not transport200, queueing, timeout or an empty player count. Unknown +outcomes require reconciliation rather than automatic resubmission. + +Offline verification: `php tests/admin-console/command-intent.php` (no dependencies, +bot bootstrap, Discord, collector, filesystem/process effect or network). Existing +PHP8.3/8.4/8.5 Ubuntu/Windows matrix runs lint and these contracts. No runtime +registration, live configuration, new dependencies or enabled UI controls. diff --git a/src/Civ13/AdminConsole/CommandIntentPreparer.php b/src/Civ13/AdminConsole/CommandIntentPreparer.php new file mode 100644 index 00000000..8717af3a --- /dev/null +++ b/src/Civ13/AdminConsole/CommandIntentPreparer.php @@ -0,0 +1,62 @@ + 32) { + throw new \InvalidArgumentException('Bounded explicit target inventory required'); + } + foreach ($targetIds as $id) { + if (!is_string($id) || preg_match('/\A[A-Za-z0-9_-]{1,64}\z/D', $id) !== 1 || isset($this->targets['server:'.$id])) { + throw new \InvalidArgumentException('Invalid trusted target identity'); + } + $this->targets['server:'.$id] = true; + } + } + + /** Actor must come from an independently authenticated server-side session. */ + public function prepare(string $actor, array $request): array + { + if (!ReadOnlyStatusService::actor($actor)) { return self::error(401, 'authentication_required'); } + $keys = array_keys($request); sort($keys); + if ($keys !== ['action', 'request_id', 'target_id'] + || !is_string($request['target_id']) || preg_match('/\A[A-Za-z0-9_-]{1,64}\z/D', $request['target_id']) !== 1 + || !is_string($request['action']) || !is_string($request['request_id']) + || preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $request['request_id']) !== 1) { + return self::error(400, 'invalid_intent'); + } + // No arbitrary verbs, shell commands, maps or implicit save-and-shutdown. + if ($request['action'] !== 'restart') { return self::error(422, 'unsupported_action'); } + if ($this->authorize === null) { return self::error(503, 'authorization_unavailable'); } + try { + $allowed = ($this->authorize)($actor, $request['target_id'], 'restart'); + if (!is_bool($allowed)) { return self::error(503, 'authorization_unavailable'); } + // Same denial for unknown and forbidden targets; no target enumeration. + if (!$allowed || !isset($this->targets['server:'.$request['target_id']])) { return self::error(403, 'access_denied'); } + $intent = new PreparedCommandIntent($actor, $request['target_id'], $request['request_id']); + // Future dispatch MUST independently reauthorize and resolve target generation. + // This synchronous response does not assert readiness or execute anything. + return ['status' => 200, 'body' => $intent->toArray()]; + } catch (\Throwable) { + return self::error(503, 'preparation_unavailable'); + } + } + + private static function error(int $status, string $code): array + { + return ['status' => $status, 'body' => ['error' => $code]]; + } +} diff --git a/src/Civ13/AdminConsole/PreparedCommandIntent.php b/src/Civ13/AdminConsole/PreparedCommandIntent.php new file mode 100644 index 00000000..b1bca610 --- /dev/null +++ b/src/Civ13/AdminConsole/PreparedCommandIntent.php @@ -0,0 +1,37 @@ +actorId, $this->targetId, 'restart']; + return [ + 'schema' => 'civ13.admin-command-intent.v1', + 'state' => 'prepared_not_executable', + 'execution_enabled' => false, + 'target_id' => $this->targetId, + 'action' => 'restart', + 'request_id' => $this->requestId, + 'deduplication_key' => hash('sha256', json_encode(['civ13.admin-command-request.v1', $this->actorId, $this->requestId], JSON_THROW_ON_ERROR)), + 'payload_fingerprint' => hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)), + ]; + } +} diff --git a/tests/admin-console/command-intent.php b/tests/admin-console/command-intent.php new file mode 100644 index 00000000..fadc0526 --- /dev/null +++ b/tests/admin-console/command-intent.php @@ -0,0 +1,72 @@ + 'nomads', 'action' => 'restart', 'request_id' => '12345678-1234-4234-8234-123456789abc']; +$first = $service->prepare($actor, $request); +check($first['status'] === 200, 'Authorized typed restart preparation'); +check($first['body']['state'] === 'prepared_not_executable' && $first['body']['execution_enabled'] === false, 'Never execution permission'); +check($calls === [[$actor, 'nomads', 'restart']], 'Explicit action-scoped policy'); +check(!isset($first['body']['accepted']) && !isset($first['body']['completed']) && !isset($first['body']['operation_id']), 'No queue or operation receipt'); +check($service->prepare($actor, $request) === $first && count($calls) === 2, 'Same request produces stable intent but policy is called anew'); +$access = false; +check($service->prepare($actor, $request)['status'] === 403, 'Retry does not retain authorization'); +$access = true; +$second = $service->prepare($actor, array_replace($request, ['target_id' => 'tdm'])); +check($second['body']['deduplication_key'] === $first['body']['deduplication_key'], 'Actor request key spans targets for conflict detection'); +check($second['body']['payload_fingerprint'] !== $first['body']['payload_fingerprint'], 'Changed target has conflict fingerprint'); +$third = $service->prepare($actor, array_replace($request, ['request_id' => '12345678-1234-4234-8234-123456789abd'])); +check($third['body']['deduplication_key'] !== $first['body']['deduplication_key'], 'New request key differs'); +check($third['body']['payload_fingerprint'] === $first['body']['payload_fingerprint'], 'Same payload independent of request key'); +$other = $service->prepare('22345678901234567', $request); +check($other['body']['deduplication_key'] !== $first['body']['deduplication_key'] && $other['body']['payload_fingerprint'] !== $first['body']['payload_fingerprint'], 'Actor identity bound to both hashes'); +check($service->prepare($actor, array_replace($request, ['target_id' => '123']))['body']['target_id'] === '123', 'Numeric target identity remains a string'); +check($service->prepare($actor, array_replace($request, ['target_id' => 'unknown']))['status'] === 403, 'Unknown target cannot be authorized by broad policy'); +$before = count($calls); +check($service->prepare('name-not-session-id', $request)['status'] === 401 && count($calls) === $before, 'Malformed actor never calls policy'); +foreach (['save', 'verb', 'save_shutdown', 'status', 'Restart', 'restart;echo'] as $action) { + check($service->prepare($actor, array_replace($request, ['action' => $action]))['status'] === 422, 'Only fixed restart intent supported'); +} +foreach (['url', 'actor', 'authorization', 'operation_id', 'map', 'arguments'] as $extra) { + check($service->prepare($actor, $request + [$extra => 'never-copy'])['status'] === 400, 'Reject surplus fields'); +} +foreach (['', 'https://host', '../nomads', "nomads\n", 123, null] as $id) { + check($service->prepare($actor, array_replace($request, ['target_id' => $id]))['status'] === 400, 'Invalid target shape'); +} +foreach (['', '12345678-1234-1234-8234-123456789abc', strtoupper($request['request_id']), "\n".$request['request_id'], 123, null] as $id) { + check($service->prepare($actor, array_replace($request, ['request_id' => $id]))['status'] === 400, 'Require canonical caller UUIDv4'); +} +check($service->prepare($actor, [])['status'] === 400, 'Missing fields fail closed'); +check((new CommandIntentPreparer(['nomads']))->prepare($actor, $request)['status'] === 503, 'Absent policy fails closed'); +$available = false; +check($service->prepare($actor, $request)['status'] === 503, 'Maintained cache unavailable'); +$throws = new CommandIntentPreparer(['nomads'], static function (): bool { throw new RuntimeException('private-policy-secret'); }); +$result = $throws->prepare($actor, $request); +check($result['status'] === 503 && !str_contains(json_encode($result), 'private-policy-secret'), 'Policy exception sanitized'); +foreach ([['nomads', 'nomads'], ['bad target'], [123], ['key' => 'nomads'], array_fill(0, 33, 'nomads')] as $inventory) { + try { new CommandIntentPreparer($inventory); check(false, 'Invalid inventory accepted'); } + catch (InvalidArgumentException) { check(true, 'Invalid inventory rejected'); } +} +try { new PreparedCommandIntent($actor, '../bad', $request['request_id']); check(false, 'Invalid DTO accepted'); } +catch (InvalidArgumentException) { check(true, 'DTO independently validates shape, not authority'); } +echo "PASS: {$checks} command-intent checks\n"; From 1803f0b0d3dfed5372a8a3bad28b4d1def47abc8 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 28 Oct 2026 01:21:10 -0400 Subject: [PATCH 7/7] feat(admin): add inert operation admission contract --- .github/workflows/admin-console.yml | 8 ++ docs/admin-console-operation-admission.md | 54 +++++++++ src/Civ13/AdminConsole/OperationAdmission.php | 74 ++++++++++++ .../AdminConsole/OperationAdmissionStore.php | 18 +++ tests/admin-console/operation-admission.php | 114 ++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 docs/admin-console-operation-admission.md create mode 100644 src/Civ13/AdminConsole/OperationAdmission.php create mode 100644 src/Civ13/AdminConsole/OperationAdmissionStore.php create mode 100644 tests/admin-console/operation-admission.php diff --git a/.github/workflows/admin-console.yml b/.github/workflows/admin-console.yml index 1147b4f6..2995a109 100644 --- a/.github/workflows/admin-console.yml +++ b/.github/workflows/admin-console.yml @@ -64,6 +64,14 @@ jobs: - name: Dependency-free command preparation contracts timeout-minutes: 2 run: php tests/admin-console/command-intent.php + - name: Lint inert operation admission contract + run: | + php -l src/Civ13/AdminConsole/OperationAdmissionStore.php + php -l src/Civ13/AdminConsole/OperationAdmission.php + php -l tests/admin-console/operation-admission.php + - name: Serial fake admission API contracts + timeout-minutes: 2 + run: php tests/admin-console/operation-admission.php - name: Dependency-free projection contracts timeout-minutes: 2 run: php tests/admin-console/projection.php diff --git a/docs/admin-console-operation-admission.md b/docs/admin-console-operation-admission.md new file mode 100644 index 00000000..ca228bf6 --- /dev/null +++ b/docs/admin-console-operation-admission.md @@ -0,0 +1,54 @@ +# Inert operation admission boundary + +This is an application API and serial fake test, **not a production storage provider**. +No caller or route instantiates it at runtime. There is no transport dependency and no +controller call, listener, database migration, production journal or live command. + +`OperationAdmission` checks the independently authenticated actor against the typed +intent, configured target inventory and injected restart policy. Policy is checked +again after storage. Denial/unavailability after a reservation hides the response but +must not erase the reservation. Every replay rechecks authorization. + +`OperationAdmissionStore::reserve` is the future atomic admission-index seam: +key -> (payload fingerprint, lifecycle operation ID), with outcomes reserved, +existing, conflict or uncertain. Existing means BOTH stored fields match. Keys +must never be overwritten/released on conflict. A production implementation must +use durable unique-key/transaction semantics. There is intentionally no default +provider; missing storage fails closed. Exceptions and unknown provider outcomes +are treated as uncertain, never as proof that no record exists. + +The service recomputes the canonical keys from typed intent identity. Its downstream +operation identity is `adm_` plus the actor/request SHA256 key (68 permitted ASCII +characters). It remains stable even if the admission response is lost. The payload +fingerprint binds target/action; reusing a request ID for another target conflicts. +The hashes/ID are not secrets, signed capabilities or authorization tokens. + +## Align with the existing lifecycle journal + +At source f104515, `Local/OpenDreamControl::request` accepts the explicit operation +ID for a versioned restart; `recover` issues a read-only operation-status query. +Existing receipt validation binds operation ID, server/action/map and digest. +Preserve this consumer and the controller's authoritative lifecycle journal rather +than creating an admin copy of game execution state. The admission index only binds +the authenticated logical request to that existing operation identity. + +**Concrete future integration:** a reviewed durable implementation of the reserve +interface, followed by a separate dispatcher resolving the trusted adapter/target +generation and rechecking action policy/readiness. Pass the retained operation ID +to that versioned adapter. Reserve/write ordering alone cannot provide exactly-once +game execution. A crash/timeout after dispatch requires read-only reconciliation. + +All responses here have execution_enabled=false and retry_dispatch=false. An +existing reservation says existing_requires_reconciliation, never “run again”. +Existing consumer pending/not_found recovery results do not authorize mutation +replay. A committed downstream receipt is not invented by this admission API. +Save-only/arbitrary verbs remain unsupported. No existing UI capability is enabled. + +## Verification limits + +`php tests/admin-console/operation-admission.php` tests stable serial replay, +conflicts, revocation before/after storage, lost acknowledgements, unknown outcomes, +unavailable policy/provider and downstream identity syntax using in-memory fakes. +It does NOT prove process races, database atomicity, disk durability or live lifecycle +completion. Production-provider crash/race tests, audit retention, target-generation +binding and actual dispatch acceptance remain separate review gates. diff --git a/src/Civ13/AdminConsole/OperationAdmission.php b/src/Civ13/AdminConsole/OperationAdmission.php new file mode 100644 index 00000000..8a4be7aa --- /dev/null +++ b/src/Civ13/AdminConsole/OperationAdmission.php @@ -0,0 +1,74 @@ + 32) { + throw new \InvalidArgumentException('Bounded target inventory required'); + } + foreach ($targetIds as $id) { + if (!is_string($id) || preg_match('/\A[A-Za-z0-9_-]{1,64}\z/D', $id) !== 1 || isset($this->targets['server:'.$id])) { + throw new \InvalidArgumentException('Invalid trusted target identity'); + } + $this->targets['server:'.$id] = true; + } + } + + /** Supplied actor is from a future real session, not from the intent or request. */ + public function admit(string $actor, PreparedCommandIntent $intent): array + { + if (!ReadOnlyStatusService::actor($actor)) { return self::error(401, 'authentication_required'); } + if ($actor !== $intent->actorId || !isset($this->targets['server:'.$intent->targetId])) { return self::error(403, 'access_denied'); } + if ($this->authorize === null) { return self::error(503, 'authorization_unavailable'); } + if ($this->store === null) { return self::error(503, 'admission_unavailable'); } + try { + $allowed = ($this->authorize)($actor, $intent->targetId, 'restart'); + if (!is_bool($allowed)) { return self::error(503, 'authorization_unavailable'); } + if (!$allowed) { return self::error(403, 'access_denied'); } + } catch (\Throwable) { return self::error(503, 'authorization_unavailable'); } + + // Recompute from typed identity, never caller hashes. Stable after lost response. + $canonical = $intent->toArray(); + $key = $canonical['deduplication_key']; + $operationId = 'adm_'.$key; // Existing Local lifecycle identity alphabet, <=80 bytes. + try { $outcome = $this->store->reserve($key, $canonical['payload_fingerprint'], $operationId); } + catch (\Throwable) { $outcome = 'uncertain'; } + // Revocation during storage cannot leak a receipt or authorize subsequent dispatch. + // Keep any reservation: deleting it would destroy retry identity. + try { + $allowed = ($this->authorize)($actor, $intent->targetId, 'restart'); + if (!is_bool($allowed)) { return self::error(503, 'authorization_unavailable'); } + if (!$allowed) { return self::error(403, 'access_denied'); } + } catch (\Throwable) { return self::error(503, 'authorization_unavailable'); } + if ($outcome === 'conflict') { return self::error(409, 'request_conflict'); } + if (!in_array($outcome, ['reserved', 'existing'], true)) { + return ['status' => 503, 'body' => ['error' => 'admission_outcome_unknown', 'recovery_required' => true, 'retry_dispatch' => false]]; + } + return ['status' => 200, 'body' => [ + 'schema' => 'civ13.admin-admission.v1', + 'state' => $outcome === 'reserved' ? 'reserved_not_dispatched' : 'existing_requires_reconciliation', + 'operation_id' => $operationId, + 'target_id' => $intent->targetId, + 'action' => 'restart', + 'execution_enabled' => false, + 'retry_dispatch' => false, + 'recovery_required' => $outcome === 'existing', + ]]; + } + + private static function error(int $status, string $error): array + { + return ['status' => $status, 'body' => ['error' => $error]]; + } +} diff --git a/src/Civ13/AdminConsole/OperationAdmissionStore.php b/src/Civ13/AdminConsole/OperationAdmissionStore.php new file mode 100644 index 00000000..2757e386 --- /dev/null +++ b/src/Civ13/AdminConsole/OperationAdmissionStore.php @@ -0,0 +1,18 @@ + (fingerprint, operationId), or compare existing binding. + * Return exactly reserved|existing|conflict|uncertain. existing MUST mean both + * fingerprint AND operationId matched. Never overwrite/release a conflicting key. + * A transport/commit exception is uncertain, not proof no reservation exists. + * Real provider must durably commit before reserved, with a unique-key transaction. + */ + public function reserve(string $key, string $fingerprint, string $operationId): string; +} diff --git a/tests/admin-console/operation-admission.php b/tests/admin-console/operation-admission.php new file mode 100644 index 00000000..3a5b5e68 --- /dev/null +++ b/tests/admin-console/operation-admission.php @@ -0,0 +1,114 @@ +calls; + if ($this->mode === 'throw_before') { throw new RuntimeException('private-storage-error'); } + if ($this->mode === 'uncertain') { return 'uncertain'; } + if ($this->mode === 'invalid') { return 'committed'; } + $binding = [$fingerprint, $operationId]; + if (isset($this->rows[$key])) { return $this->rows[$key] === $binding ? 'existing' : 'conflict'; } + $this->rows[$key] = $binding; + if ($this->afterWrite !== null) { ($this->afterWrite)(); } + if ($this->mode === 'throw_after') { throw new RuntimeException('lost-reservation-response'); } + return 'reserved'; + } +} +$actor = '12345678901234567'; +$requestId = '12345678-1234-4234-8234-123456789abc'; +$intent = new PreparedCommandIntent($actor, 'nomads', $requestId); +$allow = true; $ready = true; $decisions = []; +$policy = static function (string $who, string $target, string $action) use (&$allow, &$ready, &$decisions): ?bool { + $decisions[] = [$who, $target, $action]; return $ready ? $allow : null; +}; +$store = new FakeAdmissionStore(); +$service = new OperationAdmission(['nomads', 'tdm'], $store, $policy); +$first = $service->admit($actor, $intent); +check($first['status'] === 200 && $first['body']['state'] === 'reserved_not_dispatched', 'New reservation only'); +check($first['body']['execution_enabled'] === false && $first['body']['retry_dispatch'] === false, 'No dispatch authorization'); +check($first['body']['recovery_required'] === false && !isset($first['body']['completed']), 'No completion receipt'); +check(count($store->rows) === 1 && count($decisions) === 2, 'Reservation bracketed by authorization'); +check($decisions[0] === [$actor, 'nomads', 'restart'], 'Explicit action policy'); +$id = $first['body']['operation_id']; +check(strlen($id) <= 80 && preg_match('/\A[A-Za-z0-9_-]+\z/D', $id) === 1, 'Existing lifecycle consumer identity contract'); +$again = $service->admit($actor, $intent); +check($again['body']['operation_id'] === $id && count($store->rows) === 1, 'Serial replay retains one binding'); +check($again['body']['state'] === 'existing_requires_reconciliation' && $again['body']['recovery_required'] && !$again['body']['retry_dispatch'], 'Existing never grants redispatch'); +$otherTarget = new PreparedCommandIntent($actor, 'tdm', $requestId); +check($service->admit($actor, $otherTarget)['status'] === 409 && count($store->rows) === 1, 'Same actor request changed target conflicts'); +$allow = false; $calls = $store->calls; +check($service->admit($actor, $intent)['status'] === 403 && $store->calls === $calls, 'Revocation blocks replay before storage'); +$allow = true; $ready = false; +check($service->admit($actor, $intent)['status'] === 503 && $store->calls === $calls, 'Unready cache denies'); +$ready = true; +check($service->admit('invalid', $intent)['status'] === 401 && $store->calls === $calls, 'Invalid session actor'); +check($service->admit('22345678901234567', $intent)['status'] === 403 && $store->calls === $calls, 'Intent actor is not session authority'); +check($service->admit($actor, new PreparedCommandIntent($actor, 'hidden', $requestId))['status'] === 403 && $store->calls === $calls, 'Unknown trusted target denied'); +check((new OperationAdmission(['nomads'], $store))->admit($actor, $intent)['status'] === 503, 'Absent authorization provider'); +check((new OperationAdmission(['nomads'], null, $policy))->admit($actor, $intent)['status'] === 503, 'Absent durable provider'); +$lost = new FakeAdmissionStore(); $lost->mode = 'throw_after'; +$lostService = new OperationAdmission(['nomads'], $lost, $policy); +$unknown = $lostService->admit($actor, $intent); +check($unknown['status'] === 503 && $unknown['body']['recovery_required'] && !$unknown['body']['retry_dispatch'], 'Lost commit acknowledgement is uncertain'); +check(!str_contains(json_encode($unknown), 'lost-reservation-response') && count($lost->rows) === 1, 'Exception hidden, binding retained'); +$lost->mode = 'normal'; +$recovered = $lostService->admit($actor, $intent); +check($recovered['body']['operation_id'] === $id && $recovered['body']['state'] === 'existing_requires_reconciliation', 'Lost-response retry retains deterministic lifecycle identity'); +foreach (['throw_before', 'uncertain', 'invalid'] as $mode) { + $fake = new FakeAdmissionStore(); $fake->mode = $mode; + $out = (new OperationAdmission(['nomads'], $fake, $policy))->admit($actor, $intent); + check($out['status'] === 503 && $out['body']['recovery_required'] && !$out['body']['retry_dispatch'], 'Storage failure never becomes admission'); +} +$revoked = new FakeAdmissionStore(); +$revoked->afterWrite = static function () use (&$allow): void { $allow = false; }; +$revokedService = new OperationAdmission(['nomads'], $revoked, $policy); +$out = $revokedService->admit($actor, $intent); +check($out['status'] === 403 && !isset($out['body']['operation_id']) && count($revoked->rows) === 1, 'Revocation during reservation hides receipt without deleting binding'); +$allow = true; +check($revokedService->admit($actor, $intent)['body']['operation_id'] === $id, 'Reauthorized replay keeps original identity'); +$otherActor = '22345678901234567'; +$out = $service->admit($otherActor, new PreparedCommandIntent($otherActor, 'nomads', $requestId)); +check($out['body']['operation_id'] !== $id && count($store->rows) === 2, 'Separate actor namespace'); +$conflicting = new FakeAdmissionStore(); +$canonical = $intent->toArray(); $conflicting->rows[$canonical['deduplication_key']] = [$canonical['payload_fingerprint'], 'different-operation']; +check((new OperationAdmission(['nomads'], $conflicting, $policy))->admit($actor, $intent)['status'] === 409, 'Existing operation identity mismatch conflicts'); +$throws = static function (): bool { throw new RuntimeException('private-policy-error'); }; +check((new OperationAdmission(['nomads'], $store, $throws))->admit($actor, $intent)['status'] === 503, 'Policy exception fails closed'); +$unready = new FakeAdmissionStore(); +$unready->afterWrite = static function () use (&$ready): void { $ready = false; }; +check((new OperationAdmission(['nomads'], $unready, $policy))->admit($actor, $intent)['status'] === 503 && count($unready->rows) === 1, 'Cache becomes unavailable after reservation'); +$ready = true; $policyCalls = 0; +$throwSecond = static function () use (&$policyCalls): bool { + if (++$policyCalls === 2) { throw new RuntimeException('post-reservation-policy-failure'); } + return true; +}; +check((new OperationAdmission(['nomads'], new FakeAdmissionStore(), $throwSecond))->admit($actor, $intent)['status'] === 503, 'Policy throw after reservation fails closed'); +foreach ([['nomads', 'nomads'], [123], ['bad target'], ['key' => 'nomads'], array_fill(0, 33, 'nomads')] as $inventory) { + try { new OperationAdmission($inventory); check(false, 'Invalid inventory accepted'); } + catch (InvalidArgumentException) { check(true, 'Invalid inventory rejected'); } +} +echo "PASS: {$checks} admission API checks (serial fake; not durability/concurrency proof)\n";