From f6bd9ef5d53495c0f36adba479c0a71dd2f4906f Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 00:26:17 -0600 Subject: [PATCH 01/42] Add complete Control management suite --- AGENTS.md | 135 ++ README.md | 111 +- docs/control-management.md | 461 +++++++ .../control/ControlApplication.java | 13 +- .../domain/ConfigurationOperationJournal.java | 179 +++ .../domain/ConfigurationOperations.java | 219 ++- .../domain/ConfigurationSnapshots.java | 209 +++ .../control/domain/InMemoryNodeRegistry.java | 3 +- .../control/domain/InspectionOperations.java | 235 ++++ .../control/http/ControlHttpServer.java | 140 +- .../control/protocol/InspectionQuery.java | 29 + .../control/protocol/InspectionRequests.java | 11 + .../control/protocol/InspectionTask.java | 5 + .../protocol/InspectionTaskResult.java | 8 + .../protocol/ManagedConfiguration.java | 48 +- src/main/resources/web/app.css | 42 +- src/main/resources/web/app.js | 1211 ++++++++++++++++- src/main/resources/web/index.html | 149 +- .../ConfigurationOperationJournalTest.java | 136 ++ .../domain/ConfigurationOperationsTest.java | 251 +++- .../domain/ConfigurationSnapshotsTest.java | 136 ++ .../domain/InspectionOperationsTest.java | 184 +++ .../control/http/ControlHttpServerTest.java | 66 + .../control/protocol/InspectionQueryTest.java | 62 + 24 files changed, 3963 insertions(+), 80 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/control-management.md create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationSnapshots.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/InspectionRequests.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTask.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTaskResult.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournalTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationSnapshotsTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/protocol/InspectionQueryTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..05b2966 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,135 @@ +# Maintainer and AI-agent guide + +This repository is the standalone VotingPlugin Control service. Treat it as an optional management plane: it may inspect +and configure enrolled VotingPlugin nodes, but it must never become part of vote processing. VotingPlugin must continue to +start, accept votes, reward players, route proxy traffic, and stop normally when Control is absent or unavailable. + +## Build and verification + +Requirements: JDK 17+ and Maven 3.9+. + +```shell +mvn -B clean verify +node --check src/main/resources/web/app.js +``` + +Use a focused Maven test while iterating, then run the complete command before opening a PR: + +```shell +mvn -B -Dtest=InspectionOperationsTest test +mvn -B -Dtest=ControlHttpServerTest test +``` + +The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact is +`target/votingplugin-control--all.jar`. + +## Architecture and file map + +- `ControlApplication` parses owner commands/environment, creates durable stores, and wires the server. +- `http/ControlHttpServer` is the only HTTP boundary. It owns routing, authentication, CSRF enforcement, bounded JSON + parsing, status/error mapping, and static WebUI delivery. +- `auth/` stores credential verifiers and short-lived browser sessions. Never persist or log raw credentials. +- `domain/InMemoryNodeRegistry` owns current node sessions, liveness, negotiated capabilities, plugin inventory, and + topology. Current topology is deliberately in memory. +- `domain/ConfigurationOperations` coordinates live typed `READ`, `PREVIEW`, and `APPLY` tasks plus same-process retries. + Nodes pull tasks; Control never connects inbound to a Minecraft server. +- `domain/ConfigurationOperationJournal` stores redacted operation history across restarts. It never stores proposal values, + file contents, approval tokens, result messages/changes, or credentials; recovered operations are history-only. +- `domain/InspectionOperations` coordinates the separate, read-only `data.inspect.v1` lane. +- `domain/ConfigurationSnapshots` stores bounded copies of the redacted content returned by completed managed-file reads. + The list API omits content, full reads are admin-only, and durable files are owner-permissioned where the platform + supports POSIX modes. Restoring still uses the normal preview and one-time approval path. +- `domain/ConfigurationAuditLog` is the durable, bounded, hash-chained metadata audit log. Configuration values and query + filters do not belong there. +- `protocol/` contains the wire DTOs and capability-to-domain mapping. Keep them immutable and validate at construction or + at the HTTP boundary. +- `src/main/resources/web/` is a dependency-free browser client over the same `/api/v1` API. +- `src/test/java/` mirrors the security and protocol boundaries. Add regression tests at the narrowest responsible layer. +- `docs/control-management.md` is the human and AI reference for the management suite and inspection contract. + +## Non-negotiable invariants + +1. Control is optional and local-first. Do not add a vote-processing dependency, cloud requirement, or inbound listener to + VotingPlugin. +2. Do not add arbitrary command execution, raw SQL, generic filesystem access, unrestricted configuration paths, or an + untyped player/database/settings browser. New functions must be a narrow typed capability. Quick-setup presets and + option names are fixed in Control and revalidated by the node for the requested phase; the WebUI settings catalog is a + static versioned reference, not an arbitrary setting-write surface. +3. Capability negotiation is authoritative. Queue work only for an online node whose accepted capabilities contain the + exact versioned capability. Unknown advertised capabilities remain unaccepted. +4. Configuration writes follow `READ`/`PREVIEW`/`APPLY`. Apply consumes the one-time approval from a completely successful + preview and carries the node revisions that were previewed. Do not create a shortcut around this workflow. +5. A claimed task has a two-minute lease and a unique `attemptId`. A result must echo the current node session and attempt; + stale attempts cannot complete reissued work. +6. The inspection lane is read-only. Its allow-listed kind and bounded string filters are the whole request; results are a + structured JSON envelope whose serialized size is at most 512 KiB, and are retained only briefly. Audit the kind, never + player names or other filter values. +7. Mask passwords, credentials, tokens, API keys, authorization values, webhook secrets, and comparable secrets before a + read leaves a node. Never put secrets, proposed file contents, approval tokens, or inspection filters in logs/audit. + Configuration snapshots persist only the node's redacted read result; do not weaken masking, admin authorization, or + data-directory permissions. +8. Browser writes require both an authenticated session and its CSRF token. API automation uses the separate admin bearer + credential; node endpoints use a credential bound to the exact node ID. +9. Treat all remote strings, collections, and bodies as hostile. Preserve limits, exact routes/methods, duplicate-field + rejection, symlink checks, atomic publication, and fail-closed durable-file validation. +10. Keep the HTTP executor, password executor, operation stores, retained messages/content, topology, and inspection data + bounded. Do not replace limits with unbounded queues, streams, maps, or full database scans. +11. `reward-simulation` and `reward-builder` share one strict proposal schema, but only the latter can persist. Keep the + builder PREVIEW/APPLY-only, replace only its selected Rewards subtree, strip `proposal` from public/history views, and + never execute reward actions from Control. +12. Keep discovered service names observational. `vote-site-health` may copy a bounded view of persisted + `GottenServiceSites`, but it must not call an auto-creating resolver or turn a health read into a create/approve action. +13. The current `vote-logging` quick setup changes configuration but not the runtime VoteLog manager lifecycle. Preserve + inspection gating on `VoteLogging.Enabled`, document that a restart is required after either toggle, and do not claim + enabled/available/readable are interchangeable states. + +## Paired protocol workflow + +The implementation paired with this repository lives in `BenCodez/VotingPlugin`: + +- Bukkit configuration adapter: `VotingPlugin/.../control/BackendConfigurationService.java` +- Bukkit outbound connector: `VotingPlugin/.../control/BackendControlConnector.java` +- Bukkit inspection handlers: `VotingPlugin/.../control/ControlInspectionService.java` +- proxy connector/host lifecycle: `VotingPlugin/.../proxy/control/` +- paired contract: `docs/control-agent-contract.md` in that repository + +When changing a DTO, endpoint, capability, preset, error code, limit, or lease behavior: + +1. inspect both repositories before editing; +2. make the change additive or capability-versioned so either old side remains safe; +3. update server and connector protocol tests in their respective PRs; +4. update `docs/control-management.md`, the VotingPlugin connector docs, and both root `AGENTS.md` files when an invariant + changes; +5. link the two PRs and state a safe merge order. A Control-only deployment must reject unsupported work cleanly, and a + VotingPlugin-only deployment must simply leave the new capability unaccepted. + +Prefer one cohesive PR per repository for a paired feature (implementation, tests, and docs together). Split further only +when the pieces are independently deployable or need materially different review/rollback risk. + +Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new +capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature. + +## Safe change checklist + +- Identify the trust boundary and maximum sizes before adding the happy path. +- Validate exact methods, paths, media type, authentication role, session, capability, and request fields. +- Decide whether data may be persisted, audited, returned to the browser, or must be discarded on logout/restart. +- For configuration: prove preview/apply revision binding, approval single use, reload behavior, and rollback reporting. +- For inspection: prove the handler cannot write, enumerate users, accept SQL/commands/paths, or leak sensitive settings. +- Add negative tests for wrong node/session/attempt, lease expiry, unknown fields/kinds, oversized input/result, and lost + capability where relevant. +- For WebUI changes, escape untrusted text through DOM text nodes, clear sensitive/cached state on logout, keep CSRF on + every write, and run `node --check`. +- Run the full Maven suite and inspect `git diff --check` before pushing. +- Keep the PR scoped; never mix generated artifacts, credentials, runtime `data/`, or unrelated formatting changes. + +## Operational terminology + +- A **configuration operation** may read, preview, or apply one typed configuration proposal. +- An **inspection** is a short-lived typed read and can never mutate a node. +- A **snapshot** is a durable Control-side copy of a successful redacted managed-file read result, not a raw server backup + and not an apply operation. +- A **vote trace** is a timeline of events that VotingPlugin actually wrote to VoteLog for one correlation ID. It is not a + packet-level or command-by-command delivery trace. +- A **diagnostics bundle** is the bounded, redacted inspection result assembled by the WebUI. It deliberately omits raw + configuration, logs, player records, credentials, and infrastructure connection details. diff --git a/README.md b/README.md index eca26e6..705f13f 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,28 @@ # VotingPlugin Control -VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. The current milestone -provides authenticated discovery of multiple BungeeCord and Velocity proxies, direct Bukkit backend enrollment, full -VotingPlugin YAML configuration control, and common quick-setup workflows. It includes a local WebUI over the same -versioned API. It does not process votes, and VotingPlugin -does not depend on it for startup, joins, routing, or shutdown. +VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. It provides +authenticated discovery of multiple BungeeCord and Velocity proxies, direct Bukkit backend enrollment, full VotingPlugin +YAML configuration control, guided setup, redacted snapshots/drift comparison, durable operation history, and typed +read-only vote/data diagnostics. The local WebUI uses the same versioned API. Control does not process votes, and +VotingPlugin does not depend on it for startup, joins, routing, rewards, or shutdown. -## Trust and deployment boundary - -One Control process can observe an entire network: +Maintainers and coding agents should read [AGENTS.md](AGENTS.md). The complete management, inspection, limits, and threat +model reference is [docs/control-management.md](docs/control-management.md). -```text -Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C - ^--- Bukkit backend A / B / C -``` +## Trust and deployment boundary -Proxy and Bukkit connectors initiate outbound HTTP(S) requests. Control works without +One Control process can observe an entire network. Browsers communicate with its WebUI/API; each proxy and Bukkit +connector independently initiates outbound HTTP(S) requests to that same Control listener. Control works without Internet or a cloud account. Node calls require explicitly created, node-bound bearer credentials; management reads -require a separate admin credential. Browser management uses a distinct owner-chosen password. Raw credentials are never -stored by Control: `data/credentials.json` contains SHA-256 verifiers for 256-bit random tokens and a salted, +require a separate admin credential. Browser management uses a distinct owner-chosen password. Long-lived raw credentials +are never stored by Control: `data/credentials.json` contains SHA-256 verifiers for 256-bit random tokens and a salted, 600,000-iteration PBKDF2-HMAC-SHA256 WebUI password verifier. +Configuration snapshots persist the redacted managed-file content returned by a completed read; known secret paths use a +placeholder rather than storing credentials. Their list API omits content, full retrieval is admin-only, and Control +applies owner-only POSIX permissions where supported. Protect the entire data directory and its backups with equivalent +operating-system access controls. + Control binds to `127.0.0.1` by default. On first start it creates a permission-restricted `data/web-setup-code.txt` containing a one-time 256-bit setup code; `credentials.json` stores only its SHA-256 verifier. The WebUI consumes that code when the owner creates the first password and immediately deletes the raw-code file. @@ -147,7 +149,15 @@ All errors have the stable form: | `POST` | `/api/v1/configuration/read` | admin or WebUI session + CSRF | Queue a typed read for selected capable nodes | | `POST` | `/api/v1/configuration/preview` | admin or WebUI session + CSRF | Queue independent validation and normalized diffs | | `POST` | `/api/v1/configuration/apply` | admin or WebUI session + CSRF, plus one-time approval | Apply the exact successful preview | -| `GET` | `/api/v1/operations/{operationId}` | admin or WebUI session | Read overall and per-node operation status | +| `GET` | `/api/v1/operations` | admin or WebUI session | List at most 100 newest-first summaries without retained file bodies; unused preview approval may be present | +| `GET` | `/api/v1/operations/{operationId}` | admin or WebUI session | Read full bounded redacted per-node detail and any live approval token | +| `POST` | `/api/v1/operations/{operationId}/retry` | admin or WebUI session + CSRF | Reissue eligible failed work as a new operation | +| `POST` | `/api/v1/inspections` | admin or WebUI session + CSRF | Queue one typed read-only query for a capable Bukkit node | +| `GET` | `/api/v1/inspections/{inspectionId}` | admin or WebUI session | Read short-lived inspection status/result | +| `POST` | `/api/v1/nodes/{nodeId}/inspections` | matching node | Claim one read-only inspection, or `204` | +| `POST` | `/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result` | matching node | Complete that inspection attempt | +| `GET`, `POST` | `/api/v1/snapshots` | admin or WebUI session; CSRF for POST | List summaries or save a named snapshot from a completed file read | +| `GET` | `/api/v1/snapshots/{snapshotId}` | admin or WebUI session | Load one durable snapshot's full redacted file content | Routes are exact. Child suffixes do not inherit a handler, every known endpoint has an intentional method/structured 405, and all unknown endpoints return a structured 404. @@ -161,8 +171,15 @@ Control records its own observation time; it does not trust remote wall-clock ti Configuration is split into independently negotiated capabilities. `config.proxy-routing.v1` exposes typed proxy routing. `config.files.v1` manages `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and `BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.quick-setup.v1` supplies standalone, -proxy-backend, vote-site, easy-reward, common-settings, and vote-party presets. Readable presets load their installed -values before editing, and reward presets append without replacing existing commands or messages. Reward-safe VoteSites +proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, vote-party, and typed +reward-builder presets. +The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention +(`-1` disables purging or `1`–`3650` days), and main-MySQL reuse, never connection credentials. Readable presets load their +installed values before editing. +`easy-reward` appends a command only when it is not already present and sets a player message only when that path is +absent, so it never replaces an existing reward. The PREVIEW/APPLY-only +`reward-builder` accepts the same bounded proposal used by the simulator and replaces only the selected site's, +every-site, or vote-party Rewards subtree; unrelated reward scopes remain intact. Reward-safe VoteSites synchronization is part of the same Quick Setup workflow: it copies site definitions while preserving rewards, credentials, reward files, and target-only sites on every destination. Bukkit registration also reports a bounded set of installed plugin names (at most 16384 entries across the registry) so the WebUI can offer editable Minecraft, @@ -171,9 +188,27 @@ these are suggestions, not executed commands, and follow the normal preview/appr on every read; leaving the redaction marker in a proposal preserves the current value. A newly entered secret is accepted only in the authenticated proposal and is not returned in results or written to the audit log. +Control and VotingPlugin both enforce fixed quick-setup preset/option schemas; unknown presets/options are rejected rather +than becoming arbitrary YAML writes. The WebUI settings catalog is a static versioned reference over these typed paths, +not a generic setting API. + Read actions load only the primary server shown in the configuration header. Preview and apply still cover every server explicitly included in configuration changes, so one slow secondary node does not delay opening the editor or guided form. +`data.inspect.v1` is a separate read-only lane for overview, vote-site health (including persisted unconfigured service +observations), exact-player data, bounded VoteLog summary/search/correlation trace, non-creating service-site resolution, +no-side-effect reward simulation, and redacted diagnostics. It accepts only allow-listed string filters and bounded result +schemas; there is no raw SQL, arbitrary player +enumeration, command execution, generic file/database browsing, or write operation. VoteLog output is labeled **logged +events**, not a complete network delivery trace. Overview/diagnostics expose a bounded `voteLogReadable` probe; +summary/search/trace fail `UNAVAILABLE` when that probe fails, while vote-site health labels SQL data unavailable/unreadable +instead of turning a query failure into “no recent votes.” + +Changing `VoteLogging.Enabled` through guided setup updates and reloads `Config.yml`, but does not recreate or close the +runtime VoteLog manager. Restart VotingPlugin after either transition. Inspections immediately gate disabled logging even +if an old adapter remains; after enabling a previously disabled instance, overview reports enabled but unavailable until +the restart initializes the adapter. + Preview parses YAML and calculates path-level changes without writing. Apply consumes a single-use random approval token, carries each previewed revision to that node, and reports partial failures instead of a network-wide success. Proxies and Bukkit nodes create local backups, require atomic replacement, reload, and restore the @@ -186,9 +221,30 @@ cross-process lifetime lock prevents two Control processes from forking the same the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new operations, so record-boundary truncation also fails closed. A durable pending-append record makes audit and checkpoint publication recoverable when the process stops between their writes. It does not contain configuration -values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire after 15 minutes and -completed operations after 24 hours. Active operations are intentionally lost on Control restart and must be previewed -again. +values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire 15 minutes after +creation and completed operations are pruned 24 hours after creation. + +`configuration-operations.json` atomically retains at most 24 hours/1,000 entries/2 MiB of redacted operation history. It +excludes configuration/options values, file contents, approval tokens, result messages/changes, credentials, sessions, and +attempts. After restart, unfinished targets appear failed with `CONTROL_RESTARTED`; recovered entries are history-only and +require a fresh read or preview. Named snapshots under `configuration-snapshots/` retain bounded redacted file-read +results. Loading one for restore merely fills the editor—the normal preview, revision check, approval, backup, reload, and +rollback workflow still applies; redaction placeholders preserve each target's current secrets. The store is capped at +100 snapshots and 64 MiB of encoded files and prunes the oldest files to admit a new snapshot. + +### WebUI management suite + +- **Setup** provides a readiness checklist, browser-local non-secret profiles, dedicated auto-create and VoteLogging cards, + the existing typed setup assistant, a reward builder with simulation plus preview/apply, and a searchable common-setting + catalog. +- **Votes & Data** provides bounded server overview, exact player lookup, vote-site health and detected unconfigured + services, a 30-day VoteLog summary, logged-event search, vote-ID correlation, and a non-creating service-site test. +- **Network Doctor** combines redacted node diagnostics with Control's current topology and can download that bounded JSON + result; it never sends a vote or runs a reward. +- **Configurations** uses a 30-second session-bound read cache, compares exact revisions plus bounded redacted line + differences across selected nodes, and saves named redacted snapshots for previewed restore. +- **Activity** shows newest-first durable redacted progress/recovery history and offers safe same-process retries only where + the API marks an operation retryable. A node is online only while `lastSeen + offlineTimeout` is strictly after Control's current time. The exact timeout boundary is offline. Backend entries distinguish whether authoritative backend presence is known from the existing VotingPlugin @@ -204,6 +260,10 @@ They are not misleadingly used by these simple HTTP resource endpoints. - `409 SESSION_MISMATCH`: re-register; Control has a newer process session for that stable node. - `409 REGISTRY_LIMIT`: reduce a node's backend snapshot before retrying; the previous snapshot remains intact. - `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. +- `409 RETRY_REQUIRES_INPUT`: recovered history deliberately lacks sensitive proposal input; start a fresh read/preview. +- Vote/data view says unavailable: select an online Bukkit node that negotiated `data.inspect.v1`; VoteLog-specific reads + also require enabled, initialized, and currently readable SQL VoteLogging. Restart VotingPlugin after changing + `VoteLogging.Enabled` through guided setup so the manager lifecycle matches the new configuration. - Node stays offline: verify its connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit the configured interval. - Container/LAN access: bind `0.0.0.0` inside the container rather than the VM's address, allocate the port, and place it @@ -213,7 +273,8 @@ They are not misleadingly used by these simple HTTP resource endpoints. ## Intentionally out of scope -This milestone has no arbitrary command execution, manual rollback endpoint, topology persistence/history, diagnostics -bundle, cloud relay, or remote-support sessions. Node and operation state is currently in memory, so nodes automatically -re-register after a Control restart and an interrupted change requires a new preview. Manual installation -remains supported; the companion VotingPlugin development PR can also opt in to verified download and child-process hosting. +This milestone has no arbitrary command execution, direct backup-rollback endpoint, topology persistence/history, raw +support archive, cloud relay, or remote-support sessions. Current topology and active task inputs remain in memory, so +nodes automatically re-register after a Control restart and an interrupted change requires a new preview. Redacted +operation history, audit metadata, and configuration snapshots persist without making an ambiguous write resumable. +Manual installation remains supported; VotingPlugin may also opt in to verified download and child-process hosting. diff --git a/docs/control-management.md b/docs/control-management.md new file mode 100644 index 0000000..97fe421 --- /dev/null +++ b/docs/control-management.md @@ -0,0 +1,461 @@ +# VotingPlugin Control management suite + +This document is the implementation-oriented reference for maintainers, API clients, and AI agents. VotingPlugin Control +is an optional local-first management plane. It discovers enrolled nodes, coordinates bounded configuration changes, and +requests typed read-only inspections. It never receives or processes votes, and VotingPlugin remains fully operational +when Control is stopped. + +## Mental model + +There are three independent lanes: + +| Lane | Capability examples | Direction | Can mutate a node? | Persistence | +| --- | --- | --- | --- | --- | +| Discovery | `discovery.read`, `presence.snapshot` | Node pushes registration/heartbeat/presence | No | Current topology is in memory | +| Configuration | `config.files.v1`, `config.quick-setup.v1` | Browser queues; node polls and reports | Only after preview and approval | Redacted history and audit are durable; live task input is in memory | +| Inspection | `data.inspect.v1` | Browser queues; Bukkit node polls and reports | Never | Short-lived result is in memory; kind-only audit is durable | + +Connectors always initiate outbound HTTP(S) to Control. No Control feature adds an inbound port to a Minecraft process. +One node credential is bound to one stable node ID. Browser sessions and the API automation credential are separate from +node credentials. + +## Capability map + +Capability negotiation is the compatibility boundary. A node advertises capabilities during registration/heartbeat; +Control accepts only the intersection with its own allow-list. + +| Capability | Role | +| --- | --- | +| `discovery.read` | Current node identity and status | +| `presence.snapshot` | Full replacement backend presence snapshots from a proxy | +| `config.proxy-routing.v1` | `SendVotesToAllServers` and `BlockedServers` on a proxy | +| `config.files.v1` | Bounded reads/previews/applies for managed Bukkit YAML files | +| `config.file-comments.v1` | Preserves Control-managed comment metadata where supported | +| `config.quick-setup.v1` | Typed guided settings and reward/site presets | +| `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets | +| `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check | +| `config.proxy-method.v1` | Coordinated preview/apply of a supported network proxy method | +| `data.inspect.v1` | Typed read-only data, health, simulation, and diagnostics requests | + +Do not infer support from plugin version strings. Check `acceptedCapabilities` for the exact capability. + +## WebUI feature map + +The dependency-free WebUI is an API client, not a privileged implementation path. Every write below still uses the same +authenticated, CSRF-protected endpoint and node capability checks as an external client. + +| Area | What the WebUI does | Safety/accuracy boundary | +| --- | --- | --- | +| Network Doctor | Runs `diagnostics` (which includes the overview fields), combines node health with Control's current topology, and displays checks for connector, configuration, Votifier, vote sites, rewards, logging, and proxy topology | Read-only; “healthy” is bounded reported state, not a synthetic vote | +| Diagnostics download | Downloads the last Network Doctor result as local JSON | Redacted status bundle only; no raw configuration/logs/player records/infrastructure secrets | +| Activity | Loads the newest 50 live/recovered operation views, labels phases, lineage, reload/rollback, resumes eligible guided preview approvals, and offers retry only when `retryable` | Recovered history cannot be retried; approval is single-use and apply is CSRF-protected; proxy-method apply needs a new preview | +| Fast file reads | Caches a successful file read for 30 seconds by node ID, node session, and file | Browser memory only; cleared on logout and successful relevant writes; session binding prevents reuse after reconnect | +| Configuration drift | Reads the same redacted managed file from two or more selected capable nodes, groups exact revisions, and compares each target with the first successful baseline | Read-only; renders at most 50 differing line pairs per target and truncates each redacted line to 200 characters | +| Snapshots | Creates a named durable snapshot from the last completed file read and loads one document into the editor | Stores the full redacted read result; restore is proposed content and must be freshly previewed/approved | +| Settings catalog | Filters a static schema of commonly managed setting key, file, type, default, and effect | Reference for guided forms, not a generic setting API or claim to cover every VotingPlugin option | +| Setup checklist | Uses live node state plus `overview` to mark enrollment, topology, vote-site, reward, logging/storage, and communication readiness | Vote logging is explicitly optional; a check is not an end-to-end vote test | +| Auto-create setup | Reads/previews/applies the dedicated single-setting preset to selected Bukkit targets | Does not overwrite other common settings | +| Vote-logging setup | Reads/previews/applies enabled state, retention, and main-connection choice | Never accepts credentials; dedicated connection details stay in the redacted editor | +| Setup profiles | Stores up to 20 named guided-form profiles in browser `localStorage` | Browser-local, versioned, non-secret values only; no raw YAML or credentials; loading never applies | +| Reward builder/simulator | Builds site/every-site/vote-party proposals with commands, player/broadcast messages, items, money, permissions, chance, and online-only behavior; simulates or previews/applies the exact proposal; can copy one command/message into simple Setup | Simulation has no side effects; persistence replaces only the selected Rewards subtree through normal preview/approval; editing invalidates approval | +| Votes & Data | Shows overview, exact player lookup, vote-site health plus persisted unconfigured-service observations, a 30-day VoteLog summary, exact/bounded logged-event search, and correlation trace | Inspection-capable Bukkit node only; VoteLog reads require logging; results are cleared on logout; no player enumeration | +| Safe service-site test | Dry-runs resolution, including optional disabled-site matching and whether auto-create would be considered | Sends no fake vote, creates no site, changes no total, and runs no reward | + +The Setup tab replaces the former “Quick Setup” framing but retains existing typed presets, VoteSites sync, detected-plugin +command suggestions, preview, approval, node backup, reload, and rollback. Setup profiles are convenience input only; live +values should be loaded before modifying an existing configuration. + +## Configuration operations + +### Managed domains + +`ManagedConfiguration` is a tagged union: + +- `proxy-routing` manages only `sendVotesToAllServers` and `blockedServers`; +- `file` manages one allow-listed file: `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, + `BungeeSettings.yml`, or one validated split file under `VoteSites/`; +- `quick-setup` manages one typed preset with at most 20 bounded string options. Ordinary options are at most 500 + UTF-8 bytes; the internal VoteSites sync source may be 512 KiB and a `reward-builder` proposal may be 64 KiB. Both large + inputs are stripped from every public operation view. + +Control accepts only the fixed preset/option-name schema in `ManagedConfiguration`, including the small safe fields that a +node may return for a READ result. Proposal creation also enforces each exact required option set. VotingPlugin independently +rejects unknown presets/options and applies phase-specific rules (for example, most READ presets take no options and +`reward-builder` has no READ form). The WebUI settings catalog is static/versioned guidance over these typed operations; +it does not turn a displayed YAML key into a generic write request. + +`reward-builder` is PREVIEW/APPLY-only and requires exactly `options.proposal`, a JSON-serialized copy of the typed reward +proposal documented below. It deliberately has no READ form. The selected scope determines the managed file and path, and +the plugin replaces only that path so a second preview is deterministic and cannot leave stale actions behind. Control +never returns the proposal in a public operation view, and the durable operation journal records only the domain/preset. +The node's acknowledged result exposes only the derived target file, not proposal actions/messages. + +File content is limited to 512 KiB. Node results mask secret-like YAML paths. A replacement secret may pass through an +authenticated proposal, but Control omits file proposal contents from operation views and never records them in its audit. + +### Read, preview, and apply + +1. `POST /api/v1/configuration/read` queues a read for 1–100 online nodes that accept the selected capability. +2. `POST /api/v1/configuration/preview` queues independent parsing/validation and deterministic change reporting. A fully + successful preview returns a random one-time `approvalToken`. +3. `POST /api/v1/configuration/apply` accepts only that preview ID and exact unused token. It carries each node revision + from preview so a concurrent edit becomes a stale-revision failure. +4. Nodes stage and atomically replace managed YAML, reload VotingPlugin, and restore the local `.control-backup` if reload + fails. The result distinguishes reload and rollback from a successful save. + +Each target state is `QUEUED`, `IN_PROGRESS`, or `COMPLETE`; the aggregate state is `RUNNING`, `SUCCEEDED`, or +`COMPLETED_WITH_ERRORS`. A claim has a two-minute lease and new `attemptId`. The result must echo that attempt and the +current node session, preventing a stale execution from completing reissued work. + +### Retry behavior + +`POST /api/v1/operations/{operationId}/retry` creates a new operation; it never mutates the historical view. + +- The original operation must be complete and have at least one failed node. +- A failed `READ` or `APPLY` retries only failed nodes. Already successful applies are never repeated. +- Retrying a `PREVIEW` includes every original target and returns a new approval token after all targets pass. +- A coordinated proxy-method apply returns `PREVIEW_REQUIRED` instead of reusing old topology assumptions. +- Retry still revalidates current online state and capability support. + +Operations are bounded to 1,000 retained entries, with at most 16 file/VoteSites-sync-source operations retained. An +unleased active operation expires 15 minutes after creation; a completed operation is pruned 24 hours after creation. +`GET /api/v1/operations` returns at most the newest 100, newest first; each view includes `sourceOperationId`, `recovered`, +and `retryable`. List entries are summaries whose retained file bodies are omitted. Fetching +`GET /api/v1/operations/{operationId}` returns that operation's full bounded redacted result bodies. Both list and detail +views may include an unused completed-preview approval token so an authenticated UI can resume after refresh; applying +still requires the admin role/browser CSRF protection and consumes the token exactly once. + +Production also atomically maintains an owner-readable, 2 MiB-bounded `data/configuration-operations.json`. It stores only +operation identity/type/time, redacted domain selector (`fileName` or preset), retry lineage, and bounded per-node +completion/success/code/revision/reload/rollback metadata. It deliberately excludes options/proposal values, file content, +approval tokens, result messages/changes, credentials, sessions, and attempts. + +After a restart, journal entries are exposed as `recovered:true` history. Any node that had not completed is shown failed +with `CONTROL_RESTARTED`; a recovered entry is never resumed or retried because its sensitive input, live session binding, +and approval are not persisted. Start a fresh read or preview. This preserves operator visibility without replaying an +ambiguous write. + +### Configuration snapshots and restore + +Snapshots are durable Control-side copies of the redacted managed-file content returned by successful READ results. Known +secret paths and sensitive comment values contain `__VOTINGPLUGIN_CONTROL_REDACTED__` rather than credentials: + +- `POST /api/v1/snapshots` accepts a name and completed read operation ID; +- `GET /api/v1/snapshots` lists summaries without file content; +- `GET /api/v1/snapshots/{snapshotId}` returns the selected documents with their full stored redacted content to an + authenticated administrator/browser session. + +A snapshot can contain at most 100 documents and 8 MiB of UTF-8 content. The store retains at most 100 snapshots and +64 MiB of encoded files in aggregate; before creation it removes the oldest files by modification time until both the +count and aggregate-byte bounds can fit the new snapshot. Files are validated, published atomically under +`data/configuration-snapshots/.json`, and rejected if they are symlinks, malformed, oversized, or contain an invalid +managed document identity/revision. + +On POSIX-capable filesystems Control creates the snapshot directory owner-only and snapshot files owner read/write. POSIX +modes are not available on every platform, so operators must protect the entire Control data directory and every backup +with equivalent ACLs. + +A snapshot is not a server backup and has no privileged restore endpoint. To restore, load one snapshot document as the +proposed content, preview it against the node's current revision, review the diff, and apply with the new one-time +approval. Snapshot retrieval returns the stored redacted document. During preview/apply, an unchanged redaction marker is +resolved against each target's current secret; a snapshot never recovers or overwrites an old credential. + +## Read-only inspection protocol + +### Request lifecycle + +An administrator starts exactly one node query: + +```http +POST /api/v1/inspections +Content-Type: application/json + +{ + "nodeId": "backend-lobby", + "query": {"kind": "overview", "filters": {}} +} +``` + +The node polls its independent inspection queue: + +```http +POST /api/v1/nodes/backend-lobby/inspections +Content-Type: application/json + +{"sessionId":""} +``` + +`204` means no work. A task uses the shape: + +```json +{ + "inspectionId": "", + "query": {"kind": "overview", "filters": {}}, + "attemptId": "" +} +``` + +The node posts the result to +`/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result` with its `sessionId`, the same `attemptId`, `success`, a bounded +message, and either `data` or an error `code`. A successful result may omit/set `code` to `null` or send `"OK"` for +connector compatibility. A failed result must omit `data` and use a code matching `[A-Z][A-Z0-9_]{0,63}`. + +The Bukkit connector runs these handlers on a dedicated single-thread daemon executor, separate from its +presence/configuration executor and never on the Bukkit primary thread. One slow read delays later inspections only; +shutdown cancels the inspection lane and waits at most five seconds for that worker. + +Successful `data` is a JSON object with a common envelope: + +```json +{ + "schemaVersion": 1, + "kind": "overview", + "generatedAt": "2026-08-30T12:00:00Z", + "result": {} +} +``` + +`schemaVersion` must be the JSON integer `1` (not a string), `kind` must exactly match the assigned query, +`generatedAt` must parse as an ISO-8601 instant, and `result` must be a JSON object. Control limits serialized data to +512 KiB and the message to 4 KiB. There are at most 100 retained inspections. The task +lease is two minutes; an unleased active inspection expires five minutes after creation and a complete inspection is +pruned 15 minutes after creation. Retrying after a lost acknowledgement is safe because handlers are read-only. + +Control audit records only the inspection kind. Filter values may contain a player identity or vote correlation ID and +must never be copied into audit or ordinary application logs. + +### Query allow-list + +The outer Control DTO permits at most 12 filters. Every filter value is a JSON string on the wire. Each key must match +`[a-z][A-Za-z0-9]{0,39}` and ordinary values are bounded to 500 UTF-8 bytes before the node parses the stricter per-kind +schema below. The sole larger value is `reward-simulation`'s `proposal`, capped at 64 KiB. Examples: +`"days":"30"`, `"limit":"25"`, and `"includeDisabled":"false"`. + +API clients should always send that canonical string shape. The current Jackson mapper can coerce some scalar values in an +administrator request before constructing `Map`, but that is not a compatibility guarantee; the queued node +task is string-valued and VotingPlugin's handler validates text after selecting the kind. + +| Kind | Allowed filters | Result and important semantics | +| --- | --- | --- | +| `overview` | none | Plugin/platform versions; configuration health; bounded data-storage mode; proxy mode; vote-site counts; auto-create state; configured/available/readable VoteLog state | +| `vote-site-health` | string `days` 1–365, default 30 | Configured site state, bounded logged aggregates, unmatched logged services, and bounded persisted service observations with no configured match | +| `player` | exactly one of `name` (1–16 characters) or `uuid` (canonical 36-character UUID) | Exact existing-player lookup; totals, points, streaks, up to 100 per-site last-vote rows, and backend pending-offline count; never player enumeration | +| `vote-log-summary` | string `days` 1–365, default 30 | Vote count, immediate/cached split, unique voters, and top 20 services/servers | +| `vote-log-search` | at most one of exact `player` (1–16 characters), `service` (1–64), or `server` (1–64); optional `event` and string `days`/`limit` | Most recent bounded logged-event rows; default 25 and maximum 100 | +| `vote-trace` | required canonical 36-character UUID `voteId`; optional string `days`/`limit` | Chronological logged events sharing one correlation ID; default 50 and maximum 100 | +| `vote-site-resolution` | required valid `serviceSite` (1–64 characters); optional string boolean `includeDisabled` | Non-creating resolution and whether automatic creation would be attempted; always reports no side effects | +| `reward-simulation` | required `proposal`, a JSON object encoded as one filter string | Validation, normalization, action count, and warnings only; never executes or saves rewards | +| `diagnostics` | none | Bounded redacted runtime/configuration health, VoteLog readability, and detected plugin names | + +Unknown kinds, filters, proposal fields, or invalid types/ranges fail with `VALIDATION_ERROR`. VoteLog summary/search/trace +fail with `UNAVAILABLE` when logging is disabled, enabled without an initialized adapter, or unreadable. An oversized result fails with +`RESULT_TOO_LARGE`; an unexpected handler failure becomes `INSPECTION_FAILED` without exposing a stack trace. + +Valid event filters are `VOTE_RECEIVED`, `VOTEMILESTONE`, `VOTE_STREAK_REWARD`, `TOP_VOTER_REWARD`, and +`VOTESHOP_PURCHASE`. + +### Result field map + +Every kind returns its fields under the common envelope's `result` object. Time fields below are VotingPlugin epoch-millis +values; `generatedAt` is the ISO-8601 string generated by the connector. + +| Kind | Result fields | +| --- | --- | +| `overview` | `pluginVersion`, `platform`, `serverSoftware`, `serverVersion`, configured/enabled vote-site counts, `autoCreateVoteSites`, `processRewards`, `dataStorage`, `voteLoggingEnabled`, `voteLogAvailable`, `voteLogReadable`, proxy mode/method, `votifierDetected`, `configurationHealthy` | +| `vote-site-health` | `days`, `voteLoggingEnabled`, `voteLoggingAvailable`, `voteLogReadable`, `autoCreateVoteSites`, `sites`, `unmatchedLoggedServices`, `detectedUnconfiguredServices`, and truncation flags. Site rows always include identity/settings/reward presence and status; logged/immediate/cached counts and last-vote time are present only when VoteLog is readable. Status is `ACTIVE`, `DISABLED`, `SERVICE_SITE_MISSING`, `VOTE_LOG_UNAVAILABLE`, `VOTE_LOG_UNREADABLE`, or `NO_RECENT_VOTES` | +| `player` | Either `{found:false, entity:"player"}` or identity/online state, daily/weekly/monthly/all-time totals, points, streaks, `lastVoteTime`, `lastVotes`, `lastVotesTruncated`, and `pendingOfflineVotes` saturated at 100,000. Last-vote rows contain `siteKey`, `displayName`, `serviceSite`, and `time`, and include only stored keys that currently resolve as enabled sites | +| `vote-log-summary` | `days`, `total`, `immediate`, `cached`, `uniqueVoters`, top-20 `topServices` and `topServers` count rows | +| `vote-log-search` | `days`, `limit`, `entries`, `truncated`; each entry has `voteId`, `voteTime`, player UUID/name, service, server, event, context, status, and `cachedTotal` | +| `vote-trace` | normalized `voteId`, `found`, chronological `events` using the same entry schema, and `truncated` | +| `vote-site-resolution` | requested service/options, `matched`, optional matched-site identity/state, `wouldAutoCreate`, and `sideEffects:false` | +| `reward-simulation` | `valid`, `actionCount`, `wouldExecute:false`, `sideEffects:false`, `normalizedProposal`, and bounded warnings | +| `diagnostics` | All overview fields plus build/profile/Java/background-task/storage status, at most 128 detected plugin names, and `omittedSensitiveData` | + +### Reward simulation proposal + +The decoded `proposal` JSON is: + +```json +{ + "scope": "site", + "site": "PMC", + "commands": ["eco give %player% 100"], + "playerMessages": ["Thanks for voting"], + "broadcastMessages": [], + "items": [{"material": "DIAMOND", "amount": 2}], + "money": 0, + "permissions": [], + "chancePercent": 100, + "onlineOnly": false +} +``` + +The inspection request carries that object as a string, for example +`{"kind":"reward-simulation","filters":{"proposal":"{\"scope\":\"site\",...}"}}`. Use `JSON.stringify` (or the +equivalent standard JSON serializer); do not build this escaped text by concatenating user input. + +`scope` is `site`, `every-site`, or `vote-party`; for site scope, `site` must match `[A-Za-z0-9_-]{1,64}` and name an +existing site. For a global scope it must be omitted, null, or empty. Each action collection has at most 20 entries. Command and message +entries are nonblank/single-line and at most 500 characters; permissions have the same rules with a 200-character limit. +Item material names are normalized to uppercase, must match `[A-Z0-9_]{1,80}`, resolve through Bukkit +`Material.matchMaterial`, and be item materials; amounts are 1–64. Money is a finite number from 0–1,000,000,000 and chance is finite from 0–100; +`onlineOnly` is a native boolean. At least one command, message, item, permission, or positive money value is required. +The result always contains +`wouldExecute:false` and `sideEffects:false`. The encoded proposal filter is capped at 64 KiB. + +The same object can be persisted only through the PREVIEW/APPLY-only `reward-builder` quick setup. Persistence clears and +rebuilds exactly one selected path: + +| Scope | File and replaced path | +| --- | --- | +| `site` | `VoteSites.yml` → `VoteSites..Rewards`; the named site must already exist | +| `every-site` | `VoteSites.yml` → `EverySiteReward` | +| `vote-party` | `SpecialRewards.yml` → `VoteParty.Rewards` | + +Commands map to `Commands`; player and broadcast messages to `Messages.Player` / `Messages.Broadcast`; items to numbered +`Items.ControlItemN` material/amount entries; positive money to `Money`; and permissions to numbered +`AdvancedRewards.ControlPermissionN.TempPermission.{Permission,Expiration}` entries with `Expiration: 2147483647`. +Chance maps to `Chance`, while +online-only maps to `RewardType: ONLINE` instead of `BOTH`. The preset never executes the proposal and never changes a +different site, every-site rewards, or unrelated VoteParty settings. The regular revision, approval, backup, reload, and +rollback guarantees still apply. + +## Vote-site and VoteLog semantics + +The dedicated `auto-create-vote-sites` quick setup reads/writes only `Config.yml -> AutoCreateVoteSites`. Use it for the +prominent toggle rather than submitting the six unrelated fields in `common-settings`; its READ form takes no options. +Disabling automatic creation does not make vote-site resolution writable and does not delete previously observed service +names. It gates only inbound +unknown-service generation; explicit administrator command/GUI creation remains available. + +`vote-site-health` keeps two unconfigured-service signals distinct. `unmatchedLoggedServices` is derived from retained +VoteLog rows and remains empty/non-authoritative unless `voteLogReadable` is true. `detectedUnconfiguredServices` is the +case-insensitive, deduplicated, sorted, at-most-100 view of VotingPlugin's persisted `GottenServiceSites` observations that do not match a +configured site's `ServiceSite`; its separate truncation flag reports overflow. That observation list remains useful when +VoteLogging is disabled and when automatic site creation is turned off. It is an inbox for review, not an automatic create +or approval action. + +The dedicated `vote-logging` quick setup owns only `VoteLogging.Enabled`, `VoteLogging.PurgeDays` (`-1` disables automatic +purging, otherwise `1`–`3650`; `0` and other negatives are invalid), and `VoteLogging.UseMainMySQL`. It rejects unknown +options and never accepts or returns database connection fields or credentials. Its READ form takes no options and +round-trips `-1`. Configuring a dedicated connection remains a full redacted-editor change. + +VoteLog is optional and SQL-backed. `voteLoggingEnabled` reports the current configuration. `voteLogAvailable` is true +only when logging is enabled and its table adapter exists; `voteLogReadable` additionally requires a bounded live probe +whose JDBC statement timeout is 10 seconds. The quick setup writes and reloads `Config.yml`, but it does not create or +close the runtime VoteLog manager. Restart VotingPlugin after changing `VoteLogging.Enabled`: immediately after disabling, +all inspection reads gate the possibly stale adapter and report available/readable false; immediately after enabling a +previously disabled instance, overview can report enabled true but available false until restart. + +Summary, search, and trace require enabled, available, and readable state; otherwise they fail with `UNAVAILABLE` instead +of presenting legacy empty/zero SQL fallbacks as real data. Vote-site health remains useful without SQL: it reports +`voteLogReadable:false`, skips aggregates, and uses `VOTE_LOG_UNAVAILABLE` or `VOTE_LOG_UNREADABLE` rather than +`NO_RECENT_VOTES` for enabled rows with a configured service; `DISABLED` and `SERVICE_SITE_MISSING` retain precedence. +Readability is a point-in-time probe, not a transaction around the later query. A database failure after +a successful probe can still hit the legacy table method's empty/zero fallback; fully eliminating that narrow race needs a +future table API that propagates query errors. + +The data views expose **logged events**, not a complete vote-delivery trace. Logged event rows can contain correlation ID, +event time, player UUID/name, service, server, event, context, `IMMEDIATE` or +`CACHED` status, and cached total. The log does not promise a row for every validation rejection, transport hop, duplicate +decision, executed reward command, command result, or expiry. A vote trace therefore means “all retained logged events +with this `voteId`”, not packet tracing. + +Do not add an inspection escape hatch for SQL, table names, connection settings, raw logs, arbitrary player fields, fuzzy +player search, or all-player enumeration. + +## HTTP resource reference + +All write requests from a browser session require its `X-CSRF-Token`. API automation uses the admin bearer credential; +node resources require the bearer credential bound to the path node ID. + +| Method | Resource | Role | Purpose | +| --- | --- | --- | --- | +| `GET` | `/api/v1/health` | public | Application, instance, protocol, and optional hosted-launch identity | +| `GET/POST` | `/api/v1/auth/setup` | public/setup code | First-run password state and one-time setup | +| `POST` | `/api/v1/auth/login` | password | Create bounded browser session | +| `GET` | `/api/v1/auth/session` | browser | Restore CSRF token after refresh | +| `POST` | `/api/v1/auth/logout` | browser + CSRF | Revoke current session | +| `GET/POST` | `/api/v1/enrollments` | admin/browser | List node IDs or rotate one node credential | +| `DELETE` | `/api/v1/enrollments/{nodeId}` | admin/browser | Revoke one node credential | +| `GET` | `/api/v1/nodes` | admin/browser | Stable paginated current topology | +| `POST` | `/api/v1/nodes/register` | matching node | Register/replace a process session | +| `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and capabilities | +| `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace proxy backend presence | +| `POST` | `/api/v1/configuration/{read,preview,apply}` | admin/browser + CSRF | Queue typed configuration work | +| `GET` | `/api/v1/operations` | admin/browser | List at most 100 newest-first summaries without retained file bodies; unused preview approval may be present | +| `GET` | `/api/v1/operations/{operationId}` | admin/browser | Read full bounded redacted aggregate/per-node detail and any unused preview approval | +| `POST` | `/api/v1/operations/{operationId}/retry` | admin/browser + CSRF | Reissue safe failed work as a new operation | +| `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one configuration task or `204` | +| `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete one claimed configuration task | +| `POST` | `/api/v1/inspections` | admin/browser + CSRF | Queue one typed read-only query | +| `GET` | `/api/v1/inspections/{inspectionId}` | admin/browser | Read short-lived inspection status/result | +| `POST` | `/api/v1/nodes/{nodeId}/inspections` | matching node | Claim one inspection or `204` | +| `POST` | `/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result` | matching node | Complete one claimed inspection | +| `GET/POST` | `/api/v1/snapshots` | admin/browser | List snapshot summaries or create from a completed read | +| `GET` | `/api/v1/snapshots/{snapshotId}` | admin/browser | Load one snapshot's full stored redacted documents | + +Routes and methods are exact. Known resources return structured `405`; unknown paths return structured `404`. Errors use: + +```json +{"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","details":[]}} +``` + +## Threat model and hard limits + +Control assumes an authenticated administrator may make intended changes, but it does not trust HTTP clients, nodes, +their clocks, returned strings, or durable files. Authentication does not encrypt traffic; use HTTPS or a trusted private +tunnel/network outside loopback. + +| Boundary | Limit/behavior | +| --- | --- | +| HTTP request | 4 MiB; bounded Jackson depth/string/number constraints; duplicate and trailing JSON rejected | +| HTTP execution | 8 active request workers plus queue of 32; bounded request/response time | +| Browser sessions | 100; 30-minute idle and 8-hour absolute expiry; HttpOnly, SameSite=Strict cookie | +| Node operation targets | 1–100 distinct online capable nodes | +| Managed YAML / VoteSites sync source | 512 KiB per document/source | +| Ordinary option/filter | 500 UTF-8 bytes per value; a reward simulation/builder proposal is the sole 64 KiB exception | +| Retained operation detail | 8 MiB file content, 256 KiB changes, 256 KiB messages across live retained operations | +| Durable operation history | 1,000 entries, 100 nodes each, 24 hours, and 2 MiB; metadata only | +| Inspection | 100 retained; 512 KiB data; 4 KiB message; 2-minute lease | +| Snapshot | 100 snapshots and 64 MiB encoded aggregate; 100 documents and 8 MiB content per snapshot | +| Topology | 4,096 backends per snapshot; 65,536 retained across registry; 128 plugins per node and 16,384 total | +| Audit | bounded hash-chained JSONL; rotates at 5 MiB; values, credentials, tokens, and filters excluded | + +Sensitive output must omit credentials, password/token/API-key/authorization values, database/Redis/MQTT connection +details, webhook URLs, raw logs, raw configuration in diagnostics, and unrestricted player records. Configuration reads +and snapshots contain bounded managed YAML with known secrets replaced by redaction markers. The diagnostics result +includes an explicit list of categories it omitted so a support recipient does not assume completeness. + +## Change recipes for agents + +### Add an inspection field + +1. Confirm it is read-only, bounded, non-secret, and belongs to an existing kind. +2. Add it in VotingPlugin's typed handler and test the exact result, disabled/unavailable state, and size bound. +3. Render it as text in the WebUI and clear any cached value on logout. +4. Update this document and the paired `docs/control-agent-contract.md`. + +### Add an inspection kind + +1. Prefer extending an existing kind unless the authorization/data semantics genuinely differ. +2. Add the exact kind to Control's `InspectionQuery` and VotingPlugin's `ControlInspectionService` allow-lists. +3. Define exact filters, types, ranges, default/max rows, error states, and sensitive omissions before implementation. +4. Add coordinator, HTTP, connector, handler, and browser tests. If an old counterpart must not see the request, introduce + a new versioned capability. + +### Add a setting shortcut + +1. Give the preset the narrowest ownership possible; do not rewrite unrelated settings. +2. Implement read/preview/apply through the existing quick-setup capability. +3. Preserve deterministic revisions, secret behavior, atomic write, reload, rollback, and audit boundaries. +4. Expose it through the same preview/approval UI; never write directly from a toggle. + +### Add persistent Control data + +1. Define a hard count and byte limit plus deterministic eviction. +2. Reject symlinks and malformed/oversized files, stage in the destination directory, force file data, publish atomically, + and force the directory. +3. Never persist browser CSRF tokens, approval tokens, node raw credentials, new configuration secrets, or inspection + filters/results containing player data. Snapshot content must remain the redacted node read, admin-only, + owner-permissioned, and bounded. diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index e3606bd..64f171b 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -4,6 +4,9 @@ import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationAuditLog; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; +import com.bencodez.votingplugin.control.domain.ConfigurationOperationJournal; +import com.bencodez.votingplugin.control.domain.ConfigurationSnapshots; +import com.bencodez.votingplugin.control.domain.InspectionOperations; import com.bencodez.votingplugin.control.http.ControlHttpServer; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Protocol; @@ -153,10 +156,14 @@ static void runServer(Map environment) throws Exception { VersionInfo.applicationVersion(), Protocol.VERSION); Clock clock = Clock.systemUTC(); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, configuration.offlineTimeout()); - ConfigurationOperations operations = new ConfigurationOperations(registry, - new ConfigurationAuditLog(configuration.dataDirectory(), clock), clock); + ConfigurationAuditLog audit = new ConfigurationAuditLog(configuration.dataDirectory(), clock); + ConfigurationOperationJournal operationJournal = new ConfigurationOperationJournal( + configuration.dataDirectory(), clock); + ConfigurationOperations operations = new ConfigurationOperations(registry, audit, clock, operationJournal); + InspectionOperations inspections = new InspectionOperations(registry, audit, clock); + ConfigurationSnapshots snapshots = new ConfigurationSnapshots(configuration.dataDirectory(), clock); ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, - operations, configuration.secureCookies(), configuration.trustedProxyAddresses(), + operations, inspections, snapshots, configuration.secureCookies(), configuration.trustedProxyAddresses(), configuration.launchId()); ProcessHandle parent = parentProcess(configuration.parentPid()); CountDownLatch shutdown = new CountDownLatch(1); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java new file mode 100644 index 0000000..2ebb4a1 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java @@ -0,0 +1,179 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.DurableFiles; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Durable, redacted operation history. The journal intentionally excludes proposal values, + * file contents, approval tokens, changes, messages, and credentials. + */ +public final class ConfigurationOperationJournal { + static final int SCHEMA_VERSION = 1; + static final int MAX_OPERATIONS = 1000; + private static final int MAX_NODES = 100; + private static final int MAX_BYTES = 2 * 1024 * 1024; + private static final Duration RETENTION = Duration.ofHours(24); + private static final Set OWNER_DIRECTORY = Set.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + private static final Set OWNER_FILE = Set.of( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private final Path directory; + private final Path file; + private final Clock clock; + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + public ConfigurationOperationJournal(Path dataDirectory, Clock clock) throws IOException { + this.directory = dataDirectory.toAbsolutePath().normalize(); + this.file = directory.resolve("configuration-operations.json"); + this.clock = clock; + boolean existed = Files.exists(directory, LinkOption.NOFOLLOW_LINKS); + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Configuration operation journal directory is unsafe"); + } + if (!existed) setPermissions(directory, OWNER_DIRECTORY); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file))) { + throw new IOException("Configuration operation journal is unsafe"); + } + } + + public synchronized List load() throws IOException { + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return List.of(); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) + || Files.size(file) > MAX_BYTES) { + throw new IOException("Configuration operation journal is invalid"); + } + JournalFile stored; + try { + stored = json.readValue(Files.readAllBytes(file), JournalFile.class); + } catch (RuntimeException failure) { + throw new IOException("Configuration operation journal is invalid", failure); + } + if (stored == null || stored.schemaVersion() != SCHEMA_VERSION || stored.operations() == null + || stored.operations().size() > MAX_OPERATIONS) { + throw new IOException("Configuration operation journal is invalid"); + } + Instant cutoff = clock.instant().minus(RETENTION); + List result = new ArrayList<>(); + for (Entry entry : stored.operations()) { + validate(entry); + if (!entry.createdAt().isBefore(cutoff)) result.add(entry); + } + result.sort(Comparator.comparing(Entry::createdAt)); + return List.copyOf(result); + } + + public synchronized void save(List entries) throws IOException { + if (entries == null) throw new IOException("Configuration operation journal is invalid"); + Instant cutoff = clock.instant().minus(RETENTION); + List filtered = entries.stream().filter(entry -> !entry.createdAt().isBefore(cutoff)) + .sorted(Comparator.comparing(Entry::createdAt)).toList(); + List retained = new ArrayList<>(filtered.stream() + .skip(Math.max(0, filtered.size() - MAX_OPERATIONS)).toList()); + for (Entry entry : retained) validate(entry); + byte[] bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained)); + while (bytes.length > MAX_BYTES && retained.size() > 1) { + retained.remove(0); + bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained)); + } + if (bytes.length > MAX_BYTES) throw new IOException("Configuration operation journal exceeds its bound"); + + Path temporary = Files.createTempFile(directory, "configuration-operations-", ".temporary"); + try { + setPermissions(temporary, OWNER_FILE); + Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + setPermissions(file, OWNER_FILE); + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void validate(Entry entry) throws IOException { + if (entry == null || entry.operationId() == null || entry.createdAt() == null + || !List.of("READ", "PREVIEW", "APPLY").contains(entry.type()) + || !List.of("proxy-routing", "file", "quick-setup").contains(entry.domain()) + || entry.nodes() == null || entry.nodes().isEmpty() || entry.nodes().size() > MAX_NODES) { + throw new IOException("Configuration operation journal is invalid"); + } + if ("file".equals(entry.domain())) { + if (entry.fileName() == null || entry.preset() != null) throw new IOException("Configuration operation journal is invalid"); + try { + ManagedConfiguration.file(entry.fileName(), null); + } catch (IllegalArgumentException failure) { + throw new IOException("Configuration operation journal is invalid", failure); + } + } else if ("quick-setup".equals(entry.domain())) { + if (entry.fileName() != null || entry.preset() == null + || !entry.preset().matches("[a-z][a-z0-9-]{0,39}")) { + throw new IOException("Configuration operation journal is invalid"); + } + } else if (entry.fileName() != null || entry.preset() != null) { + throw new IOException("Configuration operation journal is invalid"); + } + Set nodeIds = new java.util.HashSet<>(); + for (NodeResult node : entry.nodes()) { + if (node == null || node.nodeId() == null + || !node.nodeId().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") || !nodeIds.add(node.nodeId())) { + throw new IOException("Configuration operation journal is invalid"); + } + if (node.complete()) { + if (node.success() == null || node.code() == null + || !node.code().matches("[A-Z][A-Z0-9_]{0,63}") + || Boolean.TRUE.equals(node.success()) && node.revision() == null + || node.revision() != null && !node.revision().matches("[0-9a-f]{64}")) { + throw new IOException("Configuration operation journal is invalid"); + } + } else if (node.success() != null || node.code() != null || node.revision() != null + || node.reloaded() || node.rolledBack()) { + throw new IOException("Configuration operation journal is invalid"); + } + } + } + + private static void setPermissions(Path path, Set permissions) throws IOException { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException ignored) { + // Windows and some network filesystems do not expose POSIX permissions. + } + } + + public record Entry(UUID operationId, String type, Instant createdAt, String domain, String fileName, + String preset, UUID sourceOperationId, List nodes) { } + + public record NodeResult(String nodeId, boolean complete, Boolean success, String code, String revision, + boolean reloaded, boolean rolledBack) { } + + private record JournalFile(int schemaVersion, List operations) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 08da5dc..9eb1bb6 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -6,6 +6,7 @@ import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import java.nio.charset.StandardCharsets; +import java.io.IOException; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Clock; @@ -13,6 +14,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; @@ -31,9 +33,11 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; private static final int MAX_OPERATIONS = 1000; + private static final int MAX_LISTED_OPERATIONS = 100; private static final int MAX_FILE_OPERATIONS = 16; static final int MAX_RETAINED_CHANGE_BYTES = 256 * 1024; static final int MAX_RETAINED_MESSAGE_BYTES = 256 * 1024; + static final int MAX_RETAINED_FILE_BYTES = 8 * 1024 * 1024; private static final Duration LEASE = Duration.ofMinutes(2); private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); private static final Duration RETENTION = Duration.ofHours(24); @@ -41,15 +45,27 @@ public final class ConfigurationOperations implements AutoCloseable { private final NodeRegistry registry; private final ConfigurationAuditLog audit; private final Clock clock; + private final ConfigurationOperationJournal journal; private final SecureRandom random = new SecureRandom(); private final LinkedHashMap operations = new LinkedHashMap<>(); private long retainedChangeBytes; private long retainedMessageBytes; + private long retainedFileBytes; public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { this.registry = Objects.requireNonNull(registry); this.audit = Objects.requireNonNull(audit); this.clock = Objects.requireNonNull(clock); + this.journal = null; + } + + public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock, + ConfigurationOperationJournal journal) throws IOException { + this.registry = Objects.requireNonNull(registry); + this.audit = Objects.requireNonNull(audit); + this.clock = Objects.requireNonNull(clock); + this.journal = Objects.requireNonNull(journal); + restore(journal.load()); } public synchronized OperationView createRead(List nodeIds) { @@ -91,6 +107,7 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok } ValidatedTargets targets = validateTargets(new ArrayList<>(preview.states.keySet()), preview.configuration.capability()); + validateApprovedTargets(preview, targets); validateProxyMethodTargets(targets, preview.configuration); rejectOverlappingProxyMethodApply(targets, preview.configuration); Map revisions = new LinkedHashMap<>(); @@ -100,6 +117,7 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok preview.approvalUsed = true; try { audit.append("APPLY_APPROVED", apply.id, null, "QUEUED"); + persist(); } catch (RuntimeException e) { operations.remove(apply.id); preview.approvalUsed = false; @@ -115,6 +133,60 @@ public synchronized OperationView get(UUID id) { return view(operation); } + public synchronized List list() { + prune(); + List result = new ArrayList<>(operations.values().stream() + .skip(Math.max(0, operations.size() - MAX_LISTED_OPERATIONS)).map(this::summaryView).toList()); + Collections.reverse(result); + return List.copyOf(result); + } + + /** Reissues safe work without repeating nodes that already applied successfully. */ + public synchronized OperationView retry(UUID id) { + prune(); + StoredOperation original = operations.get(id); + if (original == null) throw new ValidationException("OPERATION_NOT_FOUND", "Operation was not found", List.of()); + if (original.recovered) { + throw new ValidationException("RETRY_REQUIRES_INPUT", + "Recovered operations are history only; start a fresh read or preview", List.of()); + } + if (!original.complete()) { + throw new ValidationException("OPERATION_INCOMPLETE", "Wait for the operation to finish before retrying", + List.of()); + } + List failed = original.results.entrySet().stream().filter(entry -> !entry.getValue().success()) + .map(Map.Entry::getKey).toList(); + if (failed.isEmpty()) throw invalid("operation has no failed nodes"); + if ("APPLY".equals(original.type) && ManagedConfiguration.QUICK_SETUP.equals(original.configuration.domain()) + && ManagedConfiguration.PROXY_METHOD.equals(original.configuration.preset())) { + throw new ValidationException("PREVIEW_REQUIRED", "Proxy method changes must be previewed again", + List.of()); + } + List requested = "PREVIEW".equals(original.type) + ? new ArrayList<>(original.states.keySet()) : failed; + ValidatedTargets targets = validateTargets(requested, original.configuration.capability()); + if ("APPLY".equals(original.type)) validateApprovedTargets(original, targets); + String token = null; + if ("PREVIEW".equals(original.type)) { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + Map revisions = new LinkedHashMap<>(); + if ("APPLY".equals(original.type)) { + requested.forEach(nodeId -> revisions.put(nodeId, original.expectedRevisions.get(nodeId))); + } + StoredOperation retry = store(original.type, targets, original.configuration, token, revisions, original.id); + try { + audit.append("OPERATION_RETRIED", retry.id, null, original.id.toString()); + persist(); + } catch (RuntimeException failure) { + operations.remove(retry.id); + throw failure; + } + return view(retry); + } + public synchronized ConfigurationTask claim(String nodeId, UUID sessionId) { return registry.withSession(nodeId, sessionId, node -> claimCurrentSession(nodeId, node)); } @@ -291,6 +363,16 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu } } + private static void validateApprovedTargets(StoredOperation preview, ValidatedTargets current) { + for (String nodeId : current.nodeIds()) { + if (!Objects.equals(preview.targetSessions.get(nodeId), current.sessions().get(nodeId)) + || !Objects.equals(preview.targetPlatforms.get(nodeId), current.platforms().get(nodeId))) { + throw new ValidationException("TARGET_CHANGED", + "A preview target reconnected or changed role; preview again", List.of(nodeId)); + } + } + } + public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { validateResult(result); return registry.withSession(nodeId, result.sessionId(), @@ -306,7 +388,10 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (operation == null || !operation.states.containsKey(nodeId)) { throw new ValidationException("OPERATION_NOT_FOUND", "Operation task was not found", List.of()); } - if ("COMPLETE".equals(operation.states.get(nodeId))) return view(operation); + if ("COMPLETE".equals(operation.states.get(nodeId))) { + persist(); + return view(operation); + } if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); } @@ -321,6 +406,7 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co operation.states.put(nodeId, "COMPLETE"); operation.leasedAt.remove(nodeId); operation.attemptIds.remove(nodeId); + persist(); return view(operation); } @@ -328,6 +414,7 @@ private OperationView create(String type, ValidatedTargets targets, ManagedConfi StoredOperation operation = store(type, targets, config, token, Map.of(), null); try { audit.append("OPERATION_CREATED", operation.id, null, type); + persist(); } catch (RuntimeException e) { operations.remove(operation.id); throw e; @@ -346,7 +433,8 @@ private StoredOperation store(String type, ValidatedTargets targets, ManagedConf targets.nodeIds().forEach(node -> states.put(node, "QUEUED")); StoredOperation result = new StoredOperation(id, type, config, token, clock.instant(), states, new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions), - new LinkedHashMap<>(targets.platforms()), new LinkedHashMap<>(targets.sessions())); + new LinkedHashMap<>(targets.platforms()), new LinkedHashMap<>(targets.sessions()), protectedOperation, + false); operations.put(id, result); return result; } @@ -376,14 +464,16 @@ private static boolean largeContentOperation(ManagedConfiguration config) { private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { ManagedConfiguration configuration = result.success() ? result.configuration() : null; - if (configuration != null && ManagedConfiguration.VOTE_SITES_SYNC.equals(configuration.preset())) { + if (configuration != null && (ManagedConfiguration.VOTE_SITES_SYNC.equals(configuration.preset()) + || ManagedConfiguration.REWARD_BUILDER.equals(configuration.preset()))) { configuration = configuration.publicView(); } else if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { + int contentBytes = configuration.content() == null ? 0 + : configuration.content().getBytes(StandardCharsets.UTF_8).length; boolean keepContent = result.success() && "READ".equals(operation.type) - && operation.results.values().stream().filter(ConfigurationTaskResult::success) - .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) - .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); - if (!keepContent) configuration = configuration.publicView(); + && contentBytes > 0 && retainedFileBytes + contentBytes <= MAX_RETAINED_FILE_BYTES; + if (keepContent) retainedFileBytes += contentBytes; + else configuration = configuration.publicView(); } else if (configuration != null && operation.results.values().stream() .filter(ConfigurationTaskResult::success) .anyMatch(existing -> existing.configuration() != null)) { @@ -457,8 +547,12 @@ private void releaseResultDetails(ConfigurationTaskResult result) { for (String change : result.changes()) { retainedChangeBytes -= change.getBytes(StandardCharsets.UTF_8).length; } + if (result.configuration() != null && result.configuration().content() != null) { + retainedFileBytes -= result.configuration().content().getBytes(StandardCharsets.UTF_8).length; + } if (retainedChangeBytes < 0) retainedChangeBytes = 0; if (retainedMessageBytes < 0) retainedMessageBytes = 0; + if (retainedFileBytes < 0) retainedFileBytes = 0; } private ValidatedTargets validateTargets(List nodeIds, String capability) { @@ -481,14 +575,99 @@ private ValidatedTargets validateTargets(List nodeIds, String capability } private OperationView view(StoredOperation operation) { + return view(operation, true); + } + + private OperationView summaryView(StoredOperation operation) { + return view(operation, false); + } + + private OperationView view(StoredOperation operation, boolean includeRetainedContent) { String state = operation.complete() ? (operation.results.values().stream().allMatch(ConfigurationTaskResult::success) ? "SUCCEEDED" : "COMPLETED_WITH_ERRORS") : "RUNNING"; String approval = "PREVIEW".equals(operation.type) && operation.complete() && operation.results.values().stream().allMatch(ConfigurationTaskResult::success) && !operation.approvalUsed ? operation.approvalToken : null; + Map results = operation.results; + if (!includeRetainedContent) { + LinkedHashMap summaries = new LinkedHashMap<>(); + operation.results.forEach((nodeId, result) -> summaries.put(nodeId, + result.configuration() == null ? result : new ConfigurationTaskResult(result.sessionId(), + result.success(), result.code(), result.message(), result.revision(), + result.configuration().publicView(), result.changes(), result.reloaded(), + result.rolledBack(), result.attemptId()))); + results = summaries; + } return new OperationView(operation.id, operation.type, state, operation.createdAt, operation.configuration == null ? null : operation.configuration.publicView(), - Map.copyOf(operation.states), Map.copyOf(operation.results), approval); + Map.copyOf(operation.states), Map.copyOf(results), approval, operation.sourceOperationId, + operation.recovered, retryable(operation)); + } + + private static boolean retryable(StoredOperation operation) { + if (operation.recovered || !operation.complete() + || operation.results.values().stream().allMatch(ConfigurationTaskResult::success)) return false; + return !("APPLY".equals(operation.type) + && ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) + && ManagedConfiguration.PROXY_METHOD.equals(operation.configuration.preset())); + } + + private void restore(List entries) { + for (ConfigurationOperationJournal.Entry entry : entries) { + ManagedConfiguration configuration = switch (entry.domain()) { + case ManagedConfiguration.PROXY_ROUTING -> ManagedConfiguration.proxy( + new ProxyRoutingConfiguration(false, List.of())); + case ManagedConfiguration.FILE -> ManagedConfiguration.file(entry.fileName(), null); + case ManagedConfiguration.QUICK_SETUP -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, + null, List.of(), null, null, entry.preset(), Map.of()); + default -> throw new IllegalStateException("unsupported journal configuration domain"); + }; + LinkedHashMap states = new LinkedHashMap<>(); + LinkedHashMap results = new LinkedHashMap<>(); + for (ConfigurationOperationJournal.NodeResult node : entry.nodes()) { + states.put(node.nodeId(), "COMPLETE"); + boolean completed = node.complete(); + boolean success = completed && Boolean.TRUE.equals(node.success()); + String code = completed ? node.code() : "CONTROL_RESTARTED"; + String message = completed ? "Recovered from durable operation history" + : "Control restarted before this node reported completion"; + results.put(node.nodeId(), new ConfigurationTaskResult(new UUID(0, 0), success, code, message, + completed ? node.revision() : null, (ManagedConfiguration) null, List.of(), + completed && node.reloaded(), + completed && node.rolledBack(), null)); + } + StoredOperation restored = new StoredOperation(entry.operationId(), entry.type(), configuration, null, + entry.createdAt(), states, results, new LinkedHashMap<>(), new LinkedHashMap<>(), + new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(), entry.sourceOperationId(), + true); + restored.approvalUsed = true; + operations.put(restored.id, restored); + } + } + + private void persist() { + if (journal == null) return; + List entries = new ArrayList<>(); + for (StoredOperation operation : operations.values()) { + ManagedConfiguration configuration = operation.configuration; + List nodes = new ArrayList<>(); + for (Map.Entry node : operation.states.entrySet()) { + ConfigurationTaskResult result = operation.results.get(node.getKey()); + boolean complete = "COMPLETE".equals(node.getValue()) && result != null; + nodes.add(new ConfigurationOperationJournal.NodeResult(node.getKey(), complete, + complete ? result.success() : null, complete ? result.code() : null, + complete ? result.revision() : null, complete && result.reloaded(), + complete && result.rolledBack())); + } + entries.add(new ConfigurationOperationJournal.Entry(operation.id, operation.type, operation.createdAt, + configuration.domain(), configuration.fileName(), configuration.preset(), + operation.sourceOperationId, List.copyOf(nodes))); + } + try { + journal.save(entries); + } catch (IOException failure) { + throw new IllegalStateException("Could not persist redacted configuration operation history", failure); + } } private void prune() { @@ -529,12 +708,25 @@ private static void validateResult(ConfigurationTaskResult result) { @Override public void close() throws java.io.IOException { - audit.close(); + IOException failure = null; + try { + persist(); + } catch (IllegalStateException journalFailure) { + failure = new IOException("Could not persist configuration operation history", journalFailure); + } + try { + audit.close(); + } catch (IOException auditFailure) { + if (failure == null) failure = auditFailure; + else failure.addSuppressed(auditFailure); + } + if (failure != null) throw failure; } public record OperationView(UUID operationId, String type, String state, Instant createdAt, ManagedConfiguration configuration, Map nodeStates, - Map results, String approvalToken) { } + Map results, String approvalToken, + UUID sourceOperationId, boolean recovered, boolean retryable) { } private record ValidatedTargets(List nodeIds, Map platforms, Map sessions) { } @@ -552,6 +744,8 @@ private static final class StoredOperation { private final LinkedHashMap expectedRevisions; private final LinkedHashMap targetPlatforms; private final LinkedHashMap targetSessions; + private final UUID sourceOperationId; + private final boolean recovered; private boolean approvalUsed; private StoredOperation(UUID id, String type, ManagedConfiguration configuration, String approvalToken, @@ -561,13 +755,16 @@ private StoredOperation(UUID id, String type, ManagedConfiguration configuration LinkedHashMap attemptIds, LinkedHashMap expectedRevisions, LinkedHashMap targetPlatforms, - LinkedHashMap targetSessions) { + LinkedHashMap targetSessions, UUID sourceOperationId, + boolean recovered) { this.id = id; this.type = type; this.configuration = configuration; this.approvalToken = approvalToken; this.createdAt = createdAt; this.states = states; this.results = results; this.leasedAt = leasedAt; this.attemptIds = attemptIds; this.expectedRevisions = expectedRevisions; this.targetPlatforms = targetPlatforms; this.targetSessions = targetSessions; + this.sourceOperationId = sourceOperationId; + this.recovered = recovered; } private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } private boolean fileOperation() { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationSnapshots.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationSnapshots.java new file mode 100644 index 0000000..48fc310 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationSnapshots.java @@ -0,0 +1,209 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.DurableFiles; +import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.channels.FileChannel; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Set; + +/** Durable, bounded copies of redacted managed-file reads for comparison and approved restore. */ +public final class ConfigurationSnapshots { + private static final int MAX_SNAPSHOTS = 100; + private static final int MAX_DOCUMENTS = 100; + private static final int MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024; + private static final int MAX_STORED_BYTES = MAX_SNAPSHOT_BYTES + 256 * 1024; + private static final long MAX_TOTAL_STORED_BYTES = 64L * 1024 * 1024; + private final Path directory; + private final Clock clock; + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + + public ConfigurationSnapshots(Path dataDirectory, Clock clock) throws IOException { + this.directory = dataDirectory.resolve("configuration-snapshots").toAbsolutePath().normalize(); + this.clock = clock; + boolean existed = Files.exists(directory, LinkOption.NOFOLLOW_LINKS); + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory)) throw new IOException("Configuration snapshot directory is unsafe"); + if (!existed) setPermissions(directory, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE)); + } + + public synchronized Snapshot create(String name, ConfigurationOperations.OperationView operation) throws IOException { + validateName(name); + if (operation == null || !"READ".equals(operation.type()) + || !List.of("SUCCEEDED", "COMPLETED_WITH_ERRORS").contains(operation.state())) { + throw invalid("snapshot source must be a completed configuration read"); + } + List documents = new ArrayList<>(); + int bytes = 0; + for (Map.Entry entry : operation.results().entrySet()) { + ConfigurationTaskResult result = entry.getValue(); + ManagedConfiguration configuration = result == null ? null : result.configuration(); + if (result == null || !result.success() || configuration == null + || !ManagedConfiguration.FILE.equals(configuration.domain()) + || configuration.content() == null) continue; + int contentBytes = configuration.content().getBytes(StandardCharsets.UTF_8).length; + if (documents.size() >= MAX_DOCUMENTS || bytes + contentBytes > MAX_SNAPSHOT_BYTES) { + throw invalid("snapshot source exceeds the bounded snapshot size"); + } + bytes += contentBytes; + documents.add(new SnapshotDocument(entry.getKey(), configuration.fileName(), configuration.content(), + result.revision())); + } + if (documents.isEmpty()) throw invalid("snapshot source does not retain a readable file"); + Snapshot snapshot = new Snapshot(UUID.randomUUID(), name.trim(), clock.instant(), operation.operationId(), + List.copyOf(documents)); + byte[] encoded = encode(snapshot); + pruneForCapacity(encoded.length); + write(snapshot, encoded); + return snapshot; + } + + public synchronized List list() throws IOException { + List result = new ArrayList<>(); + for (Path file : files()) { + Snapshot snapshot = read(file); + result.add(new SnapshotSummary(snapshot.snapshotId(), snapshot.name(), snapshot.createdAt(), + snapshot.sourceOperationId(), snapshot.documents().stream() + .map(document -> new SnapshotDocumentSummary(document.nodeId(), document.fileName(), + document.revision())).toList())); + } + result.sort(Comparator.comparing(SnapshotSummary::createdAt).reversed()); + return List.copyOf(result); + } + + public synchronized Snapshot get(UUID id) throws IOException { + Path file = path(id); + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + throw new ValidationException("SNAPSHOT_NOT_FOUND", "Configuration snapshot was not found", List.of()); + } + Snapshot result = read(file); + if (!id.equals(result.snapshotId())) throw new IOException("Configuration snapshot identity is invalid"); + return result; + } + + private void pruneForCapacity(int incomingBytes) throws IOException { + List current = new ArrayList<>(files()); + long retainedBytes = 0; + for (Path path : current) retainedBytes += Files.size(path); + while (current.size() >= MAX_SNAPSHOTS || retainedBytes + incomingBytes > MAX_TOTAL_STORED_BYTES) { + Path oldest = current.stream().min(Comparator.comparing(path -> { + try { return Files.getLastModifiedTime(path).toInstant(); } + catch (IOException failure) { return Instant.MIN; } + })).orElseThrow(() -> invalid("snapshot capacity is unavailable")); + retainedBytes -= Files.size(oldest); + Files.delete(oldest); + DurableFiles.forceDirectory(directory); + current.remove(oldest); + } + } + + private List files() throws IOException { + try (var paths = Files.list(directory)) { + return paths.filter(path -> path.getFileName().toString().matches("[0-9a-f-]{36}\\.json")) + .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) + .limit(MAX_SNAPSHOTS + 1L).toList(); + } + } + + private Snapshot read(Path file) throws IOException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) + || Files.size(file) > MAX_STORED_BYTES) throw new IOException("Configuration snapshot is invalid"); + Snapshot snapshot = json.readValue(Files.readAllBytes(file), Snapshot.class); + validate(snapshot); + return snapshot; + } + + private byte[] encode(Snapshot snapshot) throws IOException { + byte[] bytes = json.writeValueAsBytes(snapshot); + if (bytes.length > MAX_STORED_BYTES || bytes.length > MAX_TOTAL_STORED_BYTES) { + throw invalid("snapshot exceeds the bounded snapshot size"); + } + return bytes; + } + + private void write(Snapshot snapshot, byte[] bytes) throws IOException { + Path target = path(snapshot.snapshotId()); + Path temporary = Files.createTempFile(directory, "snapshot-", ".temporary"); + try { + setPermissions(temporary, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target); + } + setPermissions(target, Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private Path path(UUID id) { + return directory.resolve(id + ".json"); + } + + private static void validate(Snapshot snapshot) throws IOException { + try { + if (snapshot == null || snapshot.snapshotId() == null || snapshot.createdAt() == null + || snapshot.sourceOperationId() == null || snapshot.documents() == null + || snapshot.documents().isEmpty() || snapshot.documents().size() > MAX_DOCUMENTS) { + throw new IllegalArgumentException(); + } + validateName(snapshot.name()); + int bytes = 0; + for (SnapshotDocument document : snapshot.documents()) { + if (document.nodeId() == null || !document.nodeId().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}") + || document.fileName() == null || document.content() == null || document.revision() == null + || !document.revision().matches("[0-9a-f]{64}")) throw new IllegalArgumentException(); + new ManagedConfiguration(ManagedConfiguration.FILE, null, List.of(), document.fileName(), + document.content(), null, Map.of()); + bytes += document.content().getBytes(StandardCharsets.UTF_8).length; + } + if (bytes > MAX_SNAPSHOT_BYTES) throw new IllegalArgumentException(); + } catch (IllegalArgumentException failure) { + throw new IOException("Configuration snapshot is invalid", failure); + } + } + + private static void validateName(String name) { + if (name == null || name.trim().isEmpty() || name.trim().length() > 80 + || name.chars().anyMatch(Character::isISOControl)) throw new IllegalArgumentException("snapshot name is invalid"); + } + + private static ValidationException invalid(String detail) { + return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); + } + + private static void setPermissions(Path path, Set permissions) throws IOException { + try { + Files.setPosixFilePermissions(path, permissions); + } catch (UnsupportedOperationException ignored) { + // Windows and some network filesystems do not expose POSIX permissions. + } + } + + public record Snapshot(UUID snapshotId, String name, Instant createdAt, UUID sourceOperationId, + List documents) { } + public record SnapshotDocument(String nodeId, String fileName, String content, String revision) { } + public record SnapshotSummary(UUID snapshotId, String name, Instant createdAt, UUID sourceOperationId, + List documents) { } + public record SnapshotDocumentSummary(String nodeId, String fileName, String revision) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 8366f07..e0d42ec 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -2,6 +2,7 @@ import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.InspectionQuery; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; @@ -28,7 +29,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, - "config.file-comments.v1"); + "config.file-comments.v1", InspectionQuery.CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java new file mode 100644 index 0000000..22730b7 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -0,0 +1,235 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.InspectionQuery; +import com.bencodez.votingplugin.control.protocol.InspectionTask; +import com.bencodez.votingplugin.control.protocol.InspectionTaskResult; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** Short-lived coordinator for typed read-only node inspections. */ +public final class InspectionOperations { + public static final int MAX_DATA_BYTES = 512 * 1024; + private static final int MAX_INSPECTIONS = 100; + private static final int MAX_MESSAGE_BYTES = 4096; + private static final Duration LEASE = Duration.ofMinutes(2); + private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(5); + private static final Duration COMPLETE_RETENTION = Duration.ofMinutes(15); + + private final NodeRegistry registry; + private final ConfigurationAuditLog audit; + private final Clock clock; + private final LinkedHashMap inspections = new LinkedHashMap<>(); + + public InspectionOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { + this.registry = Objects.requireNonNull(registry); + this.audit = audit; + this.clock = Objects.requireNonNull(clock); + } + + public InspectionOperations(NodeRegistry registry, Clock clock) { + this(registry, null, clock); + } + + public synchronized InspectionView create(String nodeId, InspectionQuery query) { + prune(); + if (query == null) throw invalid("inspection query is required"); + NodeStatus node = registry.find(nodeId); + if (node == null) throw new ValidationException("NODE_NOT_FOUND", "Node was not found", List.of(nodeId)); + if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) { + throw new ValidationException("NODE_UNAVAILABLE", "Node cannot answer inspection queries", List.of(nodeId)); + } + if (inspections.size() >= MAX_INSPECTIONS) { + throw new ValidationException("OPERATION_LIMIT", "Too many retained inspections", List.of()); + } + UUID id = UUID.randomUUID(); + StoredInspection stored = new StoredInspection(id, nodeId, node.sessionId(), query, clock.instant()); + inspections.put(id, stored); + try { + // Deliberately record only the query type. Player names and filter values are not audit metadata. + append("INSPECTION_CREATED", id, nodeId, query.kind()); + } catch (RuntimeException failure) { + inspections.remove(id); + throw failure; + } + return view(stored); + } + + public synchronized InspectionView get(UUID id) { + prune(); + StoredInspection stored = inspections.get(id); + if (stored == null) { + throw new ValidationException("OPERATION_NOT_FOUND", "Inspection was not found", List.of()); + } + return view(stored); + } + + public synchronized InspectionTask claim(String nodeId, UUID sessionId) { + return registry.withSession(nodeId, sessionId, node -> claimCurrentSession(nodeId, node)); + } + + private InspectionTask claimCurrentSession(String nodeId, NodeStatus node) { + prune(); + Instant now = clock.instant(); + for (StoredInspection stored : inspections.values()) { + if (!stored.nodeId.equals(nodeId) || "COMPLETE".equals(stored.state)) continue; + if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) { + completeUnavailable(stored, node.sessionId()); + continue; + } + if ("IN_PROGRESS".equals(stored.state) && stored.leasedAt != null + && now.isBefore(stored.leasedAt.plus(LEASE))) continue; + UUID attempt = UUID.randomUUID(); + stored.state = "IN_PROGRESS"; + stored.leasedAt = now; + stored.attemptId = attempt; + stored.targetSession = node.sessionId(); + append("INSPECTION_CLAIMED", stored.id, nodeId, stored.query.kind()); + return new InspectionTask(stored.id, stored.query, attempt); + } + return null; + } + + public synchronized InspectionView complete(UUID id, String nodeId, InspectionTaskResult result) { + if (result == null) throw invalid("inspection result is required"); + return registry.withSession(nodeId, result.sessionId(), node -> completeCurrentSession(id, node, result)); + } + + private InspectionView completeCurrentSession(UUID id, NodeStatus node, InspectionTaskResult result) { + prune(); + StoredInspection stored = inspections.get(id); + if (stored == null || !stored.nodeId.equals(node.nodeId())) { + throw new ValidationException("OPERATION_NOT_FOUND", "Inspection was not found", List.of()); + } + if (!"IN_PROGRESS".equals(stored.state)) { + if ("COMPLETE".equals(stored.state)) return view(stored); + throw new ValidationException("TASK_NOT_CLAIMED", "Inspection was not claimed", List.of()); + } + if (stored.leasedAt == null || !clock.instant().isBefore(stored.leasedAt.plus(LEASE))) { + throw new ValidationException("TASK_LEASE_EXPIRED", "Inspection lease expired", List.of()); + } + if (!Objects.equals(stored.attemptId, result.attemptId())) { + throw new ValidationException("TASK_NOT_CLAIMED", "Inspection attempt does not match", List.of()); + } + validateResult(result, stored.query.kind()); + stored.result = result; + stored.state = "COMPLETE"; + stored.leasedAt = null; + stored.attemptId = null; + append("INSPECTION_COMPLETED", id, node.nodeId(), result.success() ? "SUCCESS" : safeCode(result.code())); + return view(stored); + } + + private void completeUnavailable(StoredInspection stored, UUID sessionId) { + stored.result = new InspectionTaskResult(sessionId, false, "CAPABILITY_LOST", + "Node no longer accepts inspection queries", null, null); + stored.state = "COMPLETE"; + stored.leasedAt = null; + stored.attemptId = null; + append("INSPECTION_CANCELLED", stored.id, stored.nodeId, "CAPABILITY_LOST"); + } + + private static void validateResult(InspectionTaskResult result, String expectedKind) { + if (result.message() == null || result.message().isBlank()) { + throw invalid("inspection result message is required"); + } + if (result.success() && (result.data() == null + || result.code() != null && !"OK".equals(result.code()))) { + throw invalid("successful inspection must contain data and an optional OK code"); + } + if (!result.success() && (result.data() != null || result.code() == null + || !result.code().matches("[A-Z][A-Z0-9_]{0,63}"))) { + throw invalid("failed inspection result is invalid"); + } + if (result.success() && (!result.data().isObject() + || !result.data().path("schemaVersion").isIntegralNumber() + || result.data().path("schemaVersion").intValue() != 1 + || !expectedKind.equals(result.data().path("kind").asText()) + || !result.data().path("generatedAt").isTextual() + || !result.data().path("result").isObject())) { + throw invalid("inspection data envelope is invalid"); + } + if (result.success()) { + try { + Instant.parse(result.data().path("generatedAt").asText()); + } catch (java.time.format.DateTimeParseException failure) { + throw invalid("inspection data generatedAt is invalid"); + } + } + if (jsonBytes(result.data()) > MAX_DATA_BYTES || bytes(result.message()) > MAX_MESSAGE_BYTES) { + throw invalid("inspection result exceeds retention limits"); + } + } + + private static int jsonBytes(com.fasterxml.jackson.databind.JsonNode value) { + return value == null ? 0 : value.toString().getBytes(StandardCharsets.UTF_8).length; + } + + private static int bytes(String value) { + return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length; + } + + private static String safeCode(String value) { + return value == null ? "FAILED" : value; + } + + private InspectionView view(StoredInspection stored) { + String state = "COMPLETE".equals(stored.state) + ? stored.result != null && stored.result.success() ? "SUCCEEDED" : "FAILED" : "RUNNING"; + return new InspectionView(stored.id, stored.nodeId, stored.query, state, stored.createdAt, stored.result); + } + + private void prune() { + Instant activeCutoff = clock.instant().minus(ACTIVE_RETENTION); + Instant completeCutoff = clock.instant().minus(COMPLETE_RETENTION); + Iterator> iterator = inspections.entrySet().iterator(); + while (iterator.hasNext()) { + StoredInspection stored = iterator.next().getValue(); + Instant cutoff = "COMPLETE".equals(stored.state) ? completeCutoff : activeCutoff; + boolean leased = stored.leasedAt != null && clock.instant().isBefore(stored.leasedAt.plus(LEASE)); + if (!leased && stored.createdAt.isBefore(cutoff)) { + append("INSPECTION_EXPIRED", stored.id, stored.nodeId, stored.query.kind()); + iterator.remove(); + } + } + } + + private void append(String action, UUID id, String nodeId, String outcome) { + if (audit != null) audit.append(action, id, nodeId, outcome); + } + + private static ValidationException invalid(String detail) { + return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); + } + + public record InspectionView(UUID inspectionId, String nodeId, InspectionQuery query, String state, + Instant createdAt, InspectionTaskResult result) { } + + private static final class StoredInspection { + private final UUID id; + private final String nodeId; + private UUID targetSession; + private final InspectionQuery query; + private final Instant createdAt; + private String state = "QUEUED"; + private Instant leasedAt; + private UUID attemptId; + private InspectionTaskResult result; + + private StoredInspection(UUID id, String nodeId, UUID targetSession, InspectionQuery query, Instant createdAt) { + this.id = id; + this.nodeId = nodeId; + this.targetSession = targetSession; + this.query = query; + this.createdAt = createdAt; + } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 45ab9ad..cb370c7 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -4,12 +4,17 @@ import com.bencodez.votingplugin.control.auth.WebSessionStore; import com.bencodez.votingplugin.control.domain.NodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; +import com.bencodez.votingplugin.control.domain.ConfigurationSnapshots; +import com.bencodez.votingplugin.control.domain.InspectionOperations; import com.bencodez.votingplugin.control.domain.ValidationException; import com.bencodez.votingplugin.control.protocol.ConfigurationRequests; import com.bencodez.votingplugin.control.protocol.ConfigurationTask; import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.InspectionRequests; +import com.bencodez.votingplugin.control.protocol.InspectionTask; +import com.bencodez.votingplugin.control.protocol.InspectionTaskResult; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.NodeStatus; @@ -35,6 +40,7 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.security.MessageDigest; import java.time.Clock; import java.time.Instant; @@ -61,6 +67,8 @@ public final class ControlHttpServer implements AutoCloseable { private static final String REGISTER = "/api/v1/nodes/register"; private static final String CONFIGURATION = "/api/v1/configuration"; private static final String OPERATIONS = "/api/v1/operations"; + private static final String INSPECTIONS = "/api/v1/inspections"; + private static final String SNAPSHOTS = "/api/v1/snapshots"; private static final String AUTH_LOGIN = "/api/v1/auth/login"; private static final String AUTH_SESSION = "/api/v1/auth/session"; private static final String AUTH_LOGOUT = "/api/v1/auth/logout"; @@ -85,6 +93,8 @@ public final class ControlHttpServer implements AutoCloseable { private final ControlIdentity identity; private final CredentialStore credentials; private final ConfigurationOperations configurationOperations; + private final InspectionOperations inspectionOperations; + private final ConfigurationSnapshots configurationSnapshots; private final ThreadPoolExecutor executor; private final ThreadPoolExecutor passwordExecutor; private final PasswordAdmission passwordAdmission = new PasswordAdmission(MAX_PASSWORD_ATTEMPTS_PER_CLIENT); @@ -137,14 +147,53 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr secureCookies, trustedProxyAddresses, launchId); } + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + InspectionOperations inspectionOperations, boolean secureCookies, + Set trustedProxyAddresses, String launchId) throws IOException { + this(address, registry, identity, credentials, configurationOperations, inspectionOperations, + temporarySnapshots(), Clock.systemUTC(), System::nanoTime, secureCookies, + trustedProxyAddresses, launchId); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + InspectionOperations inspectionOperations, ConfigurationSnapshots configurationSnapshots, + boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { + this(address, registry, identity, credentials, configurationOperations, inspectionOperations, + configurationSnapshots, Clock.systemUTC(), System::nanoTime, secureCookies, + trustedProxyAddresses, launchId); + } + ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, java.util.function.LongSupplier nanoTime, boolean secureCookies, Set trustedProxyAddresses, String launchId) throws IOException { + this(address, registry, identity, credentials, configurationOperations, + new InspectionOperations(registry, clock), temporarySnapshots(), clock, nanoTime, secureCookies, + trustedProxyAddresses, launchId); + } + + ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + InspectionOperations inspectionOperations, Clock clock, + java.util.function.LongSupplier nanoTime, boolean secureCookies, + Set trustedProxyAddresses, String launchId) throws IOException { + this(address, registry, identity, credentials, configurationOperations, inspectionOperations, + temporarySnapshots(), clock, nanoTime, secureCookies, trustedProxyAddresses, launchId); + } + + ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + InspectionOperations inspectionOperations, ConfigurationSnapshots configurationSnapshots, + Clock clock, java.util.function.LongSupplier nanoTime, boolean secureCookies, + Set trustedProxyAddresses, String launchId) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); this.identity = Objects.requireNonNull(identity, "identity"); this.credentials = Objects.requireNonNull(credentials, "credentials"); this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); + this.inspectionOperations = Objects.requireNonNull(inspectionOperations, "inspectionOperations"); + this.configurationSnapshots = Objects.requireNonNull(configurationSnapshots, "configurationSnapshots"); this.secureCookies = secureCookies; this.trustedProxyAddresses = Set.copyOf(Objects.requireNonNull(trustedProxyAddresses, "trustedProxyAddresses")); this.launchId = launchId; @@ -189,6 +238,11 @@ public int port() { return server.getAddress().getPort(); } + private static ConfigurationSnapshots temporarySnapshots() throws IOException { + return new ConfigurationSnapshots(Files.createTempDirectory("votingplugin-control-test-snapshots"), + Clock.systemUTC()); + } + @Override public void close() { server.stop(0); @@ -221,11 +275,12 @@ private void handle(HttpExchange exchange) throws IOException { error(exchange, 403, "CSRF_REQUIRED", "A valid CSRF token is required", List.of()); } catch (ValidationException e) { int status = switch (e.code()) { - case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; + case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND", "SNAPSHOT_NOT_FOUND" -> 404; case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", "REGISTRY_LIMIT", "REGISTRY_CHANGED", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED", - "SETUP_COMPLETE" -> 409; + "SETUP_COMPLETE", "OPERATION_INCOMPLETE", "PREVIEW_REQUIRED", + "RETRY_REQUIRES_INPUT", "TARGET_CHANGED" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; }; @@ -429,6 +484,38 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 202, configurationOperations.createApply(request.previewOperationId(), request.approvalToken())); return; } + if (OPERATIONS.equals(path)) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + send(exchange, 200, Map.of("items", configurationOperations.list())); + return; + } + if (INSPECTIONS.equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + InspectionRequests.Start request = read(exchange, InspectionRequests.Start.class); + requireRequest(request); + send(exchange, 202, inspectionOperations.create(request.nodeId(), request.query())); + return; + } + if (SNAPSHOTS.equals(path)) { + authenticateAdmin(exchange, "POST".equals(exchange.getRequestMethod())); + if ("GET".equals(exchange.getRequestMethod())) { + send(exchange, 200, Map.of("items", configurationSnapshots.list())); + return; + } + if ("POST".equals(exchange.getRequestMethod())) { + SnapshotRequest request = read(exchange, SnapshotRequest.class); + requireRequest(request); + send(exchange, 201, configurationSnapshots.create(request.name(), + configurationOperations.get(request.operationId()))); + return; + } + exchange.getResponseHeaders().set("Allow", "GET, POST"); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", + List.of("allowed=GET", "allowed=POST")); + throw new ResponseCompleteException(); + } if (path != null && path.startsWith(OPERATIONS + "/")) { String remainder = path.substring((OPERATIONS + "/").length()); if (!remainder.contains("/")) { @@ -437,6 +524,31 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, configurationOperations.get(UUID.fromString(remainder))); return; } + String[] segments = remainder.split("/", -1); + if (segments.length == 2 && "retry".equals(segments[1])) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + send(exchange, 202, configurationOperations.retry(UUID.fromString(segments[0]))); + return; + } + } + if (path != null && path.startsWith(INSPECTIONS + "/")) { + String remainder = path.substring((INSPECTIONS + "/").length()); + if (!remainder.contains("/")) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + send(exchange, 200, inspectionOperations.get(UUID.fromString(remainder))); + return; + } + } + if (path != null && path.startsWith(SNAPSHOTS + "/")) { + String remainder = path.substring((SNAPSHOTS + "/").length()); + if (!remainder.contains("/")) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + send(exchange, 200, configurationSnapshots.get(UUID.fromString(remainder))); + return; + } } String prefix = NODES + "/"; @@ -444,7 +556,7 @@ private void route(HttpExchange exchange) throws IOException { String remainder = path.substring(prefix.length()); String[] segments = remainder.split("/", -1); if (segments.length == 2 && ("heartbeat".equals(segments[1]) || "presence".equals(segments[1]) - || "operations".equals(segments[1]))) { + || "operations".equals(segments[1]) || "inspections".equals(segments[1]))) { String nodeId = decodePathSegment(segments[0]); if ("heartbeat".equals(segments[1])) { requireMethod(exchange, "PUT"); @@ -456,7 +568,7 @@ private void route(HttpExchange exchange) throws IOException { NodeRegistry.SnapshotResult result = registry.replacePresence(nodeId, read(exchange, PresenceSnapshot.class)); send(exchange, 200, Map.of("applied", result.applied(), "node", result.node())); - } else { + } else if ("operations".equals(segments[1])) { requireMethod(exchange, "POST"); authenticateNode(exchange, nodeId); ConfigurationRequests.Claim claim = read(exchange, ConfigurationRequests.Claim.class); @@ -467,6 +579,17 @@ private void route(HttpExchange exchange) throws IOException { } else { send(exchange, 200, task); } + } else { + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + InspectionRequests.Claim claim = read(exchange, InspectionRequests.Claim.class); + requireRequest(claim); + InspectionTask task = inspectionOperations.claim(nodeId, claim.sessionId()); + if (task == null) { + noContent(exchange); + } else { + send(exchange, 200, task); + } } return; } @@ -478,6 +601,14 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, configurationOperations.complete(UUID.fromString(segments[2]), nodeId, result)); return; } + if (segments.length == 4 && "inspections".equals(segments[1]) && "result".equals(segments[3])) { + String nodeId = decodePathSegment(segments[0]); + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + InspectionTaskResult result = read(exchange, InspectionTaskResult.class); + send(exchange, 200, inspectionOperations.complete(UUID.fromString(segments[2]), nodeId, result)); + return; + } } error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } @@ -864,6 +995,7 @@ private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } private record SetupRequest(String setupCode, String password) { } private record EnrollmentRequest(String nodeId) { } + private record SnapshotRequest(String name, UUID operationId) { } record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated, List backendItemsTruncatedNodeIds) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java new file mode 100644 index 0000000..8e8a183 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionQuery.java @@ -0,0 +1,29 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** A bounded, typed, read-only question that a VotingPlugin node can answer. */ +public record InspectionQuery(String kind, Map filters) { + public static final String CAPABILITY = "data.inspect.v1"; + public static final int MAX_REWARD_PROPOSAL = 64 * 1024; + public static final Set KINDS = Set.of("overview", "player", "vote-site-health", "vote-log-summary", + "vote-log-search", "vote-trace", "vote-site-resolution", "reward-simulation", "diagnostics"); + + public InspectionQuery { + filters = filters == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(filters)); + if (!KINDS.contains(kind)) throw new IllegalArgumentException("inspection kind is unsupported"); + if (filters.size() > 12 || filters.entrySet().stream().anyMatch(entry -> entry.getKey() == null + || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null + || invalidValue(kind, entry.getKey(), entry.getValue()))) { + throw new IllegalArgumentException("inspection filters are invalid"); + } + } + + private static boolean invalidValue(String kind, String key, String value) { + int maximum = "reward-simulation".equals(kind) && "proposal".equals(key) ? MAX_REWARD_PROPOSAL : 500; + return value.getBytes(StandardCharsets.UTF_8).length > maximum || value.indexOf('\0') >= 0; + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionRequests.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionRequests.java new file mode 100644 index 0000000..d38ce79 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionRequests.java @@ -0,0 +1,11 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +public final class InspectionRequests { + private InspectionRequests() { } + + public record Start(String nodeId, InspectionQuery query) { } + + public record Claim(UUID sessionId) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTask.java new file mode 100644 index 0000000..ec9855d --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTask.java @@ -0,0 +1,5 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +public record InspectionTask(UUID inspectionId, InspectionQuery query, UUID attemptId) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTaskResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTaskResult.java new file mode 100644 index 0000000..72edc9a --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/InspectionTaskResult.java @@ -0,0 +1,8 @@ +package com.bencodez.votingplugin.control.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.UUID; + +/** A node-produced inspection result. Data is a bounded JSON document, never arbitrary database output. */ +public record InspectionTaskResult(UUID sessionId, boolean success, String code, String message, JsonNode data, + UUID attemptId) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 70ce211..c8731da 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -1,8 +1,10 @@ package com.bencodez.votingplugin.control.protocol; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** Versioned union of the configuration domains negotiated with VotingPlugin nodes. */ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, List blockedServers, @@ -13,7 +15,25 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, public static final String VOTE_SITES_SYNC = "sync-vote-sites"; public static final String COMMUNICATION_TEST = "communication-test"; public static final String PROXY_METHOD = "proxy-method"; + public static final String REWARD_BUILDER = "reward-builder"; public static final int MAX_CONTENT = 512 * 1024; + public static final int MAX_REWARD_PROPOSAL = 64 * 1024; + private static final Map> QUICK_SETUP_OPTIONS = Map.ofEntries( + Map.entry("standalone", Set.of("useBungeecord", "server", "method")), + Map.entry("proxy-backend", Set.of("useBungeecord", "server", "method")), + Map.entry("vote-site", Set.of("name", "exists", "enabled", "displayName", "priority", "hidden", + "serviceSite", "voteUrl", "voteDelay", "material")), + Map.entry("easy-reward", Set.of("scope", "name", "command", "message")), + Map.entry("common-settings", Set.of("processRewards", "autoCreateVoteSites", "extraAllSitesCheck", + "countFakeVotes", "disableNoServiceSiteMessage", "disableUpdateChecking")), + Map.entry("vote-party", Set.of("enabled", "votesRequired", "broadcast", "giveAllPlayers", + "onlineOnly", "command", "rewardCommandCount")), + Map.entry("auto-create-vote-sites", Set.of("enabled")), + Map.entry("vote-logging", Set.of("enabled", "purgeDays", "useMainMySQL")), + Map.entry(VOTE_SITES_SYNC, Set.of("sourceContent")), + Map.entry(COMMUNICATION_TEST, Set.of("server")), + Map.entry(PROXY_METHOD, Set.of("method")), + Map.entry(REWARD_BUILDER, Set.of("proposal", "targetFile"))); public ManagedConfiguration { domain = domain == null && sendVotesToAllServers != null ? PROXY_ROUTING : domain; @@ -30,7 +50,7 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, if (sendVotesToAllServers != null || !blockedServers.isEmpty() || preset != null || !options.isEmpty()) throw new IllegalArgumentException("file configuration contains fields from another domain"); validateFileName(fileName); - if (content != null && (content.length() > MAX_CONTENT || content.indexOf('\0') >= 0)) { + if (content != null && (utf8Bytes(content) > MAX_CONTENT || content.indexOf('\0') >= 0)) { throw new IllegalArgumentException("configuration file content is invalid"); } } else { @@ -40,9 +60,14 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, if (preset == null || !preset.matches("[a-z][a-z0-9-]{0,39}")) { throw new IllegalArgumentException("quick setup preset is invalid"); } + Set acceptedOptions = QUICK_SETUP_OPTIONS.get(preset); + if (acceptedOptions == null || !acceptedOptions.containsAll(options.keySet())) { + throw new IllegalArgumentException("quick setup preset or option is unsupported"); + } if (options.size() > 20 || options.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null - || invalidOption(entry.getKey(), entry.getValue(), VOTE_SITES_SYNC.equals(preset)))) { + || invalidOption(entry.getKey(), entry.getValue(), VOTE_SITES_SYNC.equals(preset), + REWARD_BUILDER.equals(preset)))) { throw new IllegalArgumentException("quick setup options are invalid"); } } @@ -72,6 +97,10 @@ public void validateProposal() { .contains(options.get("method")))) { throw new IllegalArgumentException("proxy method requires one supported method"); } + if (QUICK_SETUP.equals(domain) && REWARD_BUILDER.equals(preset) + && (options.size() != 1 || !options.containsKey("proposal"))) { + throw new IllegalArgumentException("reward builder requires one typed proposal"); + } } public String capability() { @@ -89,19 +118,26 @@ public String capability() { /** Omits file contents so proposals, including newly entered secrets, are never echoed by operation APIs. */ public ManagedConfiguration publicView() { if (FILE.equals(domain)) return file(fileName, null); - if (QUICK_SETUP.equals(domain) && VOTE_SITES_SYNC.equals(preset) && options.containsKey("sourceContent")) { + if (QUICK_SETUP.equals(domain) && (VOTE_SITES_SYNC.equals(preset) && options.containsKey("sourceContent") + || REWARD_BUILDER.equals(preset) && options.containsKey("proposal"))) { Map visible = new LinkedHashMap<>(options); visible.remove("sourceContent"); + visible.remove("proposal"); return new ManagedConfiguration(domain, sendVotesToAllServers, blockedServers, fileName, content, preset, visible); } return this; } - private static boolean invalidOption(String name, String value, boolean voteSitesSync) { + private static boolean invalidOption(String name, String value, boolean voteSitesSync, boolean rewardBuilder) { boolean sourceContent = voteSitesSync && "sourceContent".equals(name); - int maximum = sourceContent ? MAX_CONTENT : 500; - return value.indexOf('\0') >= 0 || value.length() > maximum; + boolean proposal = rewardBuilder && "proposal".equals(name); + int maximum = sourceContent ? MAX_CONTENT : proposal ? MAX_REWARD_PROPOSAL : 500; + return value.indexOf('\0') >= 0 || utf8Bytes(value) > maximum; + } + + private static int utf8Bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; } private static void validateFileName(String value) { diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 58adaef..3c0244e 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -51,6 +51,7 @@ p { line-height: 1.55; } .eyebrow { margin-bottom: 7px; color: #72b7ff; font-size: .73rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } .card { padding: 24px; border: 1px solid var(--border); border-radius: 15px; background: rgba(12, 24, 40, .92); box-shadow: 0 18px 60px rgba(0, 0, 0, .16); } +.tab-panel > .card + .card { margin-top: 18px; } .auth-card { max-width: 720px; } .page-heading, .section-title, .auth-row, .editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; } .page-heading { margin: 30px 0 20px; } @@ -124,6 +125,10 @@ p { line-height: 1.55; } .subtabs button.active { border-color: var(--border); background: var(--surface-raised); color: #fff; } .config-view { margin-top: 17px; } .settings-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.feature-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } +.feature-grid > .card { min-width: 0; } +.stacked-card { margin-top: 18px; } +.highlight-card { border-color: #3b74ac; background: linear-gradient(145deg, rgba(17, 47, 81, .82), rgba(12, 24, 40, .96)); } .settings-grid .card { display: flex; min-height: 240px; flex-direction: column; align-items: flex-start; } .settings-grid .card p { color: var(--muted); } .settings-grid .card button { margin-top: auto; } @@ -158,15 +163,45 @@ label span { color: var(--muted); font-weight: 400; } .operation-actions { display: flex; flex-wrap: wrap; gap: 9px; } .method-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 9px; } .method-buttons button.active { box-shadow: 0 0 0 3px rgba(42, 122, 226, .22); } -#operation-status, #file-operation-status, #quick-operation-status, #transport-test-status, #proxy-method-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } +#operation-status, #file-operation-status, #quick-operation-status, #transport-test-status, #proxy-method-status, +#auto-sites-status, #vote-logging-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } #file-operation-status { margin: 0; border: 0; border-top: 1px solid var(--border); border-radius: 0; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } .form-grid label { color: var(--muted); } .form-grid input { display: block; width: 100%; margin-top: 7px; } +.form-help { display: block; margin-top: 6px; color: var(--muted); font-size: .76rem; font-weight: 500; line-height: 1.4; } fieldset { margin: 16px 0; padding: 16px; border: 1px solid var(--border-strong); border-radius: 9px; } fieldset .check-row { margin: 10px 0; } #detected-plugins { color: var(--muted); } .quick-fields[hidden] { display: none; } +.compact-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.inline-form { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-top: 16px; } +.inline-form > label { flex: 1 1 220px; margin: 0; } +.inline-form input, .inline-form select { display: block; width: 100%; margin-top: 7px; margin-bottom: 0; } +.profile-form > .operation-actions { align-items: center; padding-bottom: 1px; } +.target-scope { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: 9px; background: #091421; color: var(--muted); font-size: .84rem; } +.inline-status { min-height: 1.4em; margin: 12px 0 0; color: var(--muted); } +.result-panel { min-height: 86px; margin-top: 16px; padding: 15px; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: #050c16; color: var(--muted); } +.result-panel .json-result, .result-item pre { margin: 0; color: #c6d5e8; font: .82rem/1.55 "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } +.detected-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); } +.detected-actions strong { width: 100%; color: #d6e5f7; } +.result-list { display: grid; gap: 10px; margin-top: 16px; color: var(--muted); } +.result-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: #091421; } +.result-item > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.result-item small { color: var(--muted); overflow-wrap: anywhere; } +.result-item > pre { flex: 1; } +.checklist { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 18px; counter-reset: setup; } +.checklist li { display: grid; grid-template-columns: auto 1fr; gap: 11px; align-items: start; padding: 13px; border: 1px solid var(--border); border-radius: 10px; background: #091421; } +.checklist li > div { display: flex; flex-direction: column; gap: 3px; } +.checklist li small { color: var(--muted); line-height: 1.4; } +.checklist .step-state { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; color: var(--muted); font-weight: 800; } +.checklist li.complete { border-color: #2b7750; } +.checklist li.complete .step-state { border-color: #2b7750; background: var(--green-soft); color: var(--green); } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .86rem; } +th, td { padding: 11px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +th { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } +td:first-child { color: var(--green); font-family: "SFMono-Regular", Consolas, monospace; } code { color: var(--green); } .pagination { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 20px; } .pagination output { min-width: 80px; color: var(--muted); text-align: center; } @@ -179,7 +214,7 @@ li span { color: var(--muted); font-size: .9rem; } .topbar { position: static; align-items: flex-start; } .topbar-actions { flex-wrap: wrap; } .server-picker { order: 3; width: 100%; grid-template-columns: auto 1fr; } - .metrics, .settings-grid { grid-template-columns: repeat(2, 1fr); } + .metrics, .settings-grid, .feature-grid { grid-template-columns: repeat(2, 1fr); } .sync-grid { grid-template-columns: 1fr; } .roadmap-card { grid-template-columns: 1fr; } .roadmap-card > div { border-right: 0; border-bottom: 1px solid var(--border); } @@ -194,7 +229,7 @@ li span { color: var(--muted); font-size: .9rem; } .welcome h1 { font-size: 2.35rem; } .page-heading, .section-title, .auth-row, .editor-footer { align-items: stretch; flex-direction: column; } .page-heading > button, .auth-row button { width: 100%; } - .metrics, .settings-grid { grid-template-columns: 1fr; } + .metrics, .settings-grid, .feature-grid, .checklist { grid-template-columns: 1fr; } .metrics article { min-height: 92px; } .tabs { margin: 0 -14px; padding: 0 14px; } .subtabs button { padding-inline: 8px; } @@ -202,4 +237,5 @@ li span { color: var(--muted); font-size: .9rem; } .editor-toolbar > span { justify-self: start; } .yaml-editor { min-height: 420px; padding: 16px; } .operation-actions button { width: 100%; } + .result-item { align-items: stretch; flex-direction: column; } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 18d3cb9..23180e4 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -71,6 +71,10 @@ const quickMessage = document.querySelector('#quick-message'); const quickCommandSuggestions = document.querySelector('#quick-command-suggestions'); const quickProcessRewards = document.querySelector('#quick-process-rewards'); const quickAutoSites = document.querySelector('#quick-auto-sites'); +const quickAutoSitesOnly = document.querySelector('#quick-auto-sites-only'); +const quickVoteLoggingEnabled = document.querySelector('#quick-vote-logging-enabled'); +const quickVoteLoggingDays = document.querySelector('#quick-vote-logging-days'); +const quickVoteLoggingMainMysql = document.querySelector('#quick-vote-logging-main-mysql'); const quickExtraCheck = document.querySelector('#quick-extra-check'); const quickCountFake = document.querySelector('#quick-count-fake'); const quickHideSiteWarning = document.querySelector('#quick-hide-site-warning'); @@ -106,6 +110,94 @@ const enrollmentCredential = document.querySelector('#enrollment-credential'); const enrollmentList = document.querySelector('#enrollment-list'); const enrollmentMessage = document.querySelector('#enrollment-message'); const refreshEnrollments = document.querySelector('#refresh-enrollments'); +const networkDoctorCapability = document.querySelector('#network-doctor-capability'); +const runNetworkDoctor = document.querySelector('#run-network-doctor'); +const downloadNetworkDiagnostics = document.querySelector('#download-network-diagnostics'); +const networkDoctorResults = document.querySelector('#network-doctor-results'); +const driftFile = document.querySelector('#drift-file'); +const driftCapability = document.querySelector('#drift-capability'); +const runDriftCheck = document.querySelector('#run-drift-check'); +const driftResults = document.querySelector('#drift-results'); +const snapshotForm = document.querySelector('#snapshot-form'); +const snapshotName = document.querySelector('#snapshot-name'); +const createSnapshot = document.querySelector('#create-snapshot'); +const refreshSnapshots = document.querySelector('#refresh-snapshots'); +const snapshotList = document.querySelector('#snapshot-list'); +const snapshotStatus = document.querySelector('#snapshot-status'); +const refreshSetupChecklist = document.querySelector('#refresh-setup-checklist'); +const setupChecklist = document.querySelector('#setup-checklist'); +const setupChecklistStatus = document.querySelector('#setup-checklist-status'); +const autoSitesEnabled = document.querySelector('#auto-sites-enabled'); +const autoSitesState = document.querySelector('#auto-sites-state'); +const autoSitesTargetCount = document.querySelector('#auto-sites-target-count'); +const selectAllAutoSitesTargets = document.querySelector('#select-all-auto-sites-targets'); +const loadAutoSites = document.querySelector('#load-auto-sites'); +const previewAutoSites = document.querySelector('#preview-auto-sites'); +const applyAutoSites = document.querySelector('#apply-auto-sites'); +const autoSitesStatus = document.querySelector('#auto-sites-status'); +const voteLoggingEnabled = document.querySelector('#vote-logging-enabled'); +const voteLoggingDays = document.querySelector('#vote-logging-days'); +const voteLoggingMainMysql = document.querySelector('#vote-logging-main-mysql'); +const voteLoggingState = document.querySelector('#vote-logging-state'); +const loadVoteLogging = document.querySelector('#load-vote-logging'); +const previewVoteLogging = document.querySelector('#preview-vote-logging'); +const applyVoteLogging = document.querySelector('#apply-vote-logging'); +const voteLoggingStatus = document.querySelector('#vote-logging-status'); +const profileName = document.querySelector('#profile-name'); +const profilePicker = document.querySelector('#profile-picker'); +const saveProfile = document.querySelector('#save-profile'); +const loadProfile = document.querySelector('#load-profile'); +const deleteProfile = document.querySelector('#delete-profile'); +const profileStatus = document.querySelector('#profile-status'); +const rewardSimulationForm = document.querySelector('#reward-simulation-form'); +const rewardScope = document.querySelector('#reward-scope'); +const rewardSiteLabel = document.querySelector('#reward-site-label'); +const rewardSite = document.querySelector('#reward-site'); +const rewardChance = document.querySelector('#reward-chance'); +const rewardMoney = document.querySelector('#reward-money'); +const rewardCommands = document.querySelector('#reward-commands'); +const rewardMessages = document.querySelector('#reward-messages'); +const rewardBroadcasts = document.querySelector('#reward-broadcasts'); +const rewardPermissions = document.querySelector('#reward-permissions'); +const rewardItems = document.querySelector('#reward-items'); +const rewardOnlineOnly = document.querySelector('#reward-online-only'); +const simulateReward = document.querySelector('#simulate-reward'); +const previewReward = document.querySelector('#preview-reward'); +const applyReward = document.querySelector('#apply-reward'); +const copyRewardToSetup = document.querySelector('#copy-reward-to-setup'); +const rewardSimulationCapability = document.querySelector('#reward-simulation-capability'); +const rewardSimulationResult = document.querySelector('#reward-simulation-result'); +const settingsFilter = document.querySelector('#settings-filter'); +const settingsCatalog = document.querySelector('#settings-catalog'); +const refreshDataOverview = document.querySelector('#refresh-data-overview'); +const dataOverview = document.querySelector('#data-overview'); +const playerLookupForm = document.querySelector('#player-lookup-form'); +const playerLookup = document.querySelector('#player-lookup'); +const lookupPlayer = document.querySelector('#lookup-player'); +const playerResult = document.querySelector('#player-result'); +const loadSiteHealth = document.querySelector('#load-site-health'); +const siteHealthResult = document.querySelector('#site-health-result'); +const loadVoteLogSummary = document.querySelector('#load-vote-log-summary'); +const voteLogSummaryResult = document.querySelector('#vote-log-summary-result'); +const voteLogForm = document.querySelector('#vote-log-form'); +const voteLogFilterType = document.querySelector('#vote-log-filter-type'); +const voteLogFilter = document.querySelector('#vote-log-filter'); +const voteLogEvent = document.querySelector('#vote-log-event'); +const voteLogDays = document.querySelector('#vote-log-days'); +const voteLogLimit = document.querySelector('#vote-log-limit'); +const searchVoteLog = document.querySelector('#search-vote-log'); +const voteLogResult = document.querySelector('#vote-log-result'); +const voteTraceForm = document.querySelector('#vote-trace-form'); +const voteTraceId = document.querySelector('#vote-trace-id'); +const traceVote = document.querySelector('#trace-vote'); +const voteTraceResult = document.querySelector('#vote-trace-result'); +const siteResolutionForm = document.querySelector('#site-resolution-form'); +const siteResolutionService = document.querySelector('#site-resolution-service'); +const siteResolutionDisabled = document.querySelector('#site-resolution-disabled'); +const resolveSite = document.querySelector('#resolve-site'); +const siteResolutionResult = document.querySelector('#site-resolution-result'); +const operationHistory = document.querySelector('#operation-history'); +const clearOperationHistory = document.querySelector('#clear-operation-history'); const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; @@ -147,12 +239,368 @@ let enrollmentRefreshRequested = false; let enrollmentMutationInFlight = false; let configurationOperationsInFlight = 0; let proxyMethodWorkflowInFlight = false; +const FILE_READ_CACHE_TTL_MS = 30_000; +const MAX_FILE_READ_CACHE_ENTRIES = 12; +const MAX_OPERATION_HISTORY = 50; +const SETUP_PROFILE_KEY = 'votingplugin-control.setup-profiles.v1'; +let fileReadCache = new Map(); +let lastFileReadOperation = null; +let inspectionInFlight = false; +let lastDiagnostics = null; +let lastOverview = null; +let operationHistoryItems = []; +let dedicatedSetupApprovals = new Map(); +let pendingDetectedVoteSite = null; +let voteLoggingRestartPending = new Map(); function text(element, value) { element.textContent = value; return element; } +const SETTINGS_SCHEMA = Object.freeze([ + {key: 'AutoCreateVoteSites', file: 'Config.yml', type: 'boolean', defaultValue: 'true', effect: 'Create a VoteSites.yml entry when an unknown service votes.'}, + {key: 'ProcessRewards', file: 'Config.yml', type: 'boolean', defaultValue: 'true', effect: 'Run configured vote rewards on this backend.'}, + {key: 'VoteLogging.Enabled', file: 'Config.yml', type: 'boolean', defaultValue: 'false', effect: 'Store supported vote events in MySQL for searches and traces.', afterApply: 'Backend restart required'}, + {key: 'VoteLogging.PurgeDays', file: 'Config.yml', type: 'integer -1 or 1–3650', defaultValue: '30', effect: 'Retention window for vote-log rows; -1 disables automatic purging.'}, + {key: 'VoteLogging.UseMainMySQL', file: 'Config.yml', type: 'boolean', defaultValue: 'true', effect: 'Reuse the main MySQL connection for vote logging.', afterApply: 'Backend restart required'}, + {key: 'CountFakeVotes', file: 'Config.yml', type: 'boolean', defaultValue: 'true', effect: 'Include explicitly generated test votes in totals.'}, + {key: 'ExtraAllSitesCheck', file: 'Config.yml', type: 'boolean', defaultValue: 'false', effect: 'Add duplicate protection for all-sites rewards.'}, + {key: 'UseBungeecord', file: 'BungeeSettings.yml', type: 'boolean', defaultValue: 'false', effect: 'Run this node as a proxy-connected backend.'}, + {key: 'BungeeMethod', file: 'BungeeSettings.yml', type: 'enum', defaultValue: 'PLUGINMESSAGING', effect: 'Select the proxy transport.'}, + {key: 'VoteSites..Enabled', file: 'VoteSites.yml', type: 'boolean', defaultValue: 'true', effect: 'Allow a configured site to resolve and reward votes.'}, + {key: 'VoteSites..ServiceSite', file: 'VoteSites.yml', type: 'text ≤200', defaultValue: '', effect: 'Match the service name supplied by the vote listener.'}, + {key: 'VoteParty.VotesRequired', file: 'SpecialRewards.yml', type: 'integer 1–100000', defaultValue: '20', effect: 'Number of votes required to trigger a vote party.'} +]); + +function inspectionCapableNode() { + const node = nodeIndex.get(selectedServerId); + return node?.online && node.acceptedCapabilities.includes('data.inspect.v1') ? node : null; +} + +function boundedLines(value, maximum = 20) { + return value.split(/\r?\n/).map(item => item.trim()).filter(Boolean).slice(0, maximum); +} + +function pruneFileReadCache() { + const cutoff = Date.now() - FILE_READ_CACHE_TTL_MS; + for (const [key, value] of fileReadCache) if (value.loadedAt < cutoff) fileReadCache.delete(key); + while (fileReadCache.size > MAX_FILE_READ_CACHE_ENTRIES) fileReadCache.delete(fileReadCache.keys().next().value); +} + +function cachedFile(key) { + pruneFileReadCache(); + const value = fileReadCache.get(key); + if (!value) return null; + fileReadCache.delete(key); + fileReadCache.set(key, value); + return value; +} + +function cacheFile(key, content, operationId) { + if (typeof content !== 'string') return; + pruneFileReadCache(); + fileReadCache.delete(key); + fileReadCache.set(key, {content, operationId, loadedAt: Date.now()}); + pruneFileReadCache(); +} + +function renderJsonResult(element, value, emptyMessage = 'No data returned.') { + element.replaceChildren(); + if (value == null) { + text(element, emptyMessage); + return; + } + const pre = document.createElement('pre'); + pre.className = 'json-result'; + text(pre, JSON.stringify(value, null, 2)); + element.append(pre); +} + +function renderSiteHealthResult(value) { + renderJsonResult(siteHealthResult, value); + const services = Array.isArray(value?.detectedUnconfiguredServices) + ? value.detectedUnconfiguredServices.slice(0, 20) : []; + if (services.length === 0) return; + const actions = document.createElement('div'); + actions.className = 'detected-actions'; + actions.append(text(document.createElement('strong'), 'Create a reviewed VoteSites entry:')); + services.forEach(service => { + const button = text(document.createElement('button'), String(service)); + button.type = 'button'; + button.className = 'secondary compact'; + button.addEventListener('click', () => { + const key = String(service).replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').slice(0, 64) || 'vote-site'; + quickPreset.value = 'vote-site'; + quickName.value = key; + quickSiteDisplayName.value = String(service).slice(0, 200); + quickService.value = String(service).slice(0, 200); + pendingDetectedVoteSite = {nodeId: selectedServerId, key, service: String(service).slice(0, 200)}; + selectedNodes = new Set(selectedServerId ? [selectedServerId] : []); + loadedQuickSetup = null; + updateQuickFields(); + clearApprovals(); + renderNodeViews(); + updatePluginSuggestions(); + setActiveTab('quick-setup', true); + text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Load the generated key to confirm it is unused, complete the URL and delay, then preview before creating it.'); + document.querySelector('#quick-setup-card').scrollIntoView({behavior: 'smooth', block: 'start'}); + }); + actions.append(button); + }); + siteHealthResult.append(actions); +} + +function downloadJson(name, value) { + const blob = new Blob([JSON.stringify(value, null, 2)], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = name; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +async function runInspection(kind, filters = {}, statusElement = null) { + const node = inspectionCapableNode(); + if (!node) throw new Error('Choose a connected backend with data inspection support.'); + if (inspectionInFlight) throw new Error('Another read-only inspection is still running.'); + const nodeId = node.nodeId; + const sessionId = node.sessionId; + inspectionInFlight = true; + updateExtendedButtons(); + if (statusElement) text(statusElement, `Queued ${kind} inspection…`); + try { + const boundedFilters = {}; + Object.entries(filters).forEach(([key, value]) => { + const serialized = String(value); + const maximum = kind === 'reward-simulation' && key === 'proposal' ? 64 * 1024 : 500; + const size = new TextEncoder().encode(serialized).length; + if (size > maximum) throw new Error(`${key} exceeds the bounded inspection limit.`); + boundedFilters[key] = serialized; + }); + let inspection = await authorized('/api/v1/inspections', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({nodeId, query: {kind, filters: boundedFilters}}) + }); + if (statusElement) text(statusElement, `Running ${kind} inspection on ${nodeId}…`); + const deadline = Date.now() + 180_000; + while (inspection.state === 'RUNNING') { + if (Date.now() >= deadline) throw new Error('Inspection is still running after three minutes. Check node connectivity and try again.'); + await new Promise(resolve => window.setTimeout(resolve, 1000)); + inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + } + if (nodeId !== selectedServerId || sessionId !== nodeIndex.get(nodeId)?.sessionId) { + throw new Error('The selected server changed or reconnected while the inspection ran. Run it again.'); + } + if (inspection.state !== 'SUCCEEDED' || !inspection.result?.success) { + throw new Error(inspection.result?.message || inspection.result?.code || 'Inspection failed.'); + } + let envelope = inspection.result.data; + if (typeof envelope === 'string') { + try { envelope = JSON.parse(envelope); } catch (_) { throw new Error('The node returned malformed inspection data.'); } + } + if (envelope?.schemaVersion !== 1 || envelope.kind !== kind || !Object.hasOwn(envelope, 'result')) { + throw new Error('The node returned an unsupported inspection schema.'); + } + return envelope; + } finally { + inspectionInFlight = false; + updateExtendedButtons(); + } +} + +function operationPhase(operation) { + if (operation.recovered && operation.state !== 'RUNNING') return `Recovered history · ${operation.state}`; + if (operation.state === 'RUNNING') return 'Queued or running'; + if (operation.type === 'PREVIEW' && operation.state === 'SUCCEEDED') return 'Preview ready for approval'; + if (operation.type === 'APPLY' && operation.state === 'SUCCEEDED') return 'Applied and verified'; + if (operation.state === 'COMPLETED_WITH_ERRORS') return 'Completed with failed targets'; + return operation.state; +} + +function rememberOperation(operation) { + const summary = {...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) => + [nodeId, result ? {...result, configuration: null} : result]))}; + const existing = operationHistoryItems.findIndex(item => item.operationId === operation.operationId); + if (existing >= 0) operationHistoryItems[existing] = summary; + else operationHistoryItems.unshift(summary); + operationHistoryItems = operationHistoryItems.slice(0, MAX_OPERATION_HISTORY); + renderOperationHistory(); +} + +function renderOperationHistory() { + operationHistory.replaceChildren(); + if (operationHistoryItems.length === 0) { + text(operationHistory, 'No retained configuration operations.'); + return; + } + operationHistoryItems.forEach(operation => { + const item = document.createElement('article'); + item.className = 'result-item'; + const heading = document.createElement('div'); + heading.className = 'section-title'; + const identity = document.createElement('div'); + identity.append(text(document.createElement('strong'), `${operation.type} · ${operationPhase(operation)}`)); + identity.append(text(document.createElement('small'), `${operation.operationId}${operation.sourceOperationId + ? ` · retry of ${operation.sourceOperationId}` : ''}${operation.recovered ? ' · recovered after restart' : ''}`)); + heading.append(identity); + const actions = document.createElement('div'); + actions.className = 'operation-actions'; + const alreadyRetried = operationHistoryItems.some(item => item.sourceOperationId === operation.operationId); + if (operation.retryable && !alreadyRetried) { + const retry = text(document.createElement('button'), 'Retry failed targets'); + retry.type = 'button'; + retry.className = 'secondary compact'; + retry.addEventListener('click', async () => { + retry.disabled = true; + try { + const retried = await authorized(`/api/v1/operations/${operation.operationId}/retry`, {method: 'POST'}); + const completed = await waitForOperation(retried, operationStatus); + if (completed.type === 'APPLY') { + fileReadCache.clear(); + lastFileReadOperation = null; + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); + inputGeneration++; + updateConfigurationButtons(); + updateExtendedButtons(); + } + setActiveTab('activity', true); + } catch (error) { + text(message, error.code === 'PREVIEW_REQUIRED' + ? 'That apply needs a fresh preview because the failed targets may have changed.' : error.message); + } finally { retry.disabled = false; } + }); + actions.append(retry); + } + if (operation.type === 'PREVIEW' && operation.state === 'SUCCEEDED' && operation.approvalToken + && operation.configuration?.domain === 'quick-setup' + && !['proxy-method', 'reward-builder', 'sync-vote-sites'].includes(operation.configuration?.preset)) { + const approve = text(document.createElement('button'), 'Approve this preview'); + approve.type = 'button'; + approve.className = 'compact'; + approve.addEventListener('click', async () => { + if (!window.confirm('Apply this exact completed preview? Review every listed node change before continuing.')) return; + approve.disabled = true; + try { + const applied = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: operation.operationId, approvalToken: operation.approvalToken + }, operationStatus); + if (applied.state === 'SUCCEEDED') { + fileReadCache.clear(); + lastFileReadOperation = null; + updateExtendedButtons(); + } + await loadOperationHistory(); + } catch (error) { text(message, error.message); } + finally { approve.disabled = false; } + }); + actions.append(approve); + } + if (actions.childElementCount > 0) heading.append(actions); + const detail = document.createElement('pre'); + text(detail, operationSummary(operation)); + item.append(heading, detail); + operationHistory.append(item); + }); +} + +async function loadOperationHistory() { + if (!authenticated) return; + try { + const body = await authorized('/api/v1/operations'); + operationHistoryItems = Array.isArray(body.items) ? body.items.slice(0, MAX_OPERATION_HISTORY).map(operation => + ({...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) => + [nodeId, result ? {...result, configuration: null} : result]))})) : []; + const pendingRestarts = new Map(); + operationHistoryItems.forEach(operation => { + if (operation.type !== 'APPLY' || operation.configuration?.preset !== 'vote-logging') return; + Object.entries(operation.results || {}).forEach(([nodeId, result]) => { + if (result?.success && !pendingRestarts.has(nodeId)) { + pendingRestarts.set(nodeId, result.sessionId || 'unknown'); + } + }); + }); + voteLoggingRestartPending = pendingRestarts; + renderOperationHistory(); + updateSetupChecklist(); + } catch (error) { + text(operationHistory, error.message || 'Operation history could not be loaded.'); + } +} + +function renderSettingsCatalog() { + const query = settingsFilter.value.trim().toLowerCase(); + const rows = SETTINGS_SCHEMA.filter(setting => Object.values(setting).join(' ').toLowerCase().includes(query)); + settingsCatalog.replaceChildren(...rows.map(setting => { + const row = document.createElement('tr'); + [setting.key, setting.file, setting.type, setting.defaultValue || '—', setting.effect, + setting.afterApply || (setting.file === 'BungeeSettings.yml' + ? 'Connector/runtime may restart' : 'VotingPlugin reload')].forEach(value => { + row.append(text(document.createElement('td'), value)); + }); + return row; + })); +} + +function readProfiles() { + try { + const value = JSON.parse(localStorage.getItem(SETUP_PROFILE_KEY) || '{}'); + const safe = Object.create(null); + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.entries(value).slice(0, 20).forEach(([name, profile]) => { + if (name.length <= 60 && profile && typeof profile === 'object' && !Array.isArray(profile)) safe[name] = profile; + }); + } + return safe; + } catch (_) { return Object.create(null); } +} + +function writeProfiles(profiles) { + const serialized = JSON.stringify(profiles); + if (serialized.length > 1024 * 1024) throw new Error('Setup profiles exceed the 1 MiB browser-local limit.'); + localStorage.setItem(SETUP_PROFILE_KEY, serialized); +} + +function currentProfileValues() { + return { + version: 1, preset: quickPreset.value, name: quickName.value, method: quickMethod.value, + siteDisplayName: quickSiteDisplayName.value, service: quickService.value, url: quickUrl.value, + delay: quickDelay.value, priority: quickSitePriority.value, material: quickSiteMaterial.value, + siteEnabled: quickSiteEnabled.checked, siteHidden: quickSiteHidden.checked, + rewardScope: quickRewardScope.value, command: quickCommand.value, playerMessage: quickMessage.value, + processRewards: quickProcessRewards.checked, autoSites: quickAutoSites.checked, + extraCheck: quickExtraCheck.checked, countFake: quickCountFake.checked, + hideWarning: quickHideSiteWarning.checked, disableUpdates: quickDisableUpdates.checked, + partyVotes: quickPartyVotes.value, partyCommand: quickPartyCommand.value, + partyBroadcast: quickPartyBroadcast.value, partyAll: quickPartyAll.checked, partyOnline: quickPartyOnline.checked, + autoSitesOnly: quickAutoSitesOnly.checked, voteLogging: quickVoteLoggingEnabled.checked, + voteLoggingDays: quickVoteLoggingDays.value, voteLoggingMainMysql: quickVoteLoggingMainMysql.checked, + rewardBuilder: {scope: rewardScope.value, site: rewardSite.value, chance: rewardChance.value, + money: rewardMoney.value, commands: rewardCommands.value, messages: rewardMessages.value, + broadcasts: rewardBroadcasts.value, permissions: rewardPermissions.value, items: rewardItems.value, + onlineOnly: rewardOnlineOnly.checked} + }; +} + +function populateProfilePicker() { + const profiles = readProfiles(); + const current = profilePicker.value; + const placeholder = text(document.createElement('option'), 'Choose a profile'); + placeholder.value = ''; + profilePicker.replaceChildren(placeholder, ...Object.keys(profiles).sort().map(name => { + const option = text(document.createElement('option'), name); + option.value = name; + return option; + })); + profilePicker.value = Object.hasOwn(profiles, current) ? current : ''; + loadProfile.disabled = !profilePicker.value; + deleteProfile.disabled = !profilePicker.value; +} + async function loadHealth() { try { const response = await fetch('/api/v1/health', {cache: 'no-store'}); @@ -205,6 +653,14 @@ function applyAuthenticatedSession(body) { proxyMethodCurrentFor = ''; proxyMethodCurrentSessionId = ''; proxyMethodCurrentValue = ''; + fileReadCache.clear(); + lastFileReadOperation = null; + lastDiagnostics = null; + lastOverview = null; + operationHistoryItems = []; + dedicatedSetupApprovals.clear(); + voteLoggingRestartPending.clear(); + pendingDetectedVoteSite = null; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -214,6 +670,8 @@ function applyAuthenticatedSession(body) { serverPickerLabel.hidden = false; enrollmentCard.hidden = false; pageOffset = 0; + renderOperationHistory(); + populateProfilePicker(); setActiveTab(tabFromHash()); } @@ -245,8 +703,9 @@ function friendlyCapability(capability) { 'config.vote-sites-sync.v1': 'VoteSites sync', 'config.transport-test.v1': 'Communication test', 'config.proxy-method.v1': 'Proxy method', - 'config.quick-setup.v1': 'Quick Setup', - 'config.proxy-routing.v1': 'Proxy routing' + 'config.quick-setup.v1': 'Setup assistant', + 'config.proxy-routing.v1': 'Proxy routing', + 'data.inspect.v1': 'Read-only data inspection' })[capability]; } @@ -337,10 +796,12 @@ function nodeCard(node) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); inputGeneration++; updatePluginSuggestions(); renderSelectedServer(); updateConfigurationButtons(); + updateExtendedButtons(); }); selector.append(checkbox, document.createTextNode('Include in configuration changes')); @@ -725,6 +1186,81 @@ function renderProxyMethod() { }); } +function updateSetupChecklist(overview = lastOverview) { + const node = nodeIndex.get(selectedServerId); + const loggingRestartPending = voteLoggingRestartRequired(); + const steps = [...setupChecklist.querySelectorAll('li')]; + const states = [ + Boolean(node?.online && isBackend(node)), + Boolean(overview && typeof overview.proxyMode === 'boolean'), + Boolean(overview && Number.isFinite(Number(overview.configuredVoteSites))), + Boolean(overview?.processRewards), + Boolean(overview?.dataStorage && !loggingRestartPending + && (!overview.voteLoggingEnabled || overview.voteLogReadable === true)), + Boolean(overview && (!overview.proxyMode || allNodeItems.some(item => isProxy(item) && item.online + && item.acceptedCapabilities.includes('config.transport-test.v1')))) + ]; + steps.forEach((step, index) => { + step.classList.toggle('complete', states[index]); + text(step.querySelector('.step-state'), states[index] ? '✓' : String(index + 1)); + }); + const complete = states.filter(Boolean).length; + const loggingStatus = loggingRestartPending + ? ' Vote logging configuration was saved, but it is not considered live until this backend restarts and reconnects.' + : overview?.voteLoggingEnabled === false + ? ' Vote logging is optional and currently disabled.' + : overview?.voteLoggingEnabled && overview.voteLogReadable !== true + ? ' Vote logging is enabled but its MySQL table is not readable.' : ''; + text(setupChecklistStatus, `${complete} of ${states.length} readiness checks complete.${loggingStatus}`); +} + +function updateExtendedButtons() { + const node = nodeIndex.get(selectedServerId); + const inspectionReady = authenticated && Boolean(inspectionCapableNode()) && !inspectionInFlight; + const backendTargets = backendQuickTargets(); + const allQuickBackends = allNodeItems.filter(item => isBackend(item) && item.online + && item.acceptedCapabilities.includes('config.quick-setup.v1')).slice(0, MAX_CONFIGURATION_TARGETS); + const quickReady = authenticated && Boolean(node?.online && isBackend(node) + && node.acceptedCapabilities.includes('config.quick-setup.v1')) && backendTargets.length > 0 + && configurationOperationsInFlight === 0; + const fileTargets = targets('config.files.v1'); + const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0; + runNetworkDoctor.disabled = !inspectionReady; + downloadNetworkDiagnostics.disabled = !lastDiagnostics; + refreshSetupChecklist.disabled = !inspectionReady; + refreshDataOverview.disabled = !inspectionReady; + lookupPlayer.disabled = !inspectionReady; + loadSiteHealth.disabled = !inspectionReady; + loadVoteLogSummary.disabled = !inspectionReady; + searchVoteLog.disabled = !inspectionReady; + traceVote.disabled = !inspectionReady; + resolveSite.disabled = !inspectionReady; + simulateReward.disabled = !inspectionReady; + previewReward.disabled = !quickReady; + applyReward.disabled = !quickReady || !dedicatedSetupApprovals.get('reward-builder'); + loadAutoSites.disabled = !quickReady; + previewAutoSites.disabled = !quickReady; + applyAutoSites.disabled = !quickReady || !dedicatedSetupApprovals.get('auto-create-vote-sites'); + selectAllAutoSitesTargets.disabled = !authenticated || allQuickBackends.length === 0 + || configurationOperationsInFlight > 0; + text(autoSitesTargetCount, `${backendTargets.length} selected ${backendTargets.length === 1 ? 'backend' : 'backends'}`); + loadVoteLogging.disabled = !quickReady; + previewVoteLogging.disabled = !quickReady; + applyVoteLogging.disabled = !quickReady || !dedicatedSetupApprovals.get('vote-logging'); + runDriftCheck.disabled = !driftReady; + createSnapshot.disabled = !lastFileReadOperation; + const inspectionMessage = inspectionReady ? 'Read-only inspection available' : 'Choose an inspection-capable node'; + text(networkDoctorCapability, inspectionMessage); + networkDoctorCapability.className = `pill ${inspectionReady ? 'online' : 'neutral'}`; + const rewardReady = inspectionReady && quickReady; + text(rewardSimulationCapability, rewardReady ? 'Simulation and preview/apply available' + : inspectionReady ? 'Simulation available; configuration write unavailable' : 'Choose a capable node'); + rewardSimulationCapability.className = `pill ${rewardReady ? 'online' : 'neutral'}`; + text(driftCapability, driftReady ? `${fileTargets.length} selected nodes ready` : 'Select at least two readable nodes'); + driftCapability.className = `pill ${driftReady ? 'online' : 'neutral'}`; + updateSetupChecklist(); +} + function renderNodeViews() { nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); @@ -739,6 +1275,7 @@ function renderNodeViews() { renderVoteSitesSync(); renderTransportTest(); renderProxyMethod(); + updateExtendedButtons(); } function resetServerConfigurationForms(status) { @@ -756,6 +1293,27 @@ function selectPrimaryServer(nodeId) { serverPicker.value = nodeId; selectedNodes.clear(); if (nodeId) selectedNodes.add(nodeId); + dedicatedSetupApprovals.clear(); + pendingDetectedVoteSite = null; + lastFileReadOperation = null; + lastDiagnostics = null; + lastOverview = null; + downloadNetworkDiagnostics.disabled = true; + text(networkDoctorResults, 'Server changed. Run Network Doctor again.'); + text(dataOverview, 'Server changed. Refresh the overview.'); + text(playerResult, 'No player queried on this server.'); + text(siteHealthResult, 'No health query run on this server.'); + text(voteLogSummaryResult, 'No vote-log summary loaded on this server.'); + text(voteLogResult, 'No event search run on this server.'); + text(voteTraceResult, 'No vote traced on this server.'); + text(siteResolutionResult, 'No service tested on this server.'); + text(rewardSimulationResult, 'Server changed. Simulate or preview the reward again.'); + text(autoSitesState, 'Not loaded'); + autoSitesState.className = 'pill neutral'; + text(voteLoggingState, 'Not loaded'); + voteLoggingState.className = 'pill neutral'; + text(autoSitesStatus, 'Server changed. Load the current value.'); + text(voteLoggingStatus, 'Server changed. Load the current values.'); loadedQuickSetup = null; resetServerConfigurationForms('Server changed. Read this server before previewing changes.'); const preset = quickPreset.value; @@ -800,10 +1358,15 @@ function targets(capability) { return [...selectedNodes].filter(node => nodeCapabilities.get(node)?.includes(capability)); } +function backendQuickTargets() { + return targets('config.quick-setup.v1').filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); +} + function clearApprovals() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); inputGeneration++; updateConfigurationButtons(); } @@ -856,7 +1419,8 @@ function quickPresetReadable() { } function quickPresetNeedsRead() { - return ['proxy-backend', 'vote-site', 'common-settings', 'vote-party'].includes(quickPreset.value); + return ['proxy-backend', 'vote-site', 'common-settings', 'vote-party', + 'auto-create-vote-sites', 'vote-logging'].includes(quickPreset.value); } function quickSetupValuesLoaded() { @@ -943,6 +1507,14 @@ function discardAuthenticationState(reason) { proxyMethodCurrentFor = ''; proxyMethodCurrentSessionId = ''; proxyMethodCurrentValue = ''; + fileReadCache.clear(); + lastFileReadOperation = null; + lastDiagnostics = null; + lastOverview = null; + operationHistoryItems = []; + dedicatedSetupApprovals.clear(); + voteLoggingRestartPending.clear(); + pendingDetectedVoteSite = null; selectedServerId = ''; visibleNodeItems = []; allNodeItems = []; @@ -964,6 +1536,19 @@ function discardAuthenticationState(reason) { text(quickOperationStatus, ''); text(transportTestStatus, ''); text(proxyMethodStatus, ''); + text(networkDoctorResults, 'Choose a connected backend with read-only data inspection.'); + text(dataOverview, 'Choose an inspection-capable backend.'); + text(playerResult, 'No player queried.'); + text(siteHealthResult, 'No health query run.'); + text(voteLogSummaryResult, 'No vote-log summary loaded.'); + text(voteLogResult, 'No event search run.'); + text(voteTraceResult, 'No vote traced.'); + text(siteResolutionResult, 'No service tested.'); + text(rewardSimulationResult, 'Add an action, then simulate it safely.'); + text(driftResults, 'Authenticate and choose two or more readable nodes.'); + text(snapshotList, 'Authenticate to view manual snapshots.'); + text(snapshotStatus, ''); + renderOperationHistory(); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -974,6 +1559,7 @@ function discardAuthenticationState(reason) { renderSelectedServer(); text(message, reason); updateConfigurationButtons(); + updateExtendedButtons(); } function proposal() { @@ -983,12 +1569,33 @@ function proposal() { }; } +function rememberVoteLoggingRestart(operation) { + if (operation.type !== 'APPLY' || operation.configuration?.preset !== 'vote-logging') return; + Object.entries(operation.results || {}).forEach(([nodeId, result]) => { + if (result?.success) voteLoggingRestartPending.set(nodeId, + result.sessionId || nodeIndex.get(nodeId)?.sessionId || 'unknown'); + }); +} + +function voteLoggingRestartRequired(nodeId = selectedServerId) { + const appliedSession = voteLoggingRestartPending.get(nodeId); + if (!appliedSession) return false; + const currentSession = nodeIndex.get(nodeId)?.sessionId; + if (currentSession && appliedSession !== 'unknown' && currentSession !== appliedSession) { + voteLoggingRestartPending.delete(nodeId); + return false; + } + return true; +} + function operationSummary(operation) { const lines = [`${operation.type} · ${operation.state} · ${operation.operationId}`]; + const voteLoggingChange = operation.configuration?.preset === 'vote-logging'; Object.entries(operation.nodeStates).forEach(([node, state]) => { const result = operation.results[node]; const successLabel = operation.type === 'READ' ? 'values read' : operation.type === 'PREVIEW' ? 'preview ready' + : voteLoggingChange ? 'configuration saved; backend restart required' : result?.reloaded ? 'saved and reloaded' : 'applied'; lines.push(`${result?.success ? '✓' : result ? '✗' : '…'} ${node}: ${result ? `${result.success ? successLabel : result.code} — ${result.message}` : state.toLowerCase()}`); @@ -1001,22 +1608,45 @@ function operationSummary(operation) { lines.push(`${sites.size || 'No'} site ${sites.size === 1 ? 'definition' : 'definitions'} ${operation.type === 'PREVIEW' ? 'would change' : 'changed'}.`); lines.push('Rewards and target-only sites remain local to each backend.'); } + if (voteLoggingChange && operation.type !== 'READ') { + lines.push(operation.type === 'PREVIEW' + ? 'Applying this preview requires restarting each changed backend; a plugin reload does not activate a new vote-log connection.' + : 'Restart every successfully changed backend before treating the vote-logging runtime as live.'); + } return lines.join('\n'); } async function waitForOperation(operation, statusElement = operationStatus) { text(statusElement, operationSummary(operation)); + rememberOperation(operation); while (operation.state === 'RUNNING') { await new Promise(resolve => window.setTimeout(resolve, 1500)); operation = await authorized(`/api/v1/operations/${operation.operationId}`); text(statusElement, operationSummary(operation)); + rememberOperation(operation); + } + rememberVoteLoggingRestart(operation); + if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { + fileReadCache.clear(); + lastFileReadOperation = null; + lastOverview = null; + lastDiagnostics = null; + updateExtendedButtons(); } return operation; } async function startConfigurationOperation(path, body, statusElement = operationStatus) { + if (path.endsWith('/apply')) { + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); + inputGeneration++; + } configurationOperationsInFlight++; updateConfigurationButtons(); + updateExtendedButtons(); try { return await waitForOperation(await authorized(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) @@ -1024,6 +1654,7 @@ async function startConfigurationOperation(path, body, statusElement = operation } finally { configurationOperationsInFlight--; updateConfigurationButtons(); + updateExtendedButtons(); } } @@ -1140,6 +1771,24 @@ async function loadNodes() { backendTopologyTruncated = registry.truncated; backendTopologyTruncatedNodeIds = registry.truncatedNodeIds; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); + const selectedSessionChanged = [...selectedNodes].some(node => previousNodeIndex.get(node)?.sessionId + && previousNodeIndex.get(node)?.sessionId !== nodeIndex.get(node)?.sessionId); + if (selectedSessionChanged) { + dedicatedSetupApprovals.clear(); + lastOverview = null; + lastDiagnostics = null; + lastFileReadOperation = null; + fileReadCache.clear(); + text(dataOverview, 'A selected server reconnected. Refresh the overview.'); + text(networkDoctorResults, 'A selected server reconnected. Run Network Doctor again.'); + text(playerResult, 'A selected server reconnected. Run the lookup again.'); + text(siteHealthResult, 'A selected server reconnected. Load health again.'); + text(voteLogSummaryResult, 'A selected server reconnected. Load the summary again.'); + text(voteLogResult, 'A selected server reconnected. Run the search again.'); + text(voteTraceResult, 'A selected server reconnected. Trace the vote again.'); + text(siteResolutionResult, 'A selected server reconnected. Test the service again.'); + text(rewardSimulationResult, 'A selected server reconnected. Simulate or preview the reward again.'); + } if (loadedQuickSetup?.nodeId === selectedServerId && previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId) { loadedQuickSetup = null; @@ -1152,13 +1801,16 @@ async function loadNodes() { nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []])); const selectedCapabilitiesChanged = [...selectedNodes].some(node => - ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'].some(capability => + ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1', 'data.inspect.v1'].some(capability => Boolean(previousCapabilities.get(node)?.includes(capability)) !== Boolean(nodeCapabilities.get(node)?.includes(capability)))); if (selectedCapabilitiesChanged) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); + lastOverview = null; + lastDiagnostics = null; inputGeneration++; text(operationStatus, 'A selected node changed capabilities during refresh. Preview again before apply.'); } @@ -1178,6 +1830,7 @@ async function loadNodes() { if (invalidFileApproval) approvedFilePreview = null; if (invalidQuickApproval) approvedQuickPreview = null; if (invalidVoteSitesApproval) approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } @@ -1198,6 +1851,7 @@ async function loadNodes() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + dedicatedSetupApprovals.clear(); inputGeneration++; text(operationStatus, 'The selected nodes changed during refresh. Preview again before apply.'); } @@ -1224,6 +1878,11 @@ async function loadNodes() { nodePlugins.clear(); selectedNodes.clear(); selectedServerId = ''; + dedicatedSetupApprovals.clear(); + lastOverview = null; + lastDiagnostics = null; + lastFileReadOperation = null; + fileReadCache.clear(); resetServerConfigurationForms('Network data is unavailable. Refresh before editing.'); text(quickOperationStatus, 'Network data is unavailable. Refresh before editing.'); renderServerPicker(); @@ -1231,6 +1890,8 @@ async function loadNodes() { updatePluginSuggestions(); updateConfigurationButtons(); text(nodes, 'Network data is unavailable.'); + text(dataOverview, 'Network data is unavailable.'); + text(networkDoctorResults, 'Network data is unavailable.'); text(message, error.message || 'Control request failed.'); } finally { refresh.disabled = false; @@ -1255,7 +1916,7 @@ form.addEventListener('submit', async event => { if (!response.ok) throw new Error(body?.error?.message || 'Authentication failed.'); if (loginGeneration !== authenticationGeneration) return; applyAuthenticatedSession(body); - await Promise.all([loadEnrollments(), loadNodes()]); + await Promise.all([loadEnrollments(), loadNodes(), loadOperationHistory(), loadSnapshots()]); } catch (error) { text(message, error.message || 'Authentication failed.'); } finally { @@ -1293,7 +1954,7 @@ async function restoreSession() { const body = await response.json(); if (restoreGeneration !== authenticationGeneration) return; applyAuthenticatedSession(body); - await Promise.all([loadEnrollments(), loadNodes()]); + await Promise.all([loadEnrollments(), loadNodes(), loadOperationHistory(), loadSnapshots()]); } catch (_) { /* The login form remains available. */ } } @@ -1323,7 +1984,7 @@ setupForm.addEventListener('submit', async event => { authCard.hidden = false; applyAuthenticatedSession(body); text(message, 'First-run setup completed.'); - await Promise.all([loadEnrollments(), loadNodes()]); + await Promise.all([loadEnrollments(), loadNodes(), loadOperationHistory(), loadSnapshots()]); } catch (error) { text(setupMessage, error.message || 'First-run setup failed.'); } finally { @@ -1414,6 +2075,19 @@ readFileConfiguration.addEventListener('click', async () => { const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; const selectedFile = configurationFile.value; + const selectedNode = nodeIndex.get(selectedServerId); + const cacheKey = `${selectedServerId}|${selectedNode?.sessionId || ''}|${selectedFile}`; + const cached = cachedFile(cacheKey); + if (cached) { + configurationContent.value = cached.content; + lastFileReadOperation = {operationId: cached.operationId}; + updateEditorPosition(); + text(fileOperationStatus, `Cached read · ${selectedServerId} · ${selectedFile}\nLoaded instantly; cache expires after 30 seconds. Preview still checks the live revision.`); + inputGeneration++; + updateConfigurationButtons(); + updateExtendedButtons(); + return; + } try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [selectedServerId], @@ -1424,10 +2098,13 @@ readFileConfiguration.addEventListener('click', async () => { if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { configurationContent.value = contentResult.configuration.content; + lastFileReadOperation = {operationId: operation.operationId}; + cacheFile(cacheKey, contentResult.configuration.content, operation.operationId); updateEditorPosition(); text(fileOperationStatus, operationSummary(operation)); inputGeneration++; updateConfigurationButtons(); + updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } }); @@ -1461,6 +2138,11 @@ applyFileConfiguration.addEventListener('click', async () => { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, fileOperationStatus); text(fileOperationStatus, operationSummary(operation)); + if (operation.state === 'SUCCEEDED') { + fileReadCache.clear(); + lastFileReadOperation = null; + updateExtendedButtons(); + } } catch (error) { text(fileOperationStatus, error.message); } }); @@ -1475,6 +2157,9 @@ function quickOptions() { }; if (quickPreset.value === 'easy-reward') return {scope: quickRewardScope.value, name: quickName.value.trim(), command: quickCommand.value.trim(), message: quickMessage.value.trim()}; + if (quickPreset.value === 'auto-create-vote-sites') return {enabled: String(quickAutoSitesOnly.checked)}; + if (quickPreset.value === 'vote-logging') return {enabled: String(quickVoteLoggingEnabled.checked), + purgeDays: validatedPurgeDays(quickVoteLoggingDays), useMainMySQL: String(quickVoteLoggingMainMysql.checked)}; if (quickPreset.value === 'common-settings') return { processRewards: String(quickProcessRewards.checked), autoCreateVoteSites: String(quickAutoSites.checked), extraAllSitesCheck: String(quickExtraCheck.checked), countFakeVotes: String(quickCountFake.checked), @@ -1510,6 +2195,12 @@ function populateQuickState(options) { quickCountFake.checked = options.countFakeVotes === 'true'; quickHideSiteWarning.checked = options.disableNoServiceSiteMessage === 'true'; quickDisableUpdates.checked = options.disableUpdateChecking === 'true'; + } else if (quickPreset.value === 'auto-create-vote-sites') { + quickAutoSitesOnly.checked = options.enabled === 'true'; + } else if (quickPreset.value === 'vote-logging') { + quickVoteLoggingEnabled.checked = options.enabled === 'true'; + quickVoteLoggingDays.value = options.purgeDays || '30'; + quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; } else if (quickPreset.value === 'vote-party') { quickPartyVotes.value = options.votesRequired || '20'; quickPartyBroadcast.value = options.broadcast || ''; @@ -1542,11 +2233,20 @@ readQuickSetup.addEventListener('click', async () => { text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); return; } + const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId + && pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null; populateQuickState(result.configuration.options); + if (detected && result.configuration.options.exists === 'false') { + quickSiteDisplayName.value = detected.service; + quickService.value = detected.service; + } + if (detected) pendingDetectedVoteSite = null; loadedQuickSetup = {nodeId, sessionId, preset, selector}; inputGeneration++; const suffix = preset === 'vote-site' && result.configuration.options.exists === 'false' - ? ' This site key does not exist yet; the form is ready to create it.' + ? ` This site key does not exist yet; the form is ready to create it.${detected ? ' The detected service was retained.' : ''}` + : preset === 'vote-site' && detected + ? ' The generated key already exists, so its current values were kept; choose a different key for the detected service.' : preset === 'vote-party' && Number(result.configuration.options.rewardCommandCount || 0) > 0 ? ` ${result.configuration.options.rewardCommandCount} existing reward command(s) will be preserved.` : ''; text(quickOperationStatus, `Current values loaded from ${Object.keys(operation.results).find(id => operation.results[id] === result)}.${suffix}`); @@ -1602,6 +2302,8 @@ applyQuickSetup.addEventListener('click', async () => { const sync = approvedQuickPreview?.workflow === 'sync-vote-sites'; const confirmation = sync ? 'Sync the previewed site definitions to every target? Rewards and target-only sites remain unchanged.' + : quickPreset.value === 'vote-logging' + ? 'Apply this exact vote-logging change to every selected Bukkit node? Restart every changed backend afterward; a plugin reload does not activate the new runtime connection.' : 'Apply this exact guided change to every selected Bukkit node?'; if (!approvedQuickPreview || !window.confirm(confirmation)) return; const approval = approvedQuickPreview; @@ -1736,24 +2438,509 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = } })); +function dedicatedSetupOptions(preset) { + if (preset === 'auto-create-vote-sites') return {enabled: String(autoSitesEnabled.checked)}; + return {enabled: String(voteLoggingEnabled.checked), purgeDays: validatedPurgeDays(voteLoggingDays), + useMainMySQL: String(voteLoggingMainMysql.checked)}; +} + +function validatedPurgeDays(field) { + const value = Number(field.value); + if (!Number.isInteger(value) || value !== -1 && (value < 1 || value > 3650)) { + throw new Error('Vote-log purge days must be -1 or an integer from 1 to 3650; 0 is not valid.'); + } + return String(value); +} + +function dedicatedSetupElements(preset) { + return preset === 'auto-create-vote-sites' + ? {status: autoSitesStatus, state: autoSitesState} + : {status: voteLoggingStatus, state: voteLoggingState}; +} + +async function loadDedicatedSetup(preset) { + dedicatedSetupApprovals.delete(preset); + const elements = dedicatedSetupElements(preset); + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: [selectedServerId], configuration: {domain: 'quick-setup', preset, options: {}} + }, elements.status); + const options = operation.results[selectedServerId]?.configuration?.options; + if (!options) throw new Error('The selected backend did not return this setup. Update VotingPlugin on that node.'); + if (preset === 'auto-create-vote-sites') { + autoSitesEnabled.checked = options.enabled === 'true'; + text(autoSitesState, autoSitesEnabled.checked ? 'Enabled on primary' : 'Disabled on primary'); + } else { + voteLoggingEnabled.checked = options.enabled === 'true'; + voteLoggingDays.value = options.purgeDays || '30'; + voteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; + text(voteLoggingState, voteLoggingEnabled.checked ? 'Enabled on primary' : 'Disabled on primary'); + } + elements.state.className = `pill ${options.enabled === 'true' ? 'online' : 'neutral'}`; + } catch (error) { text(elements.status, error.message); } + updateExtendedButtons(); +} + +async function previewDedicatedSetup(preset) { + dedicatedSetupApprovals.delete(preset); + const elements = dedicatedSetupElements(preset); + try { + const nodeIds = backendQuickTargets(); + const options = dedicatedSetupOptions(preset); + const signature = JSON.stringify({nodeIds, options}); + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds, configuration: {domain: 'quick-setup', preset, options} + }, elements.status); + if (signature !== JSON.stringify({nodeIds: backendQuickTargets(), options: dedicatedSetupOptions(preset)})) { + text(elements.status, 'The target scope or setup value changed while previewing. Preview again.'); + } else if (operation.state === 'SUCCEEDED' && operation.approvalToken) { + dedicatedSetupApprovals.set(preset, {operationId: operation.operationId, + approvalToken: operation.approvalToken, nodeIds}); + } + } catch (error) { text(elements.status, error.message); } + updateExtendedButtons(); +} + +async function applyDedicatedSetup(preset) { + const approval = dedicatedSetupApprovals.get(preset); + const restart = preset === 'vote-logging' + ? ' Restart every changed backend afterward; a plugin reload does not activate the new runtime connection.' : ''; + if (!approval || !window.confirm(`Apply the exact ${preset} preview to every selected Bukkit node?${restart}`)) return; + dedicatedSetupApprovals.delete(preset); + const elements = dedicatedSetupElements(preset); + try { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, elements.status); + if (operation.state === 'SUCCEEDED') { + fileReadCache.clear(); + if (preset === 'auto-create-vote-sites') { + text(elements.state, autoSitesEnabled.checked ? 'Enabled on selected' : 'Disabled on selected'); + elements.state.className = `pill ${autoSitesEnabled.checked ? 'online' : 'neutral'}`; + } else { + text(elements.state, 'Saved; restart required'); + elements.state.className = 'pill neutral'; + } + lastOverview = null; + } + } catch (error) { text(elements.status, error.message); } + updateExtendedButtons(); +} + +loadAutoSites.addEventListener('click', () => loadDedicatedSetup('auto-create-vote-sites')); +previewAutoSites.addEventListener('click', () => previewDedicatedSetup('auto-create-vote-sites')); +applyAutoSites.addEventListener('click', () => applyDedicatedSetup('auto-create-vote-sites')); +selectAllAutoSitesTargets.addEventListener('click', () => { + const available = allNodeItems.filter(node => isBackend(node) && node.online + && node.acceptedCapabilities.includes('config.quick-setup.v1')); + const candidates = available + .sort((left, right) => Number(right.nodeId === selectedServerId) - Number(left.nodeId === selectedServerId)) + .slice(0, MAX_CONFIGURATION_TARGETS); + selectedNodes = new Set(candidates.map(node => node.nodeId)); + dedicatedSetupApprovals.clear(); + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + renderNodeViews(); + updatePluginSuggestions(); + updateConfigurationButtons(); + text(autoSitesStatus, `${candidates.length} online ${candidates.length === 1 ? 'backend is' : 'backends are'} selected${available.length > candidates.length + ? ` (limited to ${MAX_CONFIGURATION_TARGETS} per operation)` : ''}. Choose enabled or disabled, then preview every target.`); +}); +loadVoteLogging.addEventListener('click', () => loadDedicatedSetup('vote-logging')); +previewVoteLogging.addEventListener('click', () => previewDedicatedSetup('vote-logging')); +applyVoteLogging.addEventListener('click', () => applyDedicatedSetup('vote-logging')); +[autoSitesEnabled, voteLoggingEnabled, voteLoggingDays, voteLoggingMainMysql].forEach(field => { + field.addEventListener('input', () => { + dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging'); + updateExtendedButtons(); + }); +}); + +async function refreshOverview(target = dataOverview) { + try { + const envelope = await runInspection('overview', {}, target); + lastOverview = {...(lastOverview || {}), ...envelope.result}; + renderJsonResult(target, envelope.result); + updateSetupChecklist(lastOverview); + } catch (error) { text(target, error.message); } +} + +refreshSetupChecklist.addEventListener('click', async () => { + try { + const envelope = await runInspection('diagnostics', {}, setupChecklistStatus); + lastOverview = envelope.result; + updateSetupChecklist(envelope.result); + } catch (error) { text(setupChecklistStatus, error.message); } +}); +refreshDataOverview.addEventListener('click', () => refreshOverview(dataOverview)); + +runNetworkDoctor.addEventListener('click', async () => { + downloadNetworkDiagnostics.disabled = true; + lastDiagnostics = null; + try { + const diagnostics = await runInspection('diagnostics', {}, networkDoctorResults); + lastOverview = diagnostics.result; + const node = nodeIndex.get(selectedServerId); + const checks = { + controlConnected: Boolean(node?.online), + configurationHealthy: diagnostics.result.configurationHealthy, + votifierDetected: diagnostics.result.votifierDetected, + voteSitesConfigured: Number(diagnostics.result.configuredVoteSites) > 0, + processRewards: diagnostics.result.processRewards, + voteLoggingEnabled: diagnostics.result.voteLoggingEnabled, + topologyReported: isBackend(node) ? proxyReportsFor(node.nodeId).length > 0 || !diagnostics.result.proxyMode : true + }; + lastDiagnostics = { + schemaVersion: 1, generatedAt: new Date().toISOString(), selectedNodeId: selectedServerId, + checks, node: diagnostics.result, + control: {application: 'VotingPlugin Control', registeredNodes: allNodeItems.length, + nodes: allNodeItems.slice(0, 100).map(item => ({nodeId: item.nodeId, displayName: item.displayName, + role: roleLabel(item), online: item.online, pluginVersion: item.pluginVersion}))} + }; + renderJsonResult(networkDoctorResults, lastDiagnostics); + updateSetupChecklist(diagnostics.result); + downloadNetworkDiagnostics.disabled = false; + } catch (error) { text(networkDoctorResults, error.message); } +}); + +downloadNetworkDiagnostics.addEventListener('click', () => { + if (lastDiagnostics) downloadJson(`votingplugin-diagnostics-${selectedServerId || 'node'}.json`, lastDiagnostics); +}); + +runDriftCheck.addEventListener('click', async () => { + const nodeIds = targets('config.files.v1'); + const selectedFile = driftFile.value; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds, configuration: {domain: 'file', fileName: selectedFile} + }, driftResults); + const rows = nodeIds.map(nodeId => { + const result = operation.results[nodeId]; + return {nodeId, success: Boolean(result?.success), revision: result?.revision || null, + content: result?.configuration?.content ?? null, error: result?.success ? null : result?.message || result?.code}; + }); + const comparable = rows.filter(row => row.success && typeof row.content === 'string'); + const contentNotRetained = rows.filter(row => row.success && typeof row.content !== 'string') + .map(row => row.nodeId); + const groups = new Map(); + comparable.forEach(row => { + const key = row.revision || row.content; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row.nodeId); + }); + lastFileReadOperation = comparable.length > 0 ? {operationId: operation.operationId} : null; + comparable.forEach(row => { + const session = nodeIndex.get(row.nodeId)?.sessionId || ''; + cacheFile(`${row.nodeId}|${session}|${selectedFile}`, row.content, operation.operationId); + }); + const baseline = comparable[0]; + const differences = comparable.filter(row => row !== baseline).map(row => { + const left = baseline.content.split('\n'); + const right = row.content.split('\n'); + const changes = []; + for (let index = 0; index < Math.max(left.length, right.length) && changes.length < 50; index++) { + if (left[index] !== right[index]) changes.push({line: index + 1, + baseline: String(left[index] ?? '').slice(0, 200), target: String(right[index] ?? '').slice(0, 200)}); + } + return {baselineNode: baseline.nodeId, targetNode: row.nodeId, changes, + truncated: changes.length === 50}; + }); + renderJsonResult(driftResults, {fileName: selectedFile, driftDetected: groups.size > 1, + warning: contentNotRetained.length > 0 + ? 'Some successful file bodies exceeded Control’s 8 MiB aggregate retention bound. Compare fewer targets in batches.' : null, + contentNotRetained, + revisionGroups: [...groups.entries()].map(([revision, nodes]) => ({revision, nodes})), + nodes: rows.map(({content, ...row}) => ({...row, contentBytes: content == null ? 0 : new Blob([content]).size})), + differences}); + } catch (error) { text(driftResults, error.message); } + updateExtendedButtons(); +}); + +async function loadSnapshots() { + try { + const body = await authorized('/api/v1/snapshots'); + snapshotList.replaceChildren(); + if (!Array.isArray(body.items) || body.items.length === 0) { + text(snapshotList, 'No snapshots saved yet.'); + return; + } + body.items.forEach(snapshot => { + const item = document.createElement('article'); + item.className = 'result-item'; + const detail = document.createElement('div'); + detail.append(text(document.createElement('strong'), snapshot.name)); + detail.append(text(document.createElement('small'), `${new Date(snapshot.createdAt).toLocaleString()} · ${snapshot.documents.length} document(s)`)); + const restore = text(document.createElement('button'), 'Load for restore preview'); + restore.type = 'button'; + restore.className = 'secondary compact'; + restore.addEventListener('click', async () => { + restore.disabled = true; + try { + const full = await authorized(`/api/v1/snapshots/${snapshot.snapshotId}`); + const document = full.documents.find(value => value.nodeId === selectedServerId) || full.documents[0]; + if (!document) throw new Error('This snapshot has no restorable document.'); + if (!nodeCapabilities.get(selectedServerId)?.includes('config.files.v1')) { + throw new Error('Choose a connected file-capable Bukkit node before restoring.'); + } + configurationFile.value = document.fileName; + configurationContent.value = document.content; + lastFileReadOperation = null; + updateEditorPosition(); + approvedFilePreview = null; + inputGeneration++; + setActiveTab('configurations', true); + setConfigView('yaml'); + text(fileOperationStatus, `Loaded snapshot “${full.name}” from ${document.nodeId}. Preview the complete file, review the exact changes, then approve to restore it to the selected targets.`); + updateConfigurationButtons(); + updateExtendedButtons(); + } catch (error) { text(snapshotStatus, error.message); } + finally { restore.disabled = false; } + }); + item.append(detail, restore); + snapshotList.append(item); + }); + } catch (error) { text(snapshotStatus, error.message); } +} + +snapshotForm.addEventListener('submit', async event => { + event.preventDefault(); + if (!lastFileReadOperation) return; + try { + const created = await authorized('/api/v1/snapshots', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: snapshotName.value.trim(), operationId: lastFileReadOperation.operationId}) + }); + snapshotName.value = ''; + text(snapshotStatus, `Saved snapshot “${created.name}”.`); + await loadSnapshots(); + } catch (error) { text(snapshotStatus, error.message); } +}); +refreshSnapshots.addEventListener('click', loadSnapshots); + +playerLookupForm.addEventListener('submit', async event => { + event.preventDefault(); + const value = playerLookup.value.trim(); + const filter = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value) ? {uuid: value} : {name: value}; + try { renderJsonResult(playerResult, (await runInspection('player', filter, playerResult)).result); } + catch (error) { text(playerResult, error.message); } +}); + +loadSiteHealth.addEventListener('click', async () => { + try { renderSiteHealthResult((await runInspection('vote-site-health', {days: '30'}, siteHealthResult)).result); } + catch (error) { text(siteHealthResult, error.message); } +}); + +loadVoteLogSummary.addEventListener('click', async () => { + try { renderJsonResult(voteLogSummaryResult, + (await runInspection('vote-log-summary', {days: '30'}, voteLogSummaryResult)).result); } + catch (error) { text(voteLogSummaryResult, error.message); } +}); + +voteLogFilterType.addEventListener('change', () => { + voteLogFilter.disabled = !voteLogFilterType.value; + voteLogFilter.required = Boolean(voteLogFilterType.value); + voteLogFilter.placeholder = voteLogFilterType.value ? `Exact ${voteLogFilterType.value}` : ''; +}); +voteLogForm.addEventListener('submit', async event => { + event.preventDefault(); + const filters = {days: voteLogDays.value, limit: voteLogLimit.value}; + if (voteLogFilterType.value) filters[voteLogFilterType.value] = voteLogFilter.value.trim(); + if (voteLogEvent.value) filters.event = voteLogEvent.value; + try { renderJsonResult(voteLogResult, (await runInspection('vote-log-search', filters, voteLogResult)).result); } + catch (error) { text(voteLogResult, error.message); } +}); + +voteTraceForm.addEventListener('submit', async event => { + event.preventDefault(); + try { renderJsonResult(voteTraceResult, (await runInspection('vote-trace', + {voteId: voteTraceId.value.trim(), days: voteLogDays.value, limit: '100'}, voteTraceResult)).result); } + catch (error) { text(voteTraceResult, error.message); } +}); + +siteResolutionForm.addEventListener('submit', async event => { + event.preventDefault(); + try { renderJsonResult(siteResolutionResult, (await runInspection('vote-site-resolution', + {serviceSite: siteResolutionService.value.trim(), includeDisabled: String(siteResolutionDisabled.checked)}, siteResolutionResult)).result); } + catch (error) { text(siteResolutionResult, error.message); } +}); + +rewardScope.addEventListener('change', () => { rewardSiteLabel.hidden = rewardScope.value !== 'site'; }); +function rewardProposal() { + const items = boundedLines(rewardItems.value).map(line => { + const match = line.match(/^([A-Za-z0-9_]{1,80})\s+([0-9]{1,2})$/); + if (!match || Number(match[2]) < 1 || Number(match[2]) > 64) { + throw new Error(`Invalid item “${line}”. Use MATERIAL and an amount from 1 to 64.`); + } + return {material: match[1].toUpperCase(), amount: Number(match[2])}; + }); + const proposal = {scope: rewardScope.value, commands: boundedLines(rewardCommands.value), + playerMessages: boundedLines(rewardMessages.value), broadcastMessages: boundedLines(rewardBroadcasts.value), + items, permissions: boundedLines(rewardPermissions.value), money: Number(rewardMoney.value), + chancePercent: Number(rewardChance.value), onlineOnly: rewardOnlineOnly.checked}; + if (rewardScope.value === 'site') proposal.site = rewardSite.value.trim(); + return proposal; +} + +rewardSimulationForm.addEventListener('submit', async event => { + event.preventDefault(); + try { + const proposal = rewardProposal(); + const envelope = await runInspection('reward-simulation', {proposal: JSON.stringify(proposal)}, rewardSimulationResult); + renderJsonResult(rewardSimulationResult, envelope.result); + copyRewardToSetup.disabled = proposal.commands.length === 0; + } catch (error) { text(rewardSimulationResult, error.message); } +}); +previewReward.addEventListener('click', async () => { + dedicatedSetupApprovals.delete('reward-builder'); + try { + const proposal = JSON.stringify(rewardProposal()); + if (new TextEncoder().encode(proposal).length > 64 * 1024) throw new Error('Reward proposal exceeds the 64 KiB limit.'); + const nodeIds = backendQuickTargets(); + const signature = JSON.stringify({nodeIds, proposal}); + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds, + configuration: {domain: 'quick-setup', preset: 'reward-builder', options: {proposal}} + }, rewardSimulationResult); + if (signature !== JSON.stringify({nodeIds: backendQuickTargets(), proposal: JSON.stringify(rewardProposal())})) { + text(rewardSimulationResult, 'The target scope or reward changed while previewing. Preview again.'); + } else if (operation.state === 'SUCCEEDED' && operation.approvalToken) { + dedicatedSetupApprovals.set('reward-builder', {operationId: operation.operationId, + approvalToken: operation.approvalToken, nodeIds}); + } + } catch (error) { text(rewardSimulationResult, error.message); } + updateExtendedButtons(); +}); +applyReward.addEventListener('click', async () => { + const approval = dedicatedSetupApprovals.get('reward-builder'); + if (!approval || !window.confirm('Apply this exact reward preview to every selected Bukkit node? It replaces the selected Rewards subtree; sibling sites, scopes, and settings remain unchanged.')) return; + dedicatedSetupApprovals.delete('reward-builder'); + try { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, rewardSimulationResult); + if (operation.state === 'SUCCEEDED') fileReadCache.clear(); + } catch (error) { text(rewardSimulationResult, error.message); } + updateExtendedButtons(); +}); +[rewardScope, rewardSite, rewardChance, rewardMoney, rewardCommands, rewardMessages, rewardBroadcasts, + rewardPermissions, rewardItems, rewardOnlineOnly].forEach(field => field.addEventListener('input', () => { + dedicatedSetupApprovals.delete('reward-builder'); + copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0; + updateExtendedButtons(); + })); +copyRewardToSetup.addEventListener('click', () => { + const command = boundedLines(rewardCommands.value)[0]; + if (!command) return; + pendingDetectedVoteSite = null; + quickPreset.value = 'easy-reward'; + quickRewardScope.value = rewardScope.value === 'site' ? 'site' : 'every-site'; + quickName.value = rewardScope.value === 'site' ? rewardSite.value.trim() : ''; + quickCommand.value = command; + quickMessage.value = boundedLines(rewardMessages.value)[0] || ''; + updateQuickFields(); + clearApprovals(); + document.querySelector('#quick-setup-card').scrollIntoView({behavior: 'smooth', block: 'start'}); +}); + +settingsFilter.addEventListener('input', renderSettingsCatalog); + +saveProfile.addEventListener('click', () => { + const name = profileName.value.trim(); + if (!name || name.length > 60 || /[\p{Cc}]/u.test(name)) { + text(profileStatus, 'Enter a profile name between 1 and 60 visible characters.'); + return; + } + try { + const profiles = readProfiles(); + if (!Object.hasOwn(profiles, name) && Object.keys(profiles).length >= 20) throw new Error('Delete a profile before saving another; the limit is 20.'); + profiles[name] = currentProfileValues(); + writeProfiles(profiles); + populateProfilePicker(); + profilePicker.value = name; + profilePicker.dispatchEvent(new Event('change')); + text(profileStatus, `Saved “${name}” on this browser. It contains the visible setup form values, including entered URLs and commands, but no raw YAML or Control/database credentials.`); + } catch (error) { text(profileStatus, error.message || 'The browser could not save this profile.'); } +}); + +profilePicker.addEventListener('change', () => { + loadProfile.disabled = !profilePicker.value; + deleteProfile.disabled = !profilePicker.value; +}); +loadProfile.addEventListener('click', () => { + const profile = readProfiles()[profilePicker.value]; + if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; } + pendingDetectedVoteSite = null; + const assign = (field, value, max = 500) => { field.value = String(value ?? '').slice(0, max); }; + if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset; + assign(quickName, profile.name, 64); assign(quickMethod, profile.method, 32); + assign(quickSiteDisplayName, profile.siteDisplayName, 200); assign(quickService, profile.service, 200); + assign(quickUrl, profile.url, 500); assign(quickDelay, profile.delay, 20); + assign(quickSitePriority, profile.priority, 3); assign(quickSiteMaterial, profile.material, 100); + quickSiteEnabled.checked = Boolean(profile.siteEnabled); quickSiteHidden.checked = Boolean(profile.siteHidden); + assign(quickRewardScope, profile.rewardScope, 20); assign(quickCommand, profile.command, 500); + assign(quickMessage, profile.playerMessage, 500); quickProcessRewards.checked = Boolean(profile.processRewards); + quickAutoSites.checked = Boolean(profile.autoSites); quickExtraCheck.checked = Boolean(profile.extraCheck); + quickCountFake.checked = Boolean(profile.countFake); quickHideSiteWarning.checked = Boolean(profile.hideWarning); + quickDisableUpdates.checked = Boolean(profile.disableUpdates); assign(quickPartyVotes, profile.partyVotes, 6); + assign(quickPartyCommand, profile.partyCommand, 500); assign(quickPartyBroadcast, profile.partyBroadcast, 500); + quickPartyAll.checked = Boolean(profile.partyAll); quickPartyOnline.checked = Boolean(profile.partyOnline); + quickAutoSitesOnly.checked = Boolean(profile.autoSitesOnly); quickVoteLoggingEnabled.checked = Boolean(profile.voteLogging); + assign(quickVoteLoggingDays, profile.voteLoggingDays, 4); quickVoteLoggingMainMysql.checked = Boolean(profile.voteLoggingMainMysql); + if (profile.rewardBuilder && typeof profile.rewardBuilder === 'object') { + assign(rewardScope, profile.rewardBuilder.scope, 20); assign(rewardSite, profile.rewardBuilder.site, 64); + assign(rewardChance, profile.rewardBuilder.chance, 8); assign(rewardMoney, profile.rewardBuilder.money, 20); + assign(rewardCommands, profile.rewardBuilder.commands, 10020); assign(rewardMessages, profile.rewardBuilder.messages, 10020); + assign(rewardBroadcasts, profile.rewardBuilder.broadcasts, 10020); assign(rewardPermissions, profile.rewardBuilder.permissions, 4020); + assign(rewardItems, profile.rewardBuilder.items, 2020); rewardOnlineOnly.checked = Boolean(profile.rewardBuilder.onlineOnly); + rewardSiteLabel.hidden = rewardScope.value !== 'site'; + copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0; + } + loadedQuickSetup = null; + updateQuickFields(); + clearApprovals(); + text(profileStatus, `Loaded “${profilePicker.value}”. Load live values first if this preset edits existing configuration.`); +}); +deleteProfile.addEventListener('click', () => { + const name = profilePicker.value; + if (!name || !window.confirm(`Delete browser-local setup profile “${name}”?`)) return; + try { + const profiles = readProfiles(); + delete profiles[name]; + writeProfiles(profiles); + populateProfilePicker(); + text(profileStatus, `Deleted “${name}”.`); + } catch (error) { text(profileStatus, error.message || 'The browser could not delete this profile.'); } +}); + +clearOperationHistory.addEventListener('click', loadOperationHistory); + [configurationContent, quickName, quickMethod, quickSiteDisplayName, quickService, quickUrl, quickDelay, quickSitePriority, quickSiteMaterial, quickSiteEnabled, quickSiteHidden, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, - quickPartyAll, quickPartyOnline].forEach(field => field.addEventListener('input', clearApprovals)); -quickName.addEventListener('input', updateQuickFields); + quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays, + quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', clearApprovals)); +quickName.addEventListener('input', () => { + if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null; + updateQuickFields(); +}); configurationContent.addEventListener('input', updateEditorPosition); configurationContent.addEventListener('click', updateEditorPosition); configurationContent.addEventListener('keyup', updateEditorPosition); configurationContent.addEventListener('keydown', handleEditorKeydown); configurationFile.addEventListener('input', () => { configurationContent.value = ''; + lastFileReadOperation = null; updateEditorPosition(); text(fileOperationStatus, 'Read the selected file before previewing changes.'); clearApprovals(); + updateExtendedButtons(); }); quickPreset.addEventListener('input', () => { loadedQuickSetup = null; + if (quickPreset.value !== 'vote-site') pendingDetectedVoteSite = null; updateQuickFields(); clearApprovals(); if (quickPresetNeedsRead()) { @@ -1792,6 +2979,10 @@ async function initialize() { setConfigView('easy'); updateQuickFields(); updatePluginSuggestions(); + renderSettingsCatalog(); + populateProfilePicker(); + rewardSiteLabel.hidden = rewardScope.value !== 'site'; + updateExtendedButtons(); if (!await loadSetupState()) { await restoreSession(); } diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 15f7139..990053c 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -73,7 +73,8 @@

Sign in to your network

- + + @@ -139,6 +140,21 @@

Network

Manage network-wide VotingPlugin behavior and verify the live proxy-to-backend transport without sending a vote.

+
+
+
+

Read-only checks

+

Network Doctor

+

Check Control connectivity, topology, configuration health, Votifier, vote sites, logging, and proxy mode without changing a server.

+
+ Choose an inspection-capable node +
+
+ + +
+
Choose a connected backend with read-only data inspection.
+
@@ -223,7 +239,7 @@

Configurations

-

Common settings

Use guided fields for frequently changed VotingPlugin options.

+

Common settings

Use guided fields for frequently changed VotingPlugin options.

Vote sites

Edit the selected server's complete VoteSites.yml today.

Proxy connection

Inspect proxy relationships before changing backend routing.

@@ -265,11 +281,33 @@

Full VotingPlugin configuration

@@ -278,11 +316,50 @@

Compare VotingPlugin configurations

Guided VotingPlugin changes

-

Quick Setup

-

Generate a safe preview for common backend and vote-site setups, then approve the exact result.

+

Setup

+

Follow the checklist, save reusable non-secret profiles, preview every change, and approve only the exact result.

+
+
+
+
+

Guided checklist

Server readiness

Checks are based on live Control state and a read-only server overview.

+
+
    +
  1. 1
    Enroll and connectChoose a connected Bukkit node.
  2. +
  3. 2
    Choose topologyStandalone or proxy-connected.
  4. +
  5. 3
    Review vote sitesChoose whether unknown services create sites automatically.
  6. +
  7. 4
    Review reward processingConfirm reward processing is enabled, then build and simulate rewards before applying.
  8. +
  9. 5
    Confirm storage and loggingVote logging requires MySQL. Restart the backend after changing Enabled or UseMainMySQL.
  10. +
  11. 6
    Communication test availableFor proxy mode, confirm the non-vote transport test is available under Network, then run it there.
  12. +
+

Choose a backend to begin.

+
+ +
+
+

Vote-site discovery

Auto-create vote sites

Change only AutoCreateVoteSites. Disabling it keeps unknown services visible in health data without generating entries; explicit admin creation remains available.

Not loaded
+ +
0 selected backends
+
+
Choose a capable Bukkit node.
+
+
+

Optional MySQL event history

Vote logging

Enable bounded event logging for searches and vote-ID traces. This is separate from ordinary player totals. Changing Enabled or UseMainMySQL requires a full backend restart after apply; a plugin reload is not enough.

Not loaded
+ +
+
+
Choose a capable Bukkit node. Dedicated MySQL credentials remain in the full YAML editor.
+
+ +
+

Browser-local form values

Setup profiles

Save the current guided form values on this browser. Control/database credentials and raw YAML are never read; review any URLs or commands you entered before saving.

+
+

+
+

Previewed configuration

Setup assistant

@@ -356,6 +435,17 @@

Quick Setup

+
+ Auto-create vote sites + +

This narrow preset changes only AutoCreateVoteSites.

+
+
+ Vote logging + +
+

Changing Enabled or UseMainMySQL requires a full backend restart after apply; a plugin reload is not enough.

+
@@ -370,18 +460,49 @@

Quick Setup

-
Choose an online Bukkit node with quick setup control.
+
Choose an online Bukkit node with setup assistant control.
+
+
+

Build, simulate, then approve

Reward builder & simulator

Describe reward actions, simulate the normalized proposal without side effects, then preview and approve the exact Rewards subtree.

Choose a capable node
+
+
+ + + + + + +

Save behavior: the selected scope's Rewards subtree is replaced; sibling sites, scopes, and settings are preserved. Permissions are stored as long-duration temporary permissions by VotingPlugin.

+
+
+
Add an action, then simulate it safely.
+
+
+

Schema-driven reference

Settings catalog

Known settings include their file, type, default, and effect. Guided forms use these same constraints.

+ +
SettingFileTypeDefaultEffectAfter apply
+ +