From 30a5b808e857e00e32786d0862f143a67f6c9683 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:57:58 -0600 Subject: [PATCH 01/20] Extend Control management foundation --- AGENTS.md | 17 + README.md | 17 +- docs/control-management.md | 31 +- .../domain/ConfigurationOperations.java | 40 +- .../control/domain/InMemoryNodeRegistry.java | 1 + .../protocol/ManagedConfiguration.java | 4 +- src/main/resources/web/app.js | 583 +++++++++++++++--- src/main/resources/web/index.html | 9 +- .../domain/ConfigurationOperationsTest.java | 57 ++ .../control/http/ControlHttpServerTest.java | 12 +- 10 files changed, 685 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e71e523..be530aa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ Requirements: JDK 17+ and Maven 3.9+. ```shell mvn -B clean verify node --check src/main/resources/web/app.js +# From the paired VotingPlugin/VotingPlugin checkout when protocol or node code changes: +mvn -B clean package ``` Use a focused Maven test while iterating, then run the complete command before opening a PR: @@ -42,6 +44,9 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact role, topology, and dependency cancellations at the state transition; do not wait for graceful shutdown. Quick-setup history uses an internal, non-serialized `redacted` marker, and proposal validation must reject that marker. - `domain/InspectionOperations` coordinates the separate, read-only `data.inspect.v1` lane. +- `config.proxy-files.v1` is the deliberately narrow proxy-file capability: it manages only a proxy node's + `bungeeconfig.yml`, through the same revision-bound READ/PREVIEW/one-time-APPLY operation model. It is not generic + proxy filesystem access. The paired node service is `VotingPlugin/.../proxy/control/ProxyConfigurationFileService`. - `domain/ConfigurationSnapshots` stores bounded copies of the redacted content returned by completed managed-file reads. A successful empty file remains a non-null empty document rather than being confused with omitted content. The list API omits content, full reads are admin-only, and durable files are owner-permissioned where the platform supports POSIX @@ -69,12 +74,19 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact 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. + `bungeeconfig.yml` is proxy-only and requires `config.proxy-files.v1`; managed Bukkit files require + `config.files.v1`. Proxy-file reads mask secrets, preserve redaction markers/comments, reject unsafe YAML and symlinks, + and atomically stage, back up, and publish. General proxy-file changes may still require a proxy restart; do not report + them as hot-applied. 5. A claimed task has a two-minute lease and a unique `attemptId`. A result must echo the current node session, the exact session that claimed the lease, and the attempt; stale attempts or post-claim reconnects cannot complete work. Claim, completion, and capability-loss state transitions must roll back if their durable audit append fails. 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. + Exact player inspection may expose only bounded, allow-listed stored VotingPlugin fields, never arbitrary database + columns, raw offline-vote payloads, or player enumeration. Adding player mutation requires a separate durable, + revisioned, capability-negotiated operation; do not turn inspection into an editor. 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 @@ -122,6 +134,11 @@ when the pieces are independently deployable or need materially different review 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. +`config.proxy-method.v1` persists a validated method on the proxy and reported backends. Backends reload only their proxy +communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so +teardown cannot lose the completion. A failed backend reload must restore its backup. Control availability or a method +switch must never delay or prevent normal proxy or backend startup. + ## Safe change checklist - Identify the trust boundary and maximum sizes before adding the happy path. diff --git a/README.md b/README.md index ca259f9e..bfc51a99 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,10 @@ 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, +`BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.proxy-files.v1` is separate and +manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict, +bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings +are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone, 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 @@ -198,8 +201,9 @@ explicitly included in configuration changes, so one slow secondary node does no `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 +schemas; exact player results may include only a bounded allow-list of safe stored VotingPlugin fields when available. +There is no raw SQL, arbitrary player enumeration or editing, 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.” @@ -216,6 +220,13 @@ backup if reload fails. The WebUI labels a rolled-back node as not saved and dis the plugin. Bukkit reads normalize YAML; administrators should expect comments and formatting to be normalized when applying through the full editor. +The `proxy-method` guided preset is intentionally different from a normal proxy configuration save. Following preview and +approval, VotingPlugin preflights and persists the selected communication method across the reported network. Backends +reload only their proxy communication handler. The proxy first acknowledges its durable result and then replaces its +runtime, preventing teardown from losing the operation result. A persistence or backend handler reload failure restores +the local backup; resolve the reported node failure and preview again rather than assuming a network-wide method change +was committed. + `configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. A cross-process lifetime lock prevents two Control processes from forking the same audit chain. Both the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new diff --git a/docs/control-management.md b/docs/control-management.md index e5cec99c..66401779 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -19,6 +19,15 @@ Connectors always initiate outbound HTTP(S) to Control. No Control feature adds One node credential is bound to one stable node ID. Browser sessions and the API automation credential are separate from node credentials. +## Paired development verification + +For a Control-only change, run the focused tests first and then `mvn -B clean verify` in this repository. Any change to +the negotiated capability map, a node task/result, proxy transport activation, or inspection schema also requires the +paired `VotingPlugin/VotingPlugin` checkout to pass its full `mvn -B clean package` verification. Run `git diff --check` +in every modified repository; run `node --check src/main/resources/web/app.js` when the static WebUI changes. Control +must remain optional throughout these checks: an unavailable or older peer must reject the new capability cleanly, never +block normal vote processing or server/proxy startup. + ## Capability map Capability negotiation is the compatibility boundary. A node advertises capabilities during registration/heartbeat; @@ -29,12 +38,13 @@ Control accepts only the intersection with its own allow-list. | `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.proxy-files.v1` | Revisioned, redacted management of that proxy's single `bungeeconfig.yml` file | | `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 | +| `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for 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. @@ -50,6 +60,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | 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 | +| Proxy configuration | Opens `bungeeconfig.yml` only for the selected online proxy that negotiated `config.proxy-files.v1` | Fixed one-file capability, never proxy file browsing; redacted READ, PREVIEW, and one-time approved APPLY still apply | | 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 | @@ -73,7 +84,8 @@ values should be loaded before modifying an existing configuration. - `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/`; + `BungeeSettings.yml`, or one validated split file under `VoteSites/` on a Bukkit node. A proxy node can separately + manage only its fixed `bungeeconfig.yml` when it negotiated `config.proxy-files.v1`; - `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. @@ -95,6 +107,11 @@ 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. +`bungeeconfig.yml` is strict UTF-8 safe YAML with duplicate keys, aliases, merge keys, symlinks, and oversized content +rejected. Its comment-aware editor preserves existing comments/formatting where possible and keeps redacted secret values +and their markers safe. It stages a permission-preserving backup and an atomic replacement before reporting a save. A +normal proxy-file save intentionally does not reload or restart the proxy: the result must tell the administrator that +general setting activation still requires a proxy restart. ### Read, preview, and apply @@ -106,6 +123,12 @@ authenticated proposal, but Control omits file proposal contents from operation 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. +The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, or `SOCKETS` +method on the proxy and its reported backends. The proxy acknowledges its durable result before replacing its runtime, so +the operation result cannot be lost during teardown. Backends reload only their proxy communication handler. If a durable +write or backend handler reload fails, its local backup is restored and the operation reports the failed/rolled-back state +rather than a false network-wide success. + 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. @@ -261,7 +284,7 @@ task is string-valued and VotingPlugin's handler validates text after selecting | --- | --- | --- | | `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 | +| `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, backend pending-offline count, and a bounded read-only allow-list of stored VotingPlugin fields when storage is available; never player enumeration or editing | | `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 | @@ -285,7 +308,7 @@ values; `generatedAt` is the ISO-8601 string generated by the connector. | --- | --- | | `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 | +| `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. When the storage row is safely available, `storage`, `storageRowAvailable`, bounded allow-listed `columns`, and `columnsTruncated` describe stored VotingPlugin values. 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` | 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 c2778861..41d45975 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -28,6 +28,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String CAPABILITY = "config.proxy-routing.v1"; public static final String FILE_CAPABILITY = "config.files.v1"; + public static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; @@ -84,13 +85,16 @@ public synchronized OperationView createRead(List nodeIds, ManagedConfig throw invalid("reward builder is preview/apply only"); } selector.validateProposal(); - return create("READ", validateTargets(nodeIds, selector.capability()), selector, null); + ValidatedTargets targets = validateTargets(nodeIds, selector.capability()); + validateConfigurationTargets(targets, selector); + return create("READ", targets, selector, null); } public synchronized OperationView createPreview(List nodeIds, ManagedConfiguration configuration) { if (configuration == null) throw invalid("configuration is required"); configuration.validateProposal(); ValidatedTargets targets = validateTargets(nodeIds, configuration.capability()); + validateConfigurationTargets(targets, configuration); validateProxyMethodTargets(targets, configuration); byte[] token = new byte[32]; random.nextBytes(token); @@ -117,6 +121,7 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok ValidatedTargets targets = validateTargets(new ArrayList<>(preview.states.keySet()), preview.configuration.capability()); validateApprovedTargets(preview, targets); + validateConfigurationTargets(targets, preview.configuration); validateProxyMethodTargets(targets, preview.configuration); rejectOverlappingProxyMethodApply(targets, preview.configuration); rejectOverlappingVoteLoggingApply(targets, preview.configuration); @@ -185,6 +190,7 @@ public synchronized OperationView retry(UUID id) { List requested = "PREVIEW".equals(original.type) ? new ArrayList<>(original.states.keySet()) : failed; ValidatedTargets targets = validateTargets(requested, original.configuration.capability()); + validateConfigurationTargets(targets, original.configuration); if ("APPLY".equals(original.type)) { validateApprovedTargets(original, targets); rejectOverlappingVoteLoggingApply(targets, original.configuration); @@ -230,6 +236,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { Instant leased = operation.leasedAt.get(nodeId); if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null && !now.isBefore(leased.plus(LEASE)))) { + if (cancelChangedFileRole(operation, node)) continue; if (cancelChangedProxyMethodRole(operation, node)) continue; if (deferProxyMethodApply(operation, node)) continue; if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { @@ -338,6 +345,21 @@ private boolean cancelChangedProxyMethodRole(StoredOperation operation, NodeStat return true; } + private boolean cancelChangedFileRole(StoredOperation operation, NodeStatus node) { + if (!ManagedConfiguration.FILE.equals(operation.configuration.domain())) return false; + String expectedPlatform = operation.targetPlatforms.get(node.nodeId()); + boolean proxyFile = "bungeeconfig.yml".equals(operation.configuration.fileName()); + boolean currentRoleMatches = proxyFile + ? !"BUKKIT".equalsIgnoreCase(node.platform()) + : "BUKKIT".equalsIgnoreCase(node.platform()); + if (expectedPlatform != null && expectedPlatform.equalsIgnoreCase(node.platform()) && currentRoleMatches) { + return false; + } + automaticCancellation(operation, node.nodeId(), sessionId(node), "TARGET_CHANGED", + "Node platform changed after the task was created; create it again", "TARGET_ROLE_CHANGED"); + return true; + } + /** * Commits an automatic task completion only after the redacted journal is durable. Both the * operation maps and retention counters are restored if either durable step fails, leaving the @@ -438,6 +460,22 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu } } + private static void validateConfigurationTargets(ValidatedTargets targets, + ManagedConfiguration configuration) { + if (!ManagedConfiguration.FILE.equals(configuration.domain())) return; + boolean proxyFile = "bungeeconfig.yml".equals(configuration.fileName()); + List invalid = targets.nodeIds().stream() + .filter(nodeId -> proxyFile + ? "BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId)) + : !"BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId))) + .toList(); + if (!invalid.isEmpty()) { + throw new ValidationException("INVALID_TARGET", + proxyFile ? "Proxy configuration files require proxy nodes" + : "Backend configuration files require Bukkit nodes", invalid); + } + } + private static void validateApprovedTargets(StoredOperation preview, ValidatedTargets current) { for (String nodeId : current.nodeIds()) { if (!Objects.equals(preview.targetSessions.get(nodeId), current.sessions().get(nodeId)) 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 e0d42ece..176c010f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -27,6 +27,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, + ConfigurationOperations.PROXY_FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, "config.file-comments.v1", InspectionQuery.CAPABILITY); 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 d0d22a0c..407ed31c 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -125,7 +125,7 @@ public void validateProposal() { public String capability() { return switch (domain) { case PROXY_ROUTING -> "config.proxy-routing.v1"; - case FILE -> "config.files.v1"; + case FILE -> "bungeeconfig.yml".equals(fileName) ? "config.proxy-files.v1" : "config.files.v1"; case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) ? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset) ? "config.transport-test.v1" : PROXY_METHOD.equals(preset) @@ -161,7 +161,7 @@ private static int utf8Bytes(String value) { private static void validateFileName(String value) { if (value == null || value.length() > 160 - || !value.matches("(?:Config|VoteSites|SpecialRewards|GUI|Shop|BungeeSettings)\\.yml|VoteSites/[A-Za-z0-9._-]{1,100}\\.yml")) { + || !value.matches("(?:Config|VoteSites|SpecialRewards|GUI|Shop|BungeeSettings)\\.yml|bungeeconfig\\.yml|VoteSites/[A-Za-z0-9._-]{1,100}\\.yml")) { throw new IllegalArgumentException("configuration file name is not managed"); } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 3790e4a8..99511eee 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -202,6 +202,8 @@ const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; const MAX_OPERATION_TARGETS = 100; +const MAX_TRACE_NODES = 12; +const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; @@ -246,6 +248,9 @@ const SETUP_PROFILE_KEY = 'votingplugin-control.setup-profiles.v1'; let fileReadCache = new Map(); let lastFileReadOperation = null; let configurationContentPresent = false; +let configurationDirty = false; +let routingDirty = false; +let configurationFileSelection = configurationFile.value; let inspectionInFlight = false; let lastDiagnostics = null; let lastOverview = null; @@ -253,6 +258,7 @@ let operationHistoryItems = []; let dedicatedSetupApprovals = new Map(); let pendingDetectedVoteSite = null; let voteLoggingRestartPending = new Map(); +let autoLoadInFlight = new Set(); function text(element, value) { element.textContent = value; @@ -279,6 +285,32 @@ function inspectionCapableNode() { return node?.online && node.acceptedCapabilities.includes('data.inspect.v1') ? node : null; } +function connectedInspectionNodes() { + return allNodeItems.filter(node => node.online && isBackend(node) + && node.acceptedCapabilities.includes('data.inspect.v1')); +} + +function selectedFileCapability(fileName = configurationFile.value) { + return fileName === 'bungeeconfig.yml' ? 'config.proxy-files.v1' : 'config.files.v1'; +} + +function fileTargetsForSelection(fileName = configurationFile.value) { + const capability = selectedFileCapability(fileName); + const selected = nodeIndex.get(selectedServerId); + if (capability === 'config.proxy-files.v1') { + return selected?.online && isProxy(selected) && selected.acceptedCapabilities.includes(capability) + ? [selected.nodeId] : []; + } + return targets(capability).filter(nodeId => isBackend(nodeIndex.get(nodeId))); +} + +function fileTargetDescription(fileName = configurationFile.value) { + const targetsForFile = fileTargetsForSelection(fileName); + if (fileName === 'bungeeconfig.yml') return targetsForFile.length + ? `the selected proxy (${targetsForFile[0]})` : 'the selected proxy'; + return `every selected Bukkit node`; +} + function boundedLines(value, maximum = 20) { const lines = value.split(/\r?\n/).map(item => item.trim()).filter(Boolean); if (lines.length > maximum) throw new Error(`At most ${maximum} reward lines are allowed.`); @@ -320,6 +352,83 @@ function renderJsonResult(element, value, emptyMessage = 'No data returned.') { element.append(pre); } +function formatEpoch(value) { + const epoch = Number(value); + return Number.isFinite(epoch) && epoch > 0 ? new Date(epoch).toLocaleString() : 'Unknown'; +} + +function renderPlayerData(value) { + playerResult.replaceChildren(); + if (!value || value.found !== true) { + text(playerResult, value?.found === false ? 'Player not found.' : 'No player queried.'); + return; + } + const summary = document.createElement('p'); + summary.append(text(document.createElement('strong'), `${value.name || 'Unknown'} · ${value.uuid || 'Unknown'}`)); + const storage = typeof value.storage === 'string' ? ` · ${value.storage}` + : value.storageRowAvailable === false ? ' · stored row unavailable' : ''; + const columnCount = Array.isArray(value.columns) ? ` · ${value.columns.length} stored columns` : ''; + summary.append(document.createTextNode(`${storage}${columnCount}`)); + playerResult.append(summary); + const profile = document.createElement('dl'); + profile.className = 'detail-list'; + const add = (label, detail) => { + profile.append(text(document.createElement('dt'), label)); + profile.append(text(document.createElement('dd'), detail)); + }; + const totals = value.totals && typeof value.totals === 'object' ? value.totals : {}; + add('Online', value.online === true ? 'Yes' : 'No'); + add('Votes', `Daily ${totals.daily ?? 'Unknown'} · Weekly ${totals.weekly ?? 'Unknown'} · Monthly ${totals.monthly ?? 'Unknown'} · All-time ${totals.allTime ?? 'Unknown'}`); + add('Points', value.points ?? 'Unknown'); + add('Last vote', formatEpoch(value.lastVoteTime)); + add('Last online', formatEpoch(value.lastOnline)); + playerResult.append(profile); + if (!Array.isArray(value.columns)) return; + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const headRow = document.createElement('tr'); + ['Column', 'Storage type', 'Exact stored value'].forEach(label => headRow.append(text(document.createElement('th'), label))); + head.append(headRow); + const body = document.createElement('tbody'); + value.columns.forEach(column => { + const row = document.createElement('tr'); + row.append(text(document.createElement('td'), column.name)); + row.append(text(document.createElement('td'), column.type)); + const value = document.createElement('td'); + const code = document.createElement('code'); + text(code, column.value); + value.append(code); + row.append(value); + body.append(row); + }); + table.append(head, body); + scroll.append(table); + playerResult.append(scroll); + if (value.columnsTruncated === true) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'Some stored values were omitted by the bounded, allow-listed inspection contract.'); + playerResult.append(warning); + } +} + +async function loadPlayerData(value) { + const request = value.trim(); + const filters = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request) + ? {uuid: request} : {name: request}; + if (!filters.uuid && !/^[A-Za-z0-9_]{1,16}$/.test(request)) { + throw new Error('Enter a complete Minecraft player name or canonical UUID.'); + } + try { + renderPlayerData((await runInspection('player', filters, playerResult)).result); + } catch (error) { + text(playerResult, error.message); + throw error; + } +} + function renderSiteHealthResult(value) { renderJsonResult(siteHealthResult, value); const services = Array.isArray(value?.detectedUnconfiguredServices) @@ -415,6 +524,168 @@ async function runInspection(kind, filters = {}, statusElement = null) { } } +async function runInspectionOnNode(node, kind, filters = {}, options = {}) { + const nodeId = node?.nodeId; + const sessionId = node?.sessionId; + if (!nodeId || !sessionId) throw new Error('The inspection node is unavailable.'); + const requestAuthenticationGeneration = authenticationGeneration; + const boundedFilters = {}; + Object.entries(filters).forEach(([key, value]) => { + const serialized = String(value); + if (new TextEncoder().encode(serialized).length > 500) throw new Error(`${key} exceeds the bounded inspection limit.`); + boundedFilters[key] = serialized; + }); + if (options.manageBusy !== false) { + inspectionInFlight = true; + updateExtendedButtons(); + } + try { + let inspection = await authorized('/api/v1/inspections', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({nodeId, query: {kind, filters: boundedFilters}}) + }); + const requestedDeadline = Number(options.deadlineAt); + const deadline = Number.isFinite(requestedDeadline) + ? Math.min(Date.now() + 180_000, requestedDeadline) + : Date.now() + 180_000; + while (inspection.state === 'RUNNING') { + if (Date.now() >= deadline) throw new Error('Inspection did not finish within this request budget.'); + await new Promise(resolve => window.setTimeout(resolve, Math.min(1000, Math.max(0, deadline - Date.now())))); + inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + } + if (requestAuthenticationGeneration !== authenticationGeneration || sessionId !== nodeIndex.get(nodeId)?.sessionId) { + throw new Error('The node reconnected or the session changed while the trace ran.'); + } + 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 { + if (options.manageBusy !== false) { + inspectionInFlight = false; + updateExtendedButtons(); + } + } +} + +function traceEventKey(event) { + return ['voteId', 'voteTime', 'playerUuid', 'playerName', 'service', 'server', 'event', 'context', 'status', 'cachedTotal'] + .map(key => String(event?.[key] ?? '')).join('\u0000'); +} + +function renderVoteTrace(trace) { + voteTraceResult.replaceChildren(); + const summary = document.createElement('p'); + text(summary, `${trace.events.length} unique retained ${trace.events.length === 1 ? 'event' : 'events'} from ${trace.sources.length} readable ${trace.sources.length === 1 ? 'node' : 'nodes'}.`); + voteTraceResult.append(summary); + if (trace.events.length) { + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const row = document.createElement('tr'); + ['Time', 'Event', 'Status', 'Player', 'Service', 'Server', 'Context', 'Source nodes'].forEach(label => row.append(text(document.createElement('th'), label))); + head.append(row); + const body = document.createElement('tbody'); + trace.events.forEach(item => { + const event = item.event; + const eventRow = document.createElement('tr'); + const time = Number(event.voteTime); + eventRow.append(text(document.createElement('td'), Number.isFinite(time) && time > 0 ? new Date(time).toLocaleString() : 'Unknown')); + eventRow.append(text(document.createElement('td'), event.event || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.status || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.playerName || event.playerUuid || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.service || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.server || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.context || '')); + eventRow.append(text(document.createElement('td'), item.sources.join(', '))); + body.append(eventRow); + }); + table.append(head, body); + scroll.append(table); + voteTraceResult.append(scroll); + } + const source = document.createElement('p'); + text(source, `Readable sources: ${trace.sources.join(', ') || 'none'}.`); + voteTraceResult.append(source); + if (trace.unavailable.length) { + const diagnostics = document.createElement('p'); + diagnostics.className = 'warning-text'; + text(diagnostics, `Unavailable or failed sources: ${trace.unavailable.join('; ')}.`); + voteTraceResult.append(diagnostics); + } + const boundary = document.createElement('p'); + boundary.className = 'warning-text'; + text(boundary, 'This is the complete retained trace returned by the readable nodes above. It cannot show an internal hop that no node recorded.'); + voteTraceResult.append(boundary); +} + +async function traceVoteAcrossNodes() { + const available = connectedInspectionNodes(); + const candidates = available.slice(0, MAX_TRACE_NODES); + if (candidates.length === 0) throw new Error('No connected backend supports vote-log inspection.'); + const voteId = voteTraceId.value.trim(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(voteId)) { + throw new Error('Enter a canonical vote UUID.'); + } + const requestAuthenticationGeneration = authenticationGeneration; + const requestInputGeneration = inputGeneration; + const requestSelectedNodeId = selectedServerId; + const requestSelectedSessionId = nodeIndex.get(requestSelectedNodeId)?.sessionId; + const days = String(voteLogDays.value); + const candidateSessions = new Map(candidates.map(node => [node.nodeId, node.sessionId])); + const traceDeadline = Date.now() + TRACE_DEADLINE_MS; + const contextCurrent = () => requestAuthenticationGeneration === authenticationGeneration + && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId + && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId + && days === String(voteLogDays.value) + && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); + const events = new Map(); + const sources = []; + const unavailable = []; + if (available.length > candidates.length) unavailable.push(`${available.length - candidates.length} additional capable nodes were omitted by the ${MAX_TRACE_NODES}-node trace limit`); + inspectionInFlight = true; + updateExtendedButtons(); + text(voteTraceResult, `Collecting retained events from ${candidates.length} connected backend ${candidates.length === 1 ? 'node' : 'nodes'}…`); + try { + for (const node of candidates) { + if (!contextCurrent() || Date.now() >= traceDeadline) break; + try { + const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, + {deadlineAt: traceDeadline, manageBusy: false}); + const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; + sources.push(`${node.displayName} (${node.nodeId})`); + listed.forEach(event => { + if (!event || typeof event !== 'object') return; + const key = traceEventKey(event); + const retained = events.get(key) || {event, sources: []}; + retained.sources.push(`${node.displayName} (${node.nodeId})`); + events.set(key, retained); + }); + } catch (error) { + unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); + } + } + if (!contextCurrent()) return; + if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); + const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => + Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) + || String(left.event.event || '').localeCompare(String(right.event.event || '')) + || String(left.event.server || '').localeCompare(String(right.event.server || ''))); + renderVoteTrace({events: ordered, sources, unavailable}); + } 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'; @@ -676,6 +947,10 @@ function applyAuthenticatedSession(body) { pendingDetectedVoteSite = null; configurationContent.value = ''; configurationContentPresent = false; + configurationDirty = false; + routingDirty = false; + configurationFileSelection = configurationFile.value; + autoLoadInFlight.clear(); inputGeneration++; logout.hidden = false; authCard.hidden = true; @@ -690,8 +965,7 @@ function applyAuthenticatedSession(body) { } function isProxy(node) { - return ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform).toUpperCase()) || - node.acceptedCapabilities.includes('config.proxy-routing.v1'); + return ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform).toUpperCase()); } function isBackend(node) { @@ -713,6 +987,7 @@ function platformLabel(platform) { function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', + 'config.proxy-files.v1': 'Proxy configuration', 'config.file-comments.v1': 'Comments preserved', 'config.vote-sites-sync.v1': 'VoteSites sync', 'config.transport-test.v1': 'Communication test', @@ -795,7 +1070,7 @@ function nodeCard(node) { selector.className = 'node-select'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'] + const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1'] .some(capability => node.acceptedCapabilities.includes(capability)); checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; checkbox.checked = selectedNodes.has(node.nodeId); @@ -848,12 +1123,74 @@ function setActiveTab(tab, updateHash = false) { tabButtons.forEach(button => button.setAttribute('aria-selected', String(button.dataset.tab === tab))); tabPanels.forEach(panel => { panel.hidden = panel.dataset.panel !== tab; }); if (updateHash && window.location.hash !== `#${tab}`) window.history.replaceState(null, '', `#${tab}`); + void autoLoadTab(tab); } function setConfigView(view) { if (!configViewPanels.some(panel => panel.dataset.configPanel === view)) view = 'easy'; configViewButtons.forEach(button => button.classList.toggle('active', button.dataset.configView === view)); configViewPanels.forEach(panel => { panel.hidden = panel.dataset.configPanel !== view; }); + if (view === 'yaml') void autoLoadTab('configurations'); +} + +function resetFileEditorForSelection(message) { + configurationContent.value = ''; + configurationContentPresent = false; + configurationDirty = false; + lastFileReadOperation = null; + approvedFilePreview = null; + updateEditorPosition(); + text(fileOperationStatus, message); +} + +function confirmDiscardUnsavedConfiguration(context) { + if (!configurationDirty && !routingDirty) return true; + return window.confirm(`Discard unsaved ${configurationDirty && routingDirty ? 'YAML and routing' : configurationDirty ? 'YAML' : 'routing'} changes before ${context}?`); +} + +function syncFileSelection() { + const selected = nodeIndex.get(selectedServerId); + const proxyFile = [...configurationFile.options].find(option => option.value === 'bungeeconfig.yml'); + if (proxyFile) proxyFile.disabled = !(selected?.online && isProxy(selected) + && selected.acceptedCapabilities.includes('config.proxy-files.v1')); + const proxySelected = Boolean(selected?.online && isProxy(selected) + && selected.acceptedCapabilities.includes('config.proxy-files.v1')); + const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; + if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') + || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { + configurationFile.value = expected; + configurationFileSelection = expected; + resetFileEditorForSelection('Read the selected file before previewing changes.'); + } +} + +async function autoLoadTab(tab) { + if (!authenticated || autoLoadInFlight.has(tab)) return; + if (tab === 'configurations') { + const yamlVisible = configViewPanels.some(panel => panel.dataset.configPanel === 'yaml' && !panel.hidden); + if (!yamlVisible || configurationDirty || configurationContentPresent || !fileTargetsForSelection().length) return; + autoLoadInFlight.add(tab); + try { await loadFileConfiguration(true); } finally { autoLoadInFlight.delete(tab); } + return; + } + if (tab === 'network' && !proxyMethodWorkflowInFlight) { + autoLoadInFlight.add(tab); + try { + await Promise.all([proxyMethodCurrentValue ? Promise.resolve() : loadProxyMethod(true), + routingDirty || approvedPreview ? Promise.resolve() : loadProxyRouting(true)]); + } finally { autoLoadInFlight.delete(tab); } + return; + } + if (tab === 'quick-setup' && quickPresetReadable() && !loadedQuickSetup + && !approvedQuickPreview && !configurationOperationsInFlight) { + autoLoadInFlight.add(tab); + try { await loadQuickSetupValues(true); } finally { autoLoadInFlight.delete(tab); } + return; + } + if (tab === 'data' && inspectionCapableNode() && !inspectionInFlight && !lastOverview) { + autoLoadInFlight.add(tab); + try { await refreshOverview(dataOverview); } finally { autoLoadInFlight.delete(tab); } + } } function chooseDefaultServer(items) { @@ -895,8 +1232,10 @@ function renderSelectedServer() { text(configurationContext, 'Choose a backend from the server picker to work with its VotingPlugin configuration.'); text(commentPreservationState, 'Comment support unknown'); commentPreservationState.className = 'pill warning'; + syncFileSelection(); return; } + syncFileSelection(); text(selectedServerName, selected.displayName); text(selectedServerState, selected.online ? 'Control connected' : 'Control disconnected'); selectedServerState.className = `pill ${selected.online ? 'online' : 'offline'}`; @@ -907,7 +1246,7 @@ function renderSelectedServer() { `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion}.${relationshipText}`); text(configurationContext, `${selected.displayName} (${selected.nodeId}) · ${roleLabel(selected)} · ${selected.online ? 'Control connected' : 'Control disconnected'}`); - const fileTargets = targets('config.files.v1'); + const fileTargets = fileTargetsForSelection(); const preservesComments = fileTargets.length > 0 && fileTargets.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.file-comments.v1')); text(commentPreservationState, preservesComments ? 'Comments preserved for every target' : 'Comments not guaranteed for every target'); @@ -1236,13 +1575,13 @@ function updateExtendedButtons() { 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 fileTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0; runNetworkDoctor.disabled = !inspectionReady; downloadNetworkDiagnostics.disabled = !lastDiagnostics; refreshSetupChecklist.disabled = !inspectionReady; refreshDataOverview.disabled = !inspectionReady; - lookupPlayer.disabled = !inspectionReady; + lookupPlayer.disabled = !inspectionReady || configurationOperationsInFlight > 0; loadSiteHealth.disabled = !inspectionReady; loadVoteLogSummary.disabled = !inspectionReady; searchVoteLog.disabled = !inspectionReady; @@ -1293,11 +1632,9 @@ function renderNodeViews() { function resetServerConfigurationForms(status) { configurationForm.reset(); - configurationContent.value = ''; - configurationContentPresent = false; + routingDirty = false; + resetFileEditorForSelection(status); text(operationStatus, status); - text(fileOperationStatus, status); - updateEditorPosition(); clearApprovals(); } @@ -1352,6 +1689,10 @@ function resetServerContextValues(reason) { function selectPrimaryServer(nodeId) { if (nodeId && !nodeIndex.has(nodeId)) return; + if (nodeId !== selectedServerId && !confirmDiscardUnsavedConfiguration('switching servers')) { + serverPicker.value = selectedServerId; + return; + } selectedServerId = nodeId; serverPicker.value = nodeId; selectedNodes.clear(); @@ -1365,8 +1706,9 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && targets('config.proxy-routing.v1').length > 0 && !busy; - const fileReady = authenticated && primaryCapabilities.includes('config.files.v1') && - targets('config.files.v1').length > 0 && !busy; + const fileCapability = selectedFileCapability(); + const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && + fileTargetsForSelection().length > 0 && !busy; const syncSelected = quickPreset.value === 'sync-vote-sites'; const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) @@ -1561,7 +1903,11 @@ function discardAuthenticationState(reason) { nodePlugins.clear(); configurationForm.reset(); fileConfigurationForm.reset(); + configurationFileSelection = configurationFile.value; configurationContentPresent = false; + configurationDirty = false; + routingDirty = false; + autoLoadInFlight.clear(); quickSetupForm.reset(); resetDedicatedSetupValues(); rewardSimulationForm.reset(); @@ -1675,17 +2021,29 @@ function operationSummary(operation) { return lines.join('\n'); } -async function waitForOperation(operation, statusElement = operationStatus) { - text(statusElement, operationSummary(operation)); +function operationContext() { + return {authenticationGeneration, inputGeneration, selectedServerId, + selectedSessionId: nodeIndex.get(selectedServerId)?.sessionId}; +} + +function operationContextCurrent(context) { + return context.authenticationGeneration === authenticationGeneration && context.inputGeneration === inputGeneration + && context.selectedServerId === selectedServerId + && context.selectedSessionId === nodeIndex.get(context.selectedServerId)?.sessionId; +} + +async function waitForOperation(operation, statusElement = operationStatus, context = operationContext()) { + if (operationContextCurrent(context)) 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)); + if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); } rememberVoteLoggingRestart(operation); - if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { + if (operationContextCurrent(context) && operation.type === 'APPLY' + && Object.values(operation.results || {}).some(result => result?.success)) { fileReadCache.clear(); lastFileReadOperation = null; lastOverview = null; @@ -1703,13 +2061,14 @@ async function startConfigurationOperation(path, body, statusElement = operation dedicatedSetupApprovals.clear(); inputGeneration++; } + const context = operationContext(); configurationOperationsInFlight++; updateConfigurationButtons(); updateExtendedButtons(); try { return await waitForOperation(await authorized(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) - }), statusElement); + }), statusElement, context); } finally { configurationOperationsInFlight--; updateConfigurationButtons(); @@ -1842,7 +2201,8 @@ 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', 'data.inspect.v1'].some(capability => + ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-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) { @@ -1858,7 +2218,7 @@ async function loadNodes() { const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); const invalidFileApproval = approvedFilePreview && !approvedFilePreview.nodeIds.every(node => - nodeCapabilities.get(node)?.includes('config.files.v1')); + nodeCapabilities.get(node)?.includes(selectedFileCapability())); const invalidQuickApproval = approvedQuickPreview && approvedQuickPreview.workflow !== 'sync-vote-sites' && !approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); @@ -1875,7 +2235,7 @@ async function loadNodes() { inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } - const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) + const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.') || value.startsWith('data.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); renderServerPicker(); @@ -1910,6 +2270,7 @@ async function loadNodes() { text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); previousPage.disabled = pageOffset === 0; nextPage.disabled = pageOffset + visibleNodeItems.length >= registry.items.length; + void autoLoadTab(tabFromHash()); } catch (error) { visibleNodeItems = []; allNodeItems = []; @@ -2049,23 +2410,34 @@ enrollmentForm.addEventListener('submit', async event => { }); refreshEnrollments.addEventListener('click', loadEnrollments); -readConfiguration.addEventListener('click', async () => { +async function loadProxyRouting(automatic = false) { + if (!automatic && routingDirty && !window.confirm('Discard unsaved routing changes and load current values?')) return; approvedPreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; + const readNodeId = selectedServerId; + const readSessionId = nodeIndex.get(readNodeId)?.sessionId; + if (!nodeCapabilities.get(readNodeId)?.includes('config.proxy-routing.v1')) return; try { - const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [selectedServerId]}); - const retained = Object.values(operation.results).find(result => result.success && result.configuration); + const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [readNodeId]}); + const retained = operation.results?.[readNodeId]; if (retained && authenticated && readAuthenticationGeneration === authenticationGeneration - && readInputGeneration === inputGeneration) { + && readInputGeneration === inputGeneration && readNodeId === selectedServerId + && readSessionId === nodeIndex.get(readNodeId)?.sessionId && retained.sessionId === readSessionId + && retained.success && retained.configuration) { sendAll.checked = retained.configuration.sendVotesToAllServers; blockedServers.value = retained.configuration.blockedServers.join('\n'); approvedPreview = null; + routingDirty = false; inputGeneration++; updateConfigurationButtons(); } - } catch (error) { text(operationStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(operationStatus, error.message); + } +} + +readConfiguration.addEventListener('click', () => { void loadProxyRouting(false); }); previewConfiguration.addEventListener('click', async () => { approvedPreview = null; @@ -2091,29 +2463,40 @@ applyConfiguration.addEventListener('click', async () => { approvedPreview = null; inputGeneration++; try { - await startConfigurationOperation('/api/v1/configuration/apply', { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); + if (operation.state === 'SUCCEEDED') routingDirty = false; } catch (error) { text(operationStatus, error.message); } }); [sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { + routingDirty = true; if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); approvedPreview = null; inputGeneration++; updateConfigurationButtons(); })); -readFileConfiguration.addEventListener('click', async () => { +async function loadFileConfiguration(automatic = false) { + if (!automatic && configurationDirty + && !window.confirm(`Discard unsaved ${configurationFile.value} changes and load the current file?`)) return; approvedFilePreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; const selectedFile = configurationFile.value; const selectedNode = nodeIndex.get(selectedServerId); + const selectedReadNodeId = selectedNode?.online && selectedNode.acceptedCapabilities.includes(selectedFileCapability(selectedFile)) + && (selectedFile === 'bungeeconfig.yml' ? isProxy(selectedNode) : isBackend(selectedNode)) ? selectedServerId : ''; + if (!selectedReadNodeId) { + if (!automatic) text(fileOperationStatus, 'Choose a connected node that supports this configuration file.'); + return; + } const cacheKey = `${selectedServerId}|${selectedNode?.sessionId || ''}|${selectedFile}`; const cached = cachedFile(cacheKey); if (cached) { configurationContent.value = cached.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = {operationId: cached.operationId}; updateEditorPosition(); text(fileOperationStatus, `Cached read · ${selectedServerId} · ${selectedFile}\nLoaded instantly; cache expires after 30 seconds. Preview still checks the live revision.`); @@ -2124,15 +2507,17 @@ readFileConfiguration.addEventListener('click', async () => { } try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { - nodeIds: [selectedServerId], + nodeIds: [selectedReadNodeId], configuration: {domain: 'file', fileName: selectedFile} }, fileOperationStatus); - const contentResult = Object.values(operation.results).find(result => - result.success && result.configuration?.content != null); + const contentResult = operation.results?.[selectedReadNodeId]; if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration - && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { + && readInputGeneration === inputGeneration && selectedFile === configurationFile.value + && selectedReadNodeId === selectedServerId && selectedNode.sessionId === nodeIndex.get(selectedReadNodeId)?.sessionId + && contentResult.success && typeof contentResult.configuration?.content === 'string') { configurationContent.value = contentResult.configuration.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = {operationId: operation.operationId}; cacheFile(cacheKey, contentResult.configuration.content, operation.operationId); updateEditorPosition(); @@ -2142,29 +2527,44 @@ readFileConfiguration.addEventListener('click', async () => { updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } -}); +} + +readFileConfiguration.addEventListener('click', () => { void loadFileConfiguration(false); }); previewFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; const previewGeneration = inputGeneration; + const selectedFile = configurationFile.value; + const previewTargets = fileTargetsForSelection(selectedFile); + const previewSessions = new Map(previewTargets.map(nodeId => [nodeId, nodeIndex.get(nodeId)?.sessionId])); try { const operation = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds: targets('config.files.v1'), - configuration: {domain: 'file', fileName: configurationFile.value, content: configurationContent.value} + nodeIds: previewTargets, + configuration: {domain: 'file', fileName: selectedFile, content: configurationContent.value} }, fileOperationStatus); - text(fileOperationStatus, operationSummary(operation)); - if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { + const targetsCurrent = selectedFile === configurationFile.value && previewTargets.length > 0 + && previewTargets.every(nodeId => previewSessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) + && previewTargets.every(nodeId => fileTargetsForSelection(selectedFile).includes(nodeId)); + if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration && targetsCurrent) { + text(fileOperationStatus, operationSummary(operation)); approvedFilePreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, - nodeIds: targets('config.files.v1')}; + nodeIds: previewTargets, fileName: selectedFile, sessions: previewSessions}; updateConfigurationButtons(); - } else if (previewGeneration !== inputGeneration) { + } else if (previewGeneration !== inputGeneration || !targetsCurrent) { text(fileOperationStatus, 'The targets or file changed while previewing. Preview again before apply.'); + } else { + text(fileOperationStatus, operationSummary(operation)); } } catch (error) { text(fileOperationStatus, error.message); } }); applyFileConfiguration.addEventListener('click', async () => { - if (!approvedFilePreview || !window.confirm(`Apply this exact ${configurationFile.value} preview to every selected Bukkit node?`)) return; + const currentTargets = fileTargetsForSelection(configurationFile.value); + if (!approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value + || !approvedFilePreview.nodeIds.every(nodeId => approvedFilePreview.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) + || currentTargets.length !== approvedFilePreview.nodeIds.length + || !approvedFilePreview.nodeIds.every(nodeId => currentTargets.includes(nodeId)) + || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; inputGeneration++; @@ -2176,6 +2576,7 @@ applyFileConfiguration.addEventListener('click', async () => { if (operation.state === 'SUCCEEDED') { fileReadCache.clear(); lastFileReadOperation = null; + configurationDirty = false; updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } @@ -2245,7 +2646,7 @@ function populateQuickState(options) { } } -readQuickSetup.addEventListener('click', async () => { +async function loadQuickSetupValues(automatic = false) { if (!quickPresetReadable()) return; approvedQuickPreview = null; loadedQuickSetup = null; @@ -2286,8 +2687,12 @@ readQuickSetup.addEventListener('click', async () => { ? ` ${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}`); updateConfigurationButtons(); - } catch (error) { text(quickOperationStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(quickOperationStatus, error.message); + } +} + +readQuickSetup.addEventListener('click', () => { void loadQuickSetupValues(false); }); previewQuickSetup.addEventListener('click', async () => { approvedQuickPreview = null; @@ -2380,12 +2785,18 @@ transportTestBackend.addEventListener('change', () => { runTransportTest.addEventListener('click', async () => { const proxyId = transportTestProxyId; const server = transportTestBackendId; + const requestAuthenticationGeneration = authenticationGeneration; + const proxySessionId = nodeIndex.get(proxyId)?.sessionId; + const backendSessionId = nodeIndex.get(server)?.sessionId; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [proxyId], configuration: {domain: 'quick-setup', preset: 'communication-test', options: {server}} }, transportTestStatus); - if (proxyId !== transportTestProxyId || server !== transportTestBackendId) { + if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== transportTestProxyId + || server !== transportTestBackendId || proxySessionId !== nodeIndex.get(proxyId)?.sessionId + || backendSessionId !== nodeIndex.get(server)?.sessionId + || operation.results?.[proxyId]?.sessionId !== proxySessionId) { text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); return; } @@ -2393,9 +2804,10 @@ runTransportTest.addEventListener('click', async () => { } catch (error) { text(transportTestStatus, error.message); } }); -readProxyMethod.addEventListener('click', async () => { +async function loadProxyMethod(automatic = false) { const proxyId = proxyMethodProxyId; const sessionId = proxyMethodNetwork().proxy?.sessionId; + const requestAuthenticationGeneration = authenticationGeneration; if (!proxyId) return; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { @@ -2405,14 +2817,19 @@ readProxyMethod.addEventListener('click', async () => { const result = operation.results[proxyId]; const method = result?.success ? result.configuration?.options?.method : ''; if (!method) throw new Error('The proxy did not return its active communication method.'); - if (proxyId !== proxyMethodProxyId || sessionId !== proxyMethodNetwork().proxy?.sessionId) return; + if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId + || sessionId !== proxyMethodNetwork().proxy?.sessionId || result?.sessionId !== sessionId) return; proxyMethodCurrentFor = proxyId; proxyMethodCurrentSessionId = sessionId; proxyMethodCurrentValue = method; renderProxyMethod(); text(proxyMethodStatus, `Active method on ${proxyId}: ${method}`); - } catch (error) { text(proxyMethodStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(proxyMethodStatus, error.message); + } +} + +readProxyMethod.addEventListener('click', () => { void loadProxyMethod(false); }); proxyMethodProxy.addEventListener('change', () => { proxyMethodProxyId = proxyMethodProxy.value; @@ -2441,7 +2858,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = }, proxyMethodStatus); if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + - 'The proxy runtime will restart after Control records the result.')) return; + 'Backends reload their communication handler; the proxy replaces its runtime after Control records the result.')) return; const refreshedRegistry = await loadAllNodes(); const refreshedNetwork = proxyMethodNetworkFor(refreshedRegistry.items, refreshedRegistry.truncatedNodeIds, proxyMethodProxyId); @@ -2462,7 +2879,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = renderProxyMethod(); } const nextStep = applied.state === 'SUCCEEDED' - ? 'Reconnect the proxy if needed, then run the communication test.' + ? 'Wait for the proxy to reconnect, then run the communication test to confirm the active transport.' : 'No network-wide method change was committed. Fix the failed nodes, refresh the active method, and preview again.'; text(proxyMethodStatus, `${operationSummary(applied)}\n${nextStep}`); } catch (error) { @@ -2625,18 +3042,23 @@ runNetworkDoctor.addEventListener('click', async () => { const diagnostics = await runInspection('diagnostics', {}, networkDoctorResults); lastOverview = diagnostics.result; const node = nodeIndex.get(selectedServerId); + const voteLog = diagnostics.result.voteLoggingEnabled !== true + ? {state: 'DISABLED', message: 'Vote logging is disabled; no retained logged-event history is expected.'} + : diagnostics.result.voteLogReadable === true + ? {state: 'READABLE', message: 'Retained logged-event history is readable. It is not a guaranteed record of every internal vote-delivery hop.'} + : {state: 'UNREADABLE', message: 'Vote logging is enabled, but retained logged-event history is not currently readable.'}; 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, + voteLogging: voteLog, 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, + checks, voteLog, 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}))} @@ -2652,19 +3074,26 @@ downloadNetworkDiagnostics.addEventListener('click', () => { }); runDriftCheck.addEventListener('click', async () => { - const nodeIds = targets('config.files.v1'); + const nodeIds = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); const selectedFile = driftFile.value; const requestAuthenticationGeneration = authenticationGeneration; + const requestInputGeneration = inputGeneration; + const requestSelectedNodeId = selectedServerId; + const requestSelectedSessionId = nodeIndex.get(requestSelectedNodeId)?.sessionId; const requestSessions = new Map(nodeIds.map(nodeId => [nodeId, nodeIndex.get(nodeId)?.sessionId])); try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds, configuration: {domain: 'file', fileName: selectedFile} }, driftResults); - if (requestAuthenticationGeneration !== authenticationGeneration) { - throw new Error('Authentication changed while the drift check ran. Run it again.'); - } - if (nodeIds.some(nodeId => requestSessions.get(nodeId) !== operation.results?.[nodeId]?.sessionId)) { - throw new Error('A selected server reconnected while the drift check ran. Run it again.'); + const currentTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); + const targetsStillCurrent = selectedFile === driftFile.value && nodeIds.length >= 2 + && nodeIds.length === currentTargets.length && nodeIds.every(nodeId => currentTargets.includes(nodeId)) + && nodeIds.every(nodeId => requestSessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId + && requestSessions.get(nodeId) === operation.results?.[nodeId]?.sessionId); + if (requestAuthenticationGeneration !== authenticationGeneration || requestInputGeneration !== inputGeneration + || requestSelectedNodeId !== selectedServerId || requestSelectedSessionId !== nodeIndex.get(requestSelectedNodeId)?.sessionId + || !targetsStillCurrent) { + return; } const rows = nodeIds.map(nodeId => { const result = operation.results[nodeId]; @@ -2726,6 +3155,7 @@ async function loadSnapshots() { restore.type = 'button'; restore.className = 'secondary compact'; restore.addEventListener('click', async () => { + if (configurationDirty && !window.confirm('Discard unsaved YAML changes and load this snapshot?')) return; restore.disabled = true; const restoreServerId = selectedServerId; const restoreGeneration = inputGeneration; @@ -2733,15 +3163,20 @@ async function loadSnapshots() { const full = await authorized(`/api/v1/snapshots/${snapshot.snapshotId}`); if (restoreServerId !== selectedServerId || restoreGeneration !== inputGeneration) { throw new Error('The selected server changed while loading the snapshot. Load it again.'); - } + } 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.'); + const restoreNode = nodeIndex.get(selectedServerId); + const proxyFile = document.fileName === 'bungeeconfig.yml'; + if (!restoreNode?.online || !nodeCapabilities.get(selectedServerId)?.includes(selectedFileCapability(document.fileName)) + || (proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode))) { + throw new Error('Choose a connected node that supports this snapshot file before restoring.'); } configurationFile.value = document.fileName; + configurationFileSelection = document.fileName; configurationContent.value = document.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = null; updateEditorPosition(); approvedFilePreview = null; @@ -2778,8 +3213,7 @@ 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); } + try { await loadPlayerData(value); } catch (error) { text(playerResult, error.message); } }); @@ -2810,8 +3244,7 @@ voteLogForm.addEventListener('submit', async event => { 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); } + try { await traceVoteAcrossNodes(); } catch (error) { text(voteTraceResult, error.message); } }); @@ -2984,6 +3417,7 @@ quickName.addEventListener('input', () => { }); configurationContent.addEventListener('input', () => { configurationContentPresent = true; + configurationDirty = true; clearApprovals(); updateEditorPosition(); }); @@ -2991,13 +3425,17 @@ configurationContent.addEventListener('click', updateEditorPosition); configurationContent.addEventListener('keyup', updateEditorPosition); configurationContent.addEventListener('keydown', handleEditorKeydown); configurationFile.addEventListener('input', () => { - configurationContent.value = ''; - configurationContentPresent = false; - lastFileReadOperation = null; - updateEditorPosition(); - text(fileOperationStatus, 'Read the selected file before previewing changes.'); + const requestedFile = configurationFile.value; + if (requestedFile !== configurationFileSelection && configurationDirty + && !window.confirm('Discard unsaved YAML changes and switch files?')) { + configurationFile.value = configurationFileSelection; + return; + } + configurationFileSelection = requestedFile; + resetFileEditorForSelection('Read the selected file before previewing changes.'); clearApprovals(); updateExtendedButtons(); + void autoLoadTab('configurations'); }); quickPreset.addEventListener('input', () => { loadedQuickSetup = null; @@ -3025,6 +3463,11 @@ document.querySelectorAll('[data-open-config-view]').forEach(button => button.ad } })); window.addEventListener('hashchange', () => setActiveTab(tabFromHash())); +window.addEventListener('beforeunload', event => { + if (!configurationDirty && !routingDirty) return; + event.preventDefault(); + event.returnValue = ''; +}); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 990053c2..556bcba2 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -249,7 +249,7 @@

Configurations

Full VotingPlugin configuration

-

Edit any managed Bukkit YAML file. Secret values use __VOTINGPLUGIN_CONTROL_REDACTED__; leave the marker unchanged to preserve the stored value.

+

Edit a managed Bukkit YAML file, or the selected proxy's bungeeconfig.yml. Secret values use __VOTINGPLUGIN_CONTROL_REDACTED__; leave the marker unchanged to preserve the stored value.

Comment support unknown
@@ -263,6 +263,7 @@

Full VotingPlugin configuration

+ YAML · 512 KiB maximum @@ -485,14 +486,14 @@

Setup

diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 1dfb4315..fa07aeef 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1054,6 +1054,63 @@ class ConfigurationOperationsTest { ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())))).code()); } + @Test void bungeeConfigUsesTheOptionalProxyFileCapabilityAndRejectsLegacyTargets() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID capableProxySession = UUID.randomUUID(); + UUID legacyProxySession = UUID.randomUUID(); + UUID misleadingBackendSession = UUID.randomUUID(); + registry.register(new NodeRegistration("velocity-a", capableProxySession, "Velocity A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("bungee-legacy", legacyProxySession, "Legacy Bungee", "BUNGEECORD", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("misleading-backend", misleadingBackendSession, "Backend", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ManagedConfiguration bungeeConfig = ManagedConfiguration.file("bungeeconfig.yml", null); + assertEquals(ConfigurationOperations.PROXY_FILE_CAPABILITY, bungeeConfig.capability()); + ConfigurationOperations.OperationView read = operations.createRead(List.of("velocity-a"), bungeeConfig); + assertEquals("bungeeconfig.yml", operations.claim("velocity-a", capableProxySession).configuration().fileName()); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("bungee-legacy"), bungeeConfig)).code()); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("velocity-a", "bungee-legacy"), bungeeConfig)).code()); + ValidationException wrongRole = assertThrows(ValidationException.class, + () -> operations.createRead(List.of("misleading-backend"), bungeeConfig)); + assertEquals("INVALID_TARGET", wrongRole.code()); + assertEquals(List.of("misleading-backend"), wrongRole.details()); + + ValidationException backendFileOnProxy = assertThrows(ValidationException.class, + () -> operations.createRead(List.of("bungee-legacy"), ManagedConfiguration.file("Config.yml", null))); + assertEquals("INVALID_TARGET", backendFileOnProxy.code()); + assertEquals(List.of("bungee-legacy"), backendFileOnProxy.details()); + assertEquals(1, operations.list().size()); + assertEquals(read.operationId(), operations.get(read.operationId()).operationId()); + } + + @Test void claimCancelsProxyFileTaskWhenTheNodeChangesRoleWithinItsSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a"), + ManagedConfiguration.file("bungeeconfig.yml", null)).operationId(); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "BUKKIT", "test", 1, + capabilities, Set.of())); + + assertEquals(null, operations.claim("proxy-a", session)); + ConfigurationOperations.OperationView view = operations.get(operation); + assertEquals("COMPLETED_WITH_ERRORS", view.state()); + assertEquals("TARGET_CHANGED", view.results().get("proxy-a").code()); + } + @Test void claimCancelsTaskWhenCurrentSessionLostItsCapability() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index ad5465c0..216211f7 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -123,11 +123,18 @@ class ControlHttpServerTest { assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("sessionId !== nodeIndex.get(nodeId)?.sessionId")); + assertTrue(script.body().contains("retained.sessionId === readSessionId")); + assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); + assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); + assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); + assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); + assertTrue(script.body().contains("window.addEventListener('beforeunload'")); assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}")); assertTrue(script.body().contains("configurationOperationsInFlight")); - assertTrue(script.body().contains("approvedPreview = null;\n inputGeneration++;")); + assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); + assertTrue(script.body().contains("proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode)")); assertTrue(script.body().contains("discardAuthenticationState")); assertTrue(script.body().contains("text(operationStatus, '');")); assertTrue(script.body().contains("text(fileOperationStatus, '');")); @@ -281,7 +288,8 @@ class ControlHttpServerTest { @Test void inspectionRetryAndSnapshotRoutesAreEndToEnd() throws Exception { String capableRegistration = registration().replace("\"presence.snapshot\"]", - "\"presence.snapshot\",\"data.inspect.v1\",\"config.files.v1\"]"); + "\"presence.snapshot\",\"data.inspect.v1\",\"config.files.v1\"]") + .replace("\"platform\":\"VELOCITY\"", "\"platform\":\"BUKKIT\""); assertEquals(201, send("POST", "/api/v1/nodes/register", capableRegistration, nodeToken).statusCode()); HttpResponse inspectionQueued = send("POST", "/api/v1/inspections", From 5519399db8d72ee134dacc49108af21a1995378f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:28:19 -0600 Subject: [PATCH 02/20] Harden configuration task state changes --- .../domain/ConfigurationOperations.java | 22 ++++++--- src/main/resources/web/app.js | 33 +++++++++---- .../domain/ConfigurationOperationsTest.java | 46 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 6 ++- 4 files changed, 89 insertions(+), 18 deletions(-) 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 41d45975..9b4cf0f3 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -239,11 +239,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { if (cancelChangedFileRole(operation, node)) continue; if (cancelChangedProxyMethodRole(operation, node)) continue; if (deferProxyMethodApply(operation, node)) continue; - if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { - automaticCancellation(operation, nodeId, sessionId(node), "CAPABILITY_LOST", - "Node no longer accepts this configuration capability", "CAPABILITY_LOST"); - continue; - } + if (cancelLostCapability(operation, node)) continue; UUID previousAttempt = operation.attemptIds.get(nodeId); UUID previousClaimSession = operation.claimSessions.get(nodeId); UUID attemptId = UUID.randomUUID(); @@ -360,6 +356,13 @@ private boolean cancelChangedFileRole(StoredOperation operation, NodeStatus node return true; } + private boolean cancelLostCapability(StoredOperation operation, NodeStatus node) { + if (node.online() && node.acceptedCapabilities().contains(operation.configuration.capability())) return false; + automaticCancellation(operation, node.nodeId(), sessionId(node), "CAPABILITY_LOST", + "Node no longer accepts this configuration capability", "CAPABILITY_LOST"); + return true; + } + /** * Commits an automatic task completion only after the redacted journal is durable. Both the * operation maps and retention counters are restored if either durable step fails, leaving the @@ -489,14 +492,15 @@ private static void validateApprovedTargets(StoredOperation preview, ValidatedTa public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { validateResult(result); return registry.withSession(nodeId, result.sessionId(), - ignored -> completeCurrentSession(operationId, nodeId, result)); + node -> completeCurrentSession(operationId, nodeId, result, node)); } private static UUID sessionId(NodeStatus node) { return node.sessionId(); } - private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result) { + private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result, + NodeStatus node) { StoredOperation operation = operations.get(operationId); if (operation == null || !operation.states.containsKey(nodeId)) { throw new ValidationException("OPERATION_NOT_FOUND", "Operation task was not found", List.of()); @@ -516,6 +520,10 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (!Objects.equals(operation.claimSessions.get(nodeId), result.sessionId())) { throw new ValidationException("SESSION_MISMATCH", "Operation task belongs to another node session", List.of()); } + if (cancelChangedFileRole(operation, node) || cancelChangedProxyMethodRole(operation, node) + || cancelLostCapability(operation, node)) { + return view(operation); + } validateResultConfiguration(operation, result); String priorState = operation.states.get(nodeId); ConfigurationTaskResult priorResult = operation.results.get(nodeId); diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 99511eee..c4b4f301 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -645,7 +645,7 @@ async function traceVoteAcrossNodes() { const contextCurrent = () => requestAuthenticationGeneration === authenticationGeneration && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId - && days === String(voteLogDays.value) + && voteId === voteTraceId.value.trim() && days === String(voteLogDays.value) && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); const events = new Map(); const sources = []; @@ -673,7 +673,12 @@ async function traceVoteAcrossNodes() { unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); } } - if (!contextCurrent()) return; + if (!contextCurrent()) { + if (requestAuthenticationGeneration === authenticationGeneration) { + text(voteTraceResult, 'The selected server, node session, or trace window changed. Run the trace again.'); + } + return; + } if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) @@ -1158,6 +1163,10 @@ function syncFileSelection() { const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { + if (!proxySelected && configurationFile.value === 'bungeeconfig.yml' && configurationDirty) { + text(fileOperationStatus, 'The selected proxy is no longer available for this file. Your unsaved draft is retained; reconnect the proxy or explicitly switch files to discard it.'); + return; + } configurationFile.value = expected; configurationFileSelection = expected; resetFileEditorForSelection('Read the selected file before previewing changes.'); @@ -1215,7 +1224,7 @@ function renderServerPicker() { if (!nodeIndex.has(previousValue)) { selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; if (previousValue) { - resetServerContextValues('The selected server is no longer available. Load current values from its replacement.'); + resetServerContextValues('The selected server is no longer available. Load current values from its replacement.', true); } } serverPicker.value = selectedServerId; @@ -1630,10 +1639,16 @@ function renderNodeViews() { updateExtendedButtons(); } -function resetServerConfigurationForms(status) { +function resetServerConfigurationForms(status, preserveDirtyFile = false) { configurationForm.reset(); routingDirty = false; - resetFileEditorForSelection(status); + if (preserveDirtyFile && configurationDirty) { + lastFileReadOperation = null; + approvedFilePreview = null; + text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); + } else { + resetFileEditorForSelection(status); + } text(operationStatus, status); clearApprovals(); } @@ -1649,7 +1664,7 @@ function resetDedicatedSetupValues() { voteLoggingState.className = 'pill neutral'; } -function resetServerContextValues(reason) { +function resetServerContextValues(reason, preserveDirtyFile = false) { dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; lastFileReadOperation = null; @@ -1679,7 +1694,7 @@ function resetServerContextValues(reason) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; - resetServerConfigurationForms(reason); + resetServerConfigurationForms(reason, preserveDirtyFile); const preset = quickPreset.value; quickSetupForm.reset(); quickPreset.value = preset; @@ -2194,7 +2209,7 @@ async function loadNodes() { const selectedSessionChanged = primarySessionChanged || [...selectedNodes].some(node => previousNodeIndex.get(node)?.sessionId && previousNodeIndex.get(node)?.sessionId !== nodeIndex.get(node)?.sessionId); if (selectedSessionChanged) { - resetServerContextValues('A selected server reconnected. Load current values before continuing.'); + resetServerContextValues('A selected server reconnected. Load current values before continuing.', true); } const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); @@ -2280,7 +2295,7 @@ async function loadNodes() { nodePlugins.clear(); selectedNodes.clear(); selectedServerId = ''; - resetServerContextValues('Network data is unavailable. Refresh and load current values before continuing.'); + resetServerContextValues('Network data is unavailable. Refresh and load current values before continuing.', true); renderServerPicker(); renderNodeViews(); updatePluginSuggestions(); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index fa07aeef..15bd06d0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1111,6 +1111,52 @@ class ConfigurationOperationsTest { assertEquals("TARGET_CHANGED", view.results().get("proxy-a").code()); } + @Test void completionCancelsClaimedProxyFileTaskWhenTheNodeChangesRoleWithinItsSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = ManagedConfiguration.file("bungeeconfig.yml", null); + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a"), selector); + ConfigurationTask task = operations.claim("proxy-a", session); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "BUKKIT", "test", 1, + capabilities, Set.of())); + read = operations.complete(read.operationId(), "proxy-a", new ConfigurationTaskResult(session, true, + "OK", "read", "a".repeat(64), ManagedConfiguration.file("bungeeconfig.yml", "Key: value\n"), + List.of(), false, false, task.attemptId())); + + assertEquals("COMPLETED_WITH_ERRORS", read.state()); + assertEquals("TARGET_CHANGED", read.results().get("proxy-a").code()); + assertEquals(false, read.results().get("proxy-a").success()); + } + + @Test void completionCancelsClaimedTaskWhenTheNodeLosesItsCapabilityWithinTheSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a")); + ConfigurationTask task = operations.claim("proxy-a", session); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(), Set.of())); + read = operations.complete(read.operationId(), "proxy-a", new ConfigurationTaskResult(session, true, + "OK", "read", "a".repeat(64), new ProxyRoutingConfiguration(false, List.of()), + List.of(), false, false, task.attemptId())); + + assertEquals("COMPLETED_WITH_ERRORS", read.state()); + assertEquals("CAPABILITY_LOST", read.results().get("proxy-a").code()); + assertFalse(read.results().get("proxy-a").success()); + } + @Test void claimCancelsTaskWhenCurrentSessionLostItsCapability() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 216211f7..ec441323 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -80,7 +80,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Control enrollment unavailable")); assertTrue(script.body().contains("Comments preserved for every target")); assertTrue(script.body().contains("Backend topology is truncated")); - assertTrue(script.body().contains("function resetServerConfigurationForms(status)")); + assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyFile = false)")); assertTrue(script.body().contains("Network data is unavailable. Refresh and load current values before continuing.")); assertTrue(script.body().contains("backendTopologyTruncated = false;")); assertTrue(script.body().contains("nextPage.addEventListener")); @@ -116,7 +116,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains( "configurationContent.value = document.content;\n configurationContentPresent = true;")); assertTrue(script.body().contains( - "resetServerContextValues('A selected server reconnected. Load current values before continuing.');")); + "resetServerContextValues('A selected server reconnected. Load current values before continuing.', true);")); assertTrue(script.body().contains( "configurationContent.addEventListener('input', () => {\n configurationContentPresent = true;")); assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); @@ -126,6 +126,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("retained.sessionId === readSessionId")); assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); + assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); assertTrue(script.body().contains("window.addEventListener('beforeunload'")); From aadc70efcf4eb3322c650ff8e304556cad674e77 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:04 -0600 Subject: [PATCH 03/20] Preserve Control drafts across refreshes --- src/main/resources/web/app.js | 46 +++++++++++++------ .../control/http/ControlHttpServerTest.java | 8 +++- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c4b4f301..410e31e0 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -250,6 +250,7 @@ let lastFileReadOperation = null; let configurationContentPresent = false; let configurationDirty = false; let routingDirty = false; +let routingDraftNodeId = ''; let configurationFileSelection = configurationFile.value; let inspectionInFlight = false; let lastDiagnostics = null; @@ -954,6 +955,7 @@ function applyAuthenticatedSession(body) { configurationContentPresent = false; configurationDirty = false; routingDirty = false; + routingDraftNodeId = ''; configurationFileSelection = configurationFile.value; autoLoadInFlight.clear(); inputGeneration++; @@ -1578,6 +1580,7 @@ function updateSetupChecklist(overview = lastOverview) { function updateExtendedButtons() { const node = nodeIndex.get(selectedServerId); const inspectionReady = authenticated && Boolean(inspectionCapableNode()) && !inspectionInFlight; + const traceReady = authenticated && connectedInspectionNodes().length > 0 && !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); @@ -1594,7 +1597,7 @@ function updateExtendedButtons() { loadSiteHealth.disabled = !inspectionReady; loadVoteLogSummary.disabled = !inspectionReady; searchVoteLog.disabled = !inspectionReady; - traceVote.disabled = !inspectionReady; + traceVote.disabled = !traceReady; resolveSite.disabled = !inspectionReady; simulateReward.disabled = !inspectionReady; previewReward.disabled = !quickReady; @@ -1639,17 +1642,27 @@ function renderNodeViews() { updateExtendedButtons(); } -function resetServerConfigurationForms(status, preserveDirtyFile = false) { - configurationForm.reset(); - routingDirty = false; - if (preserveDirtyFile && configurationDirty) { +function routingDraftStatus(status) { + return routingDirty + ? `${status} Your unsaved proxy-routing draft is retained for ${routingDraftNodeId || 'the previous server'}; explicitly switch servers or load current values to discard it.` + : status; +} + +function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { + const retainedRoutingDraft = preserveDirtyDrafts && routingDirty; + if (!retainedRoutingDraft) { + configurationForm.reset(); + routingDirty = false; + routingDraftNodeId = ''; + } + if (preserveDirtyDrafts && configurationDirty) { lastFileReadOperation = null; approvedFilePreview = null; text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); } else { resetFileEditorForSelection(status); } - text(operationStatus, status); + text(operationStatus, routingDraftStatus(status)); clearApprovals(); } @@ -1664,7 +1677,7 @@ function resetDedicatedSetupValues() { voteLoggingState.className = 'pill neutral'; } -function resetServerContextValues(reason, preserveDirtyFile = false) { +function resetServerContextValues(reason, preserveDirtyDrafts = false) { dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; lastFileReadOperation = null; @@ -1694,7 +1707,7 @@ function resetServerContextValues(reason, preserveDirtyFile = false) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; - resetServerConfigurationForms(reason, preserveDirtyFile); + resetServerConfigurationForms(reason, preserveDirtyDrafts); const preset = quickPreset.value; quickSetupForm.reset(); quickPreset.value = preset; @@ -1720,7 +1733,8 @@ function selectPrimaryServer(nodeId) { function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && - targets('config.proxy-routing.v1').length > 0 && !busy; + targets('config.proxy-routing.v1').length > 0 + && (!routingDirty || routingDraftNodeId === selectedServerId) && !busy; const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; @@ -1922,6 +1936,7 @@ function discardAuthenticationState(reason) { configurationContentPresent = false; configurationDirty = false; routingDirty = false; + routingDraftNodeId = ''; autoLoadInFlight.clear(); quickSetupForm.reset(); resetDedicatedSetupValues(); @@ -2228,7 +2243,7 @@ async function loadNodes() { lastOverview = null; lastDiagnostics = null; inputGeneration++; - text(operationStatus, 'A selected node changed capabilities during refresh. Preview again before apply.'); + text(operationStatus, routingDraftStatus('A selected node changed capabilities during refresh. Preview again before apply.')); } const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); @@ -2248,7 +2263,7 @@ async function loadNodes() { if (invalidVoteSitesApproval) approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; - text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); + text(operationStatus, routingDraftStatus('A preview target went offline or lost the required capability. Preview again before apply.')); } const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.') || value.startsWith('data.'))) .map(node => node.nodeId)); @@ -2269,7 +2284,7 @@ async function loadNodes() { approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; - text(operationStatus, 'The selected nodes changed during refresh. Preview again before apply.'); + text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.')); } selectedNodes = filteredSelection; renderNodeViews(); @@ -2444,6 +2459,7 @@ async function loadProxyRouting(automatic = false) { blockedServers.value = retained.configuration.blockedServers.join('\n'); approvedPreview = null; routingDirty = false; + routingDraftNodeId = ''; inputGeneration++; updateConfigurationButtons(); } @@ -2481,10 +2497,14 @@ applyConfiguration.addEventListener('click', async () => { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); - if (operation.state === 'SUCCEEDED') routingDirty = false; + if (operation.state === 'SUCCEEDED') { + routingDirty = false; + routingDraftNodeId = ''; + } } catch (error) { text(operationStatus, error.message); } }); [sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { + if (!routingDirty) routingDraftNodeId = selectedServerId; routingDirty = true; if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); approvedPreview = null; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index ec441323..63533590 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -80,7 +80,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Control enrollment unavailable")); assertTrue(script.body().contains("Comments preserved for every target")); assertTrue(script.body().contains("Backend topology is truncated")); - assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyFile = false)")); + assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyDrafts = false)")); assertTrue(script.body().contains("Network data is unavailable. Refresh and load current values before continuing.")); assertTrue(script.body().contains("backendTopologyTruncated = false;")); assertTrue(script.body().contains("nextPage.addEventListener")); @@ -127,6 +127,12 @@ class ControlHttpServerTest { assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); + assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); + assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); + assertTrue(script.body().contains("routingDraftNodeId === selectedServerId")); + assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); + assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); From 208306a000e16685752f64355e88290c2ac584c7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:06:17 -0600 Subject: [PATCH 04/20] Complete Control inspection presentation --- src/main/resources/web/app.js | 47 +++++++++++++++++-- .../control/http/ControlHttpServerTest.java | 4 ++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 410e31e0..bf39ee48 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -380,10 +380,42 @@ function renderPlayerData(value) { const totals = value.totals && typeof value.totals === 'object' ? value.totals : {}; add('Online', value.online === true ? 'Yes' : 'No'); add('Votes', `Daily ${totals.daily ?? 'Unknown'} · Weekly ${totals.weekly ?? 'Unknown'} · Monthly ${totals.monthly ?? 'Unknown'} · All-time ${totals.allTime ?? 'Unknown'}`); + const streaks = value.streaks && typeof value.streaks === 'object' ? value.streaks : {}; + add('Vote streaks', `Daily ${streaks.daily ?? 'Unknown'} · Weekly ${streaks.weekly ?? 'Unknown'} · Monthly ${streaks.monthly ?? 'Unknown'}`); add('Points', value.points ?? 'Unknown'); + add('Pending offline votes', value.pendingOfflineVotes ?? 'Unknown'); add('Last vote', formatEpoch(value.lastVoteTime)); add('Last online', formatEpoch(value.lastOnline)); playerResult.append(profile); + const lastVotes = Array.isArray(value.lastVotes) + ? value.lastVotes.filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, 100) : []; + if (lastVotes.length) { + const heading = text(document.createElement('h4'), 'VoteSite history'); + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const headRow = document.createElement('tr'); + ['Vote Site', 'ServiceSite', 'Last vote'].forEach(label => headRow.append(text(document.createElement('th'), label))); + head.append(headRow); + const body = document.createElement('tbody'); + lastVotes.forEach(lastVote => { + const row = document.createElement('tr'); + row.append(text(document.createElement('td'), lastVote.displayName || lastVote.siteKey || 'Unknown')); + row.append(text(document.createElement('td'), lastVote.serviceSite || 'Not configured')); + row.append(text(document.createElement('td'), formatEpoch(lastVote.time))); + body.append(row); + }); + table.append(head, body); + scroll.append(table); + playerResult.append(heading, scroll); + } + if (value.lastVotesTruncated === true) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'Additional VoteSite history was omitted by the 100-row inspection limit.'); + playerResult.append(warning); + } if (!Array.isArray(value.columns)) return; const scroll = document.createElement('div'); scroll.className = 'table-scroll'; @@ -622,9 +654,15 @@ function renderVoteTrace(trace) { text(diagnostics, `Unavailable or failed sources: ${trace.unavailable.join('; ')}.`); voteTraceResult.append(diagnostics); } + if (trace.truncatedSources.length) { + const truncation = document.createElement('p'); + truncation.className = 'warning-text'; + text(truncation, `Node result limit reached; this trace is incomplete for: ${trace.truncatedSources.join(', ')}.`); + voteTraceResult.append(truncation); + } const boundary = document.createElement('p'); boundary.className = 'warning-text'; - text(boundary, 'This is the complete retained trace returned by the readable nodes above. It cannot show an internal hop that no node recorded.'); + text(boundary, 'This bounded view contains only events returned by the readable nodes above. A missing logged event is unknown or not logged, not proof that an internal hop failed.'); voteTraceResult.append(boundary); } @@ -651,6 +689,7 @@ async function traceVoteAcrossNodes() { const events = new Map(); const sources = []; const unavailable = []; + const truncatedSources = []; if (available.length > candidates.length) unavailable.push(`${available.length - candidates.length} additional capable nodes were omitted by the ${MAX_TRACE_NODES}-node trace limit`); inspectionInFlight = true; updateExtendedButtons(); @@ -662,7 +701,9 @@ async function traceVoteAcrossNodes() { const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, {deadlineAt: traceDeadline, manageBusy: false}); const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; - sources.push(`${node.displayName} (${node.nodeId})`); + const source = `${node.displayName} (${node.nodeId})`; + sources.push(source); + if (envelope.result?.truncated === true) truncatedSources.push(source); listed.forEach(event => { if (!event || typeof event !== 'object') return; const key = traceEventKey(event); @@ -685,7 +726,7 @@ async function traceVoteAcrossNodes() { Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) || String(left.event.event || '').localeCompare(String(right.event.event || '')) || String(left.event.server || '').localeCompare(String(right.event.server || ''))); - renderVoteTrace({events: ordered, sources, unavailable}); + renderVoteTrace({events: ordered, sources, unavailable, truncatedSources}); } finally { inspectionInFlight = false; updateExtendedButtons(); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 63533590..826d01f0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -127,6 +127,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("Pending offline votes")); + assertTrue(script.body().contains("Additional VoteSite history was omitted")); + assertTrue(script.body().contains("Node result limit reached; this trace is incomplete")); + assertFalse(script.body().contains("This is the complete retained trace")); assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); From 4a433e64718f594e3bc276f3979fbb4dfee8e5c8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:19:56 -0600 Subject: [PATCH 05/20] Keep orphaned routing drafts recoverable --- src/main/resources/web/app.js | 12 ++++++------ .../control/http/ControlHttpServerTest.java | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index bf39ee48..d141d121 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1773,9 +1773,9 @@ function selectPrimaryServer(nodeId) { function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; - const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && - targets('config.proxy-routing.v1').length > 0 - && (!routingDirty || routingDraftNodeId === selectedServerId) && !busy; + const routingReadReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && + targets('config.proxy-routing.v1').length > 0 && !busy; + const routingDraftReady = routingReadReady && (!routingDirty || routingDraftNodeId === selectedServerId); const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; @@ -1783,9 +1783,9 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) : primaryCapabilities.includes('config.quick-setup.v1') && targets('config.quick-setup.v1').length > 0); - readConfiguration.disabled = !routingReady; - previewConfiguration.disabled = !routingReady; - applyConfiguration.disabled = !routingReady || !approvedPreview; + readConfiguration.disabled = !routingReadReady; + previewConfiguration.disabled = !routingDraftReady; + applyConfiguration.disabled = !routingDraftReady || !approvedPreview; readFileConfiguration.disabled = !fileReady; previewFileConfiguration.disabled = !fileReady || !configurationContentPresent; applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 826d01f0..4b9e4217 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -135,6 +135,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); assertTrue(script.body().contains("routingDraftNodeId === selectedServerId")); + assertTrue(script.body().contains("readConfiguration.disabled = !routingReadReady;")); + assertTrue(script.body().contains("previewConfiguration.disabled = !routingDraftReady;")); + assertTrue(script.body().contains("applyConfiguration.disabled = !routingDraftReady || !approvedPreview;")); assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); From 574613a2631a01faaadb7cd85c6c37acf27836c9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:53:32 -0600 Subject: [PATCH 06/20] Protect trace and YAML draft contexts --- docs/control-management.md | 3 +- src/main/resources/web/app.js | 142 +++++++++++++++--- .../control/http/ControlHttpServerTest.java | 23 ++- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/docs/control-management.md b/docs/control-management.md index 66401779..2df6c290 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -60,6 +60,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | 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 | +| Full-YAML drafts | Keeps unsaved editor contents during a registry refresh | A dirty draft is bound to its source node, session, and file; it cannot preview or apply after that session changes. The operator must explicitly confirm a current-file read/reload, which discards the retained draft and rebinds the editor. | | Proxy configuration | Opens `bungeeconfig.yml` only for the selected online proxy that negotiated `config.proxy-files.v1` | Fixed one-file capability, never proxy file browsing; redacted READ, PREVIEW, and one-time approved APPLY still apply | | 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 | @@ -69,7 +70,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | 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 and form inputs are cleared on logout; no player enumeration | +| 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; trace starts its bounded node set concurrently under one 90-second budget, so a stalled node cannot delay healthy sources; VoteLog reads require logging; results and form inputs 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 diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index d141d121..cc3dd03e 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -203,6 +203,7 @@ const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; const MAX_OPERATION_TARGETS = 100; const MAX_TRACE_NODES = 12; +const MAX_TRACE_EVENTS_PER_NODE = 100; const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; @@ -249,6 +250,9 @@ let fileReadCache = new Map(); let lastFileReadOperation = null; let configurationContentPresent = false; let configurationDirty = false; +let configurationDraftNodeId = ''; +let configurationDraftSessionId = ''; +let configurationDraftFileName = ''; let routingDirty = false; let routingDraftNodeId = ''; let configurationFileSelection = configurationFile.value; @@ -572,19 +576,40 @@ async function runInspectionOnNode(node, kind, filters = {}, options = {}) { inspectionInFlight = true; updateExtendedButtons(); } + const requestedDeadline = Number(options.deadlineAt); + const deadline = Number.isFinite(requestedDeadline) + ? Math.min(Date.now() + 180_000, requestedDeadline) + : Date.now() + 180_000; + const ensureActive = () => { + if (typeof options.contextCurrent === 'function' && !options.contextCurrent()) { + throw new Error('The trace context changed while an inspection was running.'); + } + if (Date.now() >= deadline || options.signal?.aborted) { + throw new Error('Inspection did not finish within this request budget.'); + } + }; + const request = async (path, requestOptions = {}) => { + ensureActive(); + try { + const response = await authorized(path, {...requestOptions, signal: options.signal}); + ensureActive(); + return response; + } catch (error) { + if (Date.now() >= deadline || options.signal?.aborted) { + throw new Error('Inspection did not finish within this request budget.'); + } + throw error; + } + }; try { - let inspection = await authorized('/api/v1/inspections', { + let inspection = await request('/api/v1/inspections', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({nodeId, query: {kind, filters: boundedFilters}}) }); - const requestedDeadline = Number(options.deadlineAt); - const deadline = Number.isFinite(requestedDeadline) - ? Math.min(Date.now() + 180_000, requestedDeadline) - : Date.now() + 180_000; while (inspection.state === 'RUNNING') { - if (Date.now() >= deadline) throw new Error('Inspection did not finish within this request budget.'); + ensureActive(); await new Promise(resolve => window.setTimeout(resolve, Math.min(1000, Math.max(0, deadline - Date.now())))); - inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + inspection = await request(`/api/v1/inspections/${inspection.inspectionId}`); } if (requestAuthenticationGeneration !== authenticationGeneration || sessionId !== nodeIndex.get(nodeId)?.sessionId) { throw new Error('The node reconnected or the session changed while the trace ran.'); @@ -685,7 +710,11 @@ async function traceVoteAcrossNodes() { && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId && voteId === voteTraceId.value.trim() && days === String(voteLogDays.value) - && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); + && candidates.every(node => { + const current = nodeIndex.get(node.nodeId); + return Boolean(current?.online) && candidateSessions.get(node.nodeId) === current.sessionId + && Array.isArray(current.acceptedCapabilities) && current.acceptedCapabilities.includes('data.inspect.v1'); + }); const events = new Map(); const sources = []; const unavailable = []; @@ -694,13 +723,26 @@ async function traceVoteAcrossNodes() { inspectionInFlight = true; updateExtendedButtons(); text(voteTraceResult, `Collecting retained events from ${candidates.length} connected backend ${candidates.length === 1 ? 'node' : 'nodes'}…`); + const traceAbortController = new AbortController(); + const abortTrace = () => { + if (!traceAbortController.signal.aborted) traceAbortController.abort(); + }; + const deadlineTimer = window.setTimeout(abortTrace, Math.max(0, traceDeadline - Date.now())); + const contextTimer = window.setInterval(() => { + if (!contextCurrent()) abortTrace(); + }, 250); try { - for (const node of candidates) { - if (!contextCurrent() || Date.now() >= traceDeadline) break; - try { - const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, - {deadlineAt: traceDeadline, manageBusy: false}); - const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; + const results = await Promise.allSettled(candidates.map(async node => { + const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: String(MAX_TRACE_EVENTS_PER_NODE)}, { + deadlineAt: traceDeadline, signal: traceAbortController.signal, contextCurrent, manageBusy: false + }); + return {node, envelope}; + })); + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const {node, envelope} = result.value; + const listed = Array.isArray(envelope.result?.events) + ? envelope.result.events.slice(0, MAX_TRACE_EVENTS_PER_NODE) : []; const source = `${node.displayName} (${node.nodeId})`; sources.push(source); if (envelope.result?.truncated === true) truncatedSources.push(source); @@ -711,23 +753,28 @@ async function traceVoteAcrossNodes() { retained.sources.push(`${node.displayName} (${node.nodeId})`); events.set(key, retained); }); - } catch (error) { + } else { + const node = candidates[index]; + const error = result.reason; unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); } - } + }); if (!contextCurrent()) { if (requestAuthenticationGeneration === authenticationGeneration) { text(voteTraceResult, 'The selected server, node session, or trace window changed. Run the trace again.'); } return; } - if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); + if (Date.now() >= traceDeadline) unavailable.push('The 90-second trace budget expired before one or more node inspections completed'); const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) || String(left.event.event || '').localeCompare(String(right.event.event || '')) || String(left.event.server || '').localeCompare(String(right.event.server || ''))); renderVoteTrace({events: ordered, sources, unavailable, truncatedSources}); } finally { + window.clearTimeout(deadlineTimer); + window.clearInterval(contextTimer); + abortTrace(); inspectionInFlight = false; updateExtendedButtons(); } @@ -995,6 +1042,9 @@ function applyAuthenticatedSession(body) { configurationContent.value = ''; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; routingDirty = false; routingDraftNodeId = ''; configurationFileSelection = configurationFile.value; @@ -1185,12 +1235,29 @@ function resetFileEditorForSelection(message) { configurationContent.value = ''; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = null; approvedFilePreview = null; updateEditorPosition(); text(fileOperationStatus, message); } +function fileDraftMatchesCurrentContext() { + return !configurationDirty || configurationDraftNodeId === selectedServerId + && configurationDraftSessionId === nodeIndex.get(selectedServerId)?.sessionId + && configurationDraftFileName === configurationFile.value; +} + +function fileDraftStatus(status) { + if (!configurationDirty) return status; + const owner = configurationDraftNodeId + ? `${configurationDraftNodeId}${configurationDraftSessionId ? ` (session ${configurationDraftSessionId})` : ''}` + : 'the previous server'; + return `${status} Your unsaved ${configurationFile.value} draft is retained for ${owner}; read/reload the current file to explicitly discard it and bind the editor to this server.`; +} + function confirmDiscardUnsavedConfiguration(context) { if (!configurationDirty && !routingDirty) return true; return window.confirm(`Discard unsaved ${configurationDirty && routingDirty ? 'YAML and routing' : configurationDirty ? 'YAML' : 'routing'} changes before ${context}?`); @@ -1206,8 +1273,9 @@ function syncFileSelection() { const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { - if (!proxySelected && configurationFile.value === 'bungeeconfig.yml' && configurationDirty) { - text(fileOperationStatus, 'The selected proxy is no longer available for this file. Your unsaved draft is retained; reconnect the proxy or explicitly switch files to discard it.'); + if (configurationDirty) { + text(fileOperationStatus, fileDraftStatus( + 'The selected server cannot manage this file. Reconnect the original server or explicitly switch files to discard it.')); return; } configurationFile.value = expected; @@ -1699,7 +1767,7 @@ function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { if (preserveDirtyDrafts && configurationDirty) { lastFileReadOperation = null; approvedFilePreview = null; - text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); + text(fileOperationStatus, fileDraftStatus(status)); } else { resetFileEditorForSelection(status); } @@ -1779,6 +1847,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; + const fileDraftReady = fileReady && fileDraftMatchesCurrentContext(); const syncSelected = quickPreset.value === 'sync-vote-sites'; const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) @@ -1787,8 +1856,8 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | previewConfiguration.disabled = !routingDraftReady; applyConfiguration.disabled = !routingDraftReady || !approvedPreview; readFileConfiguration.disabled = !fileReady; - previewFileConfiguration.disabled = !fileReady || !configurationContentPresent; - applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; + previewFileConfiguration.disabled = !fileDraftReady || !configurationContentPresent; + applyFileConfiguration.disabled = !fileDraftReady || !approvedFilePreview; readQuickSetup.disabled = !quickReady || !quickPresetReadable(); previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded()); applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; @@ -1976,6 +2045,9 @@ function discardAuthenticationState(reason) { configurationFileSelection = configurationFile.value; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; routingDirty = false; routingDraftNodeId = ''; autoLoadInFlight.clear(); @@ -2555,7 +2627,7 @@ applyConfiguration.addEventListener('click', async () => { async function loadFileConfiguration(automatic = false) { if (!automatic && configurationDirty - && !window.confirm(`Discard unsaved ${configurationFile.value} changes and load the current file?`)) return; + && !window.confirm(`Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?`)) return; approvedFilePreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; @@ -2573,6 +2645,9 @@ async function loadFileConfiguration(automatic = false) { configurationContent.value = cached.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = {operationId: cached.operationId}; updateEditorPosition(); text(fileOperationStatus, `Cached read · ${selectedServerId} · ${selectedFile}\nLoaded instantly; cache expires after 30 seconds. Preview still checks the live revision.`); @@ -2594,6 +2669,9 @@ async function loadFileConfiguration(automatic = false) { configurationContent.value = contentResult.configuration.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = {operationId: operation.operationId}; cacheFile(cacheKey, contentResult.configuration.content, operation.operationId); updateEditorPosition(); @@ -2609,6 +2687,11 @@ readFileConfiguration.addEventListener('click', () => { void loadFileConfigurati previewFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; + if (!fileDraftMatchesCurrentContext()) { + text(fileOperationStatus, fileDraftStatus('This draft belongs to a different node session and cannot be previewed here.')); + updateConfigurationButtons(); + return; + } const previewGeneration = inputGeneration; const selectedFile = configurationFile.value; const previewTargets = fileTargetsForSelection(selectedFile); @@ -2636,7 +2719,7 @@ previewFileConfiguration.addEventListener('click', async () => { applyFileConfiguration.addEventListener('click', async () => { const currentTargets = fileTargetsForSelection(configurationFile.value); - if (!approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value + if (!fileDraftMatchesCurrentContext() || !approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value || !approvedFilePreview.nodeIds.every(nodeId => approvedFilePreview.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) || currentTargets.length !== approvedFilePreview.nodeIds.length || !approvedFilePreview.nodeIds.every(nodeId => currentTargets.includes(nodeId)) @@ -2653,6 +2736,9 @@ applyFileConfiguration.addEventListener('click', async () => { fileReadCache.clear(); lastFileReadOperation = null; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } @@ -3253,6 +3339,9 @@ async function loadSnapshots() { configurationContent.value = document.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = null; updateEditorPosition(); approvedFilePreview = null; @@ -3492,6 +3581,11 @@ quickName.addEventListener('input', () => { updateQuickFields(); }); configurationContent.addEventListener('input', () => { + if (!configurationDirty) { + configurationDraftNodeId = selectedServerId; + configurationDraftSessionId = nodeIndex.get(selectedServerId)?.sessionId || ''; + configurationDraftFileName = configurationFile.value; + } configurationContentPresent = true; configurationDirty = true; clearApprovals(); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 4b9e4217..222460c1 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -111,14 +111,31 @@ class ControlHttpServerTest { assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("let configurationContentPresent = false;")); + assertTrue(script.body().contains("const MAX_TRACE_EVENTS_PER_NODE = 100;")); + assertTrue(script.body().contains("const traceAbortController = new AbortController();")); + assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); + assertTrue(script.body().contains("const response = await authorized(path, {...requestOptions, signal: options.signal});\n ensureActive();")); + assertTrue(script.body().contains("current.acceptedCapabilities.includes('data.inspect.v1')")); assertTrue(script.body().contains( - "previewFileConfiguration.disabled = !fileReady || !configurationContentPresent;")); + "signal: traceAbortController.signal, contextCurrent, manageBusy: false")); + assertTrue(script.body().contains("window.clearTimeout(deadlineTimer);")); + assertFalse(script.body().contains("for (const node of candidates)")); + assertTrue(script.body().contains("let configurationDraftNodeId = '';")); + assertTrue(script.body().contains("let configurationDraftSessionId = '';")); + assertTrue(script.body().contains("function fileDraftMatchesCurrentContext()")); + assertTrue(script.body().contains("if (configurationDirty) {\n text(fileOperationStatus, fileDraftStatus("), + "Routine refresh must retain dirty drafts when the replacement has a different node role."); + assertTrue(script.body().contains("const fileDraftReady = fileReady && fileDraftMatchesCurrentContext();")); + assertTrue(script.body().contains( + "previewFileConfiguration.disabled = !fileDraftReady || !configurationContentPresent;")); + assertTrue(script.body().contains( + "applyFileConfiguration.disabled = !fileDraftReady || !approvedFilePreview;")); assertTrue(script.body().contains( "configurationContent.value = document.content;\n configurationContentPresent = true;")); assertTrue(script.body().contains( "resetServerContextValues('A selected server reconnected. Load current values before continuing.', true);")); assertTrue(script.body().contains( - "configurationContent.addEventListener('input', () => {\n configurationContentPresent = true;")); + "configurationContent.addEventListener('input', () => {\n if (!configurationDirty) {\n configurationDraftNodeId = selectedServerId;")); assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); @@ -142,7 +159,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); - assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); + assertTrue(script.body().contains("Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?")); assertTrue(script.body().contains("window.addEventListener('beforeunload'")); assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}")); assertTrue(script.body().contains("configurationOperationsInFlight")); From 0ca72798b65cdc4a3e163cc29e3da761b839277a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:30:01 -0600 Subject: [PATCH 07/20] Fix Control UI review findings --- src/main/resources/web/app.js | 14 +++++++++----- .../control/http/ControlHttpServerTest.java | 3 +++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index cc3dd03e..217a3129 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -421,6 +421,8 @@ function renderPlayerData(value) { playerResult.append(warning); } if (!Array.isArray(value.columns)) return; + const columns = value.columns.filter(column => column && typeof column === 'object' && !Array.isArray(column)) + .slice(0, 100); const scroll = document.createElement('div'); scroll.className = 'table-scroll'; const table = document.createElement('table'); @@ -429,7 +431,7 @@ function renderPlayerData(value) { ['Column', 'Storage type', 'Exact stored value'].forEach(label => headRow.append(text(document.createElement('th'), label))); head.append(headRow); const body = document.createElement('tbody'); - value.columns.forEach(column => { + columns.forEach(column => { const row = document.createElement('tr'); row.append(text(document.createElement('td'), column.name)); row.append(text(document.createElement('td'), column.type)); @@ -443,7 +445,7 @@ function renderPlayerData(value) { table.append(head, body); scroll.append(table); playerResult.append(scroll); - if (value.columnsTruncated === true) { + if (value.columnsTruncated === true || columns.length < value.columns.length) { const warning = document.createElement('p'); warning.className = 'warning-text'; text(warning, 'Some stored values were omitted by the bounded, allow-listed inspection contract.'); @@ -741,11 +743,13 @@ async function traceVoteAcrossNodes() { results.forEach((result, index) => { if (result.status === 'fulfilled') { const {node, envelope} = result.value; - const listed = Array.isArray(envelope.result?.events) - ? envelope.result.events.slice(0, MAX_TRACE_EVENTS_PER_NODE) : []; + const received = Array.isArray(envelope.result?.events) ? envelope.result.events : []; + const listed = received.slice(0, MAX_TRACE_EVENTS_PER_NODE); const source = `${node.displayName} (${node.nodeId})`; sources.push(source); - if (envelope.result?.truncated === true) truncatedSources.push(source); + if (envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE) { + truncatedSources.push(source); + } listed.forEach(event => { if (!event || typeof event !== 'object') return; const key = traceEventKey(event); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 222460c1..f8dee593 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -112,6 +112,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("let configurationContentPresent = false;")); assertTrue(script.body().contains("const MAX_TRACE_EVENTS_PER_NODE = 100;")); + assertTrue(script.body().contains( + "envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE")); + assertTrue(script.body().contains("columns.length < value.columns.length")); assertTrue(script.body().contains("const traceAbortController = new AbortController();")); assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); assertTrue(script.body().contains("const response = await authorized(path, {...requestOptions, signal: options.signal});\n ensureActive();")); From 609fb9df184529e10de93bff7a2d0c0ced7dfe0f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:56:37 -0600 Subject: [PATCH 08/20] Fix bounded player data rendering --- src/main/resources/web/app.js | 13 +++++++------ .../control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 217a3129..9528982b 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -391,8 +391,9 @@ function renderPlayerData(value) { add('Last vote', formatEpoch(value.lastVoteTime)); add('Last online', formatEpoch(value.lastOnline)); playerResult.append(profile); - const lastVotes = Array.isArray(value.lastVotes) - ? value.lastVotes.filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, 100) : []; + const receivedLastVotes = Array.isArray(value.lastVotes) ? value.lastVotes : []; + const lastVotes = receivedLastVotes + .filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, 100); if (lastVotes.length) { const heading = text(document.createElement('h4'), 'VoteSite history'); const scroll = document.createElement('div'); @@ -414,7 +415,7 @@ function renderPlayerData(value) { scroll.append(table); playerResult.append(heading, scroll); } - if (value.lastVotesTruncated === true) { + if (value.lastVotesTruncated === true || receivedLastVotes.length > 100) { const warning = document.createElement('p'); warning.className = 'warning-text'; text(warning, 'Additional VoteSite history was omitted by the 100-row inspection limit.'); @@ -435,11 +436,11 @@ function renderPlayerData(value) { const row = document.createElement('tr'); row.append(text(document.createElement('td'), column.name)); row.append(text(document.createElement('td'), column.type)); - const value = document.createElement('td'); + const cell = document.createElement('td'); const code = document.createElement('code'); text(code, column.value); - value.append(code); - row.append(value); + cell.append(code); + row.append(cell); body.append(row); }); table.append(head, body); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f8dee593..e54cc0ef 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -207,6 +207,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("preset: 'proxy-method'")); assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); + assertTrue(script.body().contains("receivedLastVotes.length > 100")); + assertTrue(script.body().contains("const cell = document.createElement('td');")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); HttpResponse stylesheet = get("/app.css", null); From ed84424ea55fc05de7c4a9ca611d9fc59559a008 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:06:15 -0600 Subject: [PATCH 09/20] Report client-side history truncation --- src/main/resources/web/app.js | 7 ++++--- .../votingplugin/control/http/ControlHttpServerTest.java | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 9528982b..9d3b59f4 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -204,6 +204,7 @@ const MAX_SYNC_TARGETS = 100; const MAX_OPERATION_TARGETS = 100; const MAX_TRACE_NODES = 12; const MAX_TRACE_EVENTS_PER_NODE = 100; +const MAX_PLAYER_LAST_VOTES = 100; const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; @@ -393,7 +394,7 @@ function renderPlayerData(value) { playerResult.append(profile); const receivedLastVotes = Array.isArray(value.lastVotes) ? value.lastVotes : []; const lastVotes = receivedLastVotes - .filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, 100); + .filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, MAX_PLAYER_LAST_VOTES); if (lastVotes.length) { const heading = text(document.createElement('h4'), 'VoteSite history'); const scroll = document.createElement('div'); @@ -415,10 +416,10 @@ function renderPlayerData(value) { scroll.append(table); playerResult.append(heading, scroll); } - if (value.lastVotesTruncated === true || receivedLastVotes.length > 100) { + if (value.lastVotesTruncated === true || receivedLastVotes.length > MAX_PLAYER_LAST_VOTES) { const warning = document.createElement('p'); warning.className = 'warning-text'; - text(warning, 'Additional VoteSite history was omitted by the 100-row inspection limit.'); + text(warning, `Additional VoteSite history was omitted by the ${MAX_PLAYER_LAST_VOTES}-row inspection limit.`); playerResult.append(warning); } if (!Array.isArray(value.columns)) return; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index e54cc0ef..1e23a6d4 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -112,6 +112,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("let configurationContentPresent = false;")); assertTrue(script.body().contains("const MAX_TRACE_EVENTS_PER_NODE = 100;")); + assertTrue(script.body().contains("const MAX_PLAYER_LAST_VOTES = 100;")); assertTrue(script.body().contains( "envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE")); assertTrue(script.body().contains("columns.length < value.columns.length")); @@ -207,7 +208,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("preset: 'proxy-method'")); assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); - assertTrue(script.body().contains("receivedLastVotes.length > 100")); + assertTrue(script.body().contains("receivedLastVotes.length > MAX_PLAYER_LAST_VOTES")); + assertTrue(script.body().contains(".slice(0, MAX_PLAYER_LAST_VOTES)")); assertTrue(script.body().contains("const cell = document.createElement('td');")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From e022fbd337d70da68aa2d9337e900fcedab0a7da Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:17:27 -0600 Subject: [PATCH 10/20] Reject malformed trace event payloads --- src/main/resources/web/app.js | 8 ++++++-- .../votingplugin/control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 9d3b59f4..37960cf7 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -745,9 +745,13 @@ async function traceVoteAcrossNodes() { results.forEach((result, index) => { if (result.status === 'fulfilled') { const {node, envelope} = result.value; - const received = Array.isArray(envelope.result?.events) ? envelope.result.events : []; - const listed = received.slice(0, MAX_TRACE_EVENTS_PER_NODE); const source = `${node.displayName} (${node.nodeId})`; + if (!Array.isArray(envelope.result?.events)) { + unavailable.push(`${source}: malformed vote-trace events`); + return; + } + const received = envelope.result.events; + const listed = received.slice(0, MAX_TRACE_EVENTS_PER_NODE); sources.push(source); if (envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE) { truncatedSources.push(source); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 1e23a6d4..2c8ca75f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -115,6 +115,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("const MAX_PLAYER_LAST_VOTES = 100;")); assertTrue(script.body().contains( "envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE")); + assertTrue(script.body().contains( + "if (!Array.isArray(envelope.result?.events)) {\n unavailable.push(`${source}: malformed vote-trace events`);")); assertTrue(script.body().contains("columns.length < value.columns.length")); assertTrue(script.body().contains("const traceAbortController = new AbortController();")); assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); From 3f3fd5697f22f1293da79125993fa4d384b63e5f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:32:37 -0600 Subject: [PATCH 11/20] Validate trace correlation responses --- src/main/resources/web/app.js | 10 +++++++++- .../control/http/ControlHttpServerTest.java | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 37960cf7..edca84e0 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -751,6 +751,12 @@ async function traceVoteAcrossNodes() { return; } const received = envelope.result.events; + if (typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId + || received.some(event => !event || typeof event !== 'object' || Array.isArray(event) + || typeof event.voteId !== 'string' || event.voteId !== voteId)) { + unavailable.push(`${source}: vote-trace correlation did not match the requested vote`); + return; + } const listed = received.slice(0, MAX_TRACE_EVENTS_PER_NODE); sources.push(source); if (envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE) { @@ -2690,7 +2696,9 @@ async function loadFileConfiguration(automatic = false) { updateConfigurationButtons(); updateExtendedButtons(); } - } catch (error) { text(fileOperationStatus, error.message); } + } catch (error) { + if (!automatic) text(fileOperationStatus, error.message); + } } readFileConfiguration.addEventListener('click', () => { void loadFileConfiguration(false); }); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 2c8ca75f..430b13fb 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -117,6 +117,9 @@ class ControlHttpServerTest { "envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE")); assertTrue(script.body().contains( "if (!Array.isArray(envelope.result?.events)) {\n unavailable.push(`${source}: malformed vote-trace events`);")); + assertTrue(script.body().contains( + "typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId")); + assertTrue(script.body().contains("typeof event.voteId !== 'string' || event.voteId !== voteId")); assertTrue(script.body().contains("columns.length < value.columns.length")); assertTrue(script.body().contains("const traceAbortController = new AbortController();")); assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); @@ -212,6 +215,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("handleEditorKeydown")); assertTrue(script.body().contains("receivedLastVotes.length > MAX_PLAYER_LAST_VOTES")); assertTrue(script.body().contains(".slice(0, MAX_PLAYER_LAST_VOTES)")); + assertTrue(script.body().contains("if (!automatic) text(fileOperationStatus, error.message);")); assertTrue(script.body().contains("const cell = document.createElement('td');")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From 59a4e0d2fc5ddbf9c03c67150beb1e5d5ed8de40 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:47:14 -0600 Subject: [PATCH 12/20] Normalize vote trace identifiers --- src/main/resources/web/app.js | 7 ++++--- .../votingplugin/control/http/ControlHttpServerTest.java | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index edca84e0..2cb069dc 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -699,10 +699,11 @@ async function traceVoteAcrossNodes() { const available = connectedInspectionNodes(); const candidates = available.slice(0, MAX_TRACE_NODES); if (candidates.length === 0) throw new Error('No connected backend supports vote-log inspection.'); - const voteId = voteTraceId.value.trim(); - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(voteId)) { + const enteredVoteId = voteTraceId.value.trim(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(enteredVoteId)) { throw new Error('Enter a canonical vote UUID.'); } + const voteId = enteredVoteId.toLowerCase(); const requestAuthenticationGeneration = authenticationGeneration; const requestInputGeneration = inputGeneration; const requestSelectedNodeId = selectedServerId; @@ -713,7 +714,7 @@ async function traceVoteAcrossNodes() { const contextCurrent = () => requestAuthenticationGeneration === authenticationGeneration && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId - && voteId === voteTraceId.value.trim() && days === String(voteLogDays.value) + && enteredVoteId === voteTraceId.value.trim() && days === String(voteLogDays.value) && candidates.every(node => { const current = nodeIndex.get(node.nodeId); return Boolean(current?.online) && candidateSessions.get(node.nodeId) === current.sessionId diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 430b13fb..bcd0c31f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -120,6 +120,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains( "typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId")); assertTrue(script.body().contains("typeof event.voteId !== 'string' || event.voteId !== voteId")); + assertTrue(script.body().contains("const voteId = enteredVoteId.toLowerCase();")); + assertTrue(script.body().contains("&& enteredVoteId === voteTraceId.value.trim()")); assertTrue(script.body().contains("columns.length < value.columns.length")); assertTrue(script.body().contains("const traceAbortController = new AbortController();")); assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); @@ -152,7 +154,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("retained.sessionId === readSessionId")); assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); - assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("enteredVoteId === voteTraceId.value.trim()")); assertTrue(script.body().contains("Pending offline votes")); assertTrue(script.body().contains("Additional VoteSite history was omitted")); assertTrue(script.body().contains("Node result limit reached; this trace is incomplete")); From 7612daba8bbb739b61be2011cd2fd6a48da672a8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:00:52 -0600 Subject: [PATCH 13/20] Preserve edits during configuration apply --- src/main/resources/web/app.js | 8 ++++---- .../votingplugin/control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 2cb069dc..ca87f071 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2622,12 +2622,12 @@ applyConfiguration.addEventListener('click', async () => { if (!approvedPreview || !window.confirm('Apply this exact preview to every selected proxy? Each node may still reject a stale revision.')) return; const approval = approvedPreview; approvedPreview = null; - inputGeneration++; + const applyGeneration = inputGeneration + 1; try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); - if (operation.state === 'SUCCEEDED') { + if (operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration) { routingDirty = false; routingDraftNodeId = ''; } @@ -2745,13 +2745,13 @@ applyFileConfiguration.addEventListener('click', async () => { || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; - inputGeneration++; + const applyGeneration = inputGeneration + 1; try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, fileOperationStatus); text(fileOperationStatus, operationSummary(operation)); - if (operation.state === 'SUCCEEDED') { + if (operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration) { fileReadCache.clear(); lastFileReadOperation = null; configurationDirty = false; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index bcd0c31f..366d1532 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -219,6 +219,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains(".slice(0, MAX_PLAYER_LAST_VOTES)")); assertTrue(script.body().contains("if (!automatic) text(fileOperationStatus, error.message);")); assertTrue(script.body().contains("const cell = document.createElement('td');")); + assertTrue(script.body().contains("const applyGeneration = inputGeneration + 1;")); + assertTrue(script.body().contains("operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); HttpResponse stylesheet = get("/app.css", null); From 06b53a9e6a7b24b587467f301a907af7188d1995 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:15:07 -0600 Subject: [PATCH 14/20] Harden control inspection state handling --- .../control/domain/InspectionOperations.java | 13 +++++++++ src/main/resources/web/app.js | 20 ++++++++++--- .../domain/InspectionOperationsTest.java | 29 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 5 ++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index 14b5061c..40d92a4d 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -50,6 +50,7 @@ public synchronized InspectionView create(String nodeId, InspectionQuery query) if (!node.online() || !node.acceptedCapabilities().contains(InspectionQuery.CAPABILITY)) { throw new ValidationException("NODE_UNAVAILABLE", "Node cannot answer inspection queries", List.of(nodeId)); } + evictOldestCompletedAtCapacity(); if (inspections.size() >= MAX_INSPECTIONS) { throw new ValidationException("OPERATION_LIMIT", "Too many retained inspections", List.of()); } @@ -245,6 +246,18 @@ private void prune() { } } + private void evictOldestCompletedAtCapacity() { + if (inspections.size() < MAX_INSPECTIONS) return; + Iterator> iterator = inspections.entrySet().iterator(); + while (iterator.hasNext()) { + StoredInspection stored = iterator.next().getValue(); + if (!"COMPLETE".equals(stored.state)) continue; + append("INSPECTION_EVICTED", stored.id, stored.nodeId, stored.query.kind()); + iterator.remove(); + return; + } + } + private void append(String action, UUID id, String nodeId, String outcome) { if (audit != null) audit.append(action, id, nodeId, outcome); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index ca87f071..3f1aabb8 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -393,8 +393,12 @@ function renderPlayerData(value) { add('Last online', formatEpoch(value.lastOnline)); playerResult.append(profile); const receivedLastVotes = Array.isArray(value.lastVotes) ? value.lastVotes : []; + const malformedLastVotes = value.lastVotes !== undefined + && (!Array.isArray(value.lastVotes) + || value.lastVotes.some(lastVote => !lastVote || typeof lastVote !== 'object' || Array.isArray(lastVote))); const lastVotes = receivedLastVotes - .filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, MAX_PLAYER_LAST_VOTES); + .filter(lastVote => lastVote && typeof lastVote === 'object' && !Array.isArray(lastVote)) + .slice(0, MAX_PLAYER_LAST_VOTES); if (lastVotes.length) { const heading = text(document.createElement('h4'), 'VoteSite history'); const scroll = document.createElement('div'); @@ -416,7 +420,12 @@ function renderPlayerData(value) { scroll.append(table); playerResult.append(heading, scroll); } - if (value.lastVotesTruncated === true || receivedLastVotes.length > MAX_PLAYER_LAST_VOTES) { + if (malformedLastVotes) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'VoteSite history is unavailable because the node returned malformed history data.'); + playerResult.append(warning); + } else if (value.lastVotesTruncated === true || receivedLastVotes.length > MAX_PLAYER_LAST_VOTES) { const warning = document.createElement('p'); warning.className = 'warning-text'; text(warning, `Additional VoteSite history was omitted by the ${MAX_PLAYER_LAST_VOTES}-row inspection limit.`); @@ -2202,10 +2211,12 @@ async function waitForOperation(operation, statusElement = operationStatus, cont rememberOperation(operation); } rememberVoteLoggingRestart(operation); - if (operationContextCurrent(context) && operation.type === 'APPLY' - && Object.values(operation.results || {}).some(result => result?.success)) { + if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { fileReadCache.clear(); lastFileReadOperation = null; + } + if (operationContextCurrent(context) && operation.type === 'APPLY' + && Object.values(operation.results || {}).some(result => result?.success)) { lastOverview = null; lastDiagnostics = null; updateExtendedButtons(); @@ -3274,6 +3285,7 @@ runDriftCheck.addEventListener('click', async () => { if (requestAuthenticationGeneration !== authenticationGeneration || requestInputGeneration !== inputGeneration || requestSelectedNodeId !== selectedServerId || requestSelectedSessionId !== nodeIndex.get(requestSelectedNodeId)?.sessionId || !targetsStillCurrent) { + text(driftResults, 'The selected targets or file changed while reading. Drift results were discarded; run the comparison again.'); return; } const rows = nodeIds.map(nodeId => { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index 1c9a077b..32fb23b0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -238,6 +238,35 @@ class InspectionOperationsTest { () -> operations.get(completed)).code()); } + @Test void completedInspectionsYieldCapacityToNewRequests() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + UUID oldest = null; + for (int index = 0; index < 100; index++) { + UUID inspection = operations.create("backend-a", new InspectionQuery("overview", Map.of())).inspectionId(); + if (index == 0) oldest = inspection; + InspectionTask task = operations.claim("backend-a", session); + operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", envelope("overview"), task.attemptId())); + } + + assertNotNull(operations.create("backend-a", new InspectionQuery("overview", Map.of()))); + UUID evicted = oldest; + assertEquals("OPERATION_NOT_FOUND", assertThrows(ValidationException.class, + () -> operations.get(evicted)).code()); + } + + @Test void activeInspectionsStillEnforceCapacityLimit() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + for (int index = 0; index < 100; index++) { + operations.create("backend-a", new InspectionQuery("overview", Map.of())); + } + + assertEquals("OPERATION_LIMIT", assertThrows(ValidationException.class, + () -> operations.create("backend-a", new InspectionQuery("overview", Map.of()))).code()); + } + private void register(UUID sessionId, Set capabilities) { registry.register(new NodeRegistration("backend-a", sessionId, "Backend A", "BUKKIT", "test", 1, capabilities, Set.of())); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 366d1532..86e957a3 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -157,6 +157,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("enteredVoteId === voteTraceId.value.trim()")); assertTrue(script.body().contains("Pending offline votes")); assertTrue(script.body().contains("Additional VoteSite history was omitted")); + assertTrue(script.body().contains("VoteSite history is unavailable because the node returned malformed history data.")); assertTrue(script.body().contains("Node result limit reached; this trace is incomplete")); assertFalse(script.body().contains("This is the complete retained trace")); assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); @@ -167,6 +168,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewConfiguration.disabled = !routingDraftReady;")); assertTrue(script.body().contains("applyConfiguration.disabled = !routingDraftReady || !approvedPreview;")); assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); + assertTrue(script.body().contains("fileReadCache.clear();\n lastFileReadOperation = null;\n }\n if (operationContextCurrent(context)"), + "Every successful apply must invalidate cached reads even after the view context changes."); + assertTrue(script.body().contains("Drift results were discarded; run the comparison again."), + "A drift read completed for stale context must show an explicit discarded-result status."); assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); From f4ff92835a022cb7b229d96256dd3618890cf91b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:51:04 -0600 Subject: [PATCH 15/20] Validate and retain inspection results --- docs/control-management.md | 6 +- .../control/domain/InspectionOperations.java | 7 +- src/main/resources/web/app.js | 72 +++++++++++++++++-- .../domain/InspectionOperationsTest.java | 20 +++++- .../control/http/ControlHttpServerTest.java | 7 +- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/docs/control-management.md b/docs/control-management.md index 2df6c290..bd4644c5 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -262,8 +262,10 @@ Successful `data` is a JSON object with a common envelope: `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. +lease is two minutes; an unleased active inspection expires five minutes after creation and an unobserved complete +inspection is pruned 15 minutes after creation. After an API client retrieves a terminal result, that acknowledged entry +may be evicted early only when the 100-entry store needs capacity. 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. Claim, completion, and capability-loss cancellation update diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index 40d92a4d..edbf522a 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -73,7 +73,9 @@ public synchronized InspectionView get(UUID id) { if (stored == null) { throw new ValidationException("OPERATION_NOT_FOUND", "Inspection was not found", List.of()); } - return view(stored); + InspectionView result = view(stored); + if ("COMPLETE".equals(stored.state)) stored.terminalResultObserved = true; + return result; } public synchronized InspectionTask claim(String nodeId, UUID sessionId) { @@ -251,7 +253,7 @@ private void evictOldestCompletedAtCapacity() { Iterator> iterator = inspections.entrySet().iterator(); while (iterator.hasNext()) { StoredInspection stored = iterator.next().getValue(); - if (!"COMPLETE".equals(stored.state)) continue; + if (!"COMPLETE".equals(stored.state) || !stored.terminalResultObserved) continue; append("INSPECTION_EVICTED", stored.id, stored.nodeId, stored.query.kind()); iterator.remove(); return; @@ -279,6 +281,7 @@ private static final class StoredInspection { private Instant leasedAt; private UUID attemptId; private InspectionTaskResult result; + private boolean terminalResultObserved; private StoredInspection(UUID id, String nodeId, UUID targetSession, InspectionQuery query, Instant createdAt) { this.id = id; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 3f1aabb8..78a91274 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -205,6 +205,15 @@ const MAX_OPERATION_TARGETS = 100; const MAX_TRACE_NODES = 12; const MAX_TRACE_EVENTS_PER_NODE = 100; const MAX_PLAYER_LAST_VOTES = 100; +const PLAYER_STRING_COLUMNS = new Set(['UUID', 'PlayerName', 'LastOnline', 'DayVoteStreakLastUpdate', 'VoteRemindersLast']); +const PLAYER_BOOLEAN_COLUMNS = new Set(['TopVoterIgnore', 'Reminded', 'DisableBroadcast', 'CoolDownCheck']); +const PLAYER_INTEGER_COLUMNS = new Set(['VotePartyVotes', 'MonthTotal', 'AllTimeTotal', 'DailyTotal', 'WeeklyTotal', + 'Points', 'DayVoteStreak', 'BestDayVoteStreak', 'WeekVoteStreak', 'BestWeekVoteStreak', 'MonthVoteStreak', + 'BestMonthVoteStreak', 'HighestDailyTotal', 'HighestMonthlyTotal', 'HighestWeeklyTotal', 'LastMonthTotal', + 'LastWeeklyTotal', 'LastDailyTotal', 'AllSitesLast', 'AlmostAllSitesLast']); +const VOTE_LOG_EVENTS = new Set(['VOTE_RECEIVED', 'VOTEMILESTONE', 'VOTE_STREAK_REWARD', 'TOP_VOTER_REWARD', + 'VOTESHOP_PURCHASE']); +const VOTE_LOG_STATUSES = new Set(['IMMEDIATE', 'CACHED']); const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; @@ -363,6 +372,49 @@ function formatEpoch(value) { return Number.isFinite(epoch) && epoch > 0 ? new Date(epoch).toLocaleString() : 'Unknown'; } +function plainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function exactObjectKeys(value, expected) { + if (!plainObject(value)) return false; + const keys = Object.keys(value).sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function validPlayerColumn(column) { + if (!exactObjectKeys(column, ['name', 'type', 'value'])) return false; + if (typeof column.name !== 'string' || typeof column.type !== 'string' || typeof column.value !== 'string' + || new TextEncoder().encode(column.value).length > 16 * 1024) return false; + const runtimeSuffix = '[A-Za-z0-9_-]{1,64}'; + const runtimeString = new RegExp(`^CoolDownCheck(?:_${runtimeSuffix})?_Sites$`).test(column.name); + if (PLAYER_STRING_COLUMNS.has(column.name) || runtimeString) return column.type === 'STRING'; + const runtimeBoolean = new RegExp(`^CoolDownCheck(?:_${runtimeSuffix})?$`).test(column.name); + if (PLAYER_BOOLEAN_COLUMNS.has(column.name) || runtimeBoolean) { + return column.type === 'BOOLEAN' && /^(?:true|false)$/.test(column.value) + || column.type === 'STRING' && /^(?:true|false)$/i.test(column.value); + } + const runtimeInteger = new RegExp(`^(?:AllSitesLast|AlmostAllSitesLast)(?:_${runtimeSuffix})?$`).test(column.name); + const integerName = PLAYER_INTEGER_COLUMNS.has(column.name) || runtimeInteger + || /^(?:MonthTotal-(?:JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)-[0-9]{4}|VoteShopLimit[A-Za-z0-9_-]{1,64})$/.test(column.name); + if (!integerName || column.type !== 'INTEGER' || !/^-?(?:0|[1-9][0-9]*)$/.test(column.value)) return false; + const number = Number(column.value); + return Number.isInteger(number) && number >= -2147483648 && number <= 2147483647; +} + +function validVoteTraceEvent(event, voteId) { + const fields = ['cachedTotal', 'context', 'event', 'playerName', 'playerUuid', 'server', 'service', 'status', + 'voteId', 'voteTime']; + if (!exactObjectKeys(event, fields) || event.voteId !== voteId + || !Number.isSafeInteger(event.voteTime) || event.voteTime < 0 + || !Number.isInteger(event.cachedTotal) || event.cachedTotal < -2147483648 || event.cachedTotal > 2147483647) return false; + const limits = {voteId: 36, playerUuid: 36, playerName: 16, service: 64, server: 64, event: 64, context: 255, + status: 16}; + if (!Object.entries(limits).every(([field, maximum]) => typeof event[field] === 'string' + && event[field].length <= maximum && !/[\u0000-\u001f\u007f-\u009f]/.test(event[field]))) return false; + return VOTE_LOG_EVENTS.has(event.event) && VOTE_LOG_STATUSES.has(event.status); +} + function renderPlayerData(value) { playerResult.replaceChildren(); if (!value || value.found !== true) { @@ -431,9 +483,14 @@ function renderPlayerData(value) { text(warning, `Additional VoteSite history was omitted by the ${MAX_PLAYER_LAST_VOTES}-row inspection limit.`); playerResult.append(warning); } - if (!Array.isArray(value.columns)) return; - const columns = value.columns.filter(column => column && typeof column === 'object' && !Array.isArray(column)) - .slice(0, 100); + if (!Array.isArray(value.columns) || value.columns.some(column => !validPlayerColumn(column))) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'Stored values are unavailable because the node returned fields outside the allow-listed column schema.'); + playerResult.append(warning); + return; + } + const columns = value.columns.slice(0, 100); const scroll = document.createElement('div'); scroll.className = 'table-scroll'; const table = document.createElement('table'); @@ -761,12 +818,15 @@ async function traceVoteAcrossNodes() { return; } const received = envelope.result.events; - if (typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId - || received.some(event => !event || typeof event !== 'object' || Array.isArray(event) - || typeof event.voteId !== 'string' || event.voteId !== voteId)) { + if (typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId) { unavailable.push(`${source}: vote-trace correlation did not match the requested vote`); return; } + if (typeof envelope.result.found !== 'boolean' || typeof envelope.result.truncated !== 'boolean' + || received.some(event => !validVoteTraceEvent(event, voteId))) { + unavailable.push(`${source}: malformed vote-trace events`); + return; + } const listed = received.slice(0, MAX_TRACE_EVENTS_PER_NODE); sources.push(source); if (envelope.result?.truncated === true || received.length > MAX_TRACE_EVENTS_PER_NODE) { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index 32fb23b0..a9d20652 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -238,7 +238,7 @@ class InspectionOperationsTest { () -> operations.get(completed)).code()); } - @Test void completedInspectionsYieldCapacityToNewRequests() { + @Test void observedCompletedInspectionsYieldCapacityToNewRequests() { register(session, Set.of(InspectionQuery.CAPABILITY)); InspectionOperations operations = new InspectionOperations(registry, clock); UUID oldest = null; @@ -248,6 +248,7 @@ class InspectionOperationsTest { InspectionTask task = operations.claim("backend-a", session); operations.complete(inspection, "backend-a", new InspectionTaskResult(session, true, "OK", "done", envelope("overview"), task.attemptId())); + operations.get(inspection); } assertNotNull(operations.create("backend-a", new InspectionQuery("overview", Map.of()))); @@ -256,6 +257,23 @@ class InspectionOperationsTest { () -> operations.get(evicted)).code()); } + @Test void unobservedCompletedInspectionsAreNotEvictedAtCapacity() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + UUID oldest = null; + for (int index = 0; index < 100; index++) { + UUID inspection = operations.create("backend-a", new InspectionQuery("overview", Map.of())).inspectionId(); + if (index == 0) oldest = inspection; + InspectionTask task = operations.claim("backend-a", session); + operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", envelope("overview"), task.attemptId())); + } + + assertEquals("OPERATION_LIMIT", assertThrows(ValidationException.class, + () -> operations.create("backend-a", new InspectionQuery("overview", Map.of()))).code()); + assertEquals("SUCCEEDED", operations.get(oldest).state()); + } + @Test void activeInspectionsStillEnforceCapacityLimit() { register(session, Set.of(InspectionQuery.CAPABILITY)); InspectionOperations operations = new InspectionOperations(registry, clock); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 86e957a3..a86d3c98 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -119,7 +119,7 @@ class ControlHttpServerTest { "if (!Array.isArray(envelope.result?.events)) {\n unavailable.push(`${source}: malformed vote-trace events`);")); assertTrue(script.body().contains( "typeof envelope.result.voteId !== 'string' || envelope.result.voteId !== voteId")); - assertTrue(script.body().contains("typeof event.voteId !== 'string' || event.voteId !== voteId")); + assertTrue(script.body().contains("event.voteId !== voteId")); assertTrue(script.body().contains("const voteId = enteredVoteId.toLowerCase();")); assertTrue(script.body().contains("&& enteredVoteId === voteTraceId.value.trim()")); assertTrue(script.body().contains("columns.length < value.columns.length")); @@ -158,6 +158,11 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Pending offline votes")); assertTrue(script.body().contains("Additional VoteSite history was omitted")); assertTrue(script.body().contains("VoteSite history is unavailable because the node returned malformed history data.")); + assertTrue(script.body().contains("function validPlayerColumn(column)")); + assertTrue(script.body().contains("fields outside the allow-listed column schema")); + assertTrue(script.body().contains("function validVoteTraceEvent(event, voteId)")); + assertTrue(script.body().contains("received.some(event => !validVoteTraceEvent(event, voteId))")); + assertTrue(script.body().contains("VOTE_LOG_STATUSES.has(event.status)")); assertTrue(script.body().contains("Node result limit reached; this trace is incomplete")); assertFalse(script.body().contains("This is the complete retained trace")); assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); From 99665635bc9a3ec918b015a8cdb65b65a581c8a4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:33:41 -0600 Subject: [PATCH 16/20] Harden control result presentation --- src/main/resources/web/app.js | 31 +++++++++++++++---- .../control/http/ControlHttpServerTest.java | 10 ++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 78a91274..82fde901 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -402,6 +402,14 @@ function validPlayerColumn(column) { return Number.isInteger(number) && number >= -2147483648 && number <= 2147483647; } +function validPlayerLastVote(lastVote) { + if (!exactObjectKeys(lastVote, ['displayName', 'serviceSite', 'siteKey', 'time']) + || !Number.isSafeInteger(lastVote.time) || lastVote.time < 0) return false; + const limits = {siteKey: 64, displayName: 100, serviceSite: 64}; + return Object.entries(limits).every(([field, maximum]) => typeof lastVote[field] === 'string' + && lastVote[field].length <= maximum && !/[\u0000-\u001f\u007f-\u009f]/.test(lastVote[field])); +} + function validVoteTraceEvent(event, voteId) { const fields = ['cachedTotal', 'context', 'event', 'playerName', 'playerUuid', 'server', 'service', 'status', 'voteId', 'voteTime']; @@ -446,11 +454,8 @@ function renderPlayerData(value) { playerResult.append(profile); const receivedLastVotes = Array.isArray(value.lastVotes) ? value.lastVotes : []; const malformedLastVotes = value.lastVotes !== undefined - && (!Array.isArray(value.lastVotes) - || value.lastVotes.some(lastVote => !lastVote || typeof lastVote !== 'object' || Array.isArray(lastVote))); - const lastVotes = receivedLastVotes - .filter(lastVote => lastVote && typeof lastVote === 'object' && !Array.isArray(lastVote)) - .slice(0, MAX_PLAYER_LAST_VOTES); + && (!Array.isArray(value.lastVotes) || receivedLastVotes.some(lastVote => !validPlayerLastVote(lastVote))); + const lastVotes = malformedLastVotes ? [] : receivedLastVotes.slice(0, MAX_PLAYER_LAST_VOTES); if (lastVotes.length) { const heading = text(document.createElement('h4'), 'VoteSite history'); const scroll = document.createElement('div'); @@ -483,13 +488,16 @@ function renderPlayerData(value) { text(warning, `Additional VoteSite history was omitted by the ${MAX_PLAYER_LAST_VOTES}-row inspection limit.`); playerResult.append(warning); } - if (!Array.isArray(value.columns) || value.columns.some(column => !validPlayerColumn(column))) { + const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined; + if (!columnsOmittedForUnavailableStorage + && (!Array.isArray(value.columns) || value.columns.some(column => !validPlayerColumn(column)))) { const warning = document.createElement('p'); warning.className = 'warning-text'; text(warning, 'Stored values are unavailable because the node returned fields outside the allow-listed column schema.'); playerResult.append(warning); return; } + if (columnsOmittedForUnavailableStorage) return; const columns = value.columns.slice(0, 100); const scroll = document.createElement('div'); scroll.className = 'table-scroll'; @@ -867,6 +875,10 @@ async function traceVoteAcrossNodes() { } function operationPhase(operation) { + const proxyFileRestartRequired = operation.type === 'APPLY' && operation.state === 'SUCCEEDED' + && operation.configuration?.domain === 'file' && operation.configuration?.fileName === 'bungeeconfig.yml'; + if (proxyFileRestartRequired) return operation.recovered + ? 'Recovered history · Saved; proxy restart required' : 'Saved; proxy restart required'; 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'; @@ -2215,6 +2227,8 @@ function voteLoggingRestartRequired(nodeId = selectedServerId) { function operationSummary(operation) { const lines = [`${operation.type} · ${operation.state} · ${operation.operationId}`]; const voteLoggingOperation = operation.configuration?.preset === 'vote-logging'; + const proxyFileOperation = operation.configuration?.domain === 'file' + && operation.configuration?.fileName === 'bungeeconfig.yml'; let voteLoggingRuntimeChange = false; let voteLoggingRestartWarning = false; Object.entries(operation.nodeStates).forEach(([node, state]) => { @@ -2229,6 +2243,8 @@ function operationSummary(operation) { const successLabel = operation.type === 'READ' ? 'values read' : operation.type === 'PREVIEW' ? 'preview ready' : restartRequired ? 'configuration saved; backend restart required' + : proxyFileOperation && result?.success && operation.type === 'APPLY' + ? 'configuration saved; proxy restart required' : result?.reloaded ? 'saved and reloaded' : 'applied'; lines.push(`${result?.success ? '✓' : result ? '✗' : '…'} ${node}: ${result ? `${result.success ? successLabel : result.code} — ${result.message}` : state.toLowerCase()}`); @@ -2247,6 +2263,9 @@ function operationSummary(operation) { ? '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.'); } + if (proxyFileOperation && operation.type === 'APPLY' && operation.state === 'SUCCEEDED') { + lines.push('Restart the proxy before treating the saved proxy configuration as active.'); + } return lines.join('\n'); } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index a86d3c98..ea5fa448 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -158,8 +158,18 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Pending offline votes")); assertTrue(script.body().contains("Additional VoteSite history was omitted")); assertTrue(script.body().contains("VoteSite history is unavailable because the node returned malformed history data.")); + assertTrue(script.body().contains("function validPlayerLastVote(lastVote)")); + assertTrue(script.body().contains("exactObjectKeys(lastVote, ['displayName', 'serviceSite', 'siteKey', 'time'])")); + assertTrue(script.body().contains("const limits = {siteKey: 64, displayName: 100, serviceSite: 64};")); + assertTrue(script.body().contains("receivedLastVotes.some(lastVote => !validPlayerLastVote(lastVote))")); + assertTrue(script.body().contains("const lastVotes = malformedLastVotes ? [] : receivedLastVotes.slice(0, MAX_PLAYER_LAST_VOTES);")); assertTrue(script.body().contains("function validPlayerColumn(column)")); + assertTrue(script.body().contains("const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined;")); + assertTrue(script.body().contains("if (columnsOmittedForUnavailableStorage) return;")); assertTrue(script.body().contains("fields outside the allow-listed column schema")); + assertTrue(script.body().contains("Saved; proxy restart required")); + assertTrue(script.body().contains("configuration saved; proxy restart required")); + assertTrue(script.body().contains("Restart the proxy before treating the saved proxy configuration as active.")); assertTrue(script.body().contains("function validVoteTraceEvent(event, voteId)")); assertTrue(script.body().contains("received.some(event => !validVoteTraceEvent(event, voteId))")); assertTrue(script.body().contains("VOTE_LOG_STATUSES.has(event.status)")); From d6ffc22eb2a3379d5935f5a05fed5baae19fb19e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:56:49 -0600 Subject: [PATCH 17/20] Validate player inspections before retention --- .../control/domain/InspectionOperations.java | 188 ++++++++++++++++++ src/main/resources/web/app.js | 12 +- .../domain/InspectionOperationsTest.java | 97 ++++++++- .../control/http/ControlHttpServerTest.java | 1 + 4 files changed, 292 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index edbf522a..cea42cfe 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -4,15 +4,18 @@ import com.bencodez.votingplugin.control.protocol.InspectionTask; import com.bencodez.votingplugin.control.protocol.InspectionTaskResult; import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.fasterxml.jackson.databind.JsonNode; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -21,7 +24,26 @@ 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 int MAX_PLAYER_ROWS = 100; + private static final int MAX_PLAYER_COLUMN_VALUE_BYTES = 16 * 1024; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern PLAYER_NAME = Pattern.compile("[A-Za-z0-9_]{1,16}"); + private static final Pattern PLAYER_MONTH_TOTAL = Pattern.compile( + "MonthTotal-(?:JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)-[0-9]{4}"); + private static final Pattern PLAYER_VOTE_SHOP_LIMIT = Pattern.compile("VoteShopLimit[A-Za-z0-9_-]{1,64}"); + private static final Set PLAYER_STRING_COLUMNS = Set.of("UUID", "PlayerName", "LastOnline", + "DayVoteStreakLastUpdate", "VoteRemindersLast"); + private static final Set PLAYER_BOOLEAN_COLUMNS = Set.of("TopVoterIgnore", "Reminded", + "DisableBroadcast", "CoolDownCheck"); + private static final Set PLAYER_INTEGER_COLUMNS = Set.of("VotePartyVotes", "MonthTotal", "AllTimeTotal", + "DailyTotal", "WeeklyTotal", "Points", "DayVoteStreak", "BestDayVoteStreak", "WeekVoteStreak", + "BestWeekVoteStreak", "MonthVoteStreak", "BestMonthVoteStreak", "HighestDailyTotal", + "HighestMonthlyTotal", "HighestWeeklyTotal", "LastMonthTotal", "LastWeeklyTotal", "LastDailyTotal", + "AllSitesLast", "AlmostAllSitesLast"); + private static final Set PLAYER_FOUND_FIELDS = Set.of("found", "uuid", "name", "lastOnline", "online", + "totals", "points", "streaks", "lastVoteTime", "lastVotes", "lastVotesTruncated", + "pendingOfflineVotes", "storageRowAvailable"); + private static final Set PLAYER_OPTIONAL_STORAGE_FIELDS = Set.of("storage", "columns", "columnsTruncated"); 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); @@ -209,6 +231,9 @@ private static void validateResult(InspectionTaskResult result, String expectedK } catch (java.time.format.DateTimeParseException failure) { throw invalid("inspection data generatedAt is invalid"); } + if ("player".equals(expectedKind) && !validPlayerResult(result.data().path("result"))) { + throw invalid("player inspection data is invalid"); + } } if (jsonBytes(result.data()) > MAX_DATA_BYTES || bytes(result.message()) > MAX_MESSAGE_BYTES) { throw invalid("inspection result exceeds retention limits"); @@ -219,6 +244,169 @@ private static int jsonBytes(com.fasterxml.jackson.databind.JsonNode value) { return value == null ? 0 : value.toString().getBytes(StandardCharsets.UTF_8).length; } + /** + * Player data is the only inspection result that includes values read from a storage row. The node is remote, so + * enforce the same narrow column contract here before retaining a result for a browser GET. + */ + private static boolean validPlayerResult(JsonNode value) { + if (!value.isObject() || !value.path("found").isBoolean()) return false; + if (!value.path("found").booleanValue()) { + return exactFields(value, Set.of("found", "entity")) && value.path("entity").isTextual() + && "player".equals(value.path("entity").textValue()); + } + if (!onlyFields(value, PLAYER_FOUND_FIELDS, PLAYER_OPTIONAL_STORAGE_FIELDS) + || !hasFields(value, PLAYER_FOUND_FIELDS) + || !canonicalUuid(value.path("uuid")) || !boundedText(value.path("name"), 16) + || !PLAYER_NAME.matcher(value.path("name").textValue()).matches() + || !nonNegativeLong(value.path("lastOnline")) || !value.path("online").isBoolean() + || !validTotals(value.path("totals")) || !signedInt(value.path("points")) + || !validStreaks(value.path("streaks")) || !nonNegativeLong(value.path("lastVoteTime")) + || !validLastVotes(value.path("lastVotes")) || !value.path("lastVotesTruncated").isBoolean() + || !nonNegativeInt(value.path("pendingOfflineVotes")) + || value.path("pendingOfflineVotes").intValue() > 100_000 + || !value.path("storageRowAvailable").isBoolean()) { + return false; + } + boolean storageAvailable = value.path("storageRowAvailable").booleanValue(); + if (!storageAvailable) { + return !value.has("storage") + && (!value.has("columns") || value.path("columns").isArray() && value.path("columns").isEmpty()) + && (!value.has("columnsTruncated") || !value.path("columnsTruncated").booleanValue()); + } + return boundedText(value.path("storage"), 32) && !value.path("storage").textValue().isBlank() + && validColumns(value.path("columns")) && value.path("columnsTruncated").isBoolean(); + } + + private static boolean validTotals(JsonNode value) { + return exactFields(value, Set.of("daily", "weekly", "monthly", "allTime")) + && nonNegativeInt(value.path("daily")) && nonNegativeInt(value.path("weekly")) + && nonNegativeInt(value.path("monthly")) && nonNegativeInt(value.path("allTime")); + } + + private static boolean validStreaks(JsonNode value) { + return exactFields(value, Set.of("daily", "weekly", "monthly")) + && nonNegativeInt(value.path("daily")) && nonNegativeInt(value.path("weekly")) + && nonNegativeInt(value.path("monthly")); + } + + private static boolean validLastVotes(JsonNode value) { + if (!value.isArray() || value.size() > MAX_PLAYER_ROWS) return false; + for (JsonNode row : value) { + if (!exactFields(row, Set.of("siteKey", "displayName", "serviceSite", "time")) + || !boundedCharacters(row.path("siteKey"), 64) || !boundedCharacters(row.path("displayName"), 100) + || !boundedCharacters(row.path("serviceSite"), 64) || !nonNegativeLong(row.path("time"))) return false; + } + return true; + } + + private static boolean validColumns(JsonNode value) { + if (!value.isArray() || value.size() > MAX_PLAYER_ROWS) return false; + Set names = new HashSet<>(); + for (JsonNode column : value) { + if (!exactFields(column, Set.of("name", "type", "value")) || !column.path("name").isTextual() + || !column.path("type").isTextual() || !column.path("value").isTextual() + || !boundedText(column.path("value"), MAX_PLAYER_COLUMN_VALUE_BYTES) + || !names.add(column.path("name").textValue()) + || !validPlayerColumn(column.path("name").textValue(), column.path("type").textValue(), + column.path("value").textValue())) return false; + } + return true; + } + + private static boolean validPlayerColumn(String name, String type, String rendered) { + if (PLAYER_STRING_COLUMNS.contains(name) || runtimeStringColumn(name)) return "STRING".equals(type); + if (PLAYER_BOOLEAN_COLUMNS.contains(name) || runtimeBooleanColumn(name)) { + return "BOOLEAN".equals(type) && ("true".equals(rendered) || "false".equals(rendered)) + || "STRING".equals(type) && rendered.matches("(?i:true|false)"); + } + if (!(PLAYER_INTEGER_COLUMNS.contains(name) || runtimeIntegerColumn(name) + || PLAYER_MONTH_TOTAL.matcher(name).matches() || PLAYER_VOTE_SHOP_LIMIT.matcher(name).matches()) + || !"INTEGER".equals(type) || !rendered.matches("-?(?:0|[1-9][0-9]*)")) return false; + try { + Integer.parseInt(rendered); + return true; + } catch (NumberFormatException failure) { + return false; + } + } + + private static boolean runtimeStringColumn(String name) { + if (!name.startsWith("CoolDownCheck") || !name.endsWith("_Sites")) return false; + String middle = name.substring("CoolDownCheck".length(), name.length() - "_Sites".length()); + return middle.isEmpty() || middle.startsWith("_") && validRuntimeSuffix(middle.substring(1)); + } + + private static boolean runtimeBooleanColumn(String name) { + if (!name.startsWith("CoolDownCheck")) return false; + String suffix = name.substring("CoolDownCheck".length()); + return suffix.startsWith("_") && validRuntimeSuffix(suffix.substring(1)); + } + + private static boolean runtimeIntegerColumn(String name) { + for (String prefix : List.of("AllSitesLast", "AlmostAllSitesLast")) { + if (!name.startsWith(prefix)) continue; + String suffix = name.substring(prefix.length()); + if (suffix.startsWith("_") && validRuntimeSuffix(suffix.substring(1))) return true; + } + return false; + } + + private static boolean validRuntimeSuffix(String value) { + return !value.isEmpty() && !hasControlCharacter(value); + } + + private static boolean canonicalUuid(JsonNode value) { + if (!boundedText(value, 36)) return false; + try { + return UUID.fromString(value.textValue()).toString().equals(value.textValue()); + } catch (IllegalArgumentException failure) { + return false; + } + } + + private static boolean boundedText(JsonNode value, int maximumBytes) { + return value.isTextual() && bytes(value.textValue()) <= maximumBytes && !hasControlCharacter(value.textValue()); + } + + private static boolean boundedCharacters(JsonNode value, int maximumCharacters) { + return value.isTextual() && value.textValue().length() <= maximumCharacters + && !hasControlCharacter(value.textValue()); + } + + private static boolean hasControlCharacter(String value) { + return value.codePoints().anyMatch(character -> character <= 0x1f || character >= 0x7f && character <= 0x9f); + } + + private static boolean nonNegativeLong(JsonNode value) { + return value.isIntegralNumber() && value.canConvertToLong() && value.longValue() >= 0; + } + + private static boolean nonNegativeInt(JsonNode value) { + return value.isIntegralNumber() && value.canConvertToInt() && value.intValue() >= 0; + } + + private static boolean signedInt(JsonNode value) { + return value.isIntegralNumber() && value.canConvertToInt(); + } + + private static boolean exactFields(JsonNode value, Set expected) { + return value.isObject() && value.size() == expected.size() && hasFields(value, expected) + && onlyFields(value, expected, Set.of()); + } + + private static boolean hasFields(JsonNode value, Set expected) { + return expected.stream().allMatch(value::has); + } + + private static boolean onlyFields(JsonNode value, Set required, Set optional) { + Iterator fields = value.fieldNames(); + while (fields.hasNext()) { + String field = fields.next(); + if (!required.contains(field) && !optional.contains(field)) return false; + } + return true; + } + private static int bytes(String value) { return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 82fde901..436d8009 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -386,15 +386,19 @@ function validPlayerColumn(column) { if (!exactObjectKeys(column, ['name', 'type', 'value'])) return false; if (typeof column.name !== 'string' || typeof column.type !== 'string' || typeof column.value !== 'string' || new TextEncoder().encode(column.value).length > 16 * 1024) return false; - const runtimeSuffix = '[A-Za-z0-9_-]{1,64}'; - const runtimeString = new RegExp(`^CoolDownCheck(?:_${runtimeSuffix})?_Sites$`).test(column.name); + const runtimeSuffix = suffix => suffix.length > 0 && !/[\u0000-\u001f\u007f-\u009f]/.test(suffix); + const runtimeString = column.name === 'CoolDownCheck_Sites' + || column.name.startsWith('CoolDownCheck_') && column.name.endsWith('_Sites') + && runtimeSuffix(column.name.slice('CoolDownCheck_'.length, -'_Sites'.length)); if (PLAYER_STRING_COLUMNS.has(column.name) || runtimeString) return column.type === 'STRING'; - const runtimeBoolean = new RegExp(`^CoolDownCheck(?:_${runtimeSuffix})?$`).test(column.name); + const runtimeBoolean = column.name.startsWith('CoolDownCheck_') + && runtimeSuffix(column.name.slice('CoolDownCheck_'.length)); if (PLAYER_BOOLEAN_COLUMNS.has(column.name) || runtimeBoolean) { return column.type === 'BOOLEAN' && /^(?:true|false)$/.test(column.value) || column.type === 'STRING' && /^(?:true|false)$/i.test(column.value); } - const runtimeInteger = new RegExp(`^(?:AllSitesLast|AlmostAllSitesLast)(?:_${runtimeSuffix})?$`).test(column.name); + const runtimeInteger = ['AllSitesLast_', 'AlmostAllSitesLast_'].some(prefix => column.name.startsWith(prefix) + && runtimeSuffix(column.name.slice(prefix.length))); const integerName = PLAYER_INTEGER_COLUMNS.has(column.name) || runtimeInteger || /^(?:MonthTotal-(?:JANUARY|FEBRUARY|MARCH|APRIL|MAY|JUNE|JULY|AUGUST|SEPTEMBER|OCTOBER|NOVEMBER|DECEMBER)-[0-9]{4}|VoteShopLimit[A-Za-z0-9_-]{1,64})$/.test(column.name); if (!integerName || column.type !== 'INTEGER' || !/^-?(?:0|[1-9][0-9]*)$/.test(column.value)) return false; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index a9d20652..1bf58cce 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -45,8 +45,7 @@ class InspectionOperationsTest { assertNotNull(task); assertEquals(created.inspectionId(), task.inspectionId()); assertEquals("player", task.query().kind()); - ObjectNode data = envelope("player"); - ((ObjectNode) data.get("result")).put("name", "Example"); + ObjectNode data = playerEnvelope(false); InspectionOperations.InspectionView completed = operations.complete(created.inspectionId(), "backend-a", new InspectionTaskResult(session, true, null, "Current data", data, task.attemptId())); @@ -220,6 +219,61 @@ class InspectionOperationsTest { task.attemptId()))).code()); } + @Test void playerResultsRejectColumnsOutsideTheServerAllowListBeforeRetention() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + UUID inspection = operations.create("backend-a", new InspectionQuery("player", Map.of("name", "Example"))) + .inspectionId(); + InspectionTask task = operations.claim("backend-a", session); + ObjectNode data = playerEnvelope(true); + ((ObjectNode) data.path("result").path("lastVotes").get(0)).put("displayName", "é".repeat(100)); + ObjectNode runtimeColumn = ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) + .addObject(); + runtimeColumn.put("name", "CoolDownCheck_lobby east"); + runtimeColumn.put("type", "BOOLEAN"); + runtimeColumn.put("value", "false"); + ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) + .get(0)).put("name", "OfflineVotes"); + + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + assertNull(operations.get(inspection).result()); + assertEquals("RUNNING", operations.get(inspection).state()); + + ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) + .get(0)).put("name", "Points"); + assertEquals("SUCCEEDED", operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); + } + + @Test void playerResultsRequireBoundedLastVotesAndConsistentStorageAvailability() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + UUID inspection = operations.create("backend-a", new InspectionQuery("player", Map.of("name", "Example"))) + .inspectionId(); + InspectionTask task = operations.claim("backend-a", session); + ObjectNode data = playerEnvelope(false); + ((ObjectNode) data.path("result")).put("storage", "SQLITE"); + + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + + ((ObjectNode) data.path("result")).remove("storage"); + ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("lastVotes")) + .get(0)).put("unexpected", "field"); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + assertNull(operations.get(inspection).result()); + + ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("lastVotes")) + .get(0)).remove("unexpected"); + assertEquals("SUCCEEDED", operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); + } + @Test void abandonedAndCompletedInspectionsHaveSeparateRetentionWindows() { register(session, Set.of(InspectionQuery.CAPABILITY)); InspectionOperations operations = new InspectionOperations(registry, clock); @@ -297,6 +351,45 @@ private static ObjectNode envelope(String kind) { return envelope; } + private static ObjectNode playerEnvelope(boolean storageAvailable) { + ObjectNode envelope = envelope("player"); + ObjectNode result = envelope.putObject("result"); + result.put("found", true); + result.put("uuid", "3b0c76c1-b7ef-4a2c-a565-b7bc662531f9"); + result.put("name", "Example"); + result.put("lastOnline", 0L); + result.put("online", false); + ObjectNode totals = result.putObject("totals"); + totals.put("daily", 1); + totals.put("weekly", 2); + totals.put("monthly", 3); + totals.put("allTime", 4); + result.put("points", 5); + ObjectNode streaks = result.putObject("streaks"); + streaks.put("daily", 1); + streaks.put("weekly", 2); + streaks.put("monthly", 3); + result.put("lastVoteTime", 0L); + com.fasterxml.jackson.databind.node.ArrayNode lastVotes = result.putArray("lastVotes"); + ObjectNode lastVote = lastVotes.addObject(); + lastVote.put("siteKey", "ExampleSite"); + lastVote.put("displayName", "Example Site"); + lastVote.put("serviceSite", "example.org"); + lastVote.put("time", 0L); + result.put("lastVotesTruncated", false); + result.put("pendingOfflineVotes", 0); + result.put("storageRowAvailable", storageAvailable); + if (storageAvailable) { + result.put("storage", "SQLITE"); + ObjectNode column = result.putArray("columns").addObject(); + column.put("name", "Points"); + column.put("type", "INTEGER"); + column.put("value", "5"); + result.put("columnsTruncated", false); + } + return envelope; + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index ea5fa448..604b3a30 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -164,6 +164,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("receivedLastVotes.some(lastVote => !validPlayerLastVote(lastVote))")); assertTrue(script.body().contains("const lastVotes = malformedLastVotes ? [] : receivedLastVotes.slice(0, MAX_PLAYER_LAST_VOTES);")); assertTrue(script.body().contains("function validPlayerColumn(column)")); + assertTrue(script.body().contains("const runtimeSuffix = suffix => suffix.length > 0")); assertTrue(script.body().contains("const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined;")); assertTrue(script.body().contains("if (columnsOmittedForUnavailableStorage) return;")); assertTrue(script.body().contains("fields outside the allow-listed column schema")); From 85582b5bac0a403ab29875af9626e2acdb7b5487 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:15:41 -0600 Subject: [PATCH 18/20] Preserve legacy player inspection results --- .../control/domain/InspectionOperations.java | 14 +++++++++---- src/main/resources/web/app.js | 6 ++++-- .../domain/InspectionOperationsTest.java | 20 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 3 ++- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index cea42cfe..7160146a 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -42,8 +42,9 @@ public final class InspectionOperations { "AllSitesLast", "AlmostAllSitesLast"); private static final Set PLAYER_FOUND_FIELDS = Set.of("found", "uuid", "name", "lastOnline", "online", "totals", "points", "streaks", "lastVoteTime", "lastVotes", "lastVotesTruncated", - "pendingOfflineVotes", "storageRowAvailable"); - private static final Set PLAYER_OPTIONAL_STORAGE_FIELDS = Set.of("storage", "columns", "columnsTruncated"); + "pendingOfflineVotes"); + private static final Set PLAYER_OPTIONAL_STORAGE_FIELDS = Set.of("storageRowAvailable", "storage", "columns", + "columnsTruncated"); 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); @@ -263,10 +264,15 @@ private static boolean validPlayerResult(JsonNode value) { || !validStreaks(value.path("streaks")) || !nonNegativeLong(value.path("lastVoteTime")) || !validLastVotes(value.path("lastVotes")) || !value.path("lastVotesTruncated").isBoolean() || !nonNegativeInt(value.path("pendingOfflineVotes")) - || value.path("pendingOfflineVotes").intValue() > 100_000 - || !value.path("storageRowAvailable").isBoolean()) { + || value.path("pendingOfflineVotes").intValue() > 100_000) { return false; } + // data.inspect.v1 predates the additive storage metadata. Preserve exact legacy envelopes while still + // rejecting any storage fields unless their availability is explicitly declared by a newer node. + if (!value.has("storageRowAvailable")) { + return !value.has("storage") && !value.has("columns") && !value.has("columnsTruncated"); + } + if (!value.path("storageRowAvailable").isBoolean()) return false; boolean storageAvailable = value.path("storageRowAvailable").booleanValue(); if (!storageAvailable) { return !value.has("storage") diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 436d8009..f294b321 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -492,8 +492,10 @@ function renderPlayerData(value) { text(warning, `Additional VoteSite history was omitted by the ${MAX_PLAYER_LAST_VOTES}-row inspection limit.`); playerResult.append(warning); } + const legacyStorageMetadata = value.storageRowAvailable === undefined && value.storage === undefined + && value.columns === undefined && value.columnsTruncated === undefined; const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined; - if (!columnsOmittedForUnavailableStorage + if (!legacyStorageMetadata && !columnsOmittedForUnavailableStorage && (!Array.isArray(value.columns) || value.columns.some(column => !validPlayerColumn(column)))) { const warning = document.createElement('p'); warning.className = 'warning-text'; @@ -501,7 +503,7 @@ function renderPlayerData(value) { playerResult.append(warning); return; } - if (columnsOmittedForUnavailableStorage) return; + if (legacyStorageMetadata || columnsOmittedForUnavailableStorage) return; const columns = value.columns.slice(0, 100); const scroll = document.createElement('div'); scroll.className = 'table-scroll'; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index 1bf58cce..eaa5aae4 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -274,6 +274,26 @@ class InspectionOperationsTest { new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); } + @Test void legacyPlayerResultsWithoutAdditiveStorageMetadataRemainCompatible() { + register(session, Set.of(InspectionQuery.CAPABILITY)); + InspectionOperations operations = new InspectionOperations(registry, clock); + UUID inspection = operations.create("backend-a", new InspectionQuery("player", Map.of("name", "Example"))) + .inspectionId(); + InspectionTask task = operations.claim("backend-a", session); + ObjectNode data = playerEnvelope(false); + ObjectNode result = (ObjectNode) data.path("result"); + result.remove("storageRowAvailable"); + result.put("storage", "SQLITE"); + + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + + result.remove("storage"); + assertEquals("SUCCEEDED", operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); + } + @Test void abandonedAndCompletedInspectionsHaveSeparateRetentionWindows() { register(session, Set.of(InspectionQuery.CAPABILITY)); InspectionOperations operations = new InspectionOperations(registry, clock); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 604b3a30..6f13d35f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -165,8 +165,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("const lastVotes = malformedLastVotes ? [] : receivedLastVotes.slice(0, MAX_PLAYER_LAST_VOTES);")); assertTrue(script.body().contains("function validPlayerColumn(column)")); assertTrue(script.body().contains("const runtimeSuffix = suffix => suffix.length > 0")); + assertTrue(script.body().contains("const legacyStorageMetadata = value.storageRowAvailable === undefined")); assertTrue(script.body().contains("const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined;")); - assertTrue(script.body().contains("if (columnsOmittedForUnavailableStorage) return;")); + assertTrue(script.body().contains("if (legacyStorageMetadata || columnsOmittedForUnavailableStorage) return;")); assertTrue(script.body().contains("fields outside the allow-listed column schema")); assertTrue(script.body().contains("Saved; proxy restart required")); assertTrue(script.body().contains("configuration saved; proxy restart required")); From a08e07fa7f0b4dcfe8d0571227a304669fc718f4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:33:05 -0600 Subject: [PATCH 19/20] Validate unavailable storage truncation flag --- .../votingplugin/control/domain/InspectionOperations.java | 3 ++- .../control/domain/InspectionOperationsTest.java | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index 7160146a..eea47bd2 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -277,7 +277,8 @@ private static boolean validPlayerResult(JsonNode value) { if (!storageAvailable) { return !value.has("storage") && (!value.has("columns") || value.path("columns").isArray() && value.path("columns").isEmpty()) - && (!value.has("columnsTruncated") || !value.path("columnsTruncated").booleanValue()); + && (!value.has("columnsTruncated") || value.path("columnsTruncated").isBoolean() + && !value.path("columnsTruncated").booleanValue()); } return boundedText(value.path("storage"), 32) && !value.path("storage").textValue().isBlank() && validColumns(value.path("columns")) && value.path("columnsTruncated").isBoolean(); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index eaa5aae4..2c98e80f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -261,6 +261,12 @@ class InspectionOperationsTest { new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); ((ObjectNode) data.path("result")).remove("storage"); + ((ObjectNode) data.path("result")).put("columnsTruncated", "false"); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", + new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId()))).code()); + + ((ObjectNode) data.path("result")).put("columnsTruncated", false); ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("lastVotes")) .get(0)).put("unexpected", "field"); assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, From 852abfcac788d7d8fcff1ec7fb1fa66e5aa4d4a1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:09:39 -0600 Subject: [PATCH 20/20] Finish Control review fixes --- .../control/domain/InspectionOperations.java | 5 ++++- src/main/resources/web/app.js | 10 ++++++++-- .../control/domain/InspectionOperationsTest.java | 11 ++++++++++- .../control/http/ControlHttpServerTest.java | 4 +++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java index eea47bd2..1077f036 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InspectionOperations.java @@ -219,7 +219,9 @@ private static void validateResult(InspectionTaskResult result, String expectedK throw invalid("failed inspection result is invalid"); } if (result.success() && (!result.data().isObject() + || !exactFields(result.data(), Set.of("schemaVersion", "kind", "generatedAt", "result")) || !result.data().path("schemaVersion").isIntegralNumber() + || !result.data().path("schemaVersion").canConvertToInt() || result.data().path("schemaVersion").intValue() != 1 || !expectedKind.equals(result.data().path("kind").asText()) || !result.data().path("generatedAt").isTextual() @@ -312,6 +314,7 @@ private static boolean validColumns(JsonNode value) { for (JsonNode column : value) { if (!exactFields(column, Set.of("name", "type", "value")) || !column.path("name").isTextual() || !column.path("type").isTextual() || !column.path("value").isTextual() + || !boundedCharacters(column.path("name"), 100) || !boundedText(column.path("value"), MAX_PLAYER_COLUMN_VALUE_BYTES) || !names.add(column.path("name").textValue()) || !validPlayerColumn(column.path("name").textValue(), column.path("type").textValue(), @@ -359,7 +362,7 @@ private static boolean runtimeIntegerColumn(String name) { } private static boolean validRuntimeSuffix(String value) { - return !value.isEmpty() && !hasControlCharacter(value); + return !value.isEmpty() && value.length() <= 64 && !hasControlCharacter(value); } private static boolean canonicalUuid(JsonNode value) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index f294b321..ba30ba65 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -384,9 +384,11 @@ function exactObjectKeys(value, expected) { function validPlayerColumn(column) { if (!exactObjectKeys(column, ['name', 'type', 'value'])) return false; - if (typeof column.name !== 'string' || typeof column.type !== 'string' || typeof column.value !== 'string' + if (typeof column.name !== 'string' || column.name.length > 100 + || typeof column.type !== 'string' || typeof column.value !== 'string' || new TextEncoder().encode(column.value).length > 16 * 1024) return false; - const runtimeSuffix = suffix => suffix.length > 0 && !/[\u0000-\u001f\u007f-\u009f]/.test(suffix); + const runtimeSuffix = suffix => suffix.length > 0 && suffix.length <= 64 + && !/[\u0000-\u001f\u007f-\u009f]/.test(suffix); const runtimeString = column.name === 'CoolDownCheck_Sites' || column.name.startsWith('CoolDownCheck_') && column.name.endsWith('_Sites') && runtimeSuffix(column.name.slice('CoolDownCheck_'.length, -'_Sites'.length)); @@ -2295,6 +2297,10 @@ async function waitForOperation(operation, statusElement = operationStatus, cont if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); } + if (!operationContextCurrent(context) && statusElement === operationStatus + && operation.type === 'APPLY' && routingDirty) { + text(statusElement, `${operationSummary(operation)}\nThe apply completed, but newer unsaved proxy-routing edits remain. Preview again before applying them.`); + } rememberVoteLoggingRestart(operation); if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { fileReadCache.clear(); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java index 2c98e80f..7b9e98e3 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InspectionOperationsTest.java @@ -217,6 +217,14 @@ class InspectionOperationsTest { () -> operations.complete(inspection, "backend-a", new InspectionTaskResult(session, true, null, "done", envelope("diagnostics").put("schemaVersion", "1"), task.attemptId()))).code()); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", new InspectionTaskResult(session, true, + null, "done", envelope("diagnostics").put("schemaVersion", 4_294_967_297L), + task.attemptId()))).code()); + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, + () -> operations.complete(inspection, "backend-a", new InspectionTaskResult(session, true, + null, "done", envelope("diagnostics").put("secret", "must not be retained"), + task.attemptId()))).code()); } @Test void playerResultsRejectColumnsOutsideTheServerAllowListBeforeRetention() { @@ -229,7 +237,7 @@ class InspectionOperationsTest { ((ObjectNode) data.path("result").path("lastVotes").get(0)).put("displayName", "é".repeat(100)); ObjectNode runtimeColumn = ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) .addObject(); - runtimeColumn.put("name", "CoolDownCheck_lobby east"); + runtimeColumn.put("name", "CoolDownCheck_" + "x".repeat(65)); runtimeColumn.put("type", "BOOLEAN"); runtimeColumn.put("value", "false"); ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) @@ -243,6 +251,7 @@ class InspectionOperationsTest { ((ObjectNode) ((com.fasterxml.jackson.databind.node.ArrayNode) data.path("result").path("columns")) .get(0)).put("name", "Points"); + runtimeColumn.put("name", "CoolDownCheck_lobby east"); assertEquals("SUCCEEDED", operations.complete(inspection, "backend-a", new InspectionTaskResult(session, true, "OK", "done", data, task.attemptId())).state()); } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 6f13d35f..34ff9bda 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -164,7 +164,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("receivedLastVotes.some(lastVote => !validPlayerLastVote(lastVote))")); assertTrue(script.body().contains("const lastVotes = malformedLastVotes ? [] : receivedLastVotes.slice(0, MAX_PLAYER_LAST_VOTES);")); assertTrue(script.body().contains("function validPlayerColumn(column)")); - assertTrue(script.body().contains("const runtimeSuffix = suffix => suffix.length > 0")); + assertTrue(script.body().contains("suffix.length > 0 && suffix.length <= 64")); assertTrue(script.body().contains("const legacyStorageMetadata = value.storageRowAvailable === undefined")); assertTrue(script.body().contains("const columnsOmittedForUnavailableStorage = value.storageRowAvailable === false && value.columns === undefined;")); assertTrue(script.body().contains("if (legacyStorageMetadata || columnsOmittedForUnavailableStorage) return;")); @@ -190,6 +190,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Drift results were discarded; run the comparison again."), "A drift read completed for stale context must show an explicit discarded-result status."); assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); + assertTrue(script.body().contains( + "The apply completed, but newer unsaved proxy-routing edits remain. Preview again before applying them.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?"));