Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/workflows/admin-console.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Read-only admin console contracts

on:
push:
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:
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: 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: 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: 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: 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

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

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

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
130 changes: 130 additions & 0 deletions docs/admin-console-cached-observations.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions docs/admin-console-command-intents.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions docs/admin-console-operation-admission.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading